先玩一下:一根骨头、两个骨骼、中间一段混合区。把扭转角拉到 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
  • 本文所有数值(半径保留率、蒙皮矩阵算例)由附录一脚本算出,交互演示里可实时对照