ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Three.js + WebSocket + AI:构建3D人形角色的工程实践与成本

Three.js + WebSocket + AI:构建3D人形角色的工程实践与成本

1. 从一句标题拆出来的真实工程问题

“让 AI 以人形角色走进 3D 世界”这句话听起来像科幻设定,但落到工程上,它其实是一道非常具体的架构题:一个跑在服务端的语言模型,怎么把它的输出变成浏览器里一个能走、能转身、能对玩家做出反应的 3D 角色。这里面没有魔法,只有三件事需要打通——渲染层、通信层、决策层。

我最初接触这类需求,是帮一个做虚拟展厅的朋友搭原型。他想要的效果很朴素:访客进入网页,看到一个虚拟讲解员站在展台旁边,访客打字提问,讲解员用文字回答,同时身体做出“点头”“抬手示意”“转身面向访客”这类动作。听起来简单,但真正动手才发现,坑几乎全在“怎么把文本变成动作”这个环节上。

这篇内容适合三类人看:一是想用 Three.js 做 3D 网页但还没理清前后端分工的前端;二是想给 AI 加一个“身体”但不知道从哪下手的应用开发者;三是单纯好奇“AI 人形角色”到底是怎么跑起来的技术爱好者。我会把架构、能力边界、实测成本三块讲透,尤其是那些文档里不会写、只有真正跑过一遍才知道的细节。

先给一个整体判断:这套东西的技术门槛不在 AI,而在“状态同步”和“动作映射”。模型调用现在已经是白菜价,Three.js 的文档也足够全,真正难的是让一个没有实体的语言模型,稳定地驱动一个有骨骼、有朝向、有动画状态机的 3D 角色,并且让多个用户同时看到一致的结果。

2. 渲染层选型:为什么是 Three.js 而不是别的

2.1 浏览器里跑 3D,Three.js 的定位到底是什么

很多人第一次听到“网页 3D”,会下意识想到 Unity WebGL 或者 Babylon.js。这两个当然能用,但对于“一个角色 + 一个场景 + 若干交互”这种量级,它们都偏重。Unity WebGL 的包体动辄几十兆,加载慢,而且和前端生态的融合度不高;Babylon.js 功能很全,但 API 风格对纯前端来说学习曲线略陡。

Three.js 的优势在于它是库而不是引擎。它不强制你用它的场景管理、不强制你用它的资源加载器、不强制你用它的动画系统。你可以只用它的渲染能力,其他全部自己控制。这对于“AI 驱动角色”这种需要高度自定义状态同步的场景,反而是好事。

我实测下来,一个带骨骼动画的人形角色,加上基础场景光照和地面,Three.js 的初始包体(gzip 后)可以控制在 600KB 左右,首屏加载在普通网络下 2 秒内能完成。这个数字对于网页应用来说是可以接受的。

2.2 模型格式与骨骼动画的坑

人形角色一般用 glTF 或 GLB 格式。GLB 是二进制版,把贴图、骨骼、动画都打包在一个文件里,加载方便,推荐优先用。但这里有个非常容易踩的坑:不同软件导出的骨骼命名和层级不一样。

比如 Blender 导出的角色,骨骼可能叫mixamorig:Hips、mixamorig:Spine;而 Maya 或 3ds Max 导出的,可能叫Bip01_Pelvis、Bip01_Spine。如果你的动作映射代码里写死了骨骼名字,换一个模型就直接崩。我的做法是:在加载模型后,先遍历一遍骨骼树,把关键骨骼(头、脊柱、左右手、左右脚)的引用存到一个映射表里,用模糊匹配来识别。

// 骨骼识别的大致思路 const boneMap = {}; model.traverse((node) => { if (node.isBone) { const name = node.name.toLowerCase(); if (name.includes('hip') || name.includes('pelvis')) boneMap.hips = node; if (name.includes('spine') && !name.includes('top')) boneMap.spine = node; if (name.includes('head')) boneMap.head = node; if (name.includes('lefthand') || name.includes('l_hand')) boneMap.leftHand = node; if (name.includes('righthand') || name.includes('r_hand')) boneMap.rightHand = node; } });

