Three.js介绍之-拆解 SpiritValers 模拟器的 3D 换装技术

举报
yd_289006361 发表于 2026/08/27 08:59:58 2026/08/27
【摘要】 拆解 SpiritValers 模拟器的 3D 换装技术:从 .pak 资源包到手写骨骼动画spiritvalers.com 的模拟器,我顺手把它的 3D 管线源码扒了一遍。整个 viz3d.js 只有 739 行,却完整实现了一套「从一个二进制包按需切模型 → 重建骨骼 → 手写采样器播动画 → 把武器挂到活骨骼上」的完整链路。没有用任何框架,也没有用 Three.js 的 Animati...


拆解 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 行代码换一个完整的、可商用级的换装系统——这就是工程上「做减法」的力量。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。