
1. 项目缘起为什么要把 AI 塞进 3D 世界去年年底我接了个私活需求方想做一个“能对话的虚拟展厅”——用户打开网页看到一个立体角色站在场景里走过去能聊天聊到某个展品时角色会转头看向它甚至走过去做手势。预算不多工期三周。我第一反应是找现成的数字人方案问了一圈报价光模型驱动和语音链路就够我再接三个私活。于是决定自己搭Three.js 负责渲染WebSocket 负责实时通信AI Agent 负责决策和对话整套跑在浏览器里服务器只做消息中转和模型调用。这个组合听起来简单但真动手会发现坑比想象中多。比如 Three.js 的贴图在跨域加载时默认不显示比如 WebSocket 连接在移动网络下十分钟就断比如 AI 返回的 JSON 里带了个换行符导致整个动作队列崩掉。这篇文章就把我从零搭这套东西的完整过程拆开讲包括架构选型、能力边界、实测成本以及那些文档里不会写的排查技巧。如果你也在琢磨“让 AI 以人形角色走进 3D 世界”这件事不管是为了做虚拟主播、游戏 NPC、还是企业展厅这篇应该能帮你省下至少两周的试错时间。先明确一下这套东西能做什么、不能做什么。能做的浏览器里渲染一个带骨骼动画的 3D 角色用户用键盘或点击控制移动靠近后触发对话AI 根据上下文决定回复内容和动作指令挥手、点头、转身动作通过 WebSocket 实时同步到前端播放。不能做的实时语音驱动口型、复杂物理交互、多人大规模同屏。适合谁看有前端基础、想入门 3D AI 结合的中级开发者或者想快速验证产品原型的独立开发者。小白也能看但建议先把 Three.js 官方示例跑一遍再回来。2. 整体架构设计三层分离与选型逻辑2.1 为什么是 Three.js WebSocket AI Agent 这个组合选型这件事我纠结了大概四天。最初想用 Unity WebGL 导出角色动画和物理都现成但导出包体动辄 30MB 起步首屏加载在移动端直接劝退。后来试了 Babylon.js功能确实强但社区示例偏少遇到问题搜出来的答案经常是三年前的。最后回到 Three.js理由很实际生态成熟、示例多、包体可控核心库压缩后 600KB 左右配合按需加载的 GLTF 模型首屏能压到 2MB 以内。通信层选 WebSocket 而不是 HTTP 轮询核心原因是双向实时性。AI 的动作指令需要低延迟下发用户的位置变化也需要持续上报给服务端做上下文判断。HTTP 轮询要么延迟高要么请求量大。WebSocket 建一次连接就能全双工通信实测在 4G 网络下消息往返稳定在 80-150ms足够支撑对话和动作同步。AI Agent 这块我没有用现成的 Agent 框架而是自己写了一个轻量调度器。原因是我需要的不是复杂的任务规划而是意图识别 动作映射 对话生成这三件事的串联。用框架反而增加了一层抽象调试时看不清中间状态。自己写的话核心逻辑不到 300 行每个环节的输入输出都能打日志排查问题快得多。2.2 三层架构的职责划分与数据流整套系统分成三层每层职责清晰避免耦合渲染层浏览器Three.js 场景、角色模型、动画混合器、用户输入处理。这一层只负责“显示”和“采集”不做任何业务判断。通信层WebSocket 服务消息路由、连接管理、心跳维持。它像一个交换机把前端的位置和对话请求转发给 AI 层把 AI 的动作指令转发回前端。决策层AI Agent接收用户输入和当前场景状态调用大模型生成回复文本和动作标签再通过通信层下发。数据流是这样的用户在浏览器里按 W 键前进 → 前端每 100ms 上报一次位置 → 服务端判断用户是否进入某个 NPC 的交互范围 → 如果是触发对话请求 → AI Agent 拿到用户历史对话和当前场景描述 → 调用模型生成回复和动作 → 服务端把动作指令通过 WebSocket 推给前端 → 前端动画混合器播放对应动作。这个链路里最容易出问题的是状态同步。比如用户快速移动时位置上报频率不够会导致服务端判断滞后上报太频繁又会造成消息堆积。我最后定的是 100ms 一次配合服务端的插值补偿实测下来角色移动和交互触发都比较跟手。2.3 能力边界这套方案做不到什么得先把丑话说前面免得你搭到一半发现方向不对。这套方案不适合以下场景场景为什么不适合替代方案实时语音口型同步浏览器端音频分析精度有限延迟难控制本地客户端 专业口型驱动多人大规模同屏WebSocket 连接数和消息量线性增长专用游戏服务器 状态同步框架复杂物理交互Three.js 物理引擎能力有限Unity/Unreal 原生高精度面部捕捉需要摄像头深度数据浏览器权限和性能受限专业动捕设备我实测下来这套方案最舒服的场景是1 对 1 或 1 对少数的对话式交互比如虚拟导览、教学助手、客服预演。角色数量控制在 5 个以内同屏用户 10 人以下体验比较稳。3. 核心细节解析从模型加载到动作映射3.1 Three.js 场景搭建与模型加载的坑Three.js 快速创建项目现在有官方脚手架一行命令就能跑起来。但模型加载这块有几个细节必须注意。第一个是贴图不显示的问题。我一开始用 GLTFLoader 加载模型几何体出来了但表面是灰的。排查了半天发现是贴图路径问题——GLTF 文件里引用的贴图是相对路径如果模型文件和贴图不在同一目录或者跨域加载贴图就会静默失败。解决办法是在加载前设置loader.setCrossOrigin(anonymous)并确保贴图和模型在同一域名下或者服务端配置正确的 CORS 头。第二个是骨骼动画的命名匹配。不同建模软件导出的骨骼命名不一样有的叫mixamorigHips有的叫Hips。如果你在代码里硬编码骨骼名换个模型就崩。我的做法是加载后遍历一次骨骼树把关键骨骼头、脊柱、左右手的引用存到一个映射表里后续动作控制都通过这个映射表访问不直接依赖原始命名。第三个是动画混合器的过渡时间。角色从走路切换到挥手如果直接播放新动画会有一个明显的跳变。需要用crossFadeTo做过渡过渡时间我设的是 0.3 秒太短会跳太长会显得拖沓。这个值可以根据动作幅度调整大幅度动作给 0.4 秒小幅度给 0.2 秒。3.2 WebSocket 心跳机制与断线重连WebSocket 连接在移动网络下特别容易断尤其是从 WiFi 切到 4G 的时候。如果不做心跳和重连用户会突然发现角色不动了体验很差。心跳机制我采用的是双向心跳客户端每 30 秒发一个 ping服务端收到后回 pong服务端如果 60 秒没收到任何消息就主动断开连接。为什么是 30 秒因为大多数移动网络 NAT 超时是 60 秒左右30 秒发一次能保证连接不被回收同时不会太频繁浪费流量。重连策略用的是指数退避第一次断线后 1 秒重连失败则 2 秒、4 秒、8 秒最大 30 秒。重连成功后需要重新同步状态——把当前用户位置、对话历史、场景状态重新发给服务端否则 AI 会丢失上下文。这一步很容易被忽略我踩过坑重连后 AI 突然问“你刚才说什么”因为服务端以为这是新用户。// 心跳与重连核心逻辑 let reconnectDelay 1000; const MAX_DELAY 30000; function connect() { const ws new WebSocket(wss://your-server/ws); ws.onopen () { reconnectDelay 1000; startHeartbeat(ws); syncState(ws); // 重连后同步状态 }; ws.onclose () { stopHeartbeat(); setTimeout(connect, reconnectDelay); reconnectDelay Math.min(reconnectDelay * 2, MAX_DELAY); }; } function startHeartbeat(ws) { heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping, ts: Date.now() })); } }, 30000); }3.3 AI Agent 的意图识别与动作映射AI Agent 这块是整个项目的“大脑”但我不建议把它做得太复杂。我的做法是把动作映射做成规则表把对话生成交给模型两者分开。规则表长这样用户说“你好” → 动作标签wave用户说“再见” → 动作标签bow用户问“这个展品是什么” → 动作标签point 转头看向展品。这些规则用关键词匹配就能覆盖 80% 的常见交互剩下的 20% 交给模型判断。模型返回的格式我强制要求 JSON包含reply回复文本和action动作标签两个字段。这里有个坑模型有时候会在 JSON 外面包一层 markdown 代码块或者返回的 JSON 里有换行符导致解析失败。我的处理方式是先用正则提取 JSON 部分再用JSON.parse解析解析失败就降级为纯文本回复动作标签用默认值。动作标签到动画的映射放在前端做因为前端才知道当前有哪些动画可用。服务端只下发标签比如wave前端查表找到对应的动画片段播放。这样服务端不需要关心模型的具体动画资源换模型时只改前端映射表就行。3.4 多 AI 协作的边界与取舍热词里提到“多 AI 协作”我实际试过让两个 Agent 分别负责对话和动作决策。结论是在实时交互场景下多 Agent 协作的收益不明显反而增加延迟。原因很简单两个 Agent 之间需要通信通信就有延迟而且容易出现决策冲突——对话 Agent 说“你好”动作 Agent 同时决定“转身”用户看到的就是角色一边打招呼一边背对着你。后来我改成单 Agent 内部做多任务输出一次调用同时返回回复和动作延迟从 1.2 秒降到 0.7 秒冲突也没了。多 AI 协作更适合离线任务比如批量生成对话脚本、自动标注动作数据。实时交互场景下单 Agent 多输出是更务实的选择。4. 实操过程从零到跑通的完整步骤4.1 环境准备与项目初始化先列一下我用的工具和版本避免你踩版本兼容的坑Node.js 18.x16 也能跑但 18 的 WebSocket 支持更好Three.js r158用 npm 安装不要用 CDN方便调试Vite 5.x构建工具热更新快ws 8.xNode.js 的 WebSocket 库初始化命令npm create vitelatest ai-3d-world -- --template vanilla cd ai-3d-world npm install three ws项目结构我分成三块src/ client/ # 前端代码 main.js # Three.js 场景入口 ws.js # WebSocket 封装 actions.js # 动作映射表 server/ # 服务端代码 index.js # WebSocket 服务 agent.js # AI Agent 调度 shared/ # 共享常量 protocol.js # 消息格式定义把协议定义单独抽出来很重要前后端用同一份消息格式改协议时不会漏改一边。4.2 Three.js 场景与角色加载实操场景搭建的核心代码不复杂但有几个参数需要调import * as THREE from three; import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js; const scene new THREE.Scene(); scene.background new THREE.Color(0x1a1a2e); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 2, 5); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 限制像素比防止移动端过热 document.body.appendChild(renderer.domElement); // 灯光环境光 方向光方向光要开阴影 const ambient new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambient); const dirLight new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(5, 10, 7); dirLight.castShadow true; scene.add(dirLight); // 加载角色 const loader new GLTFLoader(); loader.setCrossOrigin(anonymous); loader.load(/models/character.glb, (gltf) { const model gltf.scene; model.traverse((node) { if (node.isMesh) { node.castShadow true; node.receiveShadow true; } }); scene.add(model); // 建立骨骼映射 const bones {}; model.traverse((node) { if (node.isBone) { if (node.name.includes(Head)) bones.head node; if (node.name.includes(Spine)) bones.spine node; if (node.name.includes(RightHand)) bones.rightHand node; } }); // 动画混合器 mixer new THREE.AnimationMixer(model); gltf.animations.forEach((clip) { actions[clip.name] mixer.clipAction(clip); }); });setPixelRatio限制到 2 是个经验值。有些手机像素比是 3 甚至 4不限制的话渲染压力大角色动起来会掉帧手机还容易发烫。4.3 WebSocket 服务端与消息协议设计服务端用ws库起一个 WebSocket 服务核心是消息路由和连接管理import { WebSocketServer } from ws; const wss new WebSocketServer({ port: 8080 }); const clients new Map(); wss.on(connection, (ws) { const clientId generateId(); clients.set(clientId, { ws, lastActive: Date.now(), state: {} }); ws.on(message, async (raw) { const msg JSON.parse(raw); const client clients.get(clientId); client.lastActive Date.now(); switch (msg.type) { case ping: ws.send(JSON.stringify({ type: pong, ts: msg.ts })); break; case position: client.state.position msg.data; checkInteraction(clientId); break; case chat: const response await agent.process(clientId, msg.data); ws.send(JSON.stringify({ type: action, data: response })); break; } }); ws.on(close, () clients.delete(clientId)); }); // 心跳检测60秒无消息则断开 setInterval(() { const now Date.now(); clients.forEach((client, id) { if (now - client.lastActive 60000) { client.ws.close(); clients.delete(id); } }); }, 30000);消息协议我定义了五种类型ping/pong用于心跳position用于位置上报chat用于对话请求action用于动作下发。每种消息都带type和data字段格式统一前端处理起来简单。4.4 AI Agent 调度与模型调用Agent 的核心逻辑是组装上下文、调用模型、解析结果async function process(clientId, userInput) { const client clients.get(clientId); const history client.state.history || []; const prompt buildPrompt({ scene: client.state.scene, position: client.state.position, history: history.slice(-5), // 只带最近5轮控制 token input: userInput }); const raw await callModel(prompt); const parsed parseResponse(raw); history.push({ role: user, content: userInput }); history.push({ role: assistant, content: parsed.reply }); client.state.history history; return { reply: parsed.reply, action: parsed.action || idle }; } function parseResponse(raw) { try { const match raw.match(/\{[\s\S]*\}/); if (match) { return JSON.parse(match[0]); } } catch (e) { // 解析失败降级为纯文本 } return { reply: raw.trim(), action: idle }; }history.slice(-5)这个截断很重要。对话历史全带上token 消耗会线性增长成本扛不住。只带最近 5 轮既能保持上下文连贯又能控制成本。实测 5 轮足够覆盖大多数交互场景。4.5 动作映射表与前端播放前端拿到action标签后查表播放对应动画const actionMap { wave: { clip: Wave, duration: 1.2, loop: false }, bow: { clip: Bow, duration: 1.5, loop: false }, point: { clip: Point, duration: 1.0, loop: false }, idle: { clip: Idle, duration: 2.0, loop: true } }; function playAction(tag) { const config actionMap[tag] || actionMap.idle; const action actions[config.clip]; if (!action) return; // 淡出当前动画 Object.values(actions).forEach(a { if (a.isRunning() a ! action) { a.fadeOut(0.3); } }); action.reset(); action.setLoop(config.loop ? THREE.LoopRepeat : THREE.LoopOnce); action.clampWhenFinished !config.loop; action.fadeIn(0.3).play(); }clampWhenFinished设为 true 是为了让一次性动作播完后停在最后一帧而不是跳回第一帧。比如挥手动作播完停在手举着的位置等下一个动作再过渡视觉上更自然。5. 常见问题与排查技巧实录5.1 贴图不显示与模型加载失败速查这个问题我遇到不下五次每次原因都不一样。整理成速查表现象可能原因排查方法解决模型全灰贴图路径错误看 Network 面板贴图请求是否 404修正路径或配置 CORS模型不显示相机位置不对打印模型包围盒调整相机位置和朝向部分贴图丢失贴图格式不支持看控制台报错转成 PNG/JPG加载卡住文件太大看文件大小压缩模型用 Draco跨域报错CORS 未配置看控制台 CORS 提示服务端加 Access-Control-Allow-Origin最隐蔽的一个坑是贴图颜色空间。Three.js r152 之后默认用 sRGB 颜色空间如果贴图是线性空间导出的颜色会偏暗。解决办法是给贴图设置texture.colorSpace THREE.SRGBColorSpace。这个细节文档里写得不明显但影响很大。5.2 WebSocket 断线重连与消息丢失处理断线重连本身不难难的是重连后的状态恢复。我踩过的坑重连后用户位置丢了AI 以为用户还在原地结果对话触发范围判断错误。我的解决方案是重连成功后立即发一个sync消息把当前完整状态位置、对话历史、场景 ID打包发给服务端。服务端收到后覆盖旧状态再继续正常交互。这样即使断线期间用户移动了重连后也能立刻恢复正确上下文。消息丢失的处理用序号 确认机制每条消息带一个自增序号接收方收到后回一个 ack。发送方如果 3 秒没收到 ack就重发。这个机制对动作指令特别重要丢一个动作用户就能看出来。5.3 AI 返回格式异常的兜底策略模型返回格式异常是家常便饭我遇到过这些情况JSON 外面包了 markdown 代码块JSON 里有未转义的换行符返回了纯文本没有 JSON返回了多个 JSON 对象兜底策略分三层第一层用正则提取 JSON 部分第二层解析失败就尝试修复常见问题比如替换换行符第三层还失败就降级为纯文本回复动作标签用idle。这样即使模型抽风用户至少能看到文字回复不会整个交互卡死。提示在 prompt 里明确要求“只返回 JSON不要任何其他文字”能显著降低格式异常的概率。我加了这句话之后解析失败率从 15% 降到 3% 左右。5.4 性能优化与移动端适配经验移动端是重灾区。我实测下来几个优化点效果最明显限制像素比setPixelRatio(Math.min(devicePixelRatio, 2))帧率能提升 30%阴影贴图降分辨率从 2048 降到 1024视觉差异不大性能提升明显按需渲染角色不动的时候不渲染用requestAnimationFrame配合脏标记模型面数控制角色模型控制在 2 万面以内超过就减面还有一个容易忽略的点是内存泄漏。Three.js 的几何体和材质不会自动回收切换场景时需要手动dispose()。我一开始没做跑半小时后浏览器内存飙到 1G 多页面直接卡死。6. 实测成本拆解钱花在哪里6.1 模型调用成本与 token 消耗这是大家最关心的部分。我实测了一周每天大概 200 次对话统计如下项目单次消耗日消耗月成本估算输入 token约 80016 万约 48 元输出 token约 1503 万约 18 元合计约 95019 万约 66 元输入 token 占大头因为每次都要带场景描述和历史对话。优化空间主要在精简 prompt场景描述从 200 字压到 80 字历史从 10 轮压到 5 轮输入 token 能降 40%。6.2 服务器与带宽成本WebSocket 服务对服务器要求不高1 核 2G 的云服务器能撑 200 个并发连接。带宽方面位置上报每 100ms 一次每次约 100 字节单个用户每秒 1KB100 个用户每秒 100KB一个月流量大概 260GB。按云服务商的价格服务器加带宽一个月 100 元以内能搞定。6.3 开发时间成本与人力投入从零到跑通我花了大概 18 个工作日。拆解一下场景搭建和模型加载3 天WebSocket 通信层2 天AI Agent 调度4 天动作映射和动画调试5 天性能优化和移动端适配3 天联调和 bug 修复1 天其中动画调试最耗时因为动作过渡的自然度需要反复调参数。如果直接用现成的动作库能省 2-3 天。7. 后续扩展方向与个人体会这套东西跑通之后我陆续加了一些扩展。比如声音空间化——根据角色和用户的距离调整音量走近了声音大走远了声音小沉浸感提升明显。再比如多角色切换——场景里放三个 NPC用户靠近谁就触发谁的对话每个 NPC 有独立的对话历史。还有一个我觉得很有潜力的方向是人体姿态 2D 到 3D 的映射。用摄像头捕捉用户的 2D 姿态映射到 3D 角色的骨骼上实现简单的动作模仿。我试了一版延迟在 200ms 左右做实时互动还差点意思但做离线动画生成已经够用了。踩了这么多坑我最大的体会是别追求一步到位。我一开始想做一个全功能的数字人结果两周过去连角色都没动起来。后来砍掉所有非核心功能先让角色能走能说再逐步加动作、加声音、加多角色。每加一个功能就测一遍确保不破坏已有的稳定性。这个节奏比憋大招靠谱得多。最后分享一个小技巧调试动作的时候把动画混合器的过渡时间临时调到 2 秒这样能清楚看到两个动作之间的插值过程方便判断骨骼有没有错位。调好了再改回 0.3 秒。这个笨办法帮我省了不少排查时间。