先玩一下:一根骨头、两个骨骼、中间一段混合区。把扭转角拉到 170°,看左边 LBS 的管子怎么被"掐细",右边 DQS 却纹丝不动。在新窗口打开完整演示 ↗
结论
① 蒙皮只有一条公式,但"在哪算"有三个层次。
$$u(t) = \sum_{i} w_i \, B_i(t) M_i^{-1} \, p$$每帧真正变化的是 $B_i(t)$。这三个层次是:场景顶点着色器内联算、独立 compute 算完写回顶点缓冲、 CPU 算。而三家引擎的默认路径都是第二个——场景顶点着色器只读结果。
② 蒙皮里法线不做逆转置——三家一致。
尽管 RTR4 §4.4 明说"骨骼被拉伸压扁时可能需要 $M^{-\mathsf T}$",
但实测:UE 的三个蒙皮着色器里 InverseTranspose 出现 0 次,
Unity 的顶点蒙皮宏和 compute 也都是直接用混合后的矩阵变换法线再归一化。
知道规则,但在蒙皮里选择不执行。
③ 三家都不在运行时把权重重新归一化(只有一个例外,见第五节第 3 条)。
它们只做定点→浮点解码(uint8/255、uint16/65535)。这隐含一个契约:导入时就归一化好了。
一些前置说明
本文引用的引擎代码都是逐字核对的,不是转述。三份源码的位置:
UE 5.8 发布版源码(Engine/Shaders/Private/…)
Godot 官方仓库(servers/rendering/…)
Unity 本机安装的 2022.3.62 LTS 分支(团结引擎 2022.3.62t15)
—— 内置着色器在 Editor/Data/CGIncludes/builtin_shaders.zip 里
为什么强调版本:Unity 6 的
MeshDeformation三档枚举(CPU / GPU / GPUBatched) 在 2022.3 里根本不存在——2022.3 只有一个PlayerSettings.gpuSkinning布尔开关。 拿 Unity 6 的文档去讲 2022.3 的实现,会讲错。
一、蒙皮在算什么
想象一条胳膊。用刚体变换把上臂和前臂当成两个独立物体来动,肘部就会露馅——两个物体在关节处是重叠的, 不是连起来的。蒙皮解决的就是这件事:让"皮肤"在关节处连续地过渡。
数学形式是 RTR4 的 Eq 4.59(p.84):
$$u(t) = \sum_{i=0}^{n-1} w_i \, B_i(t) M_i^{-1} p \qquad \sum w_i = 1,\ w_i \ge 0$$三个符号的含义值得逐个说清,因为搞混顺序是最常见的错误来源:
| 符号 | 含义 |
|---|---|
| $p$ | 顶点的绑定姿势坐标(建模时那个静态模型里的位置) |
| $M_i$ | 把绑定姿势下的顶点送进骨骼 $i$ 的坐标系的变换 |
| $B_i(t)$ | 骨骼 $i$ 当前的世界变换(随时间变) |
| $w_i$ | 骨骼 $i$ 对这个顶点的影响权重 |
为什么要 $M_i^{-1}$,而且为什么在后面? 因为它负责"把顶点搬进骨骼自己的坐标系"。 骨骼的动画变换 $B_i(t)$ 是在骨骼自己的空间里描述的(比如"前臂绕肘部转 90°"), 所以必须先把顶点送进那个空间,让变换作用完,再出来。连贯起来就是:
顶点(模型空间) → M⁻¹ → 骨骼空间 → B 作用 → 模型空间
每帧真正要上传的矩阵是 $B_i(t)M_i^{-1}$,业界叫蒙皮矩阵。$M_i^{-1}$ 通常在导入时就算好、
和模型一起存着,运行时只做一次矩阵乘。Godot 的代码里能直接看到这个式子
(scene/3d/skeleton_3d.cpp:405):
rs->skeleton_bone_set_transform(skeleton, i, bonesptr[bone_index].global_pose * skin->get_bind_pose(i));
global_pose 就是 $B_i(t)$,skin->get_bind_pose(i) 存的是逆绑定姿态(存"逆"这个细节第五节还会说)✓
权重那两个约束为什么重要? 和为 1、非负,意味着结果 $u$ 落在几个候选点 $B_i(t)M_i^{-1}p$ 的凸包里——换句话说,顶点是在几个位置之间"插值",不是在"外推"。 RTR4 特别指出:权重超出 $[0,1]$ 或不求和为 1,只在配合 morph target 这类叠加式混合时才成立。
LBS 的固有缺陷:两个旋转矩阵加权求和,得到的不是一个旋转——它的行列式会小于 1, 也就是"缩了"。这在关节处表现为体积塌陷;如果两根骨骼之间是扭转关系,表现为"糖果纸“式的收腰。
上面那个演示就是在量化这件事。权重 $w$ 处的半径保留率是:
$$\text{retention}(w,\varphi) = \sqrt{1 - 2w(1-w)(1-\cos\varphi)}$$$w = 0.5$ 时它退化成干净的 $|\cos(\varphi/2)|$ ——扭转 170°,半径只剩 8.7%。 损失在 $w = 0$ 和 $w = 1$ 两端为零,在中间最大,所以那条"收腰"正好出现在混合区正中。
RTR4 给的替代方案是双四元数蒙皮:它混合的是旋转本身而不是矩阵,结果仍是刚体变换, 半径分毫不损。代价是"计算量不到线性混合的 1.5 倍”(RTR4 原话),以及可能出现的膨胀。
二、整体流程
抛开引擎,整条链路是五步。这张图值得记,后面讲三家差异时全靠它定位:
┌─ ① 建模时(离线)────────────────────────────────┐
│ 顶点 p、每顶点的骨骼索引/权重、每骨骼的逆绑定矩阵 M⁻¹ │ → 存进资源
└──────────────────────────────────────────────┘
┌─ ② 每帧 CPU:骨骼层级求值 ───────────────────────┐
│ local pose(动画曲线/IK/重定向的结果) │
│ ↓ 逐级乘(父先算) │
│ global pose B_i │ → 纯 CPU,与 GPU 无关
└──────────────────────────────────────────────┘
┌─ ③ 每帧 CPU:算蒙皮矩阵 ─────────────────────────┐
│ S_i = B_i · M_i⁻¹ │ → 每根骨骼一个矩阵
└──────────────────────────────────────────────┘
┌─ ④ 每帧 GPU:顶点混合 ───────────────────────────┐
│ u = Σ w_i · S_i · p │ ← ★ 三家差异都在这一步
└──────────────────────────────────────────────┘
┌─ ⑤ 之后:当普通静态网格走完剩下的管线 ─────────────┐
│ 法线/切线处理 → 投影 → 光栅化 → 着色 │
└──────────────────────────────────────────────┘
三点值得强调:
- ②③ 是纯 CPU 的,和 GPU 无关。骨骼层级求值(含 IK、重定向、状态机)全部发生在这一步, 代价随骨骼数增长,而不是顶点数。这就是为什么"骨骼数量"和"顶点数量"是两个独立的性能维度。
- 第 ④ 步才是"蒙皮"这个名字真正指的那一步,也是唯一需要 GPU 的一步。
- 第 ⑤ 步是"先算一遍写进顶点缓冲"这个设计能成立的前提:蒙皮完的顶点,和静态网格顶点 在后续管线里没有任何区别。
再看数据规模,就知道为什么三家都往 GPU 挪:
| 步骤 | 规模 | 每帧变化 |
|---|---|---|
| ③ 算蒙皮矩阵 | 骨骼数(几十到几百) × 一个 3×4 矩阵 | 每根骨骼一次矩阵乘 |
| ④ 顶点混合 | 顶点数(几万到几百万) × 4 次矩阵乘 | 每个顶点都得算 |
差三到四个数量级。所以骨骼层级求值留在 CPU(要跑游戏逻辑、要读动画曲线), 顶点混合赶去 GPU——这个分工三家完全一致。
三、最小可抄的实现
抛开引擎,这件事的全部核心代码不到 20 行。
CPU 侧:把骨骼层级算成蒙皮矩阵
def compute_skin_matrices(skeleton, pose):
"""skeleton: 父索引 + 逆绑定矩阵;pose: 每个骨骼的局部变换"""
global_pose = [None] * len(skeleton)
for i in skeleton.topo_order(): # 保证父先算
p = skeleton.parent[i]
global_pose[i] = (pose[i] if p < 0
else global_pose[p] @ pose[i])
# 每帧真正要上传的就是这个
return [global_pose[i] @ skeleton.inverse_bind[i]
for i in range(len(skeleton))]
注意 global_pose[p] @ pose[i] 的顺序:是父的全局变换左乘本骨的局部变换。
GPU 侧:每顶点混合(GLSL)
// skin[]:蒙皮矩阵数组;b:4 个骨骼索引;w:4 个归一化权重
mat4 m = w.x * skin[b.x] + w.y * skin[b.y]
+ w.z * skin[b.z] + w.w * skin[b.w];
out_position = (m * vec4(in_position, 1.0)).xyz;
out_normal = normalize(mat3(m) * in_normal); // ← 注意:没有逆转置
out_tangent = normalize(mat3(m) * in_tangent);
两处最容易写错的地方:
① 矩阵顺序。 $B M^{-1}$ 是对的,$M^{-1} B$ 是错的。差别在这个逆矩阵是"进骨骼空间"还是"出骨骼空间"。 用 2 骨骼的具体例子验一下:顶点在 $p$、骨骼 $i$ 的绑定变换是 $M_i$, 那 $M_i^{-1}p$ 给出的是"这个顶点在骨骼坐标系里的位置", 再被 $B_i$ 搬回世界——这是对的。
② 权重不是"几个位置的加权平均"这么简单。 严格写是 $\sum w_i (S_i p)$ ——先把顶点变换到几个位置,再插值。 但因为它对 $p$ 是线性的,可以等价地先算矩阵加权和 $\left(\sum w_i S_i\right) p$。 shader 里都是后者(省 3 次矩阵-向量乘)。两种写法数学上等价,但只有在线性混合下等价—— 一旦换成双四元数,就不能这么交换了。
四、三个引擎怎么落地
先给结论性的对照表(细节在下一节):
| UE 5.8 | Unity 2022.3 LTS | Godot(RD 渲染器) | |
|---|---|---|---|
| 默认蒙皮位置 | GPUSkinCache 的 compute | 引擎侧 compute | skeleton.glsl 的 compute |
| 场景 VS 里有没有蒙皮 | 有,但要显式用 GPUSkin 顶点工厂 | 没有(UnityCG.cginc 里 skin 0 命中) |
没有(VS 里只声明了属性,实际只给粒子拖尾用) |
| 备选路径 | 纯 CPU 蒙皮 | 顶点着色器蒙皮宏(老式) | GLES3 走 transform feedback + 骨骼纹理 |
| 每顶点骨骼数 | 4,可再加一组 4(上限 12) | 1 / 2 / 4,按 BonesPerVertex 分支 |
4 或 8 |
| 骨骼矩阵 | 3×float4/骨(转置打包) | compute 用 StructuredBuffer<float4x4>;VS 用 float4 Bones[N*3] |
3×vec4/骨(storage buffer) |
| 线程组 | [numthreads(64,1,1)] |
[numthreads(64, 1, 1)] |
local_size_x = 64 |
| 法线逆转置 | 无 | 无 | 无 |
| 运行时归一化权重 | 否 | 2 骨分支归一化,4 骨分支不归一化 | 否 |
UE:4 骨混合的完整函数体
真正做混合的是 ComputeBoneMatrixWithLimitedInfluences
(Engine/Shaders/Private/GpuSkinCommon.ush:230):
FBoneMatrix ComputeBoneMatrixWithLimitedInfluences(FComputeBoneMatrixInputs Inputs, FGPUSkinIndexAndWeight In, bool bExtraInfluences = GPUSKIN_USE_EXTRA_INFLUENCES)
{
FBoneMatrix
BoneMatrix = In.BlendWeights.x * LoadSkinningBoneTransform(Inputs, In.BlendIndices.x);
BoneMatrix += In.BlendWeights.y * LoadSkinningBoneTransform(Inputs, In.BlendIndices.y);
#if !GPUSKIN_LIMIT_2BONE_INFLUENCES
BoneMatrix += In.BlendWeights.z * LoadSkinningBoneTransform(Inputs, In.BlendIndices.z);
BoneMatrix += In.BlendWeights.w * LoadSkinningBoneTransform(Inputs, In.BlendIndices.w);
if (bExtraInfluences)
{
BoneMatrix += In.BlendWeights2.x * LoadSkinningBoneTransform(Inputs, In.BlendIndices2.x);
// …再累加余下三根
}
#endif
return BoneMatrix;
}
顶点工厂拿到这个混合好的矩阵后,只做一次乘法(GpuSkinVertexFactory.ush:581):
float3 SkinPosition( FVertexFactoryInput Input, FVertexFactoryIntermediates Intermediates )
{
// …
// Note the use of mul(Matrix,Vector), bone matrices are stored transposed
// for tighter packing.
Position = mul(Intermediates.BlendMatrix, float4(Position, 1));
return Position;
}
那句注释值得单说:骨骼矩阵是转置存的,为了打包更紧。
UE 每根骨骼存 3 个 float4(3×4 而不是 4×4)——4×4 的第四行永远是 (0,0,0,1),没必要存。
Unity 的 float4 Bones[N*3] 和 Godot 的 3 个 vec4 都是同一个套路。三家在这件事上完全一致。
GPU 蒙皮走的是 GpuSkinCacheComputeShader.usf:194:
[numthreads(64,1,1)]
void SkinCacheUpdateBatchCS(uint3 GroupID : SV_GroupID, uint3 DispatchThreadID : SV_DispatchThreadID, …)
写回两个 UAV——位置(每顶点 3 个 float)与切线(每顶点 2 个 SNORM float4):
PositionBufferUAV[OutputOffset * 3 + 0] = SPos.x;
PositionBufferUAV[OutputOffset * 3 + 1] = SPos.y;
PositionBufferUAV[OutputOffset * 3 + 2] = SPos.z;
// …
TangentBufferUAV[2 * OutputOffset + GPUSKIN_RWBUFFER_OFFSET_TANGENT_X] = TangentUnbias_SkinCache(half4(TangentX, Unpacked.TangentX.w));
TangentBufferUAV[2 * OutputOffset + GPUSKIN_RWBUFFER_OFFSET_TANGENT_Z] = TangentUnbias_SkinCache(half4(TangentZ, Unpacked.TangentZ.w));
这些缓冲区随后作为 SRV 绑给一个 passthrough 顶点工厂——对场景着色器来说,它就是普通的顶点流。 这正是 RTR4 那句话的实现:“UAV 存储允许复用蒙皮结果。”
Unity:着色器里一行蒙皮代码都没有
这是我在核实时最意外的一条。UnityCG.cginc(本机 1242 行)里搜 skin(不区分大小写)——0 命中。
URP 的运行时 ShaderLibrary/(92 个文件)也没有蒙皮实现。
真正的蒙皮在引擎侧提交的 compute shader 里。
Internal-Skinning.compute 有 16 个 kernel(骨骼数 1/2/4 与法线/切线各一组),核心是:
[numthreads(64, 1, 1)]
void main(uint3 threadID : SV_DispatchThreadID)
每顶点做的事(Internal-Skinning-Util.cginc,4 骨分支):
const float4x4 blendedMatrix = inMatrices[si.index0] * si.weight0 +
inMatrices[si.index1] * si.weight1 +
inMatrices[si.index2] * si.weight2 +
inMatrices[si.index3] * si.weight3;
ouputVertex.pos = mul(blendedMatrix, float4(vPos, 1)).xyz;
ouputVertex.norm = mul(blendedMatrix, float4(vNorm, 0)).xyz;
ouputVertex.tang.xyz= mul(blendedMatrix, float4(vTang, 0)).xyz;
和 UE 的形式完全一样,只是骨骼矩阵是 StructuredBuffer<float4x4>(没做 3×4 打包)。
但 Unity 还有一条老式的顶点着色器路径,就在 CGIncludes/VertexShaderSkinning.cginc 里。
骨骼矩阵以 uniform 数组存在,骨数上限是常量:
#if ENABLE_VS_SKINNING && !defined(SHADER_API_METAL)
static const int max_bone_count = 64;
uniform float4 Bones[max_bone_count * 3];
#elif ENABLE_VS_SKINNING_MORE_BONES || (ENABLE_VS_SKINNING && defined(SHADER_API_METAL))
static const int max_bone_count = 320;
64 还是 320,这个数字就是 RTR4 说的"寄存器上限"——装不下就得换回 compute 或者纹理。
文件里还留着 ENABLE_VS_SKINNING_TEX 分支(骨骼矩阵走纹理),但只有一行 // todo,没实现。
有意思的是这条路径今天还在被用:URP 的 2D 着色器就调它
(Shaders/2D/RenderAs2D-Flattening.shader:64):
DO_VS_SKINNING_PARAM3(v.positionOS, v.boneWeights, v.boneIndices);
所以准确的说法不是"URP 不做蒙皮",而是:URP 的 3D 着色器不做蒙皮,2D 的反而做。
顺便,morph target 也是 compute,而且是在蒙皮结果上原地累加
(Internal-BlendShape.compute):
MeshVertex vertex = FetchVert(inOutMeshVertices, vertIndex);
vertex.pos += blendShapeVert.pos * g_Weight;
——这正是 RTR4 Eq 4.61 的 $M = N + \sum w_i D_i$。顺序也清楚了: 先蒙皮,再在结果上叠加 morph 位移。
Godot:一个专职的 compute shader
RD 渲染器里没有顶点着色器蒙皮。场景着色器虽然声明了骨骼属性,但实际只给粒子拖尾用
(scene_forward_clustered.glsl:63):
#if defined(BONES_USED) || defined(USE_PARTICLE_TRAILS)
layout(location = 10) in uvec4 bone_attrib;
#endif
真正的蒙皮在 servers/rendering/renderer_rd/shaders/skeleton.glsl——一个专职的 compute:
#[compute]
#version 450
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;
3D 的混合(:248 起,含 8 骨分支):
uint skin_offset = params.skin_stride * index;
uvec2 bones = uvec2(src_bone_weights.data[skin_offset + 0], src_bone_weights.data[skin_offset + 1]);
uvec2 bones_01 = uvec2(bones.x & 0xFFFF, bones.x >> 16) * 3; //pre-add xform offset
// …
mat4 m = mat4(bone_transforms.data[bones_01.x], bone_transforms.data[bones_01.x + 1],
bone_transforms.data[bones_01.x + 2], vec4(0.0, 0.0, 0.0, 1.0)) * weights_01.x;
m += mat4(bone_transforms.data[bones_01.y], …) * weights_01.y;
// …
结尾那几行注意一下乘法方向:
//reverse order because its transposed
vertex = (vec4(vertex, 1.0) * m).xyz;
normal = normalize((vec4(normal, 0.0) * m).xyz);
是行向量右乘,因为矩阵按转置存的——和 UE 那句注释说的是同一件事,只是写法相反。
dispatch 在 mesh_storage.cpp:1283,每个顶点一个线程:
RD::get_singleton()->compute_list_dispatch_threads(compute_list, push_constant.vertex_count, 1, 1);
跨平台这条线 Godot 分得最清楚:RD(Forward+/Mobile)走 compute,
而 GLES3(Compatibility)走的是 transform feedback + 骨骼矩阵纹理
(drivers/gles3/shaders/skeleton.glsl 用 texelFetch(skeleton_texture, ivec2(m % 256u, m / 256u), 0),
纹理宽固定 256)。这是"移动端不能假定有 compute"时代的遗产,今天仍然健在。
五、只有翻源码才知道的五件事
① 三家都不在场景顶点着色器里蒙皮。
这不是巧合,而是同一个性能判断的必然结果:顶点数比骨骼数大几个数量级, “每帧算一遍写回顶点缓冲"比"每次绘制都在 VS 里重算"省——尤其是同一个网格被多个 pass (阴影、深度、GBuffer、速度)画的时候,蒙皮只算一次,后面全是读。
这也解释了几个平时会遇到的现成现象:
UE 为什么能看到 GPUSkinCache 的独立统计项(它是个独立的 pass,不是绘制的一部分)
Unity 为什么 MeshDeformation 要分三档(CPU / GPU / GPU Batched)来权衡 dispatch 开销
Godot 为什么 profiler 里能看到 compute 项,且 GLES3 后端行为不一样
② 法线不做逆转置。
RTR4 §4.4 明说骨骼被拉伸压扁时可能需要 $M^{-\mathsf T}$。但三家都没做。
实测 UE 的三个蒙皮文件里 InverseTranspose 出现 0 次;
为什么会这样? 因为骨骼变换绝大多数时候是"旋转 + 平移”(刚体), 非均匀缩放在骨骼动画里很少见——通常只有美术手动拉过骨骼长度,或者某些夸张形变才会出现。 每顶点做一次逆转置的代价不值,所以规则留着、执行权交给使用者。 真出现拉伸变形时,正确做法是在导入/导出阶段把缩放烘进绑定姿势,而不是在 shader 里补救。
③ 权重不在运行时重新归一化——除了 Unity 的 2 骨分支。
UE 的 GetBlendIndicesAndWeights 只做 UnpackRGBA8(÷255)和 UnpackUnorm2x16(÷65535);
Godot 的 compute 只有加权求和,没有除以权重和。但 Unity 的顶点蒙皮宏里有一个例外:
else if (BonesPerVertex == 2)
{
// assume first two bone weights should have non-zero sum
float weight1 = boneWeights_in.x / (boneWeights_in.x + boneWeights_in.y);
localToWorldMatrix = GET_MATRIX(boneIndices_in.x) * weight1;
localToWorldMatrix += GET_MATRIX(boneIndices_in.y) * (1 - weight1);
}
else if (BonesPerVertex == 4)
{
localToWorldMatrix = GET_MATRIX(boneIndices_in.x) * boneWeights_in.x;
localToWorldMatrix += GET_MATRIX(boneIndices_in.y) * boneWeights_in.y;
// …直接加权,没有归一化
}
同一个文件里,2 骨分支归一化、4 骨分支不归一化——注释写的是"假定前两个权重之和不为零", 所以它是个防御性除法(避免 0 除),不是通用归一化。这也说明引擎作者清楚权重要求和为 1, 只是把这件事交给了导入器。
④ UE 的 skin cache key 是「骨骼缓冲指针 + 版本号」,不是变换的哈希。
GPUSkinCache 判断"这个网格需不需要重新蒙皮",靠的是
(骨骼缓冲对象指针, RevisionNumber) 这个二元组,而不是比较变换数值。
代价是:骨骼缓冲被重新分配时会误判(指针变了但内容没变),
所以代码里专门有个 UpdatePreviousBoneBuffer 去修正指针。
为什么这样更快:比较几百个骨骼的矩阵哈希要读几百 KB;比较一个指针和一个计数器是常数时间。 蒙皮的失效判断发生在每帧、每个网格上,所以这个选择一点都不奇怪。
⑤ Godot 把 bind pose 存成「逆」。
skin->set_bind_pose(i, skin->get_bind_pose(i).affine_inverse());
于是上传时直接 global_pose * get_bind_pose(i) 就得到蒙皮矩阵(见第一节那段代码),
省掉了一次运行时求逆。这也解释了为什么读 Godot 代码时会困惑"这个 bind_pose 到底是哪个方向"——
它存的是 $M^{-1}$,不是 $M$。
小结
三句话:
- 蒙皮就是"把绑定姿势下的顶点搬到当前姿势下":$u = \sum w_i B_i M_i^{-1} p$,每帧变的只有 $B_i$
- “谁在算"有三个层次,三家的默认路径都是"独立 compute + 写回顶点缓冲”, 场景顶点着色器只读结果——这解释了 UE 的 SkinCache 统计、Unity 的三档模式、Godot 的 compute profiler 项
- 教科书里的规则(法线逆转置、权重归一化)在工业实现里被两种不同方式绕过了: 该做的留给使用者和导入器,运行时只做必要的事
行动点:下次你看到角色的膝关节处塌陷、或者某个网格在蒙皮时莫名变细, 先算一下这个部位的权重是不是接近 0.5——因为体积损失最大正是发生在"一半一半"的地方 ($\text{retention} = \sqrt{1-2w(1-w)(1-\cos\varphi)}$)。 那不是美术权重刷错了,是 LBS 本身的数学。
附录一:可运行的验证脚本
纯 Python 标准库。就算法本身、以及第一篇里那个"法线到底该用哪个矩阵"的问题, 都能在这里独立复现:
#!/usr/bin/env python3
"""骨骼蒙皮核心:蒙皮矩阵、LBS 的体积损失、DQS 为什么没有损失。纯标准库。"""
import math
# ── 4x4 矩阵(列主序,和 OpenGL 一致) ──
def mat_identity():
return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]
def mat_translate(x, y, z):
m = mat_identity(); m[12], m[13], m[14] = x, y, z; return m
def mat_rotate_x(a):
c, s = math.cos(a), math.sin(a)
return [1,0,0,0, 0,c,s,0, 0,-s,c,0, 0,0,0,1]
def mat_mul(A, B):
return [sum(A[k*4+i]*B[j*4+k] for k in range(4)) for j in range(4) for i in range(4)]
def mat_apply(M, p):
"""列主序:(行 r, 列 c) 存在 M[c*4+r]"""
x, y, z = p
return tuple(M[0*4+i]*x + M[1*4+i]*y + M[2*4+i]*z + M[3*4+i] for i in range(3))
def mat_inverse_rigid(M):
"""刚体矩阵求逆:旋转转置 + 平移取负(比通用求逆快也稳)"""
R = [[M[c*4+r] for c in range(3)] for r in range(3)] # 取 3x3
t = (M[12], M[13], M[14])
Rt = [[R[c][r] for c in range(3)] for r in range(3)] # 转置
nt = tuple(-sum(Rt[r][c]*t[c] for c in range(3)) for r in range(3))
out = mat_identity()
for r in range(3):
for c in range(3):
out[c*4+r] = Rt[r][c]
out[12], out[13], out[14] = nt
return out
# ── 蒙皮矩阵:S = B · M⁻¹ ──
def skin_matrix(global_pose, inverse_bind):
return mat_mul(global_pose, inverse_bind)
# ── LBS:半径保留率 ──
def lbs_retention(w, phi):
"""两个相差 phi 的旋转、权重 w 混合后,半径的保留比例"""
return math.sqrt(max(0.0, 1 - 2*w*(1-w)*(1 - math.cos(phi))))
print("=== ① 蒙皮矩阵就是 B·M⁻¹(用一个绕 X 轴转 90° 的骨骼验) ===")
M = mat_rotate_x(0) # 绑定姿势:单位变换
B = mat_rotate_x(math.pi/2) # 当前姿势:转 90°
Minv = mat_inverse_rigid(M)
S = skin_matrix(B, Minv)
p = (0.0, 1.0, 0.0)
print(f" 顶点 {p} 经蒙皮矩阵 → {tuple(round(v,6) for v in mat_apply(S, p))}")
print(f" 直接用 B 作用 → {tuple(round(v,6) for v in mat_apply(B, p))}")
print(" (绑定姿势是单位变换,所以 M⁻¹=I,两者相同)")
print("\n=== ② 绑定姿势非平凡时,先把顶点送进骨骼空间 ===")
M2 = mat_translate(1.0, 0.0, 0.0) # 骨骼装在 (1,0,0)
B2 = mat_mul(mat_translate(1.0, 0.0, 0.0), mat_rotate_x(math.pi/2)) # 绕「自己的原点」转 90°
Minv2 = mat_inverse_rigid(M2)
S2 = skin_matrix(B2, Minv2)
p2 = (1.0, 1.0, 0.0) # 骨骼原点正上方 1 处
print(f" 顶点 {p2} 在骨骼坐标系里 = {tuple(round(v,6) for v in mat_apply(Minv2, p2))}")
print(f" 经蒙皮矩阵 → {tuple(round(v,6) for v in mat_apply(S2, p2))}")
print(" (绕骨骼自身原点转 90°:(1,1,0) → (1,0,1);注意不是绕世界原点)")
print("\n=== ③ LBS 的半径保留率:w=0.5 时退化为 |cos(φ/2)| ===")
print(f" {'φ':>6} {'w=0.5 通用式':>14} {'|cos(φ/2)|':>12} {'误差':>10}")
for deg in (0, 30, 60, 90, 120, 150, 170):
phi = math.radians(deg)
a, b = lbs_retention(0.5, phi), abs(math.cos(phi/2))
print(f" {deg:>5}° {a:>14.6f} {b:>12.6f} {abs(a-b):>10.1e}")
print("\n=== ④ 损失在两端为零、中间最大 ===")
phi = math.radians(120)
for w in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
print(f" w={w:<5} → 保留率 {lbs_retention(w, phi):.6f}")
print("\n=== ⑤ 对比:DQS 混合的是旋转,半径无损 ===")
# 两个同轴旋转(相差 phi)的对偶四元数混合 = 旋转 w·phi,半径不变
for w in (0.25, 0.5, 0.75):
phi = math.radians(120)
print(f" w={w:<5} → DQS 保留率 1.000000(旋转 {w*120:.1f}°)"
f" LBS 保留率 {lbs_retention(w, phi):.6f}")
运行输出的关键几行(下面这些是实际跑出来的,不是示意):
顶点 (0.0, 1.0, 0.0) 经蒙皮矩阵 → (0.0, 0.0, 1.0)
顶点 (1.0, 1.0, 0.0) 在骨骼坐标系里 = (0.0, 1.0, 0.0)
顶点 (1.0, 1.0, 0.0) 经蒙皮矩阵 → (1.0, 0.0, 1.0)
φ w=0.5 通用式 |cos(φ/2)| 误差
0° 1.000000 1.000000 0.0e+00
30° 0.965926 0.965926 0.0e+00
60° 0.866025 0.866025 1.1e-16
90° 0.707107 0.707107 0.0e+00
120° 0.500000 0.500000 0.0e+00
150° 0.258819 0.258819 1.7e-16
170° 0.087156 0.087156 1.7e-16
w=0.0 → 保留率 1.000000
w=0.5 → 保留率 0.500000
w=1.0 → 保留率 1.000000
最后三行就是这个毛病的全貌:权重在两端时毫无损失,在"一半一半"的地方损失最大。
附录二:三引擎速查
| 想找什么 | UE 5.8 | Unity 2022.3 | Godot |
|---|---|---|---|
| 每顶点混合 | GpuSkinCommon.ush ComputeBoneMatrixWithLimitedInfluences |
Internal-Skinning-Util.cginc DoSkinning |
skeleton.glsl 的 has_skeleton 分支 |
| GPU 蒙皮入口 | GpuSkinCacheComputeShader.usf SkinCacheUpdateBatchCS |
Internal-Skinning.compute main |
skeleton.glsl main |
| 谁发 dispatch | GPUSkinCache.cpp |
引擎侧(C++) | MeshStorage::update_mesh_instances |
| 骨骼矩阵怎么传 | 3×float4/骨 | StructuredBuffer<float4x4> |
3×vec4/骨(storage buffer) |
| 顶点着色器还会碰蒙皮吗 | 只有 GPUSkin 顶点工厂 | 只有老式 VS 宏(URP 2D 在用) | 不碰 |
| 控制开关 | r.SkinCache.* |
PlayerSettings.gpuSkinning(Unity 6 起是 MeshDeformation) |
无(RD 恒走 compute) |
附录三:这条链上的常见追问
蒙皮的公式是什么
→ u = Σ wᵢ BᵢMᵢ⁻¹ p,每帧变的只有 Bᵢ
→ M⁻¹ 为什么要放后面(先把顶点送进骨骼自己的坐标系)
→ 为什么三家都把 4x4 存成 3 行(第四行恒为 0,0,0,1)
→ 权重和必须为 1 吗(是,除非配合 morph target 这类叠加式混合)
→ 蒙皮在哪算(三个层次;三家默认都在独立 compute)
→ 法线要不要逆转置(教科书说可能需要,三家都没做,为什么)
→ 权重要不要每次归一化(三家都不做,契约在导入器;Unity 2 骨分支那个是防 0 除)
→ 关节塌陷怎么救(双四元数;代价是膨胀与近反极点的处理)
→ 移动端怎么办(Godot 给 GLES3 留了 transform feedback + 骨骼纹理那条路)
参考
- RTR4 §4.4 Vertex Blending(书内 p.84–87):Eq 4.59、权重约束与凸包、GPU 适用性、 “UAV 存储允许复用蒙皮结果”、双四元数的代价(<1.5×)与膨胀
- RTR4 §4.5 Morphing(书内 p.87–90):Eq 4.60、Eq 4.61(morph target)
- UE 5.8 源码:
Engine/Shaders/Private/GpuSkinCommon.ush、GpuSkinVertexFactory.ush、GpuSkinCacheComputeShader.usf、Engine/Source/Runtime/Engine/Public/GPUSkinPublicDefs.h - Unity 2022.3.62 LTS:
Editor/Data/CGIncludes/builtin_shaders.zip内的CGIncludes/VertexShaderSkinning.cginc、DefaultResources/Internal-Skinning.compute、DefaultResources/Internal-Skinning-Util.cginc、DefaultResources/Internal-BlendShape.compute - Godot:
servers/rendering/renderer_rd/shaders/skeleton.glsl、servers/rendering/renderer_rd/storage_rd/mesh_storage.cpp、scene/3d/skeleton_3d.cpp、drivers/gles3/shaders/skeleton.glsl - 本文所有数值(半径保留率、蒙皮矩阵算例)由附录一脚本算出,交互演示里可实时对照