浏览器里跑多人在线 3D 空间:同步、弱网、语音名额制的工程复盘
浏览器多人在线3D WebSocket实时同步 3D空间多人 弱网重连 位置同步插值 近场语音带宽 Three.js多人在线 AI玩家接入
摘要:浏览器端多人在线 3D 空间,难点从来不是"能不能连上",而是三件事:位置同步的手感、弱网重连之后还能不能互相看见、以及人多之后带宽与 CPU 怎么算账。本文按这三条拆开讲,并附上"把外部 AI 当成一个玩家接进同一条广播链路"的实现思路与 100 个 AI 同场时的实测账。
多人在线的坑,八成不在首次连接,而在第一万次断开重连。
一、先把问题定义清楚:三个真问题
多人 3D 房间的教科书式结构是"客户端发位置、服务器广播给其他人"。真做起来,落到三个具体问题上:
| 问题 | 表面症状 | 实际根因 |
|---|---|---|
| 同步手感 | 别人一卡一卡地跳、像瞬移 | 客户端直接把收到的坐标硬赋值,没有插值与容错 |
| 弱网恢复 | 双方各自掉线后,重连上了却互相看不见 | 服务端/客户端的在场状态没有重建 |
| 成本 | 人一多就卡、语音一开就爆带宽 | 没有把带宽和 CPU 变成可预算、可配置的量 |
第三条在浏览器端尤其明显:CPU 预算在玩家的设备上,你无法通过加服务器解决;带宽预算在你的服务器上,加钱能解决但很贵。所以要分开处理。
二、位置同步:滞后是可接受成本,跳变不是
服务端权威 + 客户端平滑插值,是这一层的标准答案。要点是区分"滞后"和"跳变"——玩家对滞后的容忍度远高于对跳变的容忍度。200ms 的稳定滞后,观感是"他稍微慢一点";一次坐标硬赋值,观感是"他瞬移了"。
我们的取舍是:真人端位置滞后控制在 3 米以内,换来的是移动全程平滑、没有一格一格跳。这个数字不是越小越好——把插值窗口压得太窄,网络抖动会直接传导成画面抖动,反而更难看。
还有一个容易被忽略的细节:AI 与真人的位置更新频率不一样。真人端是浏览器按帧上报,AI 端是服务端按档位推送(见第六节)。这两条流最后要汇进同一个位置表、同一套广播里,否则真人看到 AI 的移动会是另一种节奏。
三、弱网:真正难的是"重连之后还互相看得见"
这是我们在多人体验上花时间最多的一处,也是最容易在 demo 阶段被掩盖的一处——因为 demo 时不会掉线。
现象的经典版本:A、B 两人各掉一次线,各自重连成功,但彼此看不见了,必须双方手动刷新页面才能恢复。根因定位下来是一句话:
重连之后,服务器那边把你当新连接重新登记了,但"我已经在场"这个事实没有重新广播出去。
于是我们做了一层在场状态守护,四个动作:
- 缓存
PLAYER_JOIN事件,每次重连后自动重发——这是核心那一行; - 无限重连(带退避),而不是重试 N 次就放弃;
- 应用层 PING/PONG 看门狗——TCP 层面的连接可能还活着但应用层已经死了,只靠
onclose感知不到; - 服务器侧"未登记连接"告警——用于发现第 1 条没覆盖到的边角情况。
验收判据很直接:重连后对端仍能看到我移动,9/9 通过。
逻辑本身不长,示意如下(示意伪代码,不是项目源码):
// 客户端:把"我在场"这件事缓存下来,每次重连都重播一遍
let pendingJoin = null; // 缓存 PLAYER_JOIN 报文
function onLocalJoin(payload) {
pendingJoin = payload; // 本地生成的进场事件,先存着
}
function onOpen() { // 每次重连成功都走这里
if (pendingJoin) send(pendingJoin); // ← 核心一行:重播在场状态
startPingPong(); // 应用层看门狗
}
function onClose() {
reconnectWithBackoff(); // 不设重试上限,带退避
}
// 服务端:发现"连上了但没登记"的异常连接,用于兜住上面漏掉的分支
setInterval(() => {
for (const c of connections) {
if (!registry.has(c.id)) warn('unregistered connection', c.id);
}
}, 30_000);
另外一类坑是"看得见但会攒着不放":视频 DOM、灯池租用的资源、聊天气泡、僵尸连接。这些在长会话里一个都别留着——我们做过一轮内存审计,修掉四项泄漏。多人场景的内存泄漏是复利:单人的界面泄漏在多人下会被乘上人数。
四、语音:从"整区禁言"到名额制
近场语音(按住说话、30 米内可听)有两种退化设计,都不好:
- 所有人一直开着:30 米内一多,服务器带宽直接失控;
- 人多就整区禁言:简单粗暴,但把功能废掉了——用户感受到的是"这个功能一到人多就不能用"。
我们改成同时说话人数名额制:按住说话走半双工,30 米内中继,带距离衰减,但同时说话的人数是有上限的,上限还能按服务器规格在后台选。
| 同时说话人数 | 带宽量级 | 建议服务器 |
|---|---|---|
| 10 人 | 约 1.3 Mbps | 1 核 2G |
| 20 人 | 约 2.6 Mbps | 2 核 4G |
这个改法的价值在于:把"人多就不可用"换成了"人多就排队"。排队是可接受的,不可用是不可接受的。
还有一条合规上的选择:语音链路服务端零加工——不做语音识别、不做语音合成,音频只是转发(且不落库)。转写这件事交给 AI 客户端去做,归属清晰,也省掉了服务器上最贵的那部分。
五、把外部 AI 接进来:关键是"复用同一条广播链路"
这套世界后来开放了 AI 接入——外部 AI 可以以人形角色进入世界,被真人看到、能走能说。工程上最值得讲的一点不是 AI 本身,而是接入方式的选择:
不给 AI 另开一套可见性系统,而是让它伪装成一个玩家,复用现有的玩家广播。
具体只有一个焊接点:把 AI 的位置写进玩家位置表,然后复用现有的 PLAYER_JOINED / POSITION_UPDATE / CHAT 三条广播。结果是真人端的浏览器几乎零改动就能看到 AI 的 3D 形象——它和另一个真人玩家走的是同一条链路。
好处是双向的:
- 对现网零破坏:专用通道按路径分流(
/ws/agent走 AI,其余一切路径兜底给人类),不动人类链路的任何行为; - 对成本极友好:AI 的模型从不下发给 AI,它的 Avatar 由真人浏览器替它渲染。AI 拿到的是结构化的 JSON 雷达,不是画面、不是截图。
相关的一处产品决策也值得记:门禁是倒过来的。没有 API Key 也能凭域名拿到一张 30 分钟的公开票进场(只拉不推),只有稀缺的"推流"资源才需要 Key。理由很直白——上线初期的风险是没人来,不是被滥用。把门槛设在稀缺资源上,而不是设在"看一看"上。
推流本身也做了分档,因为"每个 AI 都要实时收到世界变化"是没必要的高成本:
| 档位 | 行为 | 成本 |
|---|---|---|
| eco | 纯拉取,不推送 | 0 |
| standard | 每秒聚合一次 | 中 |
| realtime | 10Hz 逐条推送 | 高(实测 9.0Hz/实体、4.5KB/s) |
六、算一次账:100 个 AI 同时在场
这是最能说明架构价值的一组数字。实测场景:100 个游客 AI + 3 个 Key Agent + 2 个真人玩家,同场持续 60 秒。
| 指标 | 实测值 |
|---|---|
| 服务器 CPU | 0.079 核 |
| 服务器内存(RSS) | 92 MB |
| 雷达接口延迟 | P50 7ms / P95 14ms |
| 限流触发 | 0 个 429 |
| 真人端帧率 | 真 GPU 60 FPS |
| 浏览器控制台报错 | 0 |
| 游客连接的推送流量 | 0 消息 / 0 字节(红线反证) |
请注意最后一行的意义:它不是在测性能,是在反证红线真的生效了——游客身份确实一条推送都没收到。安全设计如果不能被反证,就只是文档。
几条内置的硬约束也一起列出来,因为它们解释了成本为什么压得住:游客不接收任何推送、观察半径硬钳 30 米、位置流不消耗令牌桶、背压 1MB 警告 / 4MB 断开、30 秒心跳、空闲踢出、单 Agent 单连接顶替(旧的静默断开,不闪断影响真人)。
七、当前短板(如实说)
- 玩家三档分级渲染还没做。人多时按距离对玩家角色做分级渲染(远处降级/不渲染)是一块明确能省性能的地方,对应的模块当前不在代码里,需要重建。也就是说:人多时的性能目前主要靠模型侧的 LOD 撑着,玩家角色本身还没有分级。
- 位置流的抖动抑制偏朴素。低帧率设备上,角色移动的手感会受影响(源码里
player.js仍是每帧固定增量而非帧率解耦),这在低配手机上是能被感知的。
这两条都是"已知、有方案、未完成",写出来比藏起来好。
八、常见问题
问:浏览器端多人 3D,用 WebSocket 还是 WebRTC?
答:位置这种小消息、需要服务端权威仲裁的,用 WebSocket 更简单可控;语音这类连续媒体流适合走 WebRTC 或专门的媒体链路。我们的语音走的是 30 米内中继 + 半双工,服务端只转发不加工。
问:人多了怎么办,加服务器就行吗?
答:位置同步这部分 CPU 其实很便宜(100 个实体不到 0.1 核),真正吃资源的是语音和渲染。渲染在玩家设备上,你加服务器没用——要做的是模型 LOD 和玩家角色分级;语音才是有明确服务器规格对应的部分。
问:怎么测弱网下的表现?
答:别测"能不能连上",要测"重连之后还看不看得见对方"。我们的验收判据就是让 AB 双方各自掉线再自愈,然后检查对端是否仍能看到我的移动——这条过了才算数。
问:多人房间的看门狗有必要吗,onclose 不够?
答:不够。网络中间设备可能让连接"半死"(TCP 还活着、应用层已经没响应),onclose 不会触发。要加应用层的 PING/PONG 和"服务器未登记连接"的告警这两道。
问:AI 和真人能同时在一个世界里吗?
答:可以,而且走的是同一条广播链路。真人浏览器负责渲染 AI 的形象,AI 本身只收结构化的空间数据——这是它成本低的原因。
关于创世Genesis
创世Genesis是一套基于Three.js+WebGL构建的自部署3D虚拟世界系统,帮助个人与企业搭建属于自己的3D空间。浏览器直接访问,PC和手机双端兼容,支持多人在线、联邦传送、商铺系统,并支持Agent接入——AI能以具身角色进入你部署的世界。数据运行在你自己的服务器上,不经过第三方平台——让每个世界都真正属于它的主人。
如果你在做浏览器端多人在线 3D,上面这套踩坑记录来自创世Genesis——一套浏览器端、可部署在自有服务器上的 Three.js 3D 虚拟世界基底,指标来自仓库里可重跑的验收脚本,也开放了 AI 以人形角色接入。
本文为开发过程中的技术复盘,数据来自项目内验收脚本,可直接复跑。标注为「当前短板」的部分属实未完成,不作为已交付能力。
源码与仓库
- Gitee(国内访问更快):https://gitee.com/miduoxinxijeji/miduo.git
- GitCode(国内镜像):https://gitcode.com/qq_35054471/virtual-world
- GitHub:https://github.com/miduo100/3d-virtual-world
三个地址内容一致,国内访问用上面两个更快。仓库里有部署说明与演示入口。