基于Three.js的网页二次元3D小人实现:模型加载、动画交互与工程化落地方案
发布时间:2026/9/30 12:58:22来源:尧图网络
前两天有个朋友发来一个链接点开是他的个人主页加载的时候屏幕中央站着一个二次元风格的小人随着鼠标方向轻轻转动脑袋像是在好奇地打量来客。我当时第一反应是这人是不是贴了一段绿幕录制的视频结果按F12一看整个小人就是实时渲染在页面里的场景、灯光、动画全部由JavaScript驱动。最近javascript 网页二次元3D小人这个需求被问得特别多常见的使用场景是个人主页、作品集、产品介绍页甚至有人拿它做虚拟形象播报。这篇教程就按我从零到一把流程跑通的顺序来讲包含模型从哪来、用Three.js怎么搭、动画和交互怎么加、以及几个我反复踩过的坑。所有代码都在本地实测过不搞那种复制过去就报错的半成品。1. 为什么我选Three.js来做网页小人而不是手写WebGL或上车其他引擎1.1 三个常见方案的横向对比在动手写代码之前我先想清楚了一件事做成这件事的方案并不少但每个方案的代价完全不同。方案上手成本文件体积动画支持二次元风格化适用场景手写WebGL极高最小全部自己实现自己写shader学习、极致定制Three.js中等约600KBgzip后内建AnimationMixer材质后期处理可定制绝大多数网页3D展示Babylon.js中高1MB以上内建动画系统需要额外配置复杂3D场景、网页游戏PlayCanvas中等引擎运行时较大编辑器驱动可以但偏重团队协作的网页游戏很多人一说网页3D就想到手写WebGL觉得这样最酷也最可控。但老实讲手写一个能显示三角形的WebGL程序大约要50行如果目标是加载一个带骨骼动画的角色模型那意味着要自己处理模型解析、关节矩阵、动画插值、相机投影、着色器编译。这个工程量放在个人项目里完全不现实。Three.js刚好卡在中间点它把渲染器、相机、灯光、加载器都封装好了同时又保留了对材质和shader的控制权。对于网页二次元3D小人这个需求来说你需要的是一个快速能用的渲染底座而不是从零发明轮子。1.2 二次元小人的观感核心不是模型精细度是风格化渲染有一个常见的误区是以为二次元小人做得像不像取决于模型面数高不高、贴图分辨率大不大。实际上二次元观感的决定性因素是渲染风格不是多边形数量。同样是同一个模型如果用PBR物理材质渲染脸上会出现真实的明暗过渡、环境反射看起来反而像塑料手办如果用Toon风格渲染阴影被压缩成两到三个色阶边缘干净锐利看起来才是动漫截图里的样子。Three.js里提供了MeshToonMaterial配合一张渐变贴图gradientMap就能把光照的连续过渡压缩成色带效果。所以我的技术选型就定下来了Three.js负责渲染基础模型使用二次元风格的GLB文件材质在加载后做Toon化处理再用后期轮廓线补一圈描边。这样的组合能最大程度保留原模型的二次元气质。2. 二次元模型从哪来VRM、GLB与Toon材质的搭配2.1 免费且好用的模型获取路径没有模型再好的渲染管线也是空转。我的建议是不要一开始就搜二次元小人模型这个关键词容易被搜到一堆付费资源或低质量模型。实际命中率更高的搜索词是anime character glb、toon character model、chibi model。以下几个渠道我都实测过质量和授权情况简要列在下面渠道代表作授权适合度VRoid Studio VRoid Hub免费捏人可导出3D角色个人/商用需按条款确认高Sketchfab搜索anime、chibi、toon勾选CC0或CC-BY高itch.io游戏向角色模型包多数免费可商用需确认中Ready Player Me半写实风格角色免费生成低二次元感偏弱我个人的建议是优先考虑VRoid Studio导出的模型。VRoid本来是给VTuber做虚拟形象的捏人面板非常友好头发、五官、衣服都有大量预设。它导出的模型天然就是二次元脸渲染出来很讨喜不用像Sketchfab那样碰运气。2.2 把VRM转成GLB再交给Three.js这里有个关键步骤Three.js不能直接加载.vrm文件。VRM是建立在glTF基础上的一种扩展格式直接塞给GLTFLoader会报错或者漏掉材质。我踩过坑之后总结出两条稳定路线路线一用VRoid Studio直接导出VRM然后用gltf-transform命令转换npx gltf-transform/cli vrm-to-glb avatar.vrm avatar.glb --no-copy如果你的gltf-transform版本里没有vrm-to-glb这个子命令不要纠结直接走路线二。路线二在Blender里安装VRM Add-on插件导入VRM后再导出GLB。这个插件在Blender的扩展仓库里能直接搜到。导入之后推荐检查一下材质节点因为VRoid的MToon材质到了Blender里会变成一组节点直接导出通常没有问题但如果贴图缺失就需要手动重新连一遍Base Color。2.3 为什么模型加载出来会脸黑油光锃亮这是初期最容易崩溃的问题。VRoid模型用的是MToon材质它的光照模型跟Three.js默认的MeshStandardMaterial完全不同。MToon里有专门的头发高光和脸部阴影校正参数这些参数在Three.js里无法直接解释所以加载出来经常表现为头发像涂了猪油、脸部阴影发黑、眼睛高光位移。我试过两种处理方式分享下区别。方式一在导出前用Blender把MToon材质烘焙成普通贴图再用MeshToonMaterial重新挂载。优点是渲染风格可控缺点是步骤繁琐。方式二直接用MeshStandardMaterial加载然后把roughness调到0.8以上、metalness调到接近0。好处是快坏处是二次元感弱一些。后来我在实际项目里采用的是折中方案模型表面用Toon化处理脸部贴图保持原样头发的金属度强制清零。观感比较接近原版MToon的效果。3. 从零跑通最小案例一个能看到二次元小人的页面3.1 初始化项目我不建议直接往HTML里塞一个巨大的three.min.js调试起来太吃力。用Vite搭一个最小项目几秒钟就能起来后面加依赖也方便。npm create vitelatest avatar-page -- --template vanilla cd avatar-page npm install three npm run dev这样你就有了一个能热更新的开发环境。接下来把准备好的glb模型放到public/models/目录下然后清空main.js从零开始写。3.2 核心加载代码场景、相机、灯光、模型下面是一段能直接跑起来的最小代码我把注释写得比较详细import * as THREE from three; import { GLTFLoader } from three/addons/loaders/GLTFLoader.js; import { OrbitControls } from three/addons/controls/OrbitControls.js; const scene new THREE.Scene(); scene.background new THREE.Color(0xfff7f0); const camera new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 100 ); camera.position.set(0, 1.4, 3.2); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); // 轨道控制器方便调试也方便访问者拖拽查看 const controls new OrbitControls(camera, renderer.domElement); controls.target.set(0, 1.2, 0); controls.enableDamping true; // 灯光注意强度要给足下面会详细说 const ambientLight new THREE.AmbientLight(0xffffff, 1.6); scene.add(ambientLight); const dirLight new THREE.DirectionalLight(0xffffff, 2.5); dirLight.position.set(2, 3, 2); scene.add(dirLight); const loader new GLTFLoader(); loader.load(/models/avatar.glb, (gltf) { const model gltf.scene; // 如果模型尺寸不是1米左右在这里统一调整 model.position.set(0, 0, 0); model.scale.set(1, 1, 1); scene.add(model); }, undefined, (error) { console.error(模型加载失败, error); }); function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate();这段代码做完之后你已经能在浏览器里看到一个静止的二次元小人并且可以用鼠标拖动旋转观察。到这一步为止页面本身已经可以用了但距离好看还有距离。3.3 模型尺寸、灯光强度和相机位置的调参经验在写这一节之前我先把最常见的三个为什么讲清楚。第一个问题是模型尺寸。Three.js里的1个单位约定为1米。VRoid导出的模型大概是1.4到1.6米高这是合理的。如果你从Sketchfab下载的模型单位可能是厘米或者随便定的加载进来会小到看不见。此时不要盲目改相机距离而是应该计算模型的包围盒const box new THREE.Box3().setFromObject(model); const size new THREE.Vector3(); box.getSize(size); console.log(模型高度(米), size.y);拿到高度后把模型缩放到1.5米左右即可。这样做的原因是后续如果给模型添加阴影、物理碰撞或者做多角色并排展示统一比例能省掉无数麻烦。第二个问题是灯光强度。Three.js从r155版本开始默认关闭了useLegacyLights物理光照模式下的灯光强度单位变了。以前AmbientLight给0.5就够现在给到1.5以上才正常。这个变化坑了很多人包括我。如果你发现模型看起来很暗先不要把环境光去掉试着把数值往2.0方向提。第三个问题是相机位置。相机放在(0, 1.4, 3.2)看向(0, 1.2, 0)相当于站在小人面前稍微俯视这是展示人物模型最舒服的角度。眼睛平视对方眼睛附近观感自然。4. 让小人活起来待机动画、鼠标注视与滚动入场4.1 用AnimationMixer播放GLB内置动画模型文件里通常不只是一个静态网格还包含了骨骼、顶点权重和动画剪辑。VRoid模型一般自带待机、眨眼、呼吸这类动画名字可能是idle、blink或者带数字后缀。Three.js播放动画的机制不复杂核心是AnimationMixer加AnimationActionimport * as THREE from three; const clock new THREE.Clock(); let mixer null; // 在模型加载回调里初始化 loader.load(/models/avatar.glb, (gltf) { mixer new THREE.AnimationMixer(gltf.scene); const idleClip THREE.AnimationClip.findByName(gltf.animations, idle); if (idleClip) { const idleAction mixer.clipAction(idleClip); idleAction.play(); } const blinkClip THREE.AnimationClip.findByName(gltf.animations, blink); if (blinkClip) { const blinkAction mixer.clipAction(blinkClip); blinkAction.setLoop(THREE.LoopRepeat); blinkAction.play(); } }); // 在渲染循环里更新 function animate() { requestAnimationFrame(animate); const delta clock.getDelta(); if (mixer) mixer.update(delta); controls.update(); renderer.render(scene, camera); }注意clock.getDelta()每次调用后会把内部计时清零所以一帧里只能调用一次。如果你在别的地方又调了一次动画就会变成慢动作。多个动画同时播放时idle和blink不会互相干扰因为它们的骨骼权重作用部位不同。如果你想在待机和跑步两个动作之间过渡可以利用fadeIn和fadeOut做平滑切换idleAction.fadeOut(0.3); runAction.reset().fadeIn(0.3).play();这种0.3秒的交叉淡化比直接硬切自然得多。4.2 用射线检测实现鼠标看哪里小人的头转向哪里这个交互第一眼看很复杂好像需要3D空间计算实际上Three.js里有一个很顺手的工具Raycaster。思路是这样把鼠标所在的屏幕坐标转换为3D空间中的一条射线然后让这条射线与一个水平平面求交点这个交点的位置就是小人视线应该看向的位置。最后让头骨节点lookAt这个交点。代码实现const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); // 鼠标坐标归一化到[-1, 1] window.addEventListener(pointermove, (e) { pointer.x (e.clientX / window.innerWidth) * 2 - 1; pointer.y -(e.clientY / window.innerHeight) * 2 1; }); // 在动画循环里调用 function updateHeadTarget() { if (!headBone) return; raycaster.setFromCamera(pointer, camera); // 假设头部在同一水平面用一个向上的法线平面求交点 const plane new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); const hitPoint new THREE.Vector3(); raycaster.ray.intersectPlane(plane, hitPoint); if (hitPoint) { headBone.lookAt(hitPoint); } }headBone怎么拿在模型加载回调里用getObjectByName查找骨骼节点。VRoid导出模型的骨骼通常叫Head、J_Bip_C_Head或者类似名字不确定的话可以在浏览器控制台里这样排查gltf.scene.traverse((obj) { if (obj.isBone) { console.log(obj.name); } });这里有个小坑骨骼的默认朝向轴不一定是Z轴lookAt之后头部可能转得很诡异。解决办法是先让头部保持默认姿态然后加一个固定角度偏移比如在lookAt之后对headBone.rotation做插值。具体偏移值只能在运行时试出来没有通用答案。4.3 配合页面滚动的入场与淡出很多个人主页不需要一直显示小人而是希望用户滚动到某个区域时小人浮现出来滚动离开时淡出。最轻量、运行最稳定的方案是操作canvas的CSS样式而不是让3D场景本身做复杂变换。const canvas renderer.domElement; const threshold window.innerHeight * 0.2; const scrolled window.scrollY; const opacity Math.min(1, scrolled / threshold); canvas.style.opacity opacity; canvas.style.transform translateY(${20 - scrolled * 0.04}px);把这段代码放进scroll事件监听里记得要用requestAnimationFrame节流不然滚动时会频繁触发样式写入低端手机容易掉帧。如果你需要更高级的入场效果比如相机从远处推近、小人转身亮相那就引入GSAP的ScrollTrigger根据滚动进度控制相机位置。但这类方案开发成本明显更高普通场景不建议一上来就做。5. 我在实际项目中踩过的坑与排查链路5.1 模型加载出来是黑的或者整体发灰这个问题在论坛里出现频率极高根因通常是三个叠加在一起灯光强度不够、材质用了MToon但没处理、色彩空间没有校正。完整的排查链路应该按下面的顺序走打开浏览器控制台先确认有没有加载报错。把模型临时换成Three.js自带的SphereGeometry如果球体正常则基本排除灯光和相机问题。检查AmbientLight强度建议先给到1.5以上测试。检查模型的材质列表如果发现material.userData里带有MToon标记说明材质没有完全转换需要按第2章的方式处理贴图。设置渲染器的色调映射renderer.toneMapping THREE.ACESFilmicToneMapping; renderer.toneMappingExposure 1.2;ACES色调映射能压掉高光过曝也让暗部更有层次对二次元场景来说是一个稳健的默认值。5.2 模型忽大忽小、半身陷入地面这个坑基本出现在从Sketchfab下载的免费模型上。原因很简单不同模型的单位、原点位置、轴向标准都不一样。解决步骤先暂停凭感觉调缩放的冲动用Box3获取模型实际高度。把模型缩放统一到1.5米左右。检查脚底位置模型结构根节点位置归零后如果脚底在y-0.1附近就说明模型原点是两脚之间的地面如果脚底在y1.5说明原点在模型几何中心需要手动把position.y调整到0附近。如果模型是Y轴向上还是Z轴向上的问题可以用model.rotation.x Math.PI / 2试转但通常GLB都是Y轴向上少见。5.3 手机端加载慢、掉帧甚至白屏移动端是另一个世界。桌面端流畅的页面到手机上可能直接卡成幻灯片。第一步限制像素比renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));这是最便宜的优化能立刻减少一半以上的GPU填充压力。第二步给模型做压缩。用gltf-transform跑一次Draco网格压缩npx gltf-transform/cli draco model.glb model-draco.glb压缩后文件体积通常能降到原来的一半以下代价是加载时需要解压时间但整体仍划算。第三步关闭阴影。很多模板喜欢开启阴影但阴影贴图的填充开销对移动端非常不友好。如果一定要有阴影让地面接收renderer.shadowMap.enabled true但模型本身不要投射阴影只保留一个圆形假阴影贴图视觉上也干净。5.4 模型不可见时应该暂停渲染而不是硬扛这是我到项目后期才意识到的一个优化点。很多访问者打开页面后根本不会把页面滚到小人所在区域但Three.js仍然以每秒60帧的频率渲染白白消耗CPU和电量。用IntersectionObserver观察canvas是否进入视口const observer new IntersectionObserver((entries) { if (entries[0].isIntersecting) { startRenderLoop(); } else { stopRenderLoop(); } }); observer.observe(renderer.domElement);在stopRenderLoop里用cancelAnimationFrame取消循环在startRenderLoop里重新发起requestAnimationFrame。这个改动对实际体验的提升非常明显尤其对笔记本和手机用户来说页面整体滚动流畅度高了一个档次。我实际做下来最大的体会是二次元小人的最终效果素材和动画占六成灯光和色调占三成代码只占一成。花在找模型、调材质上的时间一定不要压缩。先把同一个模型在本地用不同的灯光、色调映射、背景色反复试几轮找到自己最满意的组合再往上加交互和动画。这个顺序不能反否则最后你会发现自己写了很多代码效果却一直差一口气。
网站建设高端定制企业官网