
在过去很长一段时间里做 AI 聊天角色交互和做 3D 角色展示是两套完全独立的开发流程。一边是后端接口返回一段文本另一边是前端加载一个 3D 模型两者各管各互不通信。真正想把“陪你聊天的 AI 角色”升级成一个“看得见、有表情、会做动作”的 3D 形象时很多人会卡在技术选型、模型加载、对话数据如何驱动 3D 角色这些环节上。这篇文章会把这套流程完整拆开讲从 AI 对话接口接入到 Three.js 搭建 3D 场景再到用对话结果驱动角色表情和动作最后给出常见报错和工程建议。无论你是刚接触前端 3D 的新手还是想给现有聊天机器人加一个可视化形象的后端开发者都可以按这里的步骤落地。1. 背景与核心概念1.1 什么是“AI 角色 3D 形象”方案先解释一个容易混淆的概念AI 角色聊天是“大脑”3D 形象是“身体”。大脑负责理解用户输入、生成回复文本身体负责把回复内容用可视化方式表达出来。如果只做聊天用户看到的是黑底白字的对话窗口体验相对单薄。如果给 AI 角色加上 3D 形象聊天就不再只是文本往返而是一次包含语气、表情、动作的互动体验。用户说“今天好累”AI 角色可以在回复文字的同时做出一个擦汗或叹气的动作用户说“太棒了”角色可以跳起来或者张开双臂。这种体验比单纯的文字回复更有代入感。在技术上这套方案通常由三部分组成对话服务调用大模型 API或本地部署开源模型接收用户消息并返回回复。3D 渲染端使用 Three.js、Babylon.js 或 Unity WebGL 渲染角色模型。中间数据层把 AI 回复中的情绪、动作提示词转换成 3D 角色可识别的指令。1.2 为什么是 Three.js常见的 3D 方案有 Unity、Unreal、Blender 渲染器等但如果你做的是网页端产品Three.js 是门槛最低、社区最活跃的选择。Three.js 基于 WebGL能够在浏览器中直接渲染 3D 场景。它不需要用户安装任何插件也不需要单独的 3D 编辑器。对于 AI 聊天角色这种场景Three.js 支持 GLB/GLTF 格式的角色模型加载也能用内置几何体快速搭一个卡通角色原型非常适合快速验证功能。此外Three.js 的动画系统可以直接控制骨骼、表情混合形态也就是说AI 返回的情绪标签可以映射成角色的动作和表情不需要手动逐帧做动画。1.3 这套方案能解决什么问题解决“聊天只有文字”的问题给 AI 对话增加可视化反馈。解决“3D 角色不会动”的问题通过表情、动作、镜头变化让角色更生动。解决“每次换角色都要重写前端”的问题把角色模型与对话逻辑解耦模型可替换。解决“情绪无法可视化”的问题在 AI 回复中提取情绪标签驱动 3D 角色做出对应表现。2. 技术思路与方案选型在写代码之前先想清楚整体流程。下图是核心链路用户输入文本 ↓ 前端发送请求到对话服务 ↓ 对话服务调用大模型接口得到回复内容和情绪标签 ↓ 前端把文本显示在聊天窗口 前端把情绪标签映射为 3D 角色动作/表情 ↓ Three.js 播放动画并渲染形象从这个链路可以看出AI 角色 3D 化的关键不在 3D 渲染本身而是如何让对话结果“驱动”角色表现。在后面的实战中我会把情绪标签设计成几个固定枚举值happy、sad、angry、surprised、neutral方便前端映射。2.1 AI 对话服务选型在实际项目中AI 对话服务有两种选型方式使用云端大模型 API开发简单、回复质量高但需要考虑调用成本和数据隐私。使用本地部署开源模型数据不出内网适合企业内部场景但需要 GPU 资源。本文示例采用“服务端代理大模型接口”的方式前端不直接暴露 API Key。这样做的原因是云厂商的 API 密钥属于敏感信息放在浏览器端很容易被窃取而且存在跨域和配额限制。让后端做一个统一的代理接口前端只请求自己的后端既安全又便于后续扩展。2.2 3D 角色来源选型构建 3D 角色形象一般有三种途径方式优点缺点适用场景使用现成 GLB 模型效果精细有现成骨骼动画模型版权需确认文件较大产品上线阶段Three.js 程序化建模代码生成体积小加载快形象简单细节有限原型验证、Demo、教程AI 生成 3D 模型快捷创意空间大生成结果质量不稳需要后处理设计师辅助创作在本文的第 4 节我会先使用 Three.js 程序化方式搭建一个简单卡通角色因为这种方式不需要外部模型文件所有几何体都由代码生成复制即可运行。到了第 5 节再介绍如何换成更精细的 GLB 模型。2.3 3D 角色与 AI 对话的交互方式AI 角色 3D 形象不只是“静态展示”它需要根据对话内容做出反应。目前比较现实的交互方式有如下三种文本情绪驱动AI 返回结果里附带一个情绪字段前端根据字段切换对应动画。语音情绪驱动通过语音识别和声学特征判断用户情绪再驱动角色。用户行为驱动通过摄像头识别用户面部表情让 AI 角色模仿或回应。第一种最简单也是最容易上手的方案。本文实战部分采用这一方式。3. 环境准备与项目结构3.1 环境说明本文的示例环境如下Node.js 16 或更高版本npm 8 或更高版本现代浏览器Chrome、Edge、Firefox推荐的编辑器VS Code由于 Three.js 的版本更新比较频繁不同的 API 可能存在差异。本文以常见的 Three.js 0.150.0 以上版本为例进行演示。如果你使用的是更早或更晚的版本个别 API 名称可能略有不同请以你实际安装版本的官方文档为准。3.2 创建项目并安装依赖先新建一个空目录并初始化项目mkdir ai-avatar-chat cd ai-avatar-chat npm init -y然后安装依赖npm install express three npm install -D nodemon这里解释一下依赖的作用express用于搭建一个本地静态服务器同时提供简单的后端接口。three浏览器端 3D 渲染引擎。nodemon开发模式下监听文件变化并自动重启服务。项目目录结构规划如下ai-avatar-chat/ ├── package.json ├── server.js └── public/ ├── index.html ├── style.css └── main.jsserver.js负责启动服务和代理 AI 对话接口public/index.html是页面入口public/main.js是前端逻辑包括聊天交互和 Three.js 渲染。4. 完整实战从文本聊天到 3D 角色形象4.1 创建后端服务先在项目根目录创建server.js。// server.js const express require(express); const path require(path); const app express(); const PORT 3000; // 解析 JSON 请求体 app.use(express.json()); // 静态资源托管 app.use(express.static(path.join(__dirname, public))); // 模拟 AI 对话接口后续可替换为大模型接口 app.post(/api/chat, async (req, res) { const { message } req.body; if (!message) { return res.status(400).json({ error: message is required }); } const reply 你说的是“${message}”。我是你的 AI 伙伴很高兴见到你; const emotion detectEmotion(message); res.json({ reply, emotion }); }); // 简单的情绪识别规则 function detectEmotion(text) { if (/开心|高兴|太好了|棒|喜欢/.test(text)) return happy; if (/难过|伤心|哭|失望/.test(text)) return sad; if (/生气|愤怒|讨厌|烦/.test(text)) return angry; if (/惊讶|真的吗|哇|不会吧/.test(text)) return surprised; return neutral; } app.listen(PORT, () { console.log(Server is running at http://localhost:${PORT}); });在这个示例中/api/chat接口接收前端传来的message字段返回两个字段replyAI 角色的回复文本。emotion从用户输入中识别出的情绪标签。这里的detectEmotion只是简单的关键词规则用于演示。实际项目中更推荐让大模型在生成回复时同时输出一个结构化 JSON包含reply和emotion两个字段这样情绪判断会更准确。4.2 创建页面结构public/index.html包含聊天面板和 3D 渲染区域。!-- public/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleAI 角色 3D 形象聊天/title link relstylesheet hrefstyle.css /head body div classcontainer div idthree-container/div div classchat-panel div classchat-headerAI 角色聊天/div div idchat-box classchat-box/div div classinput-row input typetext idmessage-input placeholder输入你想说的话... button idsend-btn发送/button /div /div /div script typemodule srcmain.js/script /body /html注意main.js使用了typemodule这样可以直接使用 ES Module 语法导入 Three.js。4.3 编写基础样式public/style.css用来把页面划分成左右两块区域。/* public/style.css */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Microsoft YaHei, sans-serif; background: #1a1a2e; color: #fff; overflow: hidden; } .container { display: flex; width: 100vw; height: 100vh; } #three-container { flex: 2; height: 100vh; } .chat-panel { flex: 1; display: flex; flex-direction: column; background: #16213e; border-left: 1px solid #0f3460; } .chat-header { padding: 16px; font-size: 18px; font-weight: bold; border-bottom: 1px solid #0f3460; } .chat-box { flex: 1; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; } .message { max-width: 80%; padding: 10px 14px; border-radius: 12px; line-height: 1.6; font-size: 14px; } .message.user { align-self: flex-end; background: #0f3460; border-bottom-right-radius: 2px; } .message.ai { align-self: flex-start; background: #533483; border-bottom-left-radius: 2px; } .input-row { display: flex; gap: 10px; padding: 16px; border-top: 1px solid #0f3460; } .input-row input { flex: 1; padding: 10px; border: none; border-radius: 8px; background: #0f3460; color: #fff; outline: none; } .input-row button { padding: 10px 20px; border: none; border-radius: 8px; background: #e94560; color: #fff; cursor: pointer; } .input-row button:hover { background: #c23152; }4.4 用 Three.js 程序化创建 3D 角色接下来是核心部分。public/main.js中先完成 3D 场景和角色的搭建。// public/main.js import * as THREE from three; // 创建场景、相机、渲染器 const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, 2, 0.1, 100); camera.position.set(0, 1.5, 5); camera.lookAt(0, 1, 0); const renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth * 2 / 3, window.innerHeight); renderer.shadowMap.enabled true; document.getElementById(three-container).appendChild(renderer.domElement); // 添加环境光和平行光 const ambientLight new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const directionalLight new THREE.DirectionalLight(0xffffff, 1); directionalLight.position.set(2, 4, 3); directionalLight.castShadow true; scene.add(directionalLight); // 创建一个简单的舞台平面 const floorGeometry new THREE.CircleGeometry(2, 32); const floorMaterial new THREE.MeshStandardMaterial({ color: 0x2a2a4a }); const floor new THREE.Mesh(floorGeometry, floorMaterial); floor.rotation.x -Math.PI / 2; floor.position.y 0; floor.receiveShadow true; scene.add(floor); // 创建 3D 角色由球体 圆柱体组成的卡通人形 const character new THREE.Group(); // 头 const headMaterial new THREE.MeshStandardMaterial({ color: 0xffccaa }); const head new THREE.Mesh(new THREE.SphereGeometry(0.4, 32, 32), headMaterial); head.position.y 1.6; character.add(head); // 身体 const bodyMaterial new THREE.MeshStandardMaterial({ color: 0x4a90d9 }); const body new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.45, 0.8, 16), bodyMaterial); body.position.y 1.0; character.add(body); // 左眼 const eyeMaterial new THREE.MeshStandardMaterial({ color: 0x000000 }); const leftEye new THREE.Mesh(new THREE.SphereGeometry(0.05, 16, 16), eyeMaterial); leftEye.position.set(-0.15, 1.68, 0.33); character.add(leftEye); // 右眼 const rightEye leftEye.clone(); rightEye.position.set(0.15, 1.68, 0.33); character.add(rightEye); // 嘴巴 const mouthMaterial new THREE.MeshStandardMaterial({ color: 0xcc4444 }); const mouth new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.28, 8), mouthMaterial); mouth.rotation.z Math.PI / 2; mouth.position.set(0, 1.5, 0.38); character.add(mouth); // 左手 const armMaterial new THREE.MeshStandardMaterial({ color: 0x4a90d9 }); const leftArm new THREE.Mesh(new THREE.CapsuleGeometry(0.08, 0.4, 4, 8), armMaterial); leftArm.position.set(-0.55, 1.2, 0); character.add(leftArm); // 右手 const rightArm leftArm.clone(); rightArm.position.set(0.55, 1.2, 0); character.add(rightArm); // 把角色加入场景 scene.add(character); // 动画循环 function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();这里的关键点在于PerspectiveCamera的近裁面和远裁面分别设置为 0.1 和 100避免因为裁剪面问题导致模型看不到。alpha: true让渲染器背景透明方便叠加页面背景色。程序化角色由球体、圆柱体、胶囊体组合而成适合原型验证。每个部位使用position调整位置整体放在一个Group中方便后续整体旋转或缩放。4.5 实现聊天交互与情绪驱动3D 角色已经出现了但还不会“反应”。现在把聊天接口和 3D 角色动作绑定起来。在main.js中继续补充代码把发送消息后的情绪映射到角色动作上。// 继续在 public/main.js 中添加 // 记录角色原始手部位置 const leftArmOrigin new THREE.Vector3(-0.55, 1.2, 0); const rightArmOrigin new THREE.Vector3(0.55, 1.2, 0); // DOM 元素 const messageInput document.getElementById(message-input); const sendBtn document.getElementById(send-btn); const chatBox document.getElementById(chat-box); // 发送消息 async function sendMessage() { const message messageInput.value.trim(); if (!message) return; appendMessage(user, message); messageInput.value ; try { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message }) }); const data await response.json(); appendMessage(ai, data.reply); handleEmotion(data.emotion); } catch (err) { console.error(请求失败, err); appendMessage(ai, 抱歉我暂时无法回复你。); } } function appendMessage(sender, text) { const div document.createElement(div); div.className message ${sender}; div.textContent text; chatBox.appendChild(div); chatBox.scrollTop chatBox.scrollHeight; } // 根据情绪执行 3D 动作 function handleEmotion(emotion) { switch (emotion) { case happy: playHappy(); break; case sad: playSad(); break; case angry: playAngry(); break; case surprised: playSurprised(); break; default: playNeutral(); break; } } function playHappy() { // 角色跳起来 animateCharacter(() { character.position.y 0.5; }, () { character.position.y 0; }, 600); // 双手张开 animateArm(leftArm, leftArmOrigin, new THREE.Vector3(-0.8, 1.1, 0)); animateArm(rightArm, rightArmOrigin, new THREE.Vector3(0.8, 1.1, 0)); } function playSad() { // 低头 animateHeadRotation(-0.3, 0); animateArm(leftArm, leftArmOrigin, new THREE.Vector3(-0.5, 0.9, 0)); animateArm(rightArm, rightArmOrigin, new THREE.Vector3(0.5, 0.9, 0)); } function playAngry() { // 双手举起 animateArm(leftArm, leftArmOrigin, new THREE.Vector3(-0.6, 1.6, 0)); animateArm(rightArm, rightArmOrigin, new THREE.Vector3(0.6, 1.6, 0)); } function playSurprised() { // 头部后仰嘴巴张开 animateHeadRotation(0.2, 0); mouth.scale.set(1.5, 0.6, 1); setTimeout(() { mouth.scale.set(1, 1, 1); }, 800); } function playNeutral() { // 角色回到初始位置 character.position.y 0; head.rotation.x 0; leftArm.position.copy(leftArmOrigin); rightArm.position.copy(rightArmOrigin); } // 简单的动画工具函数 function animateCharacter(before, after, duration) { before(); setTimeout(() { after(); }, duration); } function animateArm(arm, from, to) { arm.position.copy(from); setTimeout(() { arm.position.copy(to); }, 50); setTimeout(() { arm.position.copy(from); }, 800); } function animateHeadRotation(x, z) { head.rotation.x x; head.rotation.z z; setTimeout(() { head.rotation.x 0; head.rotation.z 0; }, 800); } sendBtn.addEventListener(click, sendMessage); messageInput.addEventListener(keypress, (e) { if (e.key Enter) sendMessage(); });这里用了几种非常直观的动作控制方式改变character.position.y模拟跳跃。改变手臂的position模拟张开、放下的动作。改变head.rotation.x模拟点头和低头。改变mouth.scale模拟张嘴。这些方式虽然简单但已经足以表达基础情绪。实际项目中如果使用带骨骼的 GLB 模型动作可以由骨骼动画实现效果会更自然。4.6 运行与验证在终端启动服务node server.js如果使用 nodemonnpx nodemon server.js浏览器访问http://localhost:3000你应该能看到右侧是聊天面板。左侧场景中有一个蓝色身体、白色头的卡通角色。输入“今天太开心了”角色会跳起来并张开双手。输入“我好难过”角色会低头手臂下垂。输入“你让我很生气”角色会举起双手。这套演示说明 AI 对话与 3D 形象之间的数据链路已经打通。5. 进阶使用 GLB 模型替换程序化角色程序化角色适合演示但如果你希望角色看起来更精细建议使用现成的 GLB/GLTF 模型。5.1 加载 GLB 模型Three.js 需要使用GLTFLoader来加载.glb文件。首先安装依赖npm install three/examples/jsm/loaders/GLTFLoader.js如果你的 Three.js 版本是最新版本可以直接这样导入import { GLTFLoader } from three/addons/loaders/GLTFLoader.js;然后在场景中加载模型const loader new GLTFLoader(); loader.load(/models/my-avatar.glb, (gltf) { const model gltf.scene; model.scale.set(1, 1, 1); scene.add(model); }, undefined, (error) { console.error(模型加载失败, error); });5.2 GLB 模型的动作播放带骨骼动画的 GB 模型通常会在gltf.animations中自带多个动画片段。你可以通过AnimationMixer播放这些动画。import * as THREE from three; import { GLTFLoader } from three/addons/loaders/GLTFLoader.js; let mixer; const loader new GLTFLoader(); loader.load(/models/my-avatar.glb, (gltf) { const model gltf.scene; scene.add(model); mixer new THREE.AnimationMixer(model); const clips gltf.animations; if (clips clips.length 0) { const action mixer.clipAction(clips[0]); action.play(); } }, undefined, (error) { console.error(模型加载失败, error); }); // 在动画循环中更新 mixer function animate() { requestAnimationFrame(animate); const delta clock.getDelta(); if (mixer) mixer.update(delta); renderer.render(scene, camera); }情绪驱动时可以根据情绪名称找到对应动画片段并切换播放function playAnimationByName(name) { const clips gltf.animations; const clip clips.find((c) c.name.includes(name)); if (clip) { mixer.clipAction(clip).play(); } }5.3 模型来源与版权提醒使用现成模型时需要注意版权问题。建议优先使用以下来源模型作者明确标注了允许商用。来自 CC0 或 MIT 协议的开源模型库。企业自建的模型资产库。不要随意下载未标注使用协议的模型用于商业产品这是很多项目在后期容易踩的合规坑。6. 常见问题与排查思路在开发 AI 角色 3D 形象的过程中最容易遇到下面几类问题。6.1 页面空白控制台报错问题现象常见原因解决思路页面空白没有 3D 场景Three.js 渲染器尺寸为 0检查容器是否设置了宽高页面空白控制台报错找不到模块模块导入路径错误确认three的导入路径和版本模型不显示相机朝向不对调整camera.lookAt目标点模型颜色太暗光照不足增加环境光或平行光强度其中渲染器尺寸为 0 是最常见的问题。如果使用renderer.setSize(width, height)时容器还没有完成布局宽高拿到的是 0就会导致画面不显示。推荐使用固定尺寸或者延迟到window.onload后再初始化。6.2 聊天接口跨域报错如果前端页面和后端服务不在同一个端口比如前端在 5500后端在 3000浏览器会拦截跨域请求。解决方案有三种使用 Nginx 反向代理把/api请求转发到后端。后端添加 CORS 中间件。前端和后端部署在同源环境下。在开发阶段最简单的方式是利用 Express 托管静态资源让前端页面和后端接口同源这也是本文示例采用的方式。6.3 模型加载慢或卡顿GLB 模型文件如果过大会影响首屏加载速度。优化建议优化手段说明压缩模型文件使用 glTF Pipeline 或 Draco 压缩使用 LOD 多级模型远距离显示低模近距离显示高模延迟加载角色出现后再异步加载 GLB减少材质数量合并材质减少 Draw Call6.4 情绪判断不准确简单关键词规则覆盖的场景有限。比如用户说“今天被领导骂了”文本里没有“难过”这个词但语义明显是负向情绪。这时建议用大模型返回结构化结果。请求体可以这样设计{ message: 今天被领导骂了 }后端拿到内容后要求大模型返回{ reply: 听起来你现在有些委屈愿意和我聊聊发生了什么吗, emotion: sad }这样的情绪识别准确率远高于关键词规则。6.5 动画播放抖动如果使用setTimeout控制动作多个动作快速切换时容易出现状态混乱。建议使用一个全局的状态标志当上一个动作未完成时忽略新的动作请求或者立即终止上一个动作。let isAnimating false; function playHappy() { if (isAnimating) return; isAnimating true; // 执行动作 setTimeout(() { isAnimating false; }, 800); }7. 最佳实践与工程建议7.1 把后端对话服务和 3D 渲染解耦不要让前端直接访问大模型 API。正确做法是前端只请求自己的后端/api/chat。后端负责调用大模型处理密钥、限流、日志。后端返回统一的 JSON 结构包含reply、emotion、action等字段。前端只关心如何根据emotion渲染 3D 表现。这样做的目的是后续替换模型厂商时前端代码完全不需要改。7.2 设计统一的“AI 回复协议”推荐使用下面的 JSON 结构作为 AI 回复的标准协议{ reply: 回复文本内容, emotion: happy | sad | angry | surprised | neutral, action: wave | jump | bow, duration: 1200 }其中action是可选的用于指定特殊动作duration控制动作持续时间。这个协议可以在不同角色、不同项目中重复使用。7.3 3D 资源按需加载不要把 3D 角色模型放在首屏必须加载的资源列表里。建议在页面主体内容渲染完成后再加载。如果模型超过 5MB还需要考虑加载进度提示以免用户误以为页面卡死。7.4 性能优化3D 场景渲染消耗的是 GPU 资源低端设备上需要特别注意优化项建议角色面数单角色控制在 5 万面以内纹理尺寸单张纹理不超过 1024x1024实时阴影移动端尽量关闭抗锯齿低端设备关闭 antialias帧率监控通过renderer.setAnimationLoop控制渲染频率7.5 情绪引擎的可扩展设计如果把情绪限定为少数枚举值会限制角色的表现力。工程实践上可以把“情绪”设计成一个分数空间比如const emotionState { happy: 0, sad: 0, angry: 0, surprised: 0 };每次 AI 返回情绪后累积更新分数再由插值函数驱动面部表情和动作幅度。这种方式更适合需要长期陪伴感的聊天角色情绪变化会显得更自然。7.6 安全与合规建议对话接口必须做用户输入长度限制避免超大文本攻击。后端增加简单的内容过滤防止不适宜内容直接进入 3D 角色交互。大模型 API Key 存放在环境变量或配置中心不要提交到代码仓库。涉及用户数据时需要遵循最小化采集原则并对日志中的敏感信息脱敏。8. 总结与下一步本文围绕“陪你聊天的 AI 角色可以直接做出 3D 形象”这一场景完整演示了从后端对话接口、前端聊天面板、Three.js 3D 角色渲染到情绪驱动角色动作的全流程。核心收获有三点3D 角色形象的本质是渲染 动画AI 部分只需要提供结构化的回复和情绪字段。程序化建模适合快速 Demo正式产品建议使用 GLB 模型并配合 AnimationMixer 播放动画。前后端通过一个统一的 JSON 协议解耦业务扩展时只需要更换后端模型或前端展示层。接下来你可以继续探索用语音合成让 AI 角色开口说话。加入 WebRTC 和摄像头实现更真实的互动。把动作映射从简单的位置偏移升级为骨骼动画混合。接入真实的大模型接口使用提示词约束模型输出结构化 JSON。如果你在做这个方向时遇到了其他问题或者有更好的实现思路欢迎在评论区交流。本文的示例代码可以直接复制到本地跑起来先跑通再优化是最快的学习路径。