ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

网页端二次元3D小人完整教程:从VRM加载到表情交互

网页端二次元3D小人完整教程:从VRM加载到表情交互 开头在做网页端3D内容这块其实一直有个挺尴尬的处境想做一个二次元风格的小人展示页网上搜到的教程不是停留在“贴个盒子旋转”的demo就是拿着国外引擎的复杂管线硬搬折腾一圈下来连个人物的影子都没见到。这次我打算把“javascript网页二次元3D小人”这件事彻底拆开聊从模型格式选型、Three.js加载、VRM捏人数据解析到人物动起来、表情切换、镜头交互完整走一遍可落地的流程。这篇文章适合谁看如果你是刚接触网页3D的前端开发者或者已经在Three.js里做过简单场景、但一直想加入一个能看能动的二次元角色那么这篇内容应该能帮你省下至少一周的试错时间。核心用的是一套完全开源免费的工具链Three.js负责渲染VRM格式负责承载人物模型和骨骼、表情、物理头发等元数据中间夹着一些我自己踩过的坑和绕过的弯路。不整玄乎的直接按实际项目推进的顺序来。1. 凭什么用VRM而不是直接上glTF1.1 VRM和普通glTF的区别到底在哪要做一个二次元3D小人第一步不是写代码而是想清楚人物模型用什么格式。如果只是找一个普通的glTF模型比如那种从Sketchfab下载的卡通角色加载进Three.js确实也能显示但它只是一个“静态的模型文件”里面包含了几何体、贴图最多带几段烘焙好的骨骼动画。VRM则不一样它本质上是基于glTF的一种扩展标准专门为“虚拟形象Avatar”设计的。它在glTF的基础上增加了大量人物专属的元数据包括人形骨骼映射Humanoid Bone、表情混合变形BlendShape Clip、视线追踪LookAt、头发和衣服的物理摆动Spring Bone等等。这些字段在普通glTF里是没有标准定义的但在VRM里是规范的一部分。我用一个类比来解释glTF像是给你一根木头和一盒颜料你自己去雕个人出来VRM像是给你一个已经组装好的关节可动人偶每个关节都标好了名字哪个是左手肘、哪个是右膝盖头能转、嘴能动连头发摇摆的物理参数都预设好了。对于网页二次元小人这个需求来说VRM就是那个“人偶”你只需要告诉它该怎么动。1.2 人物来源和捏人工具链既然确定了用VRM格式接下来就要解决一个现实问题模型从哪来两个途径——下载现成的或者自己捏。下载的话推荐去VRoid Hub上面有大量创作者分享的免费VRM模型下载后直接就能用。自己捏的话用VRoid Studio这是官方免费的人物编辑器操作逻辑和主流捏脸游戏基本一致可以调整脸型、发型、服装、体型导出后就是一个标准的.vrm文件。这里有一个关键参数要给新手提醒VRM文件的大小。默认导出的VRM文件可能动辄几十MB因为贴图都是4K甚至8K的。网页端直接加载几十MB的模型首屏体验会非常糟糕。所以我习惯在VRoid Studio导出前把纹理尺寸压缩到2048模型精度调低一档这样文件体积能压到5~8MB观感上几乎没区别。如果模型已经导出了也可以用gltf-transform这类命令行工具做二次压缩后面在性能优化部分我会详细说。2. 搭建加载环境让模型先出现在页面里2.1 项目初始化和Three.js引入现在开始写代码。我先说明一下技术栈用Vite作为开发服务器和打包工具Three.js负责渲染再加一个pixiv/three-vrm库来处理VRM模型的生命周期。为什么选pixiv/three-vrm而不是自己解析VRM因为VRM标准里包含了大量复杂的扩展字段从glTF加载出来后还要做骨骼映射、BlendShape绑定、Spring Bone初始化这些工作如果全部手写代码量会非常恐怖。pixiv/three-vrm把这一整套流程封装好了而且它是VRM标准的官方维护方之一持续在更新。npm create vitelatest vrm-demo -- --template vanilla cd vrm-demo npm install three pixiv/three-vrm这里我建议你把three和pixiv/three-vrm都装成最新版本。这个领域版本兼容性问题非常突出老版本three-vrm可能不支持新版Three.js的API变动我遇到过一次升级Three.js后整个模型的材质全部变黑的诡异Bug后来排查了一圈才发现是库之间的版本冲突。2.2 核心场景搭建代码创建一个main.js把基础的场景、相机、渲染器、灯光都配好然后加载VRM模型。这里需要注意几个细节渲染器的输出编码要设置成sRGBEncoding否则模型的颜色会偏灰灯光的设置上建议用一个环境光加一个方向光方向光负责打出立体感环境光负责提亮暗部否则二次元角色的脸部会显得很平。import * as THREE from three; import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js; import { VRMLoaderPlugin, VRMUtils } from pixiv/three-vrm; const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(30, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(0, 1.3, 3); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.outputEncoding THREE.sRGBEncoding; renderer.toneMapping THREE.ACESFilmicToneMapping; document.body.appendChild(renderer.domElement); const light new THREE.DirectionalLight(0xffffff, 1.2); light.position.set(1, 2, 3); scene.add(light); scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const loader new GLTFLoader(); loader.register((parser) new VRMLoaderPlugin(parser)); loader.load( /models/my-character.vrm, (gltf) { const vrm gltf.userData.vrm; scene.add(vrm.scene); VRMUtils.rotateVRM0(vrm); }, (progress) { console.log(加载进度: ${(progress.loaded / progress.total * 100).toFixed(1)}%); }, (error) { console.error(模型加载失败:, error); } ); // 动画循环 function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();这段代码跑起来后页面上应该就能显示一个站立的人物了。但先别急着高兴有几个必经的坑第一个坑是VRM0和VRM1的兼容问题。目前网上能下载到的模型很大一部分还是VRM0的旧格式而新版three-vrm默认同时支持但需要在加载后做一次VRMUtils.rotateVRM0(vrm)旋转校正。因为VRM0标准的模型原始朝向是面向Z轴负方向的如果不纠正人物会背对着相机。VRM1的模型没有这个问题但如果你的加载代码不加判断新旧模型混着用就会乱。第二个坑是渲染器的颜色空间。如果你发现人物脸色发灰、整体色调偏淡十有八九是outputEncoding没设置或者设置错了。Three.js r152之后颜色空间API发生了变化outputEncoding被移除改成了renderer.outputColorSpace THREE.SRGBColorSpace。用旧代码配新版本three库这里必然报错。第三个坑是模型加载后的回调时机。gltf.userData.vrm在gltf加载完成后其实还没有完全初始化VRM内部的Spring Bone和LookAt都在这时才构建。如果立刻去访问vrm.humanoid之类的属性可能会拿到undefined。我的建议是加载完成后不要急着操作模型先做一个update(0)强制VRM完成一次初始化。3. 让小人“活”起来——头部追踪、表情切换和眨眼3.1 鼠标控制头部转向模型显示出来只是第一步二次元小人的核心体验在于“互动感”。这一节先说头部追踪鼠标在页面上移动小人的眼睛和头能跟着看。VRM标准里专门定义了一个LookAt模块它把模型头部的旋转和眼球的转动做了归一化处理。实现思路是根据鼠标位置换算出一个视线方向向量然后调用vrm.lookAt.target去设置这个方向VRM会自动分配头部和眼球的旋转权重。const lookAtTarget new THREE.Vector3(); window.addEventListener(mousemove, (event) { // 将鼠标坐标映射到 -0.5 ~ 0.5 范围 const x event.clientX / window.innerWidth - 0.5; const y -(event.clientY / window.innerHeight - 0.5); lookAtTarget.set(x, y, 1); }); // 在动画循环中 vrm.lookAt.target lookAtTarget;如果你直接把鼠标坐标映射成lookAtTarget会发现效果非常僵硬。原因在于LookAt模块默认的头部旋转幅度上限是有限的而鼠标移动是高频事件不断更新目标向量会让头部一直处于“追目标”的紧张状态。我踩坑之后的做法是加了一个低通滤波不要让lookAtTarget直接等于鼠标位置而是让它用一个t的系数向目标值靠拢。这样头部转动会有轻微的滞后感看起来更像人的自然动作。代码很简单const currentLookAt new THREE.Vector3(); const targetLookAt new THREE.Vector3(); // 每帧更新 targetLookAt.lerp(lookAtTarget, 0.1); vrm.lookAt.target targetLookAt;3.2 BlendShape表情切换说完了头部追踪再来说表情。二次元人物的表情本质上是通过BlendShape在VRM里叫BlendShape Clip来控制的。比如一个模型导出来通常自带这些表情Joy高兴、Angry生气、Sorrow悲伤、Fun惊讶、Blink眨眼、Blink_L左眼闭、Blink_R右眼闭、A、I、U、E、O口型。在three-vrm里切换表情的API非常简洁// 预设表情 vrm.expressionManager.setValue(Joy, 1.0); // 1.0 表示完全激活 vrm.expressionManager.setValue(Angry, 0.5); // 0.5 表示半激活 // 每帧必须调用 update表情权重才会真正生效 vrm.update(deltaTime);这里有个非常重要的细节表情是可以叠加的。比如“高兴”和“眨眼”同时激活小人就会面带笑容并眨眼睛。这和我们平时用的动画状态机不太一样它更像是一个权重混合系统。实际做交互时可以先做一个基础情绪比如Joy设0.5然后叠加眨眼、口型效果非常自然。3.3 自动眨眼系统的实现眨眼是最能提升真实感的小动作。VRM预设的Blink表情权重只有0或1如果直接设成1小人就像在“闭眼瞪人”非常不自然。所以要自己做一套眨眼曲线。我用的方案是每隔3~5秒触发一次眨眼眨眼过程分成“闭眼-保持-睁眼”三个阶段每个阶段用不同的时间常数控制let blinkTimer 0; let blinkPhase open; let blinkWeight 0; function updateBlink(delta) { blinkTimer - delta; if (blinkTimer 0) { blinkPhase closing; } if (blinkPhase closing) { blinkWeight delta * 12; if (blinkWeight 1) { blinkPhase opening; blinkTimer 3 Math.random() * 2; // 3~5秒后再次眨眼 } } else if (blinkPhase opening) { blinkWeight - delta * 8; if (blinkWeight 0) { blinkPhase open; } } vrm.expressionManager.setValue(Blink, Math.max(0, Math.min(1, blinkWeight))); }闭眼速度设为睁眼速度的1.5倍符合真人眨眼“闭快睁慢”的肌肉特征。如果你观察过真人会发现闭眼是一个快速动作而睁眼相对舒缓这个细节在动画里特别加分。3.4 呼吸系统——让小人站姿充满生机很多人会忽视呼吸。一个完全静止的模型哪怕表情再丰富也会透着一股“假人”感。呼吸实际做起来非常简单给模型的胸腔骨骼或者根骨骼加一个微小的周期性位移。但我不建议直接动骨骼的position因为那样会破坏模型的原点位置后面做动画重定向容易出问题。更安全的做法是找到模型骨骼层级里的胸腔(Chest)或上半身(UpperChest)节点给它加一个局部的旋转周期const chestBone vrm.humanoid?.getNormalizedBoneNode(chest); // 在动画循环中 const breath Math.sin(time * 1.2) * 0.01; chestBone.rotation.x breath;振幅0.01弧度大概0.57度肉眼看起来是轻微的起伏非常自然。呼吸频率在1.2Hz左右也就是每分钟72次接近成年人静态心率。4. 物理效果和动画——让小人自然地和场景互动4.1 Spring Bone头发与衣服摆动VRM模型的双马尾、长裙、飘带这些部位的动态效果是由Spring Bone系统驱动的。简单理解它是一套简化的弹簧物理模拟每个骨骼节点被当成一个带有惯性、阻尼和弹性系数的质点受到重力影响会产生摆动。three-vrm库在加载模型时会自动初始化Spring Bone但默认参数比较保守摆动的幅度很小。如果你想做出“风吹头发轻轻扬起”的效果可以手动调整Spring Bone的参数// 加载完成后 vrm.springBoneManager?.setGravity(new THREE.Vector3(0, -0.2, 0));setGravity默认值是(0, -1.0, 0)就是普通的向下重力。把Y轴的重力调低到-0.2相当于减弱了重力影响头发和裙摆会显得更轻飘。如果想要更活泼的效果可以顺便加大每个Spring Bone碰撞球的半径防止头发穿模穿过身体。Spring Bone最大的坑是性能。一个中等面数的VRM模型Spring Bone节点数可能在10~20个左右每个节点都要做物理运算如果手机浏览器上跑帧率会掉得厉害。我通常的做法是在移动端检测到低帧率时直接把Spring Bone的物理更新关掉退化成静态绑骨虽然没有物理摆动但至少画面是流畅的。4.2 VRM动画重定向——让人物播放预设动作要让小人播放跑跳、挥手等完整动作思路是给VRM角色绑定一段标准的Humanoid骨骼动画。Three.js官方仓库里有一些Mixamo动画的转换示例。处理步骤大体是用FBXLoader或GLTFLoader加载动画资源然后通过THREE.AnimationMixer绑定到VRM的骨骼上最后把动画中的骨骼名称映射到VRM的归一化骨骼名称上。// 伪代码展示核心流程 const mixer new THREE.AnimationMixer(vrm.scene); const action mixer.clipAction(animationClip); action.play();Mixamo动画和VRM的动作能匹配是因为双方都遵循Humanoid骨骼规范。但要注意Mixamo动画默认的质心位移在Y轴上可能和VRM模型的重心不一致播放时容易出现模型悬空或者陷进地面的问题。遇到这种情况可以在加载动画后把动画中的根骨骼位移曲线全部清零只保留四肢和躯干的旋转动画。4.3 点击交互——鼠标点选与手势识别如果只是展示其实做到上面那步已经够了。但我个人觉得二次元小人的灵魂在于“它知道你碰了它”。简单做一层鼠标射线检测点击小人时播放一个专属动画比如挥手这个交互体验会立刻上一个档次。const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); renderer.domElement.addEventListener(click, (event) { pointer.x (event.clientX / window.innerWidth) * 2 - 1; pointer.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(pointer, camera); const intersects raycaster.intersectObject(vrm.scene, true); if (intersects.length 0) { // 触发挥手动画 playWaveAnimation(); } });有个细节需要注意intersectObject的第二个参数一定要设成true因为VRM模型是一个嵌套层级很深的场景树如果不递归检测子节点射线只会撞到最外层的包围盒点击不精确。关于手势识别如果你想让用户比一个手势比如比耶就能触发小人的反应可以接入浏览器端的MediaPipe Hands把手部关键点坐标映射成对应的指令。但这个方案对浏览器性能要求比较高我自己的项目里是放在PC端使用的手机端会卡成PPT建议谨慎评估。5. 显示优化、加载优化和常见问题速查5.1 首屏加载的性能优化网页端3D最大的拦路虎是加载时间。一个8MB的VRM模型在普通4G网络下需要好几秒用户早就划走了。我的优化习惯分成三档第一档压缩模型资源。使用gltf-transform对VRM进行纹理压缩和Draco几何压缩。VRM文件本质上是glTF的扩展所以gltf-transform也能直接处理。压缩几何体对于人物模型来说收益很大尤其是发型部分的网格。操作方式npx gltf-transform optimize input.vrm output-optimized.vrm --texture-compress webp --compress draco这个命令会把贴图转换成WebP格式几何体用Draco压缩整体体积通常能再缩小60%~70%。代价是模型加载后需要多一点解压时间但网络传输时间少了一大截综合体验是明显提升的。第二档加载进度和过渡动画。用户等待的时候画面不能是白屏。Three.js支持LoadingManager可以拿到每个资源的加载进度配合一个CSS进度条至少让用户知道“快好了”。我个人还会在加载完成前显示一个Canvas绘制的简单动画背景比纯白屏强太多。第三档按需加载和延迟加载。如果页面还有其他内容比如人物介绍文字、背景场景建议把3D小人放到底部或者弹窗里用户触发时才加载模型。这样可以避免页面一开始就因为加载模型而卡死。如果一定要首屏展示可以先用一个低精度版本占位等用户空闲时再替换成高精度模型。5.2 常见问题排查实录这里整理一下我实际碰到的频率最高的坑按排查顺序排了优先级。问题1模型加载后全黑或者颜色偏灰。检查渲染器的颜色空间设置。旧版本用renderer.outputEncoding THREE.sRGBEncoding新版本r152用renderer.outputColorSpace THREE.SRGBColorSpace。如果代码用旧API配新Three.js不会报错但颜色就是不对。另外贴图资源的色彩空间也要匹配在VRM设置里把每个贴图的texture.colorSpace设为SRGBColorSpace。问题2模型加载成功了但是整个人是躺着的。这是VRM0模型的经典问题前面说过加一行VRMUtils.rotateVRM0(vrm)就行。别忘了加载VRM1模型时跳过这段逻辑。问题3BlendShape表情设置值不为0但模型没变化。检查表情名称的拼写。不同模型的BlendShape命名可能不一样有的用Joy有的用happy最好加载完后打印vrm.expressionManager.expressions确认一下实际可用的表情key有哪些。问题4页面很卡帧率低。优先排查两点一是Spring Bone节点数量二是渲染器的像素比。把renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))限制一下像素比是性价比最高的一步。高DPI屏幕不限制渲染压力是成平方增长的。问题5模型在地面上飘着或者陷进去了。VRM模型的基准点的位置和普通glTF不同。解决办法是拿着模型的AABB包围盒把包围盒的Y轴最小值对齐到地平线0。这个计算很简单但一定要在模型完整加载后做否则包围盒数据是错的。5.3 踩过的三个隐蔽的坑除了上面那些能搜到答案的问题我再分享三个我摸索了很久才定位到的隐蔽问题。第一个是BlendShape权重重置问题。当你切换表情时每次设值前必须把上一帧的所有表情全部归零否则权重会一直累加。最坑的是VRM的表情混合权重是可以在多个帧之间累计的用if判断而不是每次循环都重新赋值就很容易出现“表情越来越夸张”的Bug。第二个是粒子系统遮挡人物的问题。给场景加粒子特效比如樱花飘落时粒子渲染的深度写入默认是开启的会遮挡人物看起来像人物被一层半透明幕布罩住了。解决办法是设置粒子的depthWrite false再进行合理的depthTest配置。第三个是相机距离与裁剪面的匹配。VRM模型的精模面数通常在5万面左右人物身高约1.5米。如果相机太远模型会因为LOD不足显得模糊如果相机太近、近裁剪面设得太大人物的头发和衣角会被切掉。我习惯把相机的near设为0.01far设为100FOV设为30度这个组合在大多数场景下都稳定。最后分享一点我的个人体会这套流程走通之后你会发现网页二次元3D小人其实没有想象中那么高不可攀它真正的复杂度不在渲染而在生态选型。Three.js本身只是渲染器VRM才是让人物“像人”的关键它把骨骼、表情、物理、视线这些东西全部标准化了。做项目时最大的心得是尽量跟着标准走不要自己造轮子。我见过很多开发者想要手动写一套表情驱动系统结果维护成本直接爆炸而VRM标准里早就有了完整的BlendShape Clip规范你要做的只是调用它。另外说一句经验之谈如果你做的页面打算面向移动端建议从一开始就限制纹理尺寸、开启内存节省模式VRMUtils.combineSkeletons、关闭Spring Bone物理优先保障60帧再逐步回填细节。效果和性能的平衡点一定要在真机上实测不能只在桌面浏览器里看。这个项目后续还可以继续延伸的方向有很多——接入语音驱动口型Lipsync、接入WebSocket同步多人姿态、接入AR相机让人物出现在真实场景里。从一个能看的二次元小人出发往哪个方向扩展都很有趣。希望这篇教程能给你打下一个稳固的基础。
返回列表