Three.js + WebSocket + AI:构建3D人形角色的工程实践与成本 1. 从一句标题拆出来的真实工程问题“让 AI 以人形角色走进 3D 世界”这句话听起来像科幻设定但落到工程上它其实是一道非常具体的架构题一个跑在服务端的语言模型怎么把它的输出变成浏览器里一个能走、能转身、能对玩家做出反应的 3D 角色。这里面没有魔法只有三件事需要打通——渲染层、通信层、决策层。我最初接触这类需求是帮一个做虚拟展厅的朋友搭原型。他想要的效果很朴素访客进入网页看到一个虚拟讲解员站在展台旁边访客打字提问讲解员用文字回答同时身体做出“点头”“抬手示意”“转身面向访客”这类动作。听起来简单但真正动手才发现坑几乎全在“怎么把文本变成动作”这个环节上。这篇内容适合三类人看一是想用 Three.js 做 3D 网页但还没理清前后端分工的前端二是想给 AI 加一个“身体”但不知道从哪下手的应用开发者三是单纯好奇“AI 人形角色”到底是怎么跑起来的技术爱好者。我会把架构、能力边界、实测成本三块讲透尤其是那些文档里不会写、只有真正跑过一遍才知道的细节。先给一个整体判断这套东西的技术门槛不在 AI而在“状态同步”和“动作映射”。模型调用现在已经是白菜价Three.js 的文档也足够全真正难的是让一个没有实体的语言模型稳定地驱动一个有骨骼、有朝向、有动画状态机的 3D 角色并且让多个用户同时看到一致的结果。2. 渲染层选型为什么是 Three.js 而不是别的2.1 浏览器里跑 3DThree.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,textai_response服务端→客户端AI 回复文本userId,text,emotionaction_cmd服务端→客户端驱动角色动作userId,action,durationstate_sync服务端→客户端同步角色位置/朝向userId,position,rotationheartbeat双向保活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, surprisedintent只能从这几个值里选greet, answer, guide, idle, farewell如果用户的问题你无法回答intent设为answeremotion设为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核4GWebSocket 服务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。这样每一步都有明确的验证目标不会一上来就被复杂度淹没。