ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

零依赖WebRTC小游戏引擎:P2P联机实现与工程实践

零依赖WebRTC小游戏引擎:P2P联机实现与工程实践 1. 先说说我为什么要做这个“零依赖”的 WebRTC 小游戏引擎1.1 从项目背景到核心目标我一直有个执念网页小游戏的工程天花板不该被“用框架”这件事锁死。做了几年 H5 游戏和互动营销页我越来越觉得 Phaser、PixiJS 这类库很好用但它们把你绑在了一套“别人定义好的运行时”里。团队换人、版本升级、包体膨胀每一件事都在消耗本应用来打磨玩法的精力。所以当我在想下一款多人网页游戏怎么做时我给自己立了一个规矩除了浏览器自带的 API什么第三方运行时都不依赖。于是就有了 OmniGame 这个项目——一个零依赖的网页小游戏运行时底层直接使用浏览器原生能力用 WebRTC 数据通道做 P2P 联机。零依赖不是装酷它解决的是非常现实的问题。首先包体可以压缩到极致整个框架加示例游戏做到 30KB gzip 以内没有任何问题首屏加载速度比任何带引擎的都快。其次你完全掌控内存对象、渲染队列和网络逻辑不会被框架内部的“魔法反射”影响性能分析。最后零依赖意味着它可以在任何支持标准 Web API 的环境下运行包括一些审核严格、不允许加载第三方脚本的场景。OmniGame 的目标很明确用 canvas 2D 加 requestAnimationFrame 做渲染用原生 WebSocket 做信令用 RTCPeerConnection 加 RTCDataChannel 做游戏实体数据的 P2P 传输彻底绕开“每人一台多人游戏服务器”的运维成本。适合谁看如果你正在做派对类小游戏、双人对抗游戏、或者想给网页小游戏加一个无需后端的多人对战层这篇文章能帮你少走大半年弯路。1.2 为什么选 WebRTC P2P 而不是传统服务器中转很多做网页多人游戏的团队第一反应都是“客户端连服务器服务器广播”。这套中心化模型很成熟但它在小游戏场景里有两个硬伤。一是服务器成本太高一个 8 人房间每秒要转发十几条状态消息月活起来之后流量费用相当可观。二是服务器中转必然引入额外的延迟哪怕你的服务器放在 BGP 机房玩家与玩家之间的物理距离问题也没法消除。而 WebRTC P2P 的天然优势在于数据直接在两个浏览器之间传输不绕远路。你可能会问“P2P 不是还要先经过信令服务器吗”对但信令服务器只在建连阶段交换 SDP 和 ICE 候选连接建立之后游戏数据的传输就再也不经过它了。也就是说信令服务器的带宽成本几乎可以忽略不计。当然WebRTC 也不是银弹极端 NAT 环境下会走 TURN 中继这部分流量仍然有成本但实际项目中 TURN 兜底的使用比例通常低于 5%。把 P2P 作为主路径、中继作为逃生通道工程上完全可行这也是 OmniGame 网络层的核心设计原则。2. 整体架构三个工程层次2.1 游戏循环从 requestAnimationFrame 出发零依赖不代表从零发明轮子而是把轮子做薄。OmniGame 的运行时核心只有三层第一层是游戏循环层。你不用引入任何引擎浏览器的 requestAnimationFrame 本身就是一个完美的游戏主循环它会在每次屏幕刷新前回调你让你更新游戏状态并绘制画面。我把它封装成一个最小调度器支持帧率控制、暂停恢复、按优先级注册 update 与 render 回调。主循环最容易被忽略的点是“更新”和“渲染”必须分离。很多新手喜欢在 update 里直接改 canvas 像素这会导致逻辑计算和绘制相互干扰帧率一旦波动整个逻辑时间轴都是乱的。我在 OmniGame 里把 update 固定为一个 accumulator 模式用 deltaTime 累加每 16.66 毫秒执行一次物理更新渲染则跟随 display 刷新率。这样一来即使某台电脑掉到 30 帧游戏内的时间步长也是稳定的不会出现“跳帧导致穿墙”的低级 bug。渲染层面我直接用 Canvas 2D API。对一个小游戏来说Canvas 2D 的性能完全够用哪怕是 60 个带阴影的 sprite 同时运动现代浏览器也能轻松扛住。我做了个非常轻量的渲染对象池所有精灵对象创建时从池里取销毁时还回去避免频繁 GC 导致帧率抖动。这一层代码加起来不到 200 行但替换掉一个完整渲染引擎的体量。// OmniGame 主循环的最小实现 class GameLoop { private accumulator 0; private lastTime 0; private rafId 0; constructor( private fixedStep 16.666, // 60Hz 逻辑步长 private update: (dt: number) void, private render: () void ) {} start() { this.lastTime performance.now(); this.loop(this.lastTime); } private loop (now: number) { const delta now - this.lastTime; this.lastTime now; this.accumulator Math.min(delta, 100); while (this.accumulator this.fixedStep) { this.update(this.fixedStep / 1000); this.accumulator - this.fixedStep; } this.render(); this.rafId requestAnimationFrame(this.loop); }; }2.2 P2P 网络层PeerConnection 的封装思路第二层是网络层这是 OmniGame 的灵魂。我把它设计成一个独立的 NetworkPeer 类内部维护一个 RTCPeerConnection 实例和若干 RTCDataChannel。对外只暴露 connect()、send(type, payload)、on(type, handler) 三个接口上层游戏代码根本不需要知道 WebRTC 的具体细节。为了做到零依赖我没有使用任何现成的 WebRTC 封装库而是直接调用浏览器原生接口。PeerConnection 的创建有几个关键参数必须调对。iceTransportPolicy 设为 all 而不是 relay保证在能直连的情况下绝不主动走中继。iceCandidatePoolSize 设成 10可以减少 ICE 候选收集过程中的等待时间。bundlePolicy 用 max-bundle把所有媒体传输合并到一个传输通道上降低握手复杂度。另外一个经常踩坑的点是如果你只需要数据通道而不传音视频记得使用 RTCPeerConnection 构造函数的第二个参数不要把音频和视频的 transceiver 初始化出来否则浏览器会请求麦克风和摄像头权限直接吓跑玩家。数据通道方面我在 OmniGame 里默认创建两条通道。一条用 reliable 模式传送关键状态消息比如加入房间、玩家离开、游戏开始这些事件保证不丢不重。另一条用 unreliable 且 unordered 模式传高频移动数据牺牲可靠性换取低延迟。后面我会展开讲这两个通道如何配合使用。// 创建 PeerConnection 的骨架 const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 }, { urls: stun:stun1.l.google.com:19302 }, ], iceCandidatePoolSize: 10, bundlePolicy: max-bundle, }); // 正式游戏数据通道 const reliableChat pc.createDataChannel(events, { ordered: true, }); const fastChannel pc.createDataChannel(motion, { ordered: false, maxRetransmits: 0, });2.3 信令再小的 P2P 也绕不开的“中间人”第三层是信令层。你必须清醒地认识到一个事实P2P 不是一个自举的网络协议两个浏览器之前没有任何“直接发现对方”的机制。它们需要一个中间人互传 SDP 提案、SDP 应答和 ICE 候选。这个中间人就是信令服务器。OmniGame 的信令服务我用 Node.js 原生模块实现没有引入 Socket.IO 或 ws 库只用了 Node 自带的 http 模块加手动处理 WebSocket 握手。为什么不用现成的 Socket.IO因为零依赖原则要求我把每一行代码都握在自己手里。Node 原生 WebSocket 握手不算复杂只要正确计算 Sec-WebSocket-Accept 的 SHA-1 哈希再按帧格式解析文本消息即可。这个信令服务器的核心逻辑其实只有两件事维护房间成员表、把消息路由给房间内指定用户。整个实现大约 300 行部署时一个 node server.js 就能跑起来。信令消息我用 JSON 格式定义了 join、offer、answer、ice、leave 五种类型。join 时客户端携带房间号服务端返回房间内已有用户的 id 列表offer 方创建 PeerConnection 后把自己的 SDP 发给目标用户answer 方收到后回传自己的 SDPICE 候选则是在整个建连过程中双向实时转发。这个流程很像两个人在互相递纸条纸条上写的是“我能听到你你能听到我吗”。3. 核心实现信令、数据通道与同步策略3.1 信令流程代码实现我先把信令服务的核心代码放出来你可以直接用 Node.js 跑。这个实现省略了房间清理和断线重连等边界逻辑但核心流程是完整可运行的。// 一个极简的 P2P 信令服务器Node.js 原生实现 const http require(http); const crypto require(crypto); const clients new Map(); // userId - { socket, roomId } function wsAccept(key) { const hash crypto.createHash(sha1) .update(key 258EAFA5-E914-47DA-95CA-C5AB0DC85B11) .digest(base64); return HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${hash}\r\n\r\n; } function decodeWsFrame(buf) { const lenByte buf[1] 0x7f; let offset 2; if (lenByte 126) offset 2; else if (lenByte 127) offset 8; const mask buf.slice(offset, offset 4); offset 4; const payload Buffer.alloc(buf.length - offset); for (let i 0; i payload.length; i) { payload[i] buf[offset i] ^ mask[i % 4]; } return payload.toString(); } const server http.createServer((req, res) { res.writeHead(200, { Content-Type: text/plain }); res.end(OmniGame signaling server is running); }); server.on(upgrade, (req, socket) { const key req.headers[sec-websocket-key]; socket.write(wsAccept(key)); let userId null; socket.on(data, (buf) { const msg JSON.parse(decodeWsFrame(buf)); if (msg.type join) { userId crypto.randomUUID(); clients.set(userId, { socket, roomId: msg.roomId }); const roomMembers [...clients.entries()] .filter(([, v]) v.roomId msg.roomId) .map(([id]) id) .filter(id id ! userId); send(socket, { type: joined, userId, members: roomMembers }); roomMembers.forEach(memberId { send(clients.get(memberId).socket, { type: newcomer, userId, }); }); } else if (msg.type offer || msg.type answer || msg.type ice) { // 路由给指定用户 const target clients.get(msg.to); if (target) send(target.socket, { ...msg, from: userId }); } else if (msg.type leave) { clients.delete(userId); } }); socket.on(close, () { if (userId) clients.delete(userId); }); }); function send(socket, obj) { const payload Buffer.from(JSON.stringify(obj)); const header Buffer.alloc(2 (payload.length 125 ? 2 : 0)); header[0] 0x81; // FIN text frame if (payload.length 125) { header[1] payload.length; socket.write(Buffer.concat([header, payload])); } else { header[1] 126; header.writeUInt16BE(payload.length, 2); socket.write(Buffer.concat([header, payload])); } } server.listen(8080, () console.log(Signaling on :8080));这段代码最核心的是帧编解码WebSocket 的文本帧有一个 4 字节掩码客户端发送的数据必须用掩码解码服务端回包则不需要掩码。如果你用现成库做信令完全不用关心这些细节但自己实现一遍之后你才真正理解 WebSocket 协议并不是什么黑魔法它只是在 TCP 上套了一层轻量分帧。连这一步都敢自己造后面的零依赖游戏引擎就不再有心理障碍了。客户端侧的信令封装也不复杂。join 之后如果是第一个进房间的人就等待 newcomer如果是第二个就向 newcomer 发起 offer。谁先邀请谁没有绝对规定双方都尝试主动连接就可能导致重复所以我在实现里固定为“房间内先到者等待后到者发起”。这个规则简单、无歧义也不需要额外的状态机。3.2 数据通道的两种模式选择WebRTC 数据通道的可靠性配置很多人一上来就懵。你要理解它本质上是 SCTP over DTLS与 TCP 和 UDP 都不完全一样。RTCDataChannel 提供了几种可靠性模式关键是 ordered、maxRetransmits、maxPacketLifeTime 三个参数。ordered 为 true 表示消息要按发送顺序到达接收端牺牲延迟保顺序适合事件类消息。ordered 为 false 配合 maxRetransmits 设为 0就是纯 fire-and-forget 模式消息丢了就丢了不重传也不等顺序这是实时候移动数据的理想选择。还有一种中间形态是把 maxRetransmits 设成 2 或 3允许轻量重传但不保证有序适合既希望减少丢包、又不想被重传阻塞后续数据的场景。我实测下来对 60Hz 的玩家操作数据流maxRetransmits: 0 的效果最稳。因为位置数据每帧都会刷新丢了一帧下一帧马上覆盖重传旧数据反而造成“瞬移回退”。事件通道用 ordered: true 保底虽然可能偶尔延迟几十毫秒但玩家加入、确认开局这类消息绝不能错序。两条通道并行时实际各走各的互不阻塞这是 WebRTC 多数据通道设计里最令人舒心的一点。3.3 帧同步与状态同步如何取舍做 P2P 多人游戏你必须先回答一个问题同步什么两种主流方案是帧同步和状态同步。帧同步是所有客户端接收同样的输入序列各自运行同样的逻辑结果自然一致。状态同步是一个客户端通常是房主计算最终状态把状态广播给其他人。帧同步的思路很诱人因为它网络开销极小每个人只发自己的操作指令。当年很多老平台对战游戏都是这么做的。但帧同步有一个前提条件所有客户端的逻辑必须完全确定。浮点数在不同浏览器里可能因为 JIT 优化产生细微差异Math.random 也不能用所有随机都要用可复现的伪随机序列。这个条件在小游戏里其实很难保证尤其当你用到一些原生 API 时行为并不跨浏览器统一。我最终在 OmniGame 里选用的是“房主权威 客户端预测”的混合状态同步方案。房主作为逻辑服务器每 100ms 广播一次所有实体的权威位置与状态客户端在两次广播之间本地预测输入效果收到权威帧时做误差修正。这个方案的网络模型仍然是 P2P 的——房主不过是一台普通浏览器只是逻辑上承担了权威职责。对 2 到 8 人的小游戏来说状态同步的带宽增长是 O(n)在 n 很小时完全可接受而且你能得到一个巨大的好处任意客户端都可以成为房主谁开房谁当服务器不参与任何游戏逻辑。4. 性能优化把工程上限往上推的关键4.1 网络包设计从 JSON 到二进制信令阶段用 JSON 没问题但游戏数据通道继续用 JSON 就浪费了。我做过一个很直接的对比一个玩家位置消息用 JSON 表示是{x:123.45,y:67.89,vx:1.2,vy:0.3,act:1}大约 45 字节用二进制协议只需要 9 字节。在 8 人房间每 1/30 秒同步一次的场景下JSON 方案每秒产生约 10KB 流量二进制方案只有 2KB。我在 OmniGame 里定义了一套极简二进制协议。前 4 字节是消息头包括 8 位消息类型、8 位玩家 id、16 位序列号。后面按类型跟不同字段。移动数据包用 16 位半精度浮点存 x 和 y速度和动作标志位进一步压缩。半精度浮点用Math.fround转成 32 位浮点再手动缩放到 16 位范围。这里的精度虽然不如单精度但在屏幕坐标系下误差小于 0.01 像素肉眼完全看不出来。// 玩家移动包的编码与解码示意 function encodeMove(playerId, seq, x, y, vx, vy, act) { const buf new DataView(new ArrayBuffer(11)); buf.setUint8(0, 0x01); // type: MOVE buf.setUint8(1, playerId); buf.setUint16(2, seq); // 序列号 buf.setInt16(4, Math.round(x * 32), true); // 精度 1/32 像素 buf.setInt16(6, Math.round(y * 32), true); buf.setInt8(8, clamp(vx, -127, 127)); buf.setInt8(9, clamp(vy, -127, 127)); buf.setUint8(10, act); return buf; }如果你不想处理二进制协议也可以退一步用“按索引拼字符串”比如01|3|1024|768|12|34|1再配合字符串压缩。性能比 JSON 好调试起来也更方便。但从工程上限的角度看二进制是最终形态而且它带来的带宽节省是实实在在的尤其是在手机上跑时弱网场景多省一字节就多一分流畅。4.2 渲染层优化Canvas 2D 的性能优化有几个老生常谈但极其重要的点。首先是避免在渲染循环里创建对象每次都新建一个路径对象或渐变对象会导致 GC 频繁触发表现为帧率周期性卡顿。我在 OmniGame 里把所有样式对象在初始化阶段创建好循环里只改变已有对象的属性。其次是减少状态切换。canvas 上下文是一个大状态机fillStyle、strokeStyle、shadow 等每次改变都会清空内部缓存。我按“先绘制所有同类型实体再切换状态”的顺序组织渲染批次而不是每画一个实体就设置一次样式。对于粒子、子弹这类大量小物体这个方法能把 draw call 状态切换成本降一半以上。第三是合理使用离屏 canvas。如果需要频繁绘制相同的纹理先把纹理画到一个离屏 canvas 上再通过 drawImage 快速贴图。爆炸特效、弹痕、拖尾光效这些用离屏预渲染能大幅减轻主线程负担。我甚至把一个半径为 80 的圆形阴影预渲染成了离屏纹理实际绘制时只做一次 drawImage替代了 20 次渐变填充计算。4.3 游戏体量控制追求零依赖必然带来一个附带收益体积控制。OmniGame 的完整运行时加示例游戏最终打包后 gzip 在 28KB 左右。这里没有魔法就是把模块拆细按需加载每个模块本身够薄而且坚决不引入 polyfill。现代浏览器统一支持 WebRTC、Canvas、WebSocket就没有为老浏览器兜底的负担。控制体积的过程实际上是一个持续的纪律训练。每次想引入一个小工具函数时先问自己“这个函数真的要 30 行吗”大多数时候答案是不用。零依赖的框架要求你亲手写每一个函数你会格外珍惜代码行数而这种珍惜最终让整个项目保持在一个非常健康的状态没有死代码没有隐藏依赖任何时候打开项目都能完全看明白它在干什么。5. 真实环境中的问题排查实录5.1 房间连不上的常见原因做 WebRTC 最容易遇到的坑不是代码本身而是“为什么我就是连不上”。我总结出三类高频原因。第一类是 STUN 服务不可达。国内环境访问某些公共 STUN 服务器经常超时表现在 ICE 候选收集阶段卡很久最后连不上。我的处理方式是在配置里同时放三个 STUN 地址包括 Google 公共 STUN 和两个自建 STUN并把超时时间设置到 3 秒以上。第二类是信令消息顺序问题。offer 比 join 先到、answer 比 offer 先到这些时序问题在开发环境很难复现因为本地网络延迟低消息到达顺序基本稳定。但到真实网络消息可能乱序。我的方案是在对端加入房间后再允许发 offer并且客户端收到 offer 时检查本地是否已有 peer如果没有就先创建再处理 SDP确保状态机始终健壮。第三类是 TURN 缺失导致对称 NAT 下无法直连。公共 STUN 只能帮你在大部分 NAT 下发现公网映射遇到对称 NAT 时直连必然失败。OmniGame 的兜底方案是提供一个可选的自建 TURN 服务配置为 coturn建连失败时自动切换到 TURN。这里要再次强调TURN 只兜底正常情况流量根本不会经过它所以即使你的服务器带宽只有 1Mbps也足够服务几十个同时在线房间的极端兜底场景。5.2 低带宽下的抖动与卡顿即便连接建立成功真实网络的抖动也会让你怀疑人生。移动端用户在 WiFi 和蜂窝网络之间切换时会出现几十毫秒到几百毫秒的突发延迟。这种场景下状态同步方案的客户端预测机制就是救命的。我在每个客户端维护了一个 200ms 的输入缓冲发送操作指令时同时写入本地预测队列房主广播权威帧后客户端根据权威帧修正预测误差。另一个关键调整是“可变同步频率”。房主在广播时动态评估最近 2 秒的平均 RTT 和丢包率如果网络质量好就按 30Hz 广播变差则降到 15Hz同时提高客户端预测权重。这个自适应机制虽然只有几十行代码但它在弱网环境下的流畅度提升非常明显。固定同步率在弱网下要么频繁超时要么缓冲区见底自适应方案总能找到当前网络能承受的极限频率。还有一个小技巧不要在 RTCDataChannel 上用超高频发送“我是谁我在哪”这种冗余信息。把多个玩家的移动包攒起来批量发每包带一个目标玩家位图空位不发。房主广播时合并 8 个玩家的状态到一个包消息总量从 8 个小包变成 1 个稍大的包净吞吐量不到原来的一半网络空包数量大幅下降对 Wi-Fi 休眠唤醒也更友好。5.3 我的几招调试技巧WebRTC 调试最实用的工具是 chrome://webrtc-internals。这个页面会记录你所有 PeerConnection 的完整状态包括 ICE 状态机变化、候选对列表、数据通道的字节收发量。当玩家报告连不上时我第一步永远是让他打开这个页面截图发回来。通过查看候选对和连接状态三分钟内就能定位是 STUN 失败、候选收集慢还是数据通道没打开。另一个技巧是在数据通道上做“心跳加 RTT 探测”。每隔 500ms 发一条携带本地时间戳的可靠通道消息对端收到后立即回一条同样带时间戳的消息。这样维护一个滑动窗口的 RTT 估值不仅用于自适应同步频率也能在连接中断前提前预警。心跳连续丢失 5 次就判定连接失效触发自动重连流程。最后强烈建议在开发阶段把 ICE 候选日志打开并且用一个“手动信令模式”方便调试。在手动模式下两个浏览器的 SDP 和 ICE 候选会打印到控制台你可以复制粘贴互传完全不依赖信令服务器。这个模式让你能把问题隔离在“P2P 建连失败”和“信令服务器故障”两个层面避免网络问题和 WebRTC 问题搅在一起排查效率能翻倍。6. 最后想说的从零开始造一个带 WebRTC P2P 的小游戏引擎技术上并不算难但它实实在在重构了我对“网页游戏工程上限”的认知。之前我总觉得多人游戏必须有后端必须有专门的同步服务必须用大而全的引擎才能撑起体面。做完 OmniGame 之后我才意识到对 2 到 8 人的小规模对抗和合作游戏来说一个 30KB 的零依赖运行时加一个不到 300 行的信令服务完完全全够用而且运营成本几乎为零。我个人在实际操作中体会最深的一条原则是工程上限不是由你用了多少技术堆出来的而是由你敢于砍掉多少不必要的依赖决定的。每一次无谓的依赖增加都让代码的可理解性、可部署性和长期可维护性下降一截。是做加法容易的但持续做减法才能真的把一个项目推到它原本摸不到的高度。OmniGame 目前还在持续扩展后续我计划加进 WebTransport 的备选传输层、支持房间迁移的跨信令服务切换以及更完整的断线重连状态机。如果你也在做类似的东西欢迎沿着零依赖的思路走下去你会发现浏览器的原生能力门槛低得惊人而天花板高得离谱。
返回列表