在浏览器里造开放世界,第 17 部分:不用重定向的动画,和一个实时资源搜索
作者:Oleg Sidorkin,Cinevva CTO 和联合创始人
刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。
第 16 部分给了我们一个可以往里放物体的世界。这一部分给玩家在里面更值得做的事:一套战斗级的动画集,还有一种通过打字搜索把上千个 CC0 模型里任何一个拉进世界的办法。
262 段动画,一副骨架,三个重定向修复
玩家骨架是一个 CC4 风格的 3MIKE 骨架,213 根骨头,包括一个 80 多骨的面部 rig 和完整的手指关节。动画来源跟它不匹配。第一版重定向了手挑的 Mixamo 战斗动画,但来源池太薄,移动动画在 Mixamo 和几段 Kimodo BVH 待机之间被尴尬地拆开。真正的胜利来自我们把来源库换成 Quaternius 的两个 Universal Animation Library 包,总共 262 段动画在同一个一致的 UE5 风格 65 骨人偶上:剑连招、弓箭、攀爬、跑墙、闪避、受击反应、表情,还有一套干净得多的移动动画集。
那是一个 65 骨来源到一个 213 骨目标,没有共享的骨头名、T 字姿势朝向或肢体比例。每段动画分三步重映射:一张骨头名映射、一次绑定姿势对齐让两副骨架共享一个参考朝向,还有位置轨道缩放,让更短的来源骨架不会把角色塞进地板齐膝深。把这个做对意味着追一连串各教了一件具体事的 bug。
角色出来扭过头了,每个肩和肘都多转了大约 30°。原因是姿势不匹配:UAL 来源发布的是真正的 T 字姿势,CC4 的绑定是 A 字姿势,而重定向器假设两副骨架都坐在匹配的参考姿势上,于是 A 到 T 的差被加进了每一帧的 delta。修法强制把 CC4 的手臂链摆成真正的 T 字姿势来捕获绑定,然后重定向,这样 delta 就保持很小。
接着身体不平移了。击退让上半身后仰,但脚钉在原地。UAL 把位移放在 root 骨上,不是骨盆上,所以读骨盆位置得到的运动接近零。修法把 root.position 和 pelvis.position 相加,缩放水平部分,写成一条单独的髋部位置轨道。做这个时我们发现竖直偏移差了大约 12%,因为我们在 Y 轴上用了全局肢体比例,而骨盆高度需要的是髋到地板的比例。两个比例、两个轴:Y 用 RL_BoneRoot.position 的 NaN 警告,追到把 UAL 的 root(在
最小的修复看起来最爽。角色用软绵绵的绑定姿势手指握剑,因为骨头映射里没有手指条目,而重定向器只为映射过的骨头写轨道。加上 30 根手指骨(五根手指、三段、两只手,丢掉 UAL 那根不会变形的第四个指尖辅助骨)让手在握柄上合拢、在松手时张开。
不看 262 段动画也能证明 262 段动画
你没法靠肉眼验证一个 262 段的库,所以我们做了一个离线轨迹一致性测试:一个无头的 Node 脚本,加载每个包,以 60Hz 采样来源骨架,跑重定向管线,把缩放后的每根骨头的世界位置和旋转跟来源比对。骨盆 Y 漂移最大 0.003 米。手显示出一个恒定的 2.5° 偏移,第一眼读成"手指没在跟踪",但一个恒定偏移是平的 UAL 手和 CC4 略微弯曲的绑定之间的绑定 delta,它在整段动画里不变。真正的动画错误表现为逐帧变化的漂移。一旦弄清这点,测试就成了一个一次性的回归检查:如果某段动画的漂移不再守住那个恒定基线,就是最近某个改动弄坏了重定向。
一个并排的参考人偶让调试的视觉那一半变得决定性。按反斜杠会在玩家旁边显示拥有当前动画的那副来源骨架,于是一个"肩膀扭了"的问题就变成"扭是在来源里,还是重定向加上去的?"数值测试抓回归,视觉参考抓数字浮不出来的绑定姿势错误,两者一起把猜谜游戏退役了。
管线稳了之后,把 WASD/跳跃/游泳的基线从 Mixamo 切到 UAL 只是一张薄薄的别名映射:FSM 仍然说 idle、walk 这种通用状态名,在播放时解析成 UAL 的动画名。游泳需要每段动画的 rig 偏移,因为自由泳和踩水姿势把骨盆锚在不同的解剖高度,所以我们在主动游泳时把 rig 下沉半米、踩水时更深,以平滑的 5Hz 在两者间混合。
打一个词,得到一个模型
Spike 34 花了一天手工策展一个 CC0 包。长期的答案是一个搜索框。Polyhaven 在一个宽松的 JSON API 和一个确定性的 CDN 后面发布了大约 1100 个 CC0 模型,这个 spike 从一个无构建步骤的静态页面把整条路径(查询、缩略图、加载、渲染)都接起来,大约 300 行原生 JS 加 three.js。
启动时它一次性拉取完整目录,约 600 KB。搜索是纯客户端打分(名字胜过 id、id 胜过类别、类别胜过标签),带 120 毫秒去抖,渲染前 60 张卡片。缩略图通过一个 IntersectionObserver 懒加载,这样打字不会一次发出 60 个请求。有意思的一块是加载。Polyhaven 的文件端点暴露的是多文件 glTF、不是 GLB,纹理跨分辨率共享并拆成单独文件,它返回一张相对路径到绝对 CDN URL 的 include 映射表。与其自己下载并打补丁那段 JSON,我们把那张映射表喂给 LoadingManager.setURLModifier,它会为加载器需要的每一个依赖(.bin、每张纹理)触发,并通过 CDN 解析。一次点击,看起来一个文件。API 和 CDN 都设了宽松的 CORS,在写任何客户端代码之前用 curl 验证过,所以不用代理。PBR 材质在 RoomEnvironment 和 ACES 色调映射默认值下正确渲染,无需每个资源单独修,而 1k 纹理把一个典型模型保持在 2 到 5 MB,而不是 4k 的 20 到 40 MB。
一个 meshoptimizer WASM 过程用一个非破坏性的简化器收尾:每个 mesh 藏一份原始几何体的克隆,改比例时从那份克隆重建一个索引 buffer,而不是累积式地简化。多材质几何体按 group 分组简化并重建 geometry.groups,所以材质槽不会塌掉。一把扶手椅从满精度的 5626 三角形到半精度的 2812。
本章涉及的技术
带绑定姿势对齐的骨架重定向。 把动画从一副骨架映射到另一副骨头名、比例和参考姿势都不同的骨架,需要三个修正:一张骨头名映射、一次绑定姿势对齐让两副骨架共享参考朝向(把目标的 A 字姿势手臂链强摆成来源的 T 字姿势)、还有位置轨道缩放。姿势不匹配会把 A 到 T 的旋转加进每一帧的 delta,让关节旋转翻倍。没映射的辅助骨必须丢掉,因为一根在
一副 rig 的两个缩放比例。 水平位移用全局肢体比例(整体骨架大小),但竖直骨盆偏移用髋到地板的比例 root 骨上、不是骨盆上,所以两者都必须求和进一条单独的髋部位置轨道,才能在击退、攀爬和移动动画里都保住运动。
离线轨迹一致性测试。 一个无头脚本以 60Hz 采样来源骨架,跑重定向管线,把每根骨头的世界变换跟来源比对。一个恒定的每帧偏移是无害的绑定 delta,而逐帧变化的漂移是真错误,所以测试成了一个回归检查,会在某个改动丢掉或扭曲一条轨道时触发。逐包的 GLB 隔离(重新加载,下一个之前先释放)避免了整轮扫描中的缓存争用。
给 CDN glTF 图用的 LoadingManager.setURLModifier。 当一个 CDN 把 glTF 作为一张相对 URI 图、配一张 include 映射表(相对路径到绝对 URL)发布时,setURLModifier 通过 CDN 解析加载器请求的每一个依赖,而不用重写 JSON。这把一个多文件、多分辨率的分发塌成一次点击就能加载。在下一次加载之前先释放上一个模型的几何体、材质和纹理 map,能防止一次浏览会话里累积上百 MB 的 GPU 内存。
非破坏性 mesh 简化。 存一份每个 mesh 原始几何体的克隆,每个简化比例只重建索引 buffer,让改动保持快、又避开重复简化带来的累积损伤。按 geometry.groups 切片分别跑并重建 group,保住多材质分配。看 LOD 和 meshoptimizer 了解这如何喂给基于距离的 LOD。
第 17 部分,共 29 部分。 上一篇:第 16 部分 - 为一个持续生长的世界做结构 下一篇:第 18 部分 - 一个感觉像 AI 摆的撒布笔刷 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide