Skip to content

在浏览器里造开放世界,第 23 部分:五十个 avatar 和房间里的一个声音

作者:Oleg Sidorkin,Cinevva CTO 和联合创始人

刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。

第 22 部分给世界罩上了一片天空。这一部分把人放进去。一个第三人称开放世界想要任意时刻有 50 多个角色可见,还想要听见站在你旁边的那些人。Spike 45 是渲染那一侧:把这么多带动画的 avatar 弄上 GPU 而不烧坏主线程。Spike 46 是音频那一侧:点对点的语音,随位置做声相和衰减,调到听起来像一通普通视频通话,而不是技术 demo。

给五十个舞者一个 draw call

在新标签页打开 Spike 45 ↗ · 看源码

默认的 Three.js 路径给每个角色一个自己的 SkinnedMesh、一个自己的 AnimationMixer、一次自己的骨骼矩阵上传、一个自己的 draw call。在本地 Mac 上 50 个 avatar 时,那在 GPU 干一件事之前就是每帧约 13 毫秒的纯 JavaScript 开销。整条多人游戏路线的去风险问题是:一套批量 skinning 架构能不能把这个控制住、并随角色数线性扩展。

答案是在"动画在哪儿计算"和"它在哪儿被画出来"之间做拆分。三个角色类共用一个 FBX 模板。本地玩家是一个普通的 Avatar,一个带自己 mixer 的完整骨架克隆,走标准的 Three.js 路径,因为它只会有一个。每个远端 peer 是一个 VirtualSkeleton:同样是带自己 mixer、跑同样片段的完整克隆,但每个 SkinnedMesh 节点在克隆后立刻被剥掉,只剩骨骼存活。它从不进入场景。每帧,在 mixer 更新、矩阵落定之后,它把全部 100 根骨骼的 (bone.matrixWorld × boneInverse) 打包进一个共享 Float32Array 的一个槽位。BatchSkinnedRenderer 随后为每个几何片拥有一个 InstancedMesh,全都从一个尺寸为 maxInstances × numBones × mat4 的单一 StorageBufferAttribute 读取骨骼矩阵,也就是 60 × 100 × 64 = 384 KB。一个带自定义 positionNodenormalNodeMeshStandardNodeMaterial 直接从那个 storage buffer 里读每个顶点的四个骨骼影响。结果是整个人群每个几何片只有一次 storage 上传和一个 draw call,不管里面有多少人。skinning 住在顶点着色器里,逐 avatar 的 JavaScript 开销降到跑一个 mixer 和复制 100 个矩阵。

测量这件事的 HUD 也得重建。旧版本在整帧 GPU 时间越过 3 毫秒时标记"超预算",但帧里总是包含 shadow map、地面、本地玩家完整的 skinned mesh,这些在真实硬件上加起来跑 3 到 5 毫秒,不管存在多少个合成 peer。修复办法是一个自我校准的预算:在没有批量 avatar 时,它通过一个快速 EMA 捕获实时 GPU 时间作为基线,然后冻结那个基线,一旦合成体出现就以每加一个 avatar 0.06 毫秒线性增长预算。它在每台机器空闲时都读 PASS,并随人群增长按比例收紧。

bug 是一张你看不见的脸

在 Chrome 里首次运行时显示地面上有银色阴影、根本没有 avatar,还带一个 WGSL 解析错误:cannot index type 'f32',出在一行试图下标访问 object.nodeUniform2[i] 的地方,而那个 uniform 被声明为标量。这个故事诚实的部分是:第一次修复是错的,却照样起作用了。当时的猜测是 InstancedMesh 的实例矩阵路径在生成那段坏代码,换上一个 StorageInstancedBufferAttribute 让错误在 Chrome 里消失了。但它消失是因为新路径发出了不同的着色器代码,而不是因为它解决了起因,这是最危险的那种修复。

