最近在做一个游戏社交功能,需要集成实时语音聊天。作为 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.js:
const 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: Map<string, 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中添加麦克风和摄像头使用描述:<key>NSCameraUsageDescription</key> <string>游戏需要摄像头进行视频聊天</string> <key>NSMicrophoneUsageDescription</key> <string>游戏需要麦克风进行语音聊天</string> - Cocos 引擎内渲染: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 中是否有a=rtcp-fb:... nack和a=rtcp-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 无法比拟的。希望这篇笔记能为你铺平道路。