最近在做一个游戏社交功能需要集成实时语音聊天。作为 Cocos Creator 的开发者我自然希望能在游戏引擎内直接搞定而不是额外引入一个庞大的 SDK。经过一番摸索成功用原生 WebRTC 实现了这个功能这里把从零搭建的过程和踩过的坑记录下来希望能帮到有同样需求的同学。1. WebRTC 在游戏里的用武之地对于游戏来说实时音视频通信能极大提升沉浸感和社交体验。最直接的应用就是队伍语音玩家在组队副本时可以直接沟通战术比打字快多了。在一些非对称对抗或社交类游戏中还可以实现远程协助比如老玩家通过实时视频指导新手操作某个复杂机关。此外像直播陪玩、虚拟形象视频互动等场景也都离不开稳定的实时通信能力。原生 WebRTC 的优势在于它是一套开放标准无需付费可控性强特别适合对包体大小敏感、且希望深度定制通信逻辑的游戏项目。2. 技术选型原生 WebRTC vs 第三方 SDK在动手之前我们先理清思路。市面上主要有两种方案原生 WebRTC优点是免费、开源、灵活你可以控制每一个细节。缺点是基础设施如信令、TURN服务器需要自己搭建移动端兼容性处理起来比较麻烦相当于自己造轮子。第三方SDK如声网、即构等优点是开箱即用服务稳定文档和客服支持好能快速上线。缺点是通常按用量收费SDK包体可能较大且功能被封装自定义程度有限。我的选型建议是如果你的项目对成本敏感团队有较强的音视频技术背景或学习意愿且对通信流程有特殊定制需求那么原生 WebRTC 是很好的选择。反之如果追求快速稳定上线且预算允许第三方 SDK 能帮你省去大量运维和调试成本。本文主要面向选择前者的同学。3. 核心实现三步走3.1 搭建信令服务器 (Node.js Socket.io)WebRTC 本身不负责发现和连接对方这就需要信令服务器来交换网络信息。我用 Node.js 和 Socket.io 快速搭了一个。首先创建一个简单的server.jsconst express require(express); const http require(http); const socketIo require(socket.io); const app express(); const server http.createServer(app); const io socketIo(server, { cors: { origin: *, // 生产环境请替换为具体域名 methods: [GET, POST] } }); // 存储房间与用户的映射 const roomUsers {}; io.on(connection, (socket) { console.log(新用户连接:, socket.id); // 加入房间 socket.on(join-room, (roomId, userId) { socket.join(roomId); if (!roomUsers[roomId]) roomUsers[roomId] new Set(); roomUsers[roomId].add(userId); // 通知房间内其他用户有新用户加入 socket.to(roomId).emit(user-joined, userId); // 给新用户发送房间内已有用户的列表 const otherUsers Array.from(roomUsers[roomId]).filter(id id ! userId); socket.emit(existing-users, otherUsers); }); // 转发SDP Offer/Answer socket.on(sdp-offer, (data) { socket.to(data.targetUserId).emit(sdp-offer, data); }); socket.on(sdp-answer, (data) { socket.to(data.targetUserId).emit(sdp-answer, data); }); // 转发ICE Candidate socket.on(ice-candidate, (data) { socket.to(data.targetUserId).emit(ice-candidate, data); }); // 用户离开 socket.on(disconnect, () { console.log(用户断开:, socket.id); // 清理逻辑...遍历roomUsers移除该用户 }); }); server.listen(3000, () { console.log(信令服务器运行在: http://localhost:3000); });运行node server.js一个最基础的信令服务器就启动了。它主要处理用户加入/离开房间并转发 SDP会话描述协议和 ICE交互式连接建立候选者信息。3.2 Cocos 与 WebRTC 的交互封装接下来是重头戏在 Cocos Creator (3.7) 中封装 WebRTC 逻辑。我们创建一个WebRTCManager.ts类。import { _decorator, Component, sys } from cc; import { SignalingClient } from ./SignalingClient; // 假设这是封装了Socket.io客户端的类 export class WebRTCManager extends Component { private localStream: MediaStream | null null; private peerConnections: Mapstring, RTCPeerConnection new Map(); private signalingClient: SignalingClient; private localUserId: string; // 初始化获取本地媒体流 async init(userId: string, roomId: string) { this.localUserId userId; this.signalingClient new SignalingClient(ws://你的服务器地址:3000); await this.signalingClient.connect(); // 1. 获取用户音视频权限移动端需要额外处理见3.3节 try { this.localStream await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 640, height: 480 } // 初始分辨率不宜过高 }); // 可以将 localStream 赋值给一个 Video/Audio 组件进行本地预览 } catch (err) { console.error(获取媒体设备失败:, err); return; } // 2. 加入信令服务器房间 this.signalingClient.joinRoom(roomId, userId); // 3. 监听信令事件 this.setupSignalingHandlers(); } private setupSignalingHandlers() { // 收到其他用户加入的通知 this.signalingClient.on(user-joined, (remoteUserId: string) { this.createPeerConnection(remoteUserId); }); // 收到SDP Offer this.signalingClient.on(sdp-offer, async (data: any) { const pc this.getOrCreatePeerConnection(data.from); await pc.setRemoteDescription(new RTCSessionDescription(data.sdp)); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); this.signalingClient.sendSdpAnswer(data.from, this.localUserId, answer); }); // 收到SDP Answer this.signalingClient.on(sdp-answer, async (data: any) { const pc this.peerConnections.get(data.from); if (pc) { await pc.setRemoteDescription(new RTCSessionDescription(data.sdp)); } }); // 收到ICE Candidate this.signalingClient.on(ice-candidate, (data: any) { const pc this.peerConnections.get(data.from); if (pc data.candidate) { pc.addIceCandidate(new RTCIceCandidate(data.candidate)).catch(e console.warn(添加ICE候选失败:, e)); } }); } // 创建与对端的PeerConnection private createPeerConnection(remoteUserId: string): RTCPeerConnection { // 使用公共STUN服务器生产环境需配置TURN const configuration: RTCConfiguration { iceServers: [ { urls: stun:stun.l.google.com:19302 }, // { urls: turn:your-turn-server.com, username: user, credential: pass } ] }; const pc new RTCPeerConnection(configuration); this.peerConnections.set(remoteUserId, pc); // 添加本地音视频轨道 if (this.localStream) { this.localStream.getTracks().forEach(track { pc.addTrack(track, this.localStream!); }); } // 监听ICE候选并发送给对端 pc.onicecandidate (event) { if (event.candidate) { this.signalingClient.sendIceCandidate(remoteUserId, this.localUserId, event.candidate); } }; // 接收到对端的远程流将其显示出来 pc.ontrack (event) { const remoteStream event.streams[0]; // 这里需要将 remoteStream 绑定到场景中的一个远程视频/音频组件上 console.log(收到来自 ${remoteUserId} 的远程流); this.scheduleOneTick(() { // 在Cocos的主线程中更新UI将流赋值给cc.AudioSource或自定义的视频渲染组件 }); }; // 创建Offer并发送 this.createAndSendOffer(pc, remoteUserId); return pc; } private async createAndSendOffer(pc: RTCPeerConnection, remoteUserId: string) { try { const offer await pc.createOffer(); await pc.setLocalDescription(offer); this.signalingClient.sendSdpOffer(remoteUserId, this.localUserId, offer); } catch (err) { console.error(创建或发送Offer失败:, err); } } private getOrCreatePeerConnection(userId: string): RTCPeerConnection { let pc this.peerConnections.get(userId); if (!pc) { pc this.createPeerConnection(userId); } return pc; } // 清理资源 cleanup() { this.peerConnections.forEach(pc pc.close()); this.peerConnections.clear(); if (this.localStream) { this.localStream.getTracks().forEach(track track.stop()); } this.signalingClient.disconnect(); } }这个管理器封装了核心的 PeerConnection 创建、信令交换和媒体流处理逻辑。注释里提到了移动端适配我们接下来就解决它。3.3 移动端适配方案 (Android/iOS 权限处理)在移动端尤其是 Cocos 打包的游戏里直接调用getUserMedia可能会失败因为权限请求必须在用户手势触发如点击事件的上下文内进行。解决方案交互触发将初始化WebRTCManager.init()的调用绑定在一个按钮的click或touchstart事件回调里不要放在onLoad或start中。Android 配置在android/app/proguard-rules.pro中确保 WebRTC 相关类不被混淆。-keep class org.webrtc.** { *; }iOS 配置在构建发布到 iOS 时需要在Capabilities中打开Audio, AirPlay, and Picture in Picture后台音频权限并在Info.plist中添加麦克风和摄像头使用描述keyNSCameraUsageDescription/key string游戏需要摄像头进行视频聊天/string keyNSMicrophoneUsageDescription/key string游戏需要麦克风进行语音聊天/stringCocos 引擎内渲染WebRTC 返回的是MediaStream在 Web 平台可以直接赋值给video元素。在 Cocos 中我们需要通过cc.AudioSource播放音频视频则可能需要借助cc.Texture2D和cc.SpriteFrame或者使用平台特定的插件/扩展来渲染这部分较为复杂是移动端集成的关键难点之一。4. 性能优化实战功能通了接下来要让它好用且稳定。4.1 带宽自适应策略我们不能固定用一个分辨率码率。WebRTC 有内置的带宽估计REMB/Transport-CC和编码器码率控制。我们可以通过RTCRtpSender的parameters进行动态调整。// 在创建PeerConnection后可以获取sender并动态设置参数 const sender pc.getSenders().find(s s.track?.kind video); if (sender) { const parameters sender.getParameters(); if (!parameters.encodings) parameters.encodings [{}]; // 设置最大、最小、初始码率单位bps parameters.encodings[0].maxBitrate 500000; // 500kbps parameters.encodings[0].minBitrate 100000; // 100kbps parameters.encodings[0].maxFramerate 20; sender.setParameters(parameters).catch(e console.error(e)); }更精细的控制可以监听pc.onconnectionstatechange和pc.onsignalingstatechange结合网络类型通过navigator.connection来动态调整视频分辨率和帧率。4.2 编解码器选择 (VP8 vs H.264)VP8 开源、免专利费所有支持 WebRTC 的浏览器和平台都兼容。在弱网下的抗丢包能力通过误码隐消通常认为比 H.264 稍好。H.264 硬件编解码支持广泛在移动端能效比高画质在某些场景下更优。但涉及专利且 iOS 的 Safari 对 WebRTC 的 H.264 支持有特定要求。建议为了最大兼容性尤其是覆盖 iOS 设备优先使用 VP8。可以在创建 Offer/Answer 时指定优先级const offerOptions { offerToReceiveAudio: true, offerToReceiveVideo: true }; // 在较新版本的浏览器中可以通过 transceiver 设置编解码器偏好 const pc new RTCPeerConnection(config); if (addTransceiver in pc) { pc.addTransceiver(video, { direction: sendrecv }); // 后续可以通过 getTransceivers 来设置 codecPreferences }4.3 弱网处理方案 (PLI/NACK)WebRTC 使用 RTP 控制协议来保证质量。两个关键机制NACK 接收端发现丢包后请求发送端重传特定包。默认开启对延迟敏感。PLI 接收端请求发送端发送一个关键帧I帧用于快速恢复画面比如刚加入房间或严重丢包后。PLI 通常也默认开启。我们需要确保信令 SDP 中协商启用了这些反馈机制。在创建 PeerConnection 时现代浏览器默认会协商使用它们。如果效果不佳可以检查 SDP 中是否有artcp-fb:... nack和artcp-fb:... pli的行。5. 生产环境 Checklist功能上线前请对照这个清单检查TURN 服务器部署STUN 服务器只能解决部分 NAT 穿透问题。对于对称型 NAT 或防火墙严格的网络必须部署 TURN 服务器作为数据中继。可以使用开源的coturn项目。部署后将urls、username、credential正确配置到iceServers中。注意TURN 服务器会消耗大量带宽是核心成本点。安卓 Chrome 84 版本特殊处理从该版本起unified-plan成为默认 SDP 格式我们代码已按此编写但需要确保不再使用已废弃的addStream方法而是使用addTrack。同时注意getStats()API 的变化。信令消息加密我们示例中的信令服务器是明文的。生产环境必须使用WSS(WebSocket Secure) 和HTTPS。此外可以对传输的 SDP 和 Candidate 消息进行端到端加密例如使用简单的对称加密防止信令服务器被攻破导致信息泄露。关键参数安全阈值iceConnectionTimeout: 建议设置 10-15 秒超时后触发重连或失败回调。iceCandidatePoolSize: 默认为 0在复杂网络下可以设置为 1以提前收集更多候选地址但会增加连接建立时间。bundlePolicy: 设置为max-bundle以鼓励复用 ICE 通道减少资源占用。rtcpMuxPolicy: 设置为require强制复用 RTP/RTCP 通道。6. 扩展思考最后留下两个更深入的问题供大家探讨和实践如何实现 1000 人以上的大规模直播这时点对点Mesh架构就不合适了。需要引入SFU或MCU服务器。SFU 将主播的单路流分别转发给每个观众适合大规模一对多直播。可以在服务端部署开源的 SFU如mediasoup,janus-gateway游戏客户端作为纯接收端连接 SFU。怎样通过 SIMD 优化视频前处理如果游戏需要对采集的视频做美颜、滤镜等处理这些像素级操作计算量很大。在支持 WebAssembly SIMD 的浏览器中可以使用 C/Rust 编写处理算法编译成 WASM 模块利用 SIMD 指令进行并行加速再在 Cocos 中调用能显著提升处理速度降低 CPU 占用。整个集成过程大概花了2-3天其中大部分时间在调试移动端兼容性和优化弱网表现。虽然自己搭建有一定门槛但完成后对 WebRTC 的理解和掌控力是使用第三方 SDK 无法比拟的。希望这篇笔记能为你铺平道路。