真正的罪魁是 morph target。3MIKE.fbx 自带 blend-shape 面部表情,克隆的几何继承了 morphAttributes,而 Three.js 的 MorphNode.setup()morphTargetInfluences 声明为标量 float,然后在一个合成出来的循环里试图对它 .element(i),这正是编译器拒绝的那个标量下标。修复是一行:在不使用 morph 的几何上清掉 geometry.morphAttributes = {},这样 Three.js 就根本不会注入 MorphNode。那个偶然的 Chrome 修复留了一阵子,然后反咬一口:在 Safari 上,storage-instanced 路径产出了 256 次 Vertex buffer is not big enough,因为 Safari 的 WebGPU 后端没法干净地翻译它。把它回退是对的决定,而普通的骨骼矩阵 storage buffer,作为核心 WebGPU 而非一条生成出来的实例路径,处处都好用。教训值得记住:当一个修复在一个浏览器上起作用、而你解释不了它的机制时,你就只是给一个症状打了补丁,所以去读实际生成的 WGSL。spike 后来加的一个 getCompilationInfo() 垫片,把 Three.js 笼统的"module is not valid"变成了真正的 Tint 错误,反复物超所值。

紧挨着它的是一个相关的"绕过框架"技巧。Three.js 检测到标准的 skinIndexskinWeight 属性名,会试图注入它自己的 SkinningNode,哪怕在一个自定义 positionNode 已经做了 skinning 的 InstancedMesh 上也一样。把那些属性改名为 boneIndexboneWeight 就把它们从框架眼前藏起来了,自定义 TSL 在新名字下读取它们。

一个在话语之间就把你忘掉的中继

第一版通过 BroadcastChannel 同步 peer,这是一个同浏览器的替身,带着真实的线格式和节奏,协议注释承诺换成真实传输只需一行。兑现这个承诺意味着一个 AvatarRoomDO,一个 74 行的 Cloudflare Durable Object,它甚至不解码那 36 字节的二进制帧。它把每条消息原样转发给房间里其他每个 peer,因为发送者的 id 嵌在帧里,每个接收者在客户端过滤掉自己的回声。这个中继对身份毫无觉察。Hibernating WebSocket 让一个空闲房间免费:DO 在消息之间从内存里掉出去,运行时在下一个数据包时恢复那些打了标签的套接字。在每 peer 每秒 10 个事件下,那是每 peer 每小时 36000 次 DO 请求,约半美分,Cloudflare 上出口免费,大致比等价的 AWS WebSocket 形态便宜 6 到 10 倍。

这次替换浮现出一个值得记住的状态机 bug。一个远端玩家在停下之后还在走。动画请求是针对 this._state(当前正在播放的片段)做守卫的,而不是针对最后入队的名字,所以当两条网络消息在同一 tick 到达时,先 walkidle,那个 idle 拿一个还没推进的状态去比,就被静悄悄丢掉了。这个 peer 永远卡在走路,因为未来的 idle 包在上游被当成未改变而去重了。修复办法是总是覆盖待定的名字,让转换辅助函数对真正的同状态请求做短路,它本来就已经会这么做了。这类 bug 是普遍的:针对错误的参照值做去重检查,会悄悄吞掉那条要紧的输入。

Safari 还需要两个额外的守卫。它打开 WebSocket 比 Chrome 快,所以第一条入站 peer 消息可能在批量渲染器构造完成之前就到达,解引用了 null;在渲染器缺席时丢掉消息是安全的,因为 peer 每 100 毫秒重广播一次。还有,'gpu' in navigator 返回 true 而 requestAdapter() 返回 null,于是 Three.js 静悄悄回退到 WebGL2,那里 storage-buffer skinning 链路没有有效翻译,喷了一堆错误。检查真有一个 adapter、并断言后端确实是 WebGPU,能把一个降级的渲染变成一条清晰的加载屏消息。甚至还有一个 WGSL 方言缺口:Three.js 发出现代的双参数 @interpolate(flat, either),而 WebKit 的编译器还没出货它,靠在进入 createShaderModule 的路上重写着色器源、丢掉第二个参数来打补丁,这是免费的,因为 flat 插值无论如何在每个顶点上都携带相同的值。

随房间做声相的语音

在新标签页打开 Spike 46 ↗ · 看源码

Spike 46 是距离语音:带 HRTF 空间音频的点对点 WebRTC,明确限定为在一个安静到中等吵闹的房间里匹配 Google Meet 和 Microsoft Teams 的质量。一个 VoiceRoomDO 把信令当作一个 JSON 中继来处理,给每个新 peer 发一份名册,宣布加入和离开,按套接字标签把 SDP 和 ICE 路由给某个特定 peer,并广播驱动空间声相器的位置更新。它在每条消息上盖上发送者 id,让 peer 无法互相假冒,而音频本身从不碰 DO。每个远端 peer 一个 RTCPeerConnection,由字典序较小的 peer id 总是发起 offer,让两边在不实现完整完美协商的情况下就谁发起达成一致。

在接收侧,每个 peer 的音频走过一个设为 HRTF、带反距离衰减的 PannerNodeAudioListener 每帧从本地玩家的位置和朝向更新,用 forwardX = sin(facing)forwardZ = cos(facing),这与场景的 atan2(wx, wz) 朝向约定相匹配。有个 Chrome 的怪癖花了一小时:一个只被 Web Audio 消费的 MediaStream 有时不会拉取数据包,所以每条流还附到一个隐藏的静音 <audio> 元素上,逼解码器去调度。在质量侧,浏览器默认大约 32 kbps 单声道 Opus,所以这个 spike 在每个 offer 和 answer 上篡改 fmtp 行,把它顶到 128 kbps、启用带内 FEC、禁用 DTX,然后用一个高最大码率调用 setParameters,以保证编码器真的用上 SDP 通告的东西。FEC 是仅次于码率提升的、第二大的可听收益,它在不重新协商的情况下从丢包中恢复。

一路删到干净的音频

最终出货的音频链路比我一开始的小得多,而把它缩小才是真正的教训。第一版有一个高通滤波器、一个调来抓键盘噪声的咔哒限制器、一个压缩器、一个噪声门、以及一个干湿交叉淡变,前面挂着一个带十二个以上滑块的浮动面板。当用户报告可听见的键盘咔哒声时,本能是把咔哒限制器调得更狠、把干声混音拉低,一堆创可贴。结构性的答案是:一旦链路里有了一个 ML 降噪器,咔哒限制器、噪声门和大部分高通就全都多余了,因为 RNNoise 恰恰是在键盘、鼠标和打字噪声上训练的,而幅度削波是同一份工作的一个严格更差的版本。生产客户端出货 ML 降噪、回声消除、自动增益、和一个用于电平的软压缩器,别无其他。于是四个阶段被拿掉了,滑块面板被拿掉了,"选你的降噪"开关也被拿掉了,留下一条固定的管线。

每个幸存的阶段都挣得了它的位置。浏览器回声消除保持开着,因为 RNNoise 不做回声,没有它,扬声器进麦克风的反馈是无界的。浏览器噪声抑制关掉,因为把它叠在 RNNoise 上会在摩擦音上产生瑕疵,所以你只挑一个降噪器。浏览器自动增益保持开着,因为把它关掉会让信号太弱、压缩器没法工作,而 Web Audio 的 DynamicsCompressorNode 没有补偿增益参数来弥补;浏览器宽泛的电平调整和 spike 的快压缩器作用在不同的时间尺度上、可以共存。RNNoise 跑在 92% 湿声混 8% 干声,因为它可能过度抑制清辅音,比如 s、sh 和 f,这些音的浊音概率会下降,而那一小条干声路径以一点点按键泄漏为代价把它们保留下来。

两个特性把它收尾。Push-to-talk 不去翻转 track.enabled,因为那会丢掉管线缓冲区里还在的一切、在松键时砍掉最后一个音节。改成在链路尾部附近用一个 GainNodesetTargetAtTime 做斜变,快起音让第一个音节活下来、慢释放让最后一个辅音排空,轨道永久保持启用。还有一个五秒的广播延迟,作为电台风格的特性提出,它跑一条旁路支路和一条 DelayNode 支路交叉淡变在一起,配一个"倒掉"按钮把延迟输出瞬间切到静音、并在音频恢复前在 HUD 上倒计时。把降噪器打包是它自己的一段小插曲:已发布的 RNNoise worklet 用了没有 CDN 能解析的裸说明符导入,所以修复办法是用一个本地 esbuild 打包,产出一个自包含的 1.9 MB 文件、WASM 以 base64 内联,提交进仓库,并用一个相对于模块的 URL 引用,让它在开发服务器、VitePress 构建和自定义域名下都能解析。万一这个 worklet 加载失败,链路仍会通过一个普通高通和压缩器产出音频,HUD 用红色显示这次失败。

本章涉及的技术

给人群做批量 GPU skinning。 远端 avatar 跑一个无头的 VirtualSkeleton(一个剥掉 skinned mesh、保留骨骼、带自己 mixer 的完整克隆),把每根骨骼的 bone.matrixWorld × boneInverse 打包进一个共享 StorageBufferAttribute。每个几何片一个 InstancedMesh,在自定义 TSL 的 positionNode/normalNode 里读那些矩阵,于是整个人群每片只花一次 storage 上传和一个 draw call,逐 avatar 的 CPU 工作只剩一次 mixer 更新和一次矩阵复制。见 GPU 驱动的 LOD

读生成的 WGSL,而非症状。 一个 cannot index type 'f32' 的编译错误追溯到 Three.js 的 MorphNodemorphTargetInfluences 声明为标量并对它下标,靠在不使用 morph 的几何上清掉 morphAttributes 修复。一个只是改变了生成哪条着色器路径的初次修复掩盖了起因,并在之后弄坏了 Safari。把 skinIndex/skinWeight 改名为 boneIndex/boneWeight 让属性从 Three.js 自动注入的 SkinningNode 眼前藏起来,由一个自定义 skinning 材质拥有这套数学。

Hibernating Durable Object 中继。 一个纯二进制的 AvatarRoomDO 把 36 字节帧转发给其他每个 peer 而不解码它们,发送者身份嵌在帧里、自回声在客户端过滤。Hibernating WebSocket 让一个空闲房间免费,在 10 Hz 下这个形态约每 peer 每小时半美分,远低于等价的托管 WebSocket 定价。一个针对正在播放的动画状态、而非最后入队状态做比较的去重守卫,悄悄丢掉了停止消息,把远端玩家卡在走路循环里。

带 HRTF 的 WebRTC 距离语音。 每个 peer 一个 RTCPeerConnection,offer/answer 角色由 peer-id 排序决定,音频走过一个 HRTF 的 PannerNode、配一个每帧从玩家朝向更新的 AudioListener,Opus 被篡改到 128 kbps 配带内 FEC 以求韧性。一个静音的隐藏 <audio> 元素逼 Chrome 从一条仅 Web Audio 的流里拉取数据包。

做减法的音频工程。 匹配 Meet/Teams 的质量意味着移除阶段、而非增加它们:ML 降噪加回声消除加自动增益加一个软压缩器,没有噪声门、没有咔哒限制器,因为一个在键盘噪声上训练的 ML 降噪器让幅度削波变得多余。Push-to-talk 用一个非对称包络给尾部 GainNode 做斜变、而非翻转轨道,这样音节不会被砍掉,而降噪器 worklet 作为单个自包含的 esbuild 包出货,以躲开裸说明符的导入解析。


第 23 部分,共 29 部分。 上一篇:第 22 部分 - 能打光的云,和必须喂饱的剔除 下一篇:第 24 部分 - 保存一个世界,和你能看见的风 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide