做网页里能互动的二次元 3D 小人这事听着玄乎但真正拆开看核心就三件事一个能跑的 Three.js 场景、一个格式正确的模型文件、再把两者接起来。这篇我直接把我实操过的完整路径写出来从选型到踩坑尽量让你照着做就能在自己页面上看到一个会眨眼、会看你鼠标方向的小人。先说清楚这套东西能拿来干嘛。最常见的是当网站吉祥物放首页角落当引导 NPC用户滚动页面时小人做点表情动作也可以做成产品展示里的虚拟角色或者干脆就是个人主页的装饰。适合谁来参考前端开发者想给自己的项目加点视觉记忆点的Three.js 刚入门想找完整案例的还有做独立站点想低成本搞个“虚拟形象”的人。如果你是零基础只要 HTML、JavaScript 基本语法没问题跟着走也能跑起来。1. 需求拆解与技术选型1.1 “二次元小人”到底选什么技术路线网页里展示一个二次元小人市面上有好几条路可以走。第一类是 Live2D本质是多层纹理插片变形模拟出来的 2D 动画效果视觉很二次元但不是真正的 3D 模型换角度会有明显纸片感。第二类是纯图片序列帧比如把各种动作帧导出成雪碧图轮播实现起来最简单但非常呆板放大一点全是马赛克。第三类就是我们这次要展开的 JavaScript 3D 方案用一个真正的三维模型放进浏览器实时渲染可以任意旋转摄像机角度交互感最强。既然决定上真 3D浏览器里的渲染引擎基本就是 Three.js 和 Babylon.js 二选一再底层就是裸写 WebGL 或者 WebGPU。裸写 WebGL 完全不建议写一个旋转立方体就要上百行样板代码做二次元小人这种级别的东西那是自己给自己挖坑。Babylon.js 功能也很强内置了材质编辑器、场景调试面板但社区案例和模型格式的适配度相比 Three.js 还是少一些。Three.js 胜在生态大遇到问题随手一搜就有答案各种 loader 插件也齐所以我的选择很明确以 Three.js 为基础再配合专门处理人形角色的 VRM 加载方案。1.2 听说 VRM 了它和 GLTF 有什么区别如果你搜索“网页 3D 小人”大概率会看到两个高频词一个是 GLTF/GLB另一个是 VRM。GLTF 是通用的 3D 交换格式被称作“3D 界的 JPEG”几乎所有 3D 工具都支持导出。但通用也意味着它不关心你导出的是人、是桌子还是太空飞船不会有人类骨骼、表情、眼神这些标准化约定。普通模型可以用但要让小人做表情、眨眼、目光跟随鼠标GLTF 格式里没有统一标准每个建模师导出的控制方式都可能不一样你写代码时就要针对每个模型单独适配非常痛苦。VRM 就是在模型格式之上针对人形角色定了一套标准专门用于二次元虚拟形象。它规定了模型必须包含人形骨骼结构定义了表情混合权重怎么命名还提供了视线控制接口。你在软件里做的 VRM 模型加载到 Three.js 后直接通过统一调用vrm.expressionManager.setValue(happy, 1)就能让小人笑不需要管底层模型内部是怎么组织的。这里就有个取舍如果你的需求只是展示一个会原地转圈的二次元手办用普通 GLB 就够了加载更轻松如果后续要表情、口型、眼睛追踪这些交互直接上 VRM省掉后面大量适配成本。1.3 三种主流方案的横向对比方案优点缺点适合场景Three.js VRM角色交互强表情眼神可控社区案例多模型必须用专门流程制作/导出依赖库版本要匹配品牌虚拟形象、NPC、需要表情互动的角色Three.js 通用 GLB模型来源广随便一个建模软件都能导出表情、骨骼动画没有统一标准交互适配备受折磨纯展示、旋转查看、无表情需求的角色Live2D / 序列帧视觉二次元感非常强性能开销低不是真 3D角度受限交互深度不足对话窗、看板娘不需要旋转场景的场合我遇过不少项目最初想着用普通 GLB 模型结果做到一半发现需要做表情切换不得不再去改模型结构非常被动。如果你心里已经想着“这个小人之后最好能说话、能表情管理”第一条路就是最省心的。如果你只是临时展示模型玩一下第二条路更快。选型这事永远是需求先行的。2. 项目环境准备与基本页面搭建2.1 最小可运行页面框架长什么样先把基础设施搭起来。我用的是一个普通index.html没有上复杂的打包器方便你把文件丢到任何静态服务器立刻能看到效果。下面这个骨架是经过实测的注意几个关键点CSS 必须把页面 margin 清零否则会出现滚动条body 背景色用深色这样 3D 场景边缘过渡会比较自然。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title二次元 3D 小人/title style * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; } #app { width: 100vw; height: 100vh; display: block; } /style /head body div idapp/div script typeimportmap { imports: { three: https://unpkg.com/three0.160.0/build/three.module.js, three/addons/: https://unpkg.com/three0.160.0/examples/jsm/, pixiv/three-vrm: https://unpkg.com/pixiv/three-vrm1.4.0/lib/three-vrm.module.js } } /script script typemodule import * as THREE from three; // 之后的代码都写在 module 里 console.log(THREE.REVISION); /script /body /html为什么用importmap因为它能让浏览器原生识别import * as THREE from three这种模块写法不用装 npm 包也不用配置打包器。我把 Three.js 版本锁在 0.160.0pixiv/three-vrm锁在 1.4.0原因是这两个版本我本地跑通过组合稳定。这里要特别提醒three 的 API 在不同小版本之间也会发生变化比如有些方法从 r140 到 r150 就改了名所以一定要锁定版本号不要用latest否则今天能跑的代码下周可能突然报错。2.2 资源目录怎么规划才不会乱很多新手教程会直接把模型文件和页面代码放一个目录里结果项目稍微一扩展就乱了。我的习惯是做一个 assets 目录下面区分models、textures、audios页面引用的模型和贴图都放相对路径。一个比较稳的目录结构是这样的project-root/ ├── index.html ├── assets/ │ ├── models/ │ │ └── avatar.vrm │ ├── textures/ │ │ └── ground.jpg │ └── audios/ │ └── click.wav ├── src/ │ ├── main.js │ ├── scene.js │ └── character.js └── styles/ └── main.css模型文件体积通常不小一个带贴图的 VRM 模型 10 MB 左右很正常。强烈建议不要放到第三方链接远程引用文件在别人的服务器上跨域、限速、随时失效都是麻烦。放本地或者自己的对象存储里最靠谱。2.3 引入 Three.js 的三种路径与避坑第一种是刚才的importmap方式适合想快速验证想法、不想搭构建工具的场景。第二种是用 npm 安装再通过 Vite 或 Webpack 打包适合正式工程能更好地做代码拆分和压缩。第三种是直接用 script 标签加载 UMD 版本这种方式对模块化开发不友好全局变量容易冲突只建议在老项目里试试看。用 CDN 有一点必须注意如果你的站点启用了严格的 CSPContent Security PolicyCDN 外部域名可能被浏览器拦截表现就是控制台报Refused to load the script。真遇到这种情况别慌把依赖文件下载到本地 static 目录再把importmap的 URL 改成相对路径即可。我自己的多个项目最后都是这样收敛的线上环境始终用本地依赖更可控。3. 从零搭一个能跑 3D 场景的骨架3.1 渲染器、相机、灯光怎么配置才合理场景搭建是固定套路这里我把每个参数都解释清楚。渲染器用WebGLRenderer开启antialias: true做抗锯齿否则模型的边缘会像狗啃一样。像素比pixelRatio要跟设备屏幕匹配特别强调最大值要限定不能盲目设成 window.devicePixelRatio 就完事手机的高 DPR 能到 3 甚至更高直接把 GPU 撑爆我一般锁在 2。相机的选择对人物展示项目来说有个经验值。二次元小人通常高度在 1.4 到 1.6 个单位之间场景单位我按米算相机放在 y 轴 1.0 位置、距离目标 2 米左右fov 视角用 30 度。为什么不用 45 度视角越广透视变形越明显小人站在画面里身体会有点畸变尤其是靠近边缘时脸会被拉长30 度相对接近人眼聚焦视角模型轮廓更自然。near 和 far 用 0.1 和 100 就够这个范围能覆盖绝大多数角色展示场景太小的 near 会造成深度精度问题太大的 far 容易引起远处物体闪烁。// 渲染器 const renderer new THREE.WebGLRenderer({ antialias: true, alpha: true, }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.getElementById(app).appendChild(renderer.domElement); // 相机 const camera new THREE.PerspectiveCamera( 30, window.innerWidth / window.innerHeight, 0.1, 100 ); camera.position.set(0, 1.0, 2.0); camera.lookAt(0, 0.9, 0); // 灯光 const ambientLight new THREE.AmbientLight(0xffffff, 0.6); const directionalLight new THREE.DirectionalLight(0xffffff, 1.2); directionalLight.position.set(1, 2, 1); const hemisphereLight new THREE.HemisphereLight(0xffffff, 0x444444, 0.4);灯光这里多说一句。二次元模型的材质通常以 toon 风格为主对环境光比较敏感。只用一盏平行光会让暗面死黑信息全丢加半球光能提供天空和地面之间的渐变过渡暗部层次就出来了。实际调灯时我习惯先开环境光开到 0.5再叠平行光最后加半球光做修正这样每盏灯的贡献心里有数。3.2 加一个网格地面让小人“站住”在模型进场之前我习惯先放一个地面辅助格。这不是为了好看而是给你一个空间参考系。没有地面的时候模型一加载你很难判断它到底站没站歪、地上的阴影方向对不对。Three.js 自带的GridHelper就够用const grid new THREE.GridHelper(4, 20, 0x666666, 0x444444); grid.position.y 0; scene.add(grid); const axes new THREE.AxesHelper(1); scene.add(axes);格子是调试工具正式出图建议关掉。我遇到不少新手把网格一直留在页面上视觉上非常出戏。调试完记得grid.visible false或者直接从 scene 里移除。3.3 轨道控制器的限制技巧OrbitControls 是查看模型最常用的控制器但直接无脑打开会有两个问题。第一用户能滚轮无限拉近直接穿透模型内部看穿模观感非常糟糕第二可以拖拽到模型正下方从底下看裙底或者穿帮面这对二次元角色模型来说是灾难。所以必须限制距离和角度范围import { OrbitControls } from three/addons/controls/OrbitControls.js; const controls new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.9, 0); controls.minDistance 1.2; controls.maxDistance 5; controls.maxPolarAngle Math.PI * 0.55; controls.enablePan false;minDistance设为 1.2 保证摄像机不会贴脸maxDistance设为 5 防止拖太远后小人变成一个小点。maxPolarAngle控制在 0.55π 以下也就是摄像机高度角不低于水平面下方一点避免仰角过低看到地面以下的穿模区域。enablePan关闭防止用户把视角拖到处乱飘找不回来。4. 二次元小人的模型准备与加载4.1 没有建模基础的人怎么搞到模型这是大多数人卡壳的一步。如果不想从零建模首选 Vroid Studio这是免费开源的二次元角色捏人软件可以调整脸型、发型、身材、服装配色一键导出 VRM 格式。我没有建模背景靠着默认模板改改动动十分钟就能导出一个像模像样的角色。这个软件内置的材质系统就是 toon 风格特别契合网页二次元展示。另一个路径是在网上找现成的 CC0公有领域授权模型比如 Sketchfab 上有不少免费 VRM 资源下载时要注意确认授权条款很多“免费下载”只允许个人学习商业站点使用会惹麻烦。我对版权的态度是如果你想做一个会长期在线的品牌形象模型的授权链条一定要查清楚这是最容易被人忽视的隐患。自制模型的成本不一定高但合规性心里最踏实。4.2 用 three-vrm 插件加载 VRM 模型VRM 格式本质上是一个 glb 文件加了 VRM 特有信息所以加载方式是用 GLTFLoader 加载再挂载 VRM 插件做解析。官方推荐的使用方法如下import { GLTFLoader } from three/addons/loaders/GLTFLoader.js; import { VRMLoaderPlugin, VRMUtils } from pixiv/three-vrm; const loader new GLTFLoader(); loader.register((parser) new VRMLoaderPlugin(parser)); loader.load( assets/models/avatar.vrm, (gltf) { const vrm gltf.userData.vrm; VRMUtils.removeUnnecessaryVertices(gltf.scene); VRMUtils.combineSkeletons(gltf.scene); vrm.scene.position.set(0, 0, 0); scene.add(vrm.scene); // 记录 vrm 实例动画循环里要用 character vrm; }, (progress) { const percent (progress.loaded / progress.total) * 100; console.log(加载进度: ${percent.toFixed(2)}%); }, (error) { console.error(模型加载失败, error); } );VRMUtils.removeUnnecessaryVertices和VRMUtils.combineSkeletons是官方推荐做的两步优化前者清除模型里不会被绘制的顶点数据后者合并骨骼数量对运行时性能有明显提升。注意这两个方法会修改模型结构如果你后面还想对模型做复杂改造要谨慎使用如果只是展示放心用。模型加载完成后的坐标很重要。VRM 模型的标准姿势是双手张开直立状态身高约 1.5 个单位底部在 y0 附近。我统一把vrm.scene.position.set(0, 0, 0)放在地面上后续调整位移就改这里不要改场景里其他辅助物体的位置保持逻辑清晰。4.3 如果拿到的只是 GLB怎么通用加载不是所有模型都是 VRM 标准很多二次元风格但非 VRM 标准的 GLB 模型也值得展示。这时不要用 VRM 插件直接用 GLTFLoader 加载即可。加载后有个容易忽略的坑模型的贴图色彩空间。现在大部分 PBR 流程导出的贴图是 sRGB 色彩空间必须在模型加载时把贴图的colorSpace设置为THREE.SRGBColorSpace否则模型会整体发灰发白颜色像褪色了一样。我当时的解决方法是加载后遍历模型所有材质遇到贴图就检查并修正loader.load(assets/models/avatar-backup.glb, (gltf) { const model gltf.scene; model.traverse((child) { if (child.isMesh) { child.material.map.colorSpace THREE.SRGBColorSpace; child.material.map.anisotropy 4; } }); model.scale.set(1, 1, 1); scene.add(model); character model; });还有一点通用 GLB 的动画可能藏在gltf.animations里你需要用THREE.AnimationMixer去播放这个在下一部分细说。4.4 模型加载失败最常见的三个原因我第一次跑通这套流程时卡在加载环节足足两天最后排查下来的原因全是低级的。第一是路径写错assets/models/avatar.vrm的相对路径要相对于当前 HTML 所在目录如果你的页面在src/目录下资产在assets/目录下路径应该是../assets/models/avatar.vrm这个错新手太容易犯了。第二是模型文件本身损坏从网上下载的 VRM 文件可能在转存过程中被截断用 Windows 文件属性看大小如果是整数的几百 KB基本就是文件不完整正常模型体积应该是 5MB 以上。第三是跨域问题如果你直接双击 index.html 用file://协议打开浏览器默认会拦截模型请求控制台报Cross origin requests are only supported for protocol schemes解决办法是本机起一个静态服务器比如在项目根目录跑npx serve或者用 VSCode 的 Live Server 插件。5. 让小人动起来、活起来5.1 待机动画怎么播放才自然一个站得直挺挺的小人很僵硬像尸体。给它加一个待机呼吸动画立刻就不一样了。如果模型里自带 idle 动画直接用 AnimationMixer 播放let mixer; if (gltf.animations gltf.animations.length 0) { mixer new THREE.AnimationMixer(gltf.scene); const idleClip THREE.AnimationClip.findByName(gltf.animations, idle); if (idleClip) { const action mixer.clipAction(idleClip); action.play(); } } // 动画循环里 const delta clock.getDelta(); if (mixer) mixer.update(delta);这里值得说明的是我会在动画循环里用THREE.Clock去取两帧之间的时间差而不是固定步长。原因很简单浏览器刷新率可能不同手机上帧率还可能动态变化固定步长会导致动画快慢不一delta 时间才是“真实时间”这样无论 60Hz 还是 120Hz 屏幕小人的动作速度是一致的。如果模型没有自带动画还有一条路从 Mixamo 下载一套动作然后做骨骼重定向。Mixamo 提供普通人形动画资源动画是 FBX 格式可以用 Three.js 的 FBXLoader 加载。之后把动画 clip 和你的模型骨骼做 bind最常见的做法是利用 VRM 的 Humaoid 骨骼映射把 Mixamo 动画的骨骼名称转换成 VRM 骨骼子节点。这块细节不少如果你只是展示用途可以先用基础转圈动作替代等真的需要动画再深入研究重定向。5.2 表情控制让小人眨眼和微笑VRM 的优势这时候就发挥出来了表情控制只需要一行代码。在动画循环里用正弦函数控制眨眼频率是比较自然的做法人大概几秒眨一次直接写个计时器就行。下面是表情切换的简单封装function setExpression(expressionName, value) { if (character character.expressionManager) { character.expressionManager.setValue(expressionName, value); } } // 在动画循环中做眨眼 blinkTimer - delta; if (blinkTimer 0) { blinkTimer 2 Math.random() * 3; // 2~5秒眨一次 setExpression(blink, 1); setTimeout(() setExpression(blink, 0), 100); } // 微笑常驻值给 0.3 不会太夸张 setExpression(happy, 0.3);出现了一个关键细节setTimeout在动画循环里使用时要特别小心如果用户切换了标签页浏览器会互踢掉 setTimeout 的执行时机有时候表情卡在高位下不来小人一直闭着眼。我后来改成在循环里用一个计时器变量实现每次都累减 delta到阈值后置位并记录表情持续时间避免依赖 setTimeout。5.3 让小人“看着”鼠标的方向VRM 最出彩的效果之一就是目光跟随。原理是每帧把鼠标在屏幕上的坐标转换到世界坐标然后用 VRM 的 lookAt 接口指向那个点。Three.js 里有现成的射线求交工具const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); window.addEventListener(mousemove, (event) { pointer.x (event.clientX / window.innerWidth) * 2 - 1; pointer.y -(event.clientY / window.innerHeight) * 2 1; }); // 动画循环里 raycaster.setFromCamera(pointer, camera); const point new THREE.Vector3(); raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0), point); if (character) { character.lookAt.target point; }用一个水平面比如 y0 或者小人眼睛高度的一个平面与射线相交把交点作为目光目标。这样小人眼珠会以很自然的方式随着鼠标位置转动而不是直勾勾盯着屏幕效果很微妙但特别加分。5.4 点击触发的交互反馈大部分项目的需求是让用户跟小人互动常见的是点击小人它会挥手或换个表情。实现思路比较简单在点击事件里做射线拾取如果检测到点击到了模型网格就触发表情或者切换动画。但这里有一个干扰点OrbitControls 也监听鼠标事件用户拖拽旋转视角时也会触发 click 事件导致小人误反应。解决方案是记录按下和松开的位置差。如果位移距离大于某个阈值比如 5 像素就判定是拖拽而非点击不触发交互let downX 0; let downY 0; const CLICK_THRESHOLD 5; renderer.domElement.addEventListener(pointerdown, (e) { downX e.clientX; downY e.clientY; }); renderer.domElement.addEventListener(pointerup, (e) { const dx e.clientX - downX; const dy e.clientY - downY; if (Math.sqrt(dx * dx dy * dy) CLICK_THRESHOLD) { handleCharacterClick(e); } });另一种更稳的方案是手动模式不用 OrbitsControls自己实现拖拽旋转这样点击事件就可以完全自由控制。但这样代码量会大不少常规项目里用阈值区分就够了。6. 常见坑位清单与性能优化复盘6.1 排查速查表照着查就完事我把自己踩过坑和帮别人解决的问题整理成一个表遇到现象直接对号入座。现象原因解决办法页面黑屏控制台无报错相机朝向不对看向空处调用camera.lookAt(0, 0.9, 0)模型加载了但全黑场景没有灯光或灯光强度过暗加环境光 0.6 平行光 1.2模型颜色发灰发白贴图 colorSpace 没设置设置map.colorSpace THREE.SRGBColorSpace说话/表情调用没反应three-vrm 版本与 three 不匹配检查版本组合锁定固定版本手机打开卡顿严重没有限制 pixelRatio / 模型面数过高setPixelRatio(Math.min(devicePixelRatio, 2))模型减面模型部分穿透显示相机角度限制没做设置controls.maxPolarAngle模型加载不出来控制台有 CORS 报错file:// 协议跨域起本地静态服务器用 http:// 打开6.2 移动端性能怎么权衡移动端是二次元 3D 展示的主要消费场景很多人忽略了这一点。浏览器 GPU 资源有限需要做下面几个控制。第一渲染器的pixelRatio必须封顶 2不要直接设置成window.devicePixelRatio对 Retina 屏那会渲染出数百万像素完全没必要肉眼几乎分辨不出差距。第二模型导入后可以用VRMUtils.removeUnnecessaryVertices清理掉冗余顶点面数能降下来不少。第三场景里尽量少用实时阴影阴影计算是移动端帧率杀手我推荐用一张圆形的模糊阴影贴图贴在地面上模拟效果和实时阴影差不多性能却高一个量级。第四如果模型带有多套高分辨率贴图比如 PBR 的 roughness、metalness、normal 全图可以考虑用纹理压缩最简单的方式是把 PNG 换成 JPEG质量损失在可接受范围内。还有一个加载体验的问题。模型文件动辄 5 到 10 MB在弱网环境下用户盯着白屏好几秒体验非常糟糕。需要做一个加载动画利用 loader 的 progress 回调更新进度条至少让用户知道没卡死正在加载。我做过一个更激进的方案先用一个低面数、低分辨率贴图的简化模型立即展示等高清模型加载完成后无缝替换实际项目反馈非常好就是实现工作量稍大。6.3 几个源于实践的独家细节第一二次元模型的 PHY 骨骼裙子和头发模拟每帧都在做物理计算如果用户切换了标签页基于时间的物理模拟会突然跳过一大段导致模型穿模或者衣服乱飞。需要在visibilitychange事件里重置物理模拟时间最粗暴的方式是重新把场景里的模型物理初始化一下。如果嫌麻烦就在重新可见后把 vrm 实例重建一次效果可接受。第二不要在 Canvas 上叠加 CSS transform 旋转。我看过有人为了做页面动画给renderer.domElement套了transform: rotate(90deg)之类的样式结果坐标系统全乱了鼠标拾取和 OrbitControls 全都失效。如果你确实想让整个页面转 90 度也请把旋转逻辑放到相机层面而不是改 CSS 样式。网上有些小技巧比如控制台里给视频元素做style.rotate -90deg在普通视频页面可能好使但 3D 场景千万别这么操作会让交互全崩。第三模型加载完成前场景里的灯光可以先在初始化时全部加好这样模型一出现就立即可见不用等光照重新编译 Shader。Three.js 首次遇到新材料时需要编译着色器会有一次肉眼可见的卡顿为了减弱这个卡顿可以在加载模型前先放一个简单的小盒子渲染一帧让 WebGL 预热管线。6.4 骨架代码的完整串联前面分散讲了很多局部逻辑这里把核心动画循环的代码汇总一下方便你把整个流程串起来。实际生产中我建议把场景、角色、交互拆分到不同模块宽度太大才容易协作但不搞工程化时先放到一个 module 里跑通再拆也不迟。import * as THREE from three; import { OrbitControls } from three/addons/controls/OrbitControls.js; import { GLTFLoader } from three/addons/loaders/GLTFLoader.js; import { VRMLoaderPlugin, VRMUtils } from pixiv/three-vrm; // 1. 场景初始化的骨架 const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(30, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(0, 1.0, 2.0); camera.lookAt(0, 0.9, 0); const renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); const controls new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.9, 0); controls.maxPolarAngle Math.PI * 0.55; controls.enablePan false; // 灯光叠加 const ambientLight new THREE.AmbientLight(0xffffff, 0.6); const directionalLight new THREE.DirectionalLight(0xffffff, 1.2); directionalLight.position.set(1, 2, 1); const hemisphereLight new THREE.HemisphereLight(0xffffff, 0x444444, 0.4); scene.add(ambientLight, directionalLight, hemisphereLight); // 2. 加载 VRM let character null; const loader new GLTFLoader(); loader.register((parser) new VRMLoaderPlugin(parser)); loader.load( assets/models/avatar.vrm, (gltf) { const vrm gltf.userData.vrm; VRMUtils.removeUnnecessaryVertices(gltf.scene); VRMUtils.combineSkeletons(gltf.scene); vrm.scene.position.set(0, 0, 0); scene.add(vrm.scene); character vrm; }, undefined, (error) console.error(模型加载失败, error) ); // 3. 渲染循环 const clock new THREE.Clock(); let blinkTimer 1; let blinkStartTime -1; function animate() { const delta clock.getDelta(); if (character) { character.update(delta); // 表情控制 if (blinkTimer 0) { if (blinkStartTime 0) { character.expressionManager.setValue(blink, 1); blinkStartTime 0; } else { blinkStartTime delta; if (blinkStartTime 0.1) { character.expressionManager.setValue(blink, 0); blinkTimer 2 Math.random() * 3; blinkStartTime -1; } } } else { blinkTimer - delta; } } controls.update(); renderer.render(scene, camera); requestAnimationFrame(animate); } animate(); window.addEventListener(resize, () { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });这套代码跑起来页面里应该出现一个二次元角色可以鼠标拖拽旋转视角滚轮缩放小人会持续做待机呼吸、自动眨眼硬件的压力也控制在合理范围。回看整个流程其实最花时间的部分往往不是写代码而是模型的准备和环境的折腾。如果你打算长期做这类项目建议把 Three.js 官方文档里的基础概念先过一遍尤其是渲染管线、材质原理、坐标空间这些底子扎实了上层的交互开发就变得很简单。我自己的经验是最先在本地起一个静态服务器把官方示例库跑起来改参数玩一段时间后对场景、灯光、相机的理解会远超对着教程抄代码的效果。多折腾跑通一次之后你就会发现二次元 3D 小人不过是一个三维场景加一个模型文件真正值钱的是你在此基础上做的交互和创意。