
简介一份面向Unity开发者的远程投屏示例工程基于WebRTC实现Unity画面到浏览器的低延迟媒体流传输覆盖信令服务、媒体流通信等核心环节打开即可运行适合希望快速入门Unity WebRTC远程传输或搭建可同步场景的开发者参考。工程内置testScene示例场景并引入WebRTC官方Sample便于从零理解信令交换与媒体流拉取流程。资源包共2000个文件主体为md说明文档、txt配置文件、json数据、meta元数据以及cs脚本、unity场景、prefab预制体、asset资源等压缩包约211MB目录结构清晰完整。当前已有454人学习使用。借助内置Web Server启动方式与示例页面开发者可自行修改Host IP与端口在浏览器中实时查看Unity窗口画面并以此为起点扩展远程控制、多端同步等能力是一份兼具教学与实用价值的起步模板。1. Unity 远程画面与 WebRTC 媒体流的落地前提一台没有接显示器的 Ubuntu 机器上跑着 Unity 数字孪生程序另一个办公室的浏览器要实时看到画面并且能回传操作指令。这是 Unity 远程画面最典型的落地场景也是 cloud rendering、远程运维、AR 协助等方向的共同底座。早期方案多走 RTSP 或 RTMP 推流延迟能压到 500ms 以下但很难在同一链路里做双向交互换成 WebRTC 后媒体流、数据通道和音轨跑在同一个 peer connection 上信令服务只负责建立连接视频数据不经过中转服务器。标题里的 stream、信令服务、WebRTC 三个词其实是三个不同层次的东西WebRTC 是传输协议族stream 是承载画面的媒体流信令服务是连接建立前的协调者。这篇文章把这三者的关系拆开给出 Unity 接入 WebRTC 远程画面时可复现的源码结构和参数配置。适合准备做 Unity 远程渲染但又不想从零趟一遍 WebRTC 坑的开发者也适合需要把现成“打开即用”项目从源码跑起来的人。2. 信令服务在 Unity WebRTC 远程传输里的分工与最小实现2.1 WebRTC 为什么不直接把 stream 推到对端而必须先走信令WebRTC 的媒体流是端到端的但两端在建立 peer connection 之前互相不知道对方地址也不知道对方支持什么编码格式。浏览器、Unity 客户端、移动端各自实现的 H.264 或 VP8/VP9 能力不同必须先把各自的 SDP 描述交换一遍。这个“交换描述”的过程就是信令。信令服务本身不传输视频帧也不参与编码它只做一件事把 A 端的 offer 转给 B 端再把 B 端的 answer 转回 A 端顺便转发 ICE candidate。我在实际项目里见过不少在信令上栽跟头的例子画面本地预览正常但一旦让 Unity 程序部署到另一台服务器浏览器端就黑屏。最后定位到原因几乎都在信令层——地址写死成本机、WebSocket 端口被防火墙拦截、ICE 候选没有按时发送。所以要理解整个远程画面方案第一件事不是写代码而是理解信令服务的时序。WebRTC 的连接建立时序大致是这样Unity 端创建RTCPeerConnection同时准备视频轨道。Unity 端调用CreateOffer()生成 SDP offer通过信令服务发送给浏览器端。浏览器端创建自己的RTCPeerConnection调用SetRemoteDescription()接收 offer。浏览器端生成 answer 回传Unity 端SetRemoteDescription()完成协商。两端持续通过信令服务交换 ICE candidate直到 candidate 配对成功。后续视频帧直接通过 UDP 或 WebRTC 回退通道传输信令服务不再参与。把这个时序画出来就能看出信令服务一旦在第一步挂掉后面的媒体流根本不可能建立。所以“打开即用”的远程画面项目里信令服务的稳定性比编码参数更重要。2.2 自建信令服务的最小代码WebSocket 转发 offer、answer 和 ICE常见做法是用 Node.js 的ws库实现一个按房间转发的信令服务。Unity 端和浏览器端都连到同一个 WebSocket 地址消息里带room参数表示归属房间服务端只做转发不解析业务逻辑。const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); // roomId - SetWebSocket const rooms new Map(); wss.on(connection, (ws, req) { const url new URL(req.url, http://localhost); const roomId url.searchParams.get(room) || default; if (!rooms.has(roomId)) { rooms.set(roomId, new Set()); } rooms.get(roomId).add(ws); ws.on(message, (data) { const msg JSON.parse(data.toString()); // 只转发给同房间的其它连接 rooms.get(roomId).forEach((client) { if (client ! ws client.readyState WebSocket.OPEN) { client.send(JSON.stringify({ from: msg.from, type: msg.type, // offer / answer / ice payload: msg.payload })); } }); }); ws.on(close, () { rooms.get(roomId).delete(ws); if (rooms.get(roomId).size 0) { rooms.delete(roomId); } }); }); console.log(signaling server on ws://0.0.0.0:8080);这段代码里有一个容易忽略的设计消息只转发给“同房间的其他连接”如果 Unity 端和浏览器端连进来时 room 参数不一致信令消息就会静默丢失。建议在 Unity 的配置文件里把信令地址写成ws://192.168.1.10:8080?roomunity-demo这种格式浏览器端打开页面时取同一个 room位置参数放在配置文件的同一个节区避免两端手写不一致。参数上值得注意的有两点。第一ws库默认会处理 TCP 粘包和拆包不需要自己维护 buffer但生产环境建议在信令服务外层加一个心跳30 秒 ping 一次防止中间网络设备把空闲连接回收掉。第二如果浏览器端和 Unity 端跨公网部署信令服务的 WebSocket 必须走 TLS也就是wss://浏览器在 https 页面里默认会阻止非加密的 WebSocket 连接这是部署时最常见的黑屏原因之一。2.3 信令服务选型自建转发、官方 WebApp 还是云信令除了上面这段自建代码Unity 的 WebRTC 生态里还有现成的信令服务形态。“打开即用”的远程画面项目通常把信令服务做成一个独立子目录方便单独部署我在接手这类项目时一般会先看信令服务的目录结构判断它是纯转发型还是带业务逻辑型。下面这张表是我常用的选型依据信令服务形态适合场景部署成本注意事项自建 WebSocket 转发局域网或固定 IP 的内网远程画面最低一个 Node 进程需要自己处理房间管理、心跳和重连Unity Render Streaming 官方 WebApp需要 Web 管理页面、多路画面切换中等需要 npm 构建前端页面和信令服务一体端口和静态资源需要统一配置云信令服务跨地域、跨运营商的大规模部署高媒体流本身仍走 P2P信令的 SLA 由云厂商保证我倾向于在项目早期用自建 WebSocket 转发因为代码短、问题好排查。等远程画面的并发路数超过 10 路再迁到带房间管理和鉴权的信令服务避免一上来就被框架绑住。这里要特别提一句自建信令服务转发的是 SDP 和 ICE 文本不是视频数据。很多人误以为远程画面卡顿是因为信令服务带宽不够其实是把信令服务和 TURN 服务搞混了。跨 NAT 时如果 P2P 无法打通WebRTC 会回退到 TURN 服务器中转媒体流这时候才需要真正的带宽扩容。3. Unity 端采集媒体流并建立 WebRTC peer connection 的代码路径3.1 用 Package Manager 安装 WebRTC 相关包并初始化Unity 远程画面的 Unity 需要采集相机的画面并编码成流这一步用官方维护的com.unity.webrtc包来做如果还需要内置信令和 Web 播放器配套可以把com.unity.renderstreaming也加进来。安装时不需要去网上找第三方插件直接在 Unity 的Window Package Manager里添加包名。常见的最小依赖写入方式是在工程根目录的Packages/manifest.json里加两个依赖项{ dependencies: { com.unity.webrtc: 3.0.0, com.unity.renderstreaming: 3.1.0 } }版本号按 Package Manager 实际可解析到的为准不同 Unity 版本对原生插件的兼容性有差异所以打开“打开即用”项目时如果编译报错第一优先级是检查包版本是否和 Unity 编辑器版本匹配。Unity 2021.3 以上的长期支持版本对这两个包的兼容性较好。安装完成后的初始化路径是固定的调用WebRTC.Initialize()初始化原生库然后是WebRTC.Finalize()负责收尾。很多远程画面项目把初始化放在MonoBehaviour.Awake()里把释放放在OnDestroy()里这是一个比较稳妥的写法。3.2 把 Camera 画面绑定到 VideoStreamTrack 的最小代码远程画面需要把 Unity 相机渲染的内容作为视频源。常见做法是给相机挂一个 RenderTexture渲染目标设为该纹理然后基于 RenderTexture 创建VideoStreamTrack再把这个轨道添加到RTCPeerConnection上。下面这段代码是一个最小可工作的发送端using UnityEngine; using Unity.WebRTC; public class MinimalRenderSender : MonoBehaviour { [SerializeField] private Camera targetCamera; private RenderTexture renderTexture; private VideoStreamTrack videoTrack; private RTCPeerConnection peer; private void Start() { // 1920x1080 的渲染目标位深 0 表示默认深度缓冲 renderTexture new RenderTexture(1920, 1080, 0); targetCamera.targetTexture renderTexture; // 初始化 WebRTC 原生模块重复调用会返回错误因此要保证只执行一次 WebRTC.Initialize(); // 创建 peer connection后续 SetLocalDescription 和 SetRemoteDescription 都围绕它进行 peer new RTCPeerConnection(); // 把 RenderTexture 作为视频轨道的输入编码后的帧就是浏览器端看到的画面 videoTrack new VideoStreamTrack(renderTexture); peer.AddTrack(videoTrack); } private void OnDestroy() { // 释放顺序先轨道后 peer 连接 videoTrack?.Dispose(); peer?.Dispose(); WebRTC.Finalize(); } }这段代码的结构要点有三个。第一WebRTC.Initialize()必须在创建任何轨道之前调用它负责加载原生解码编码库放在Start()里可以防止被其他组件在 Awake 阶段提前误用。第二RenderTexture的尺寸决定编码分辨率相机区域的宽高和纹理尺寸不一致会导致画面拉伸因此生产项目里通常会加一段对Camera.pixelRect和纹理尺寸一致性的检查。第三释放顺序不能反过来先Dispose()轨道再释放 peer 连接否则可能出现 native 层面的悬空引用。3.3 收到远端 Offer 后回传 Answer 的流程单纯采集画面还不够Unity 端要在信令服务里等待浏览器的 offer生成 answer 后回传。这个流程在 Unity 里是异步的代码里要时刻留意回调线程。using UnityEngine; using Unity.WebRTC; public class SdpExchange : MonoBehaviour { private RTCPeerConnection peer; // 由信令服务的消息回调触发sdp 来自浏览器的 offer public async void HandleOffer(string sdp) { var offer new RTCSessionDescription { type RTCSdpType.Offer, sdp sdp }; // 把远端描述应用到本地此时本地开始准备编码参数协商 await peer.SetRemoteDescription(offer); // 创建 answer本质上是对远端 offer 的编码能力响应 var answer peer.CreateAnswer(); await peer.SetLocalDescription(answer); // 把 answer 通过信令服务发回给浏览器 SignalingClient.Send(new SignalMessage { type answer, payload answer.sdp }); } // ICE candidate 收集完成后进入 OnIceCandidate 回调同样要走信令转发 public void OnIceCandidate(RTCIceCandidate candidate) { SignalingClient.Send(new SignalMessage { type ice, payload candidate.Candidate }); } }这里的坑在于CreateAnswer()返回的RTCSessionDescription并不是立刻可以发送的必须等待SetLocalDescription()完成answer 的 SDP 字段才包含真正的编码协商结果。如果提前把answer.sdp发出去浏览器端会解析失败连接会进入异常状态。处理的方法是像上面这样 await 之后再发送或者在SetLocalDescription的完成回调里发送。浏览器端需要回传的 ICE candidate 也是一个时间敏感信息。WebRTC 的 candidate 协商遵循“收集一点、通知一点”的模式不要等所有 candidate 收集完再发送否则两侧的 NAT 穿透探测会晚于媒体流启动表现为视频延迟几秒后才出画面。4. 打开即用的源码组织信令服务、Unity 工程与浏览器端一键连接4.1 项目源码目录应该按什么结构组织“打开即用”的远程画面项目源码目录结构通常分成三块Unity 工程、信令服务、浏览器端播放器。这三块互相独立又通过信令地址和房间号串联。我在实际项目中通常这样组织. ├── unity-client/ # Unity 工程含 MinimalRenderSender 等脚本 │ └── Assets/ │ ├── Scripts/ │ │ └── RemoteRender/ │ └── StreamingAssets/ │ └── webrtc-config.json ├── signaling-server/ # Node.js WebSocket 信令服务 │ ├── index.js │ ├── package.json │ └── .env └── web-player/ # 浏览器端页面负责拉流和交互 ├── index.html └── main.js这样的目录分离带来的直接好处是部署时可以单独更新信令服务不用重新打 Unity 包。Unity 端把信令地址放在StreamingAssets/webrtc-config.json而不是写死在 C# 代码里因为写死字符串在换环境时就必须重新出包和“打开即用”的预期相悖。配置文件里应该只放连接层参数不把编码参数混进来{ signalingUrl: ws://127.0.0.1:8080?roomunity-demo, stunServers: [ stun:stun.l.google.com:19302 ], iceTransportPolicy: all }这里的stunServers用于 NAT 穿透内网测试时即使不配置 STUN 也能通但公网环境没有 STUN 基本无法建立 P2P 连接。iceTransportPolicy设为all表示允许使用中继候选如果明确要求视频不能经过中继服务器可以改成relay但那样会导致绝大多数 NAT 环境连不通。4.2 启动流程先跑信令服务再开 Unity最后开浏览器启动顺序有讲究我这里给出一套经过实测的顺序# 1. 安装信令服务依赖并启动 cd signaling-server npm install node index.js# 2. 启动 Unity 前把上面的 webrtc-config.json 中信令地址改成实际部署地址 open http://localhost:8080 # 浏览器打开页面确认信令服务监听正常信令服务最先启动是因为 Unity 端和浏览器端都要在启动时主动去连它。如果 Unity 先启动时信令服务还没就绪常见做法是让 Unity 端做 3 次重连每次间隔 2 秒超过 3 次就显示“信令服务未连接”的提示而不是无限重试。浏览器端因为页面加载慢一些通常连不上时可以直接刷新页面所以重连逻辑放在 Unity 端更合理。4.3 接入前的检查清单“打开即用”的项目拿到手后先不要急着改功能按下面这张表逐项确认能避免大部分环境问题检查项预期结果失败时看什么信令服务端口可达nc -vz 127.0.0.1 8080返回 open防火墙策略、Node 进程是否存活Unity 端信令地址配置和浏览器端 room 参数一致StreamingAssets/webrtc-config.json的room值STUN 服务器可达浏览器端有 ICE candidate 生成浏览器控制台RTCPeerConnection.getStats()Unity 包版本可编译控制台无 WebRTC 原生库报错Package Manager 中的包版本和 Unity 版本浏览器能访问播放器页面页面出现 Unity 画面静态服务器是否挂在信令服务的同端口只要这些项通过远程画面的链路就通了。剩下要做的就是画面卡顿和断流的调优这是远程传输项目里最花时间的部分。5. 远程画面的断流排查与延迟调优技巧远程画面的体验瓶颈通常不是能不能连上而是连上之后的延迟和稳定性。浏览器里看到的画面如果比 Unity 端源画面慢超过 300ms操作反馈就很明显跟不上手。延迟来源主要有四个Unity 端编码延迟、网络传输延迟、浏览器端解码延迟、渲染缓冲队列。编码延迟和网络传输延迟是可控的大头。Unity 的VideoStreamTrack编码参数一般通过RTCRtpSender上的SetParameters调节把maxBitrate压在带宽上限的 70% 左右能显著减少公网抖动时的乱码。帧率方面不要盲目追求 60fps远程画面场景 30fps 已经能满足大多数操作需求降低帧率能为带宽腾出余量。分辨率也建议用固定值不要跟着浏览器窗口大小动态变化否则每切换一次窗口尺寸都会触发重新协商造成短暂黑屏。断流排查要按层次来。如果浏览器端出现类似stream disconnected before completion的报错先判断是信令链路断开还是媒体链路断开。信令链路断开时断流往往是浏览器控制台里先出现 WebSocket 关闭提示再出现媒体流错误这时候要查信令服务的客户端数量和连接日志。媒体链路断开时信令服务日志是正常的要用peer.getStats()看candidate-pair状态如果显示failed多半是 NAT 打洞失败或网络切换导致 ICE 状态没有重协商。RTCPeerConnection的状态变化是定位断流最重要的信号建议在代码里把所有状态都打到日志中peer.OnConnectionStateChange (state) { Debug.Log($[WebRTC] connection state: {state}); // Failed 状态触发重连不能只在网络层重试 if (state RTCPeerConnectionState.Failed) { Reconnect(); } };Reconnect()的正确做法是在销毁当前 peer connection 的轨道后重新创建连接和信令流程单纯重连 WebSocket 不会恢复已经失效的媒体通道。弱网环境下建议保留最近一次成功使用的 ICE candidate在新连接建立后优先复用能加快重连速度。最后一个技巧是给 HTTP 请求和心跳做统一的超时管理。Unity 端和信令服务的连接要同时开两个心跳一个走 WebSocket ping一个在媒体通道上周期发送一次数据通道消息。数据通道心跳可以放在RTCDataChannel里哪怕没有流媒体数据也保证每秒有几条小消息在跑这样媒体链路的状态就能被实时监控到。记住WebRTC 的各项状态本身不产生告警只有把状态透出到应用层并加上重连状态机远程画面才算真正能扛住公网传输。本文还有配套的精品资源点击获取