Three.js介绍之-拆解 SpiritValers 模拟器的 3D 换装技术
拆解 SpiritValers 模拟器的 3D 换装技术:从 .pak 资源包到手写骨骼动画
spiritvalers.com 的模拟器,我顺手把它的 3D 管线源码扒了一遍。整个 viz3d.js 只有 739 行,却完整实现了一套「从一个二进制包按需切模型 → 重建骨骼 → 手写采样器播动画 → 把武器挂到活骨骼上」的完整链路。没有用任何框架,也没有用 Three.js 的 AnimationMixer。这篇文章带你一层层看它到底是怎么做的。
先看全局,计算全在浏览器、对 3D 开刀之前,先花一分钟看它的整体选型,因为 3D 方案是围绕这个思路「长」出来的:
层 选择
框架 无框架。约 30 个手写 ES Module,<script type="module"> 直载,?v=<hash> 做缓存指纹
数值引擎 sim-core.js 里纯函数 applyStat() + derive(),两个页面共用一份,防止公式漂移
数据 静态 JSON(怪物、技能、装备、消耗品)
一句话总结它的哲学:计算和数据全在浏览器本地,理解了这一点,你就能明白为什么 3D 层会那样设计——一切偏向「静态、可缓存、轻量、防扒」。
第一层:渲染,朴素到极致
js
new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
new THREE.PerspectiveCamera(28, 1, 0.05, 100);
alpha: true 让 canvas 背景透明,3D 角色直接「叠」在页面主题色上,而不是套一个黑框
28° 的小广角 + 一个 568×400 的小画布,旁边就是装备栏
用一个 OrbitControls 处理拖拽旋转
没有后处理、没有光影烘焙。为什么?因为它的定位就是低多边形预览——把成本花在「换装要即时、要跟手」上,而不是把画面做多高级。
第二层:资源,自研 .pak 包 + HTTP Range 切片(最值得学的设计)
这是整个方案里我最喜欢的一环。
它不把模型当散文件存,而是把所有 GLB 拼进一个大二进制文件 spiritvale.pak:
打包时用脚本把所有 GLB 依次 gzip 后拼接,同时产出一份 pack-index.json,记录「条目名 → 字节偏移量」
前端要某件装备时,发一个带 Range 头的请求:
http
GET /viz/spiritvale.pak Range: bytes=123456-234567
服务器只回那一小段字节,浏览器用原生 DecompressionStream 解压,再 GLTFLoader.parseAsync() 直接解析出网格
配 cache: "force-cache",切过的片段就留在 HTTP 缓存里,下次秒开
这个设计的收益很实:
一个文件、一次连接、按需取片段,CDN 友好
天然防爬——想扒你散装的模型文件?抱歉,只有一个带偏移索引的大包
关键:它还有降级链
评论区代码写得很诚实,处理了两个真实世界的坑:
返回 416(Range 请求越界)→ 说明 CDN 对 pak 做了透明 gzip,把字节偏移搞对不上了
返回其它异常 → 标记 rangeUnsupported,回退成整包下载一次、在本地 slice() 切片
也就是说:「能省流量就切片,切片不行就整包兜底」,绝不会让功能直接挂。
第三层:骨骼,把静态 GLB 重建为 SkinnedMesh
游戏导出来的 GLB 根本不是带蒙皮的动画模型,而是静态网格。那动画从哪来?答案是自己造:
从 anim-idles.json 建立一套 28 节点的玩家骨架(GL 坐标空间,注意从 Unity 镜像了 x 轴)
每个加载进来的 GLB,读取它的 JOINTS_0 顶点权重数据
通过一张映射表,把模型自己的骨骼编号换算到共享骨架上的编号
重新构造成 THREE.SkinnedMesh,挂到共享骨架上
这就是 initRig / skinPiece 干的事。意思是:不管模型来自哪个文件、原本骨骼叫啥名,最终都统一「长」在同一套玩家骨架上。
另外还有 rigid-skin overrides 处理一些刚性绑定的部件(不该被骨骼拉伸变形的部分)。
第四层:动画,不用 AnimationMixer,手写采样器
这是最有意思的部分——代码里完全不用 THREE.AnimationMixer,动画是它自己播的。
数据从哪来
用 Unity 编辑器里一个批处理脚本(dev/build-anim-proto.mjs),从游戏 Demo 的 Humanoid 上一帧帧导出每个骨骼的 TRS 轨道(位移 / 旋转四元数 / 缩放),存成 anim-idles.json。JSON 结构里分:rig(骨架)、joints、rigid、clips、以及多套 idle 动画(单手 / 双手 / 空手)。
怎么播
每帧调用 sampleIdle() 采样,做两件事:
Catmull-Rom 插值平滑骨骼的位移/缩放轨道
**四元数 slerp(球面插值)**处理旋转——直接对欧拉角插值会出万向锁,四元数不会
换武器时调 setIdleClip / chooseIdle 切换单手/双手/空手三套待机动画。
容错
动画数据加载失败时,整条路径降级回静态 T-Pose 绑定姿势——3D 不至于白屏。
这套「Unity 烘焙成 JSON + 自研采样器」的玩法,本质是把动画重担从运行时挪到离线烘焙,前端只做轻量采样,这也是它能那么小、那么快的核心原因。
第五层:挂接,把武器绑到「活」的骨头上
attachHand 是最有含金量的一段数学。
武器不是挂在场景节点上的——那样「手一动武器就脱手」。它的 mount 点是会动的手部骨骼本身。
核心公式:
code
local = inverse( restWorld(烘焙时的手部挂点) )
意思是:制作时武器是「烘」在手上的,导出时带着那个姿势的世界变换。到了运行时,先把这个烘焙姿势的挂点世界矩阵求逆,把武器反解回手骨局部空间,再绑到活骨骼上——于是骨骼怎么动,武器就跟着怎么动。
副手还有一步修正:
code
delta = compose(Offhand) × inverse(compose(Mainhand))
同一把模型从主手换到副手,就是乘一个「副手相对主手」的增量变换,不需要为副手单独做一份模型。
而每个槽位(背挂 / 副手 / …)的具体挂接变换(position / 旋转四元数 / scale)和染色数据,全部记录在 model-map.json 里——这就是「换一个槽位挂上去」的数据来源。
第六层:外观,原版数据提取 + 染色
换装时 applyHairHide 自动隐藏被头盔压住的头发
applyTints 按 model-map 里的 dye 数据给共享网格染色
角色色板直接从游戏原版 GameServerConfig.asset 里提取(8 个人类肤色 + 绿/紫/蓝/红幻想色),游戏更新就重新提取
外观偏好存 localStorage(sv-look),登录后镜像到 Supabase profile
另有一份「低多边形下渲染效果差的全头套/道具」屏蔽清单
细节耐心到这个程度,是它「纸娃娃体验」顺滑的真正原因。
如果你也想复刻:一条渐进路线
阶段一(跑通):Blender 做一个人形 + 三件装备导出 GLB → 原生 Three.js 加透明背景 + OrbitControls → GLTFLoader 加载 + 按槽位硬编码挂装,体会 model-map 的用处
阶段二(打包):Node 脚本把 GLB gzip 后拼 .pak 输出偏移索引 → 前端 Range 切片 + DecompressionStream + parseAsync,再做整包降级
阶段三(动画):Blender 做骨架和 idle,烘焙成逐骨骼 30fps TRS 轨道 JSON → 手写 Catmull-Rom + 四元数采样器 → 实现 inv(restWorld(...)) 武器挂点
阶段四(可选产品化):数值引擎纯函数化共享 + Supabase 注册/云存档/分享
小结
这套方案最值得借鉴的不是某一行代码,而是两个设计判断:
把重活搬到离线(动画烘焙成 JSON、资源切成大包),运行时只留「采样 + 切片 + 解析」这类轻操作
每一步都有降级路径(Range 不行就整包、动画挂了就 T-Pose),保证体验下限
用 739 行代码换一个完整的、可商用级的换装系统——这就是工程上「做减法」的力量。
- 点赞
- 收藏
- 关注作者
评论(0)