Threejs案例
rapierjs-ragdoll
rapierjs-ragdoll

一个 GLTF 角色模型扔出去,每个关节该怎么弯、四肢以什么角度落地,全由物理引擎实时计算。头、躯干、上臂、下臂、大腿、小腿各自是独立的刚体,颈部、肩膀、肘部、髋关节、膝盖靠 9 个球形关节约束连接,整个系统跑在基于 WebAssembly 构建的 Rapier 物理引擎上。

这是 rapierjs-ragdoll 的效果。它把完整的布娃娃物理模拟搬进了浏览器,源码包含 Blender 角色文件,可以直接拿来参考。

刚体与骨骼的坐标系问题

在游戏引擎里做布娃娃通常有现成的骨骼物理组件,工程师不需要关心底层映射。但在 Web 环境里把 Rapier 和 Three.js 组合使用,就需要手动解决两个系统之间的坐标系差异。

Rapier 的刚体生活在世界坐标系中,每个刚体的位置和旋转都是绝对值,互相独立。Three.js 的骨骼则活在层级坐标系中,每根骨骼的变换是相对于父骨骼的局部值。把物理刚体的世界旋转直接写进骨骼的 quaternion,结果是错的——因为父骨骼已经贡献了一部分旋转,子骨骼需要的是「在父骨骼变换之后,还需要再转多少」。

此外,GLTF 模型的骨骼在静止姿势下就有初始朝向(rest pose),而 Rapier 刚体在创建时通常以标准正立朝向放置,两者之间存在旋转偏差。如果不做修正,一旦物理模拟启动,骨骼会立刻扭曲。

rapierjs-ragdoll 的做法

核心思路是:用物理引擎的旋转驱动骨骼旋转,同时在每帧做坐标系变换,把世界旋转转换回骨骼局部空间。

物理骨架的构建

初始化阶段创建 10 个动态刚体,对应角色的每个肢体,用具体尺寸的长方体碰撞体近似骨骼形状:躯干宽 0.4、高 0.55,大腿长 0.38,小腿长 0.43,上臂长 0.3,下臂长 0.42。这些数值和 Blender 中建模的角色骨骼比例对应。

关节使用 Rapier 的球形关节(JointData.spherical),在每对相邻刚体上各设一个局部锚点,由 createImpulseJoint 创建约束。球形关节允许任意方向旋转,但限制了相对位移——这正是肩膀、髋关节等人体关节的物理特性。关节锚点的偏移量经过仔细调整,确保颈部、肘部、膝盖等位置的连接处没有间隙。

记录初始骨骼朝向

GLTF 模型加载完成后,在开始物理模拟之前,遍历所有需要驱动的骨骼,把它们的世界四元数记录到 initialBoneWorldQuaternions 映射表里。这张表是后续每帧同步的基准——它描述了「物理还没介入时,每根骨骼在世界空间中的朝向」。

每帧骨骼同步

同步按固定顺序执行:躯干 → 头部 → 左臂链 → 右臂链 → 左腿链 → 右腿链。父骨骼必须先于子骨骼处理,因为子骨骼的坐标系变换依赖父骨骼已更新后的世界矩阵。

每根骨骼的旋转同步按以下步骤计算:

// 1. 从 Rapier 刚体取物理旋转(世界空间)
const bodyQuat = new Quaternion(rotation.x, rotation.y, rotation.z, rotation.w);
 
// 2. 叠加初始骨骼朝向,得到目标世界旋转
const targetWorld = bodyQuat.clone().multiply(initialQuat);
 
// 3. 转换回父骨骼局部空间
bone.quaternion.copy(parentQuat.invert()).multiply(targetWorld);

步骤 2 的 multiply(initialQuat) 是关键。它的含义是:以骨骼的静止朝向为基准,在此基础上叠加物理引擎产生的旋转变化。如果去掉这步,静止姿势下骨骼就会错位,因为 Rapier 刚体的"零旋转"和 GLTF 骨骼的"零旋转"并不一致。

步骤 3 将目标世界旋转除以父骨骼的世界旋转,得到局部旋转量。这和手动将世界变换转换为局部变换的矩阵操作等价,但直接操作四元数更高效。

位置只有根骨骼(躯干/spine)需要从物理刚体同步,其他骨骼的位置由 Three.js 骨骼层级根据父骨骼变换自动推算,不需要额外处理:

if (key === 'torso') {
    const t = body.translation();
    const offset = new Vector3(0, -torsoHeight / 2, 0).applyQuaternion(bodyQuat);
    const bodyPos = new Vector3(t.x + offset.x, t.y + offset.y, t.z + offset.z);
    parent.worldToLocal(bodyPos);
    bone.position.copy(bodyPos);
}

这里还做了一个偏移修正:刚体的原点在中心,骨骼的原点在根部,因此要把刚体位置向下偏移半个躯干高度,找到骨骼根部的位置。

鼠标交互:射线投射 + 冲量

交互部分直接用 Rapier 自带的射线检测(world.castRay)代替 Three.js 的 Raycaster,因为碰撞检测已经完全在 Rapier 的物理世界里进行。鼠标按下时,找到射线击中的刚体,再从该布娃娃的所有刚体中找最近的一个,每帧持续施加向上的冲量。松开时停止施力。这比用 Three.js 射线检测更准确,因为可见 mesh 和物理 collider 的形状可能不完全吻合。

上手前提

项目依赖 Vite 构建,Rapier 使用 @dimforge/rapier3d-compat 的 WASM 版本,在使用前需要 await RAPIER.init() 完成异步初始化,之后才能创建物理世界和刚体。GLTF 模型使用 DRACO 压缩,加载时需要配置解码器路径。Blender 源文件随仓库一起提供,骨骼命名(headspineupperArmllowerArml 等)在 Ragdoll.tsboneMapping 中定义,替换模型时需要保持骨骼命名一致或修改映射表。

写在最后

这个项目目前是一个早期 demo,共 5 次提交,2025 年 4 月底创建,13 个 star。它没有碰撞层分组、没有关节角度限制(球形关节允许全向旋转,手肘可以反向弯折),也没有地面摩擦和反弹参数的精细调节。用于生产环境还需要补充这些约束。

对这套方案感兴趣的场景有几类:Web 游戏开发中需要物理驱动的角色死亡效果,WebXR 应用里的可交互虚拟角色,或者学习 Rapier + Three.js 组合使用的参考实现。项目把完整的坐标系同步逻辑浓缩在不到 300 行 TypeScript 里,作为参考代码可读性比较好。

Rapier 物理引擎本身的文档和 API 较为完善,但把它和 Three.js 骨骼动画对接的完整示例在网上并不多,这个仓库在这个方向上有一定参考价值。

GitHub:https://github.com/mattvb91/rapierjs-ragdoll (opens in a new tab)