简介:一份面向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 -> Set<WebSocket> 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?room=unity-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?room=unity-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 的各项状态本身不产生告警,只有把状态透出到应用层并加上重连状态机,远程画面才算真正能扛住公网传输。
本文还有配套的精品资源,点击获取