这段代码不优雅,但实用。它解决的是“模型可替换性”问题——你不可能每换一个角色就重写一遍动作代码。

2.3 贴图不显示:一个被问烂了但每次都有人中招的问题

热词里出现了“three.js 贴图开始不显示”,这个问题我至少帮人排查过十次。原因通常有三个:

第一,贴图路径不对。GLB 里的贴图是内嵌的,一般不会有这个问题;但如果你用的是 glTF + 外部贴图,路径写错就白屏。浏览器控制台会报 404,但很多人不看控制台。

第二,色彩空间没设置。Three.js 从 r152 版本开始,默认色彩管理变了。如果你的贴图看起来发灰、发暗,大概率是需要设置texture.colorSpace = THREE.SRGBColorSpace。

第三,材质需要光照但场景里没有灯。很多人用MeshStandardMaterial,这个材质对光照敏感,场景里如果没有AmbientLight或DirectionalLight,模型就是全黑的。新手会以为“贴图没显示”,其实是“没光”。

提示:排查贴图问题时,先把材质临时换成MeshBasicMaterial。如果换成基础材质后能看到颜色,说明贴图加载没问题,问题出在光照或色彩空间上。

3. 通信层:WebSocket 在这套架构里到底承担什么

3.1 为什么不用 HTTP 轮询

AI 角色的交互有两个特点:高频和双向。用户可能连续打字,角色需要实时做出反应;同时,如果场景里有多个用户,一个人的动作需要广播给其他人。HTTP 轮询在这种场景下延迟高、开销大,而且每次请求都要重新建立连接,不适合。

WebSocket 建立一次连接后保持长连接,服务端可以主动推送消息。这对于“角色动作同步”是刚需——服务端决定角色该做什么动作后,需要立刻推给所有客户端,而不是等客户端下一次轮询。

3.2 消息协议怎么设计

我见过很多人上来就直接传 JSON 字符串,字段随便起,结果后期扩展时一团乱。我的建议是:从一开始就定义好消息类型。

一个最小可用的协议大概长这样:

消息类型方向作用关键字段
user_input客户端→服务端用户输入文本userId,text
ai_response服务端→客户端AI 回复文本userId,text,emotion
action_cmd服务端→客户端驱动角色动作userId,action,duration
state_sync服务端→客户端同步角色位置/朝向userId,position,rotation
heartbeat双向保活timestamp

这里的关键设计是把“文本回复”和“动作指令”分开。AI 返回的是一段文字,但角色要做的动作是另一套东西。服务端需要根据文字内容或情绪标签,决定发什么动作指令。这个“文本→动作”的映射,是整个系统里最需要打磨的部分。

3.3 心跳机制:不做的话,连接会在你不知不觉中断掉

WebSocket 长连接有一个现实问题:中间的网络设备(路由器、负载均衡、防火墙)可能会在一段时间没有数据传输后,主动断开连接。这个时间可能是 60 秒,也可能是 5 分钟,取决于具体环境。

如果不做心跳,表现就是:用户放着页面不动,过几分钟再操作,发现没反应了。刷新页面才好。这个体验非常差。

心跳的实现很简单:客户端每隔 30 秒发一个heartbeat消息,服务端收到后回一个heartbeat_ack。如果客户端连续两次没收到 ack,就主动重连。

let heartbeatTimer = null; let missedHeartbeats = 0; function startHeartbeat(ws) { heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })); missedHeartbeats++; if (missedHeartbeats > 2) { ws.close(); reconnect(); } } }, 30000); } function onMessage(event) { const msg = JSON.parse(event.data); if (msg.type === 'heartbeat_ack') { missedHeartbeats = 0; } // ... 其他消息处理 }

注意:心跳间隔不要设得太短。30 秒是实测比较稳的值。设成 5 秒会让服务端压力变大,而且移动端网络切换时容易误判。

3.4 多用户场景下的状态同步

如果场景里只有一个用户,那状态同步很简单。但如果有多个用户同时在线,每个人都能看到其他人的角色,事情就复杂了。

核心问题是:谁来决定角色的状态。有两种模式:

一种是客户端权威。每个客户端自己计算角色位置,然后广播出去。这种模式延迟低,但容易出现不一致——比如两个用户同时往一个方向走,各自看到的位置可能不同。

另一种是服务端权威。客户端只发送输入意图(“我要往前走”),服务端计算最终位置,再广播给所有人。这种模式一致性好,但延迟略高。

对于“AI 人形角色”这个场景,我推荐混合模式:角色的动作由服务端权威决定(因为动作是 AI 驱动的,必须一致),但角色的位置如果允许用户手动控制,可以用客户端权威加服务端校正。

4. 决策层:AI 怎么把文字变成动作

4.1 不要让模型直接输出动作指令

这是我最想强调的一点。很多人第一反应是:让 AI 直接返回类似{"action": "wave_hand"}的 JSON。听起来很合理,但实际跑起来问题很多。

模型输出的动作名可能不在你的动作库里。模型可能返回"wave",但你的动画叫"wave_hand";模型可能返回"say_hello",但你根本没有这个动画。更麻烦的是,模型的输出不稳定,同一个意思可能每次返回不同的词。

我的做法是:让模型输出结构化的语义标签,再由一层映射逻辑转成动作。

比如,让模型返回这样的结构:

{ "text": "欢迎来到展厅,这边请。", "emotion": "friendly", "intent": "guide", "emphasis": "welcome" }

然后你的映射层根据intent和emotion决定动作:

intentemotion动作
guidefriendly抬手示意 + 转身
answerneutral点头
greethappy挥手
idleneutral待机呼吸动画

这样做的另一个好处是:动作库可以独立于模型演进。你新增一个动作,只需要改映射表,不需要重新调模型。

4.2 提示词怎么写才能稳定输出

提示词的核心目标是约束输出格式。我一般会在系统提示里明确写清楚:

  • 你必须返回 JSON,不要返回其他内容
  • emotion只能从这几个值里选:neutral, happy, friendly, serious, surprised
  • intent只能从这几个值里选:greet, answer, guide, idle, farewell
  • 如果用户的问题你无法回答,intent设为answer,emotion设为neutral

实测下来,加上这些约束后,输出格式的稳定性从大概 70% 提升到 95% 以上。剩下 5% 的异常,用一层 JSON 解析的兜底逻辑处理——解析失败就当作普通文本回复,动作走默认的answer分支。

4.3 动作混合与过渡

角色从一个动作切换到另一个动作时,如果直接硬切,看起来会非常僵硬。Three.js 的AnimationMixer支持动作混合,但需要手动控制权重。

我的做法是:维护一个当前动作状态,切换时用crossFadeTo做过渡。

function playAction(newAction, duration = 0.3) { if (currentAction === newAction) return; const next = actions[newAction]; if (!next) return; next.reset(); next.setEffectiveWeight(1); next.play(); if (currentAction) { currentAction.crossFadeTo(next, duration, false); } currentAction = next; }

这里的duration是过渡时间,0.3 秒是个比较自然的默认值。太短会显得突兀,太长会显得迟钝。如果是“挥手”这种快速动作,可以缩短到 0.15 秒;如果是“转身”这种大动作,可以延长到 0.5 秒。

5. 实测成本:跑起来到底要花多少钱

5.1 模型调用成本

这是最容易被高估的部分。以目前主流的中等规模模型为例,一次对话(输入 200 token,输出 100 token)的成本大概在 0.001 到 0.003 元之间。如果按每个用户每天 50 次交互算,一个用户一天的成本不到 0.15 元。一百个日活用户,一个月也就 450 元左右。

真正花钱的地方不在模型调用,而在并发。如果同时在线用户多,你需要考虑模型的并发限制和响应延迟。这时候可能需要做请求队列或者降级策略——比如高峰期把部分请求路由到更小、更快的模型上。

5.2 服务端成本

WebSocket 服务端需要保持长连接,对内存有一定要求。一台 2 核 4G 的云服务器,实测可以稳定支撑 500 到 800 个并发连接(取决于消息频率)。如果只是做原型验证,一台最低配的服务器就够了,月成本几十元。

需要注意的是,WebSocket 服务端和模型调用最好分开部署。模型调用是 CPU/GPU 密集型,WebSocket 是 IO 密集型,混在一起容易互相影响。

5.3 前端资源成本

3D 模型的大小直接影响加载时间。一个带骨骼动画的人形角色,优化后可以控制在 2MB 以内。贴图用压缩格式(如 KTX2),可以进一步减小体积。

如果场景比较复杂,还需要考虑 LOD(细节层次)——远处的角色用低模,近处的用高模。不过对于“一个角色 + 简单场景”的原型,LOD 不是必须的。

5.4 一份粗略的成本表

项目配置月成本(估算)
云服务器2核4G,WebSocket 服务60-100 元
模型调用中等规模模型,日均 5000 次150-300 元
域名与证书基础配置10-20 元
对象存储存放模型和贴图,流量不大5-10 元
合计225-430 元

这个成本对于个人开发者做原型验证是完全可接受的。真正的大头是人力——把上面这些环节全部打通并调稳,一个人大概需要两到三周。

6. 能力边界:这套方案做不到什么

6.1 它不是一个“真正的 3D 游戏”

这套架构能做出一个“有 AI 角色的 3D 网页”,但它不具备完整游戏引擎的能力。没有物理碰撞、没有复杂的寻路、没有大规模场景管理。如果你要做的是“AI 角色在开放世界里自由探索”,那需要的是 Unity 或 Unreal,而不是 Three.js + WebSocket。

它的定位是轻量级交互场景:虚拟展厅、在线客服、教育演示、简单的社交空间。这些场景的共同特点是:场景不大、交互不复杂、重点是“角色能说话能动作”。

6.2 动作的丰富度受限于动画资源

AI 能决定“做什么动作”,但前提是你的动作库里有这个动作。如果你只有 5 个动画,那角色的表现力就受限于这 5 个动画。想要更丰富的表现,要么买现成的动画包,要么用动作捕捉或程序化动画生成。

程序化动画是一个方向——用代码实时生成骨骼旋转,而不是播放预录动画。但这需要比较深的图形学知识,不适合快速原型。

6.3 多 AI 协作目前还很粗糙

热词里出现了“多 AI 协作”,这个概念在文本层面已经有不少实践,但在 3D 角色层面,目前还很初级。让两个 AI 角色在同一个场景里“对话”,技术上可行,但效果往往很生硬——因为每个角色的动作和文本是独立生成的,缺乏真正的协调。

我试过一个简单方案:让两个角色共享一个对话状态机,轮流发言,动作上做简单的“面向对方”。效果勉强能看,但离“自然”还有距离。

7. 几个只有真正跑过才知道的细节

第一个细节:模型加载是异步的,但场景初始化是同步的。如果你在模型还没加载完就开始渲染循环,会报错。我的做法是用一个loading状态,模型加载完成后再启动requestAnimationFrame循环。

第二个细节:WebSocket 重连后,状态需要重新同步。客户端断线重连后,服务端不知道客户端当前的状态。需要在重连成功后,客户端主动发一个sync_request,服务端返回当前所有角色的状态。

第三个细节:移动端浏览器对 WebSocket 的后台限制。很多移动端浏览器在页面切到后台后会暂停 WebSocket 连接。用户切回来时,需要检测连接状态并重连。这个体验问题在桌面端不明显,但在移动端很常见。

第四个细节:动画的帧率要和渲染帧率解耦。AnimationMixer的update方法需要传入时间增量,这个增量应该来自clock.getDelta(),而不是固定值。否则在不同刷新率的屏幕上,动画速度会不一致。

第五个细节:文本转语音如果要做,延迟是最大的敌人。AI 回复的文字可以先显示,但语音如果等完整生成再播放,会有明显延迟。可以考虑流式语音合成,边生成边播放。不过这又是另一个话题了。

这套东西我前后调了大概一个月,从最开始的“能跑就行”到后来的“稳定可用”,中间踩的坑基本都写在上面的内容里了。如果你正准备动手,我的建议是:先把渲染和通信跑通,用一个假的动作映射验证流程,最后再接 AI。这样每一步都有明确的验证目标,不会一上来就被复杂度淹没。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进