[{"content":"\r先玩一下：一根骨头、两个骨骼、中间一段混合区。把扭转角拉到 170°，看左边 LBS 的管子怎么被\u0026quot;掐细\u0026quot;，右边 DQS 却纹丝不动。在新窗口打开完整演示 ↗\n结论 ① 蒙皮只有一条公式，但\u0026quot;在哪算\u0026quot;有三个层次。\n$$u(t) = \\sum_{i} w_i \\, B_i(t) M_i^{-1} \\, p$$每帧真正变化的是 $B_i(t)$。这三个层次是：场景顶点着色器内联算、独立 compute 算完写回顶点缓冲、 CPU 算。而三家引擎的默认路径都是第二个——场景顶点着色器只读结果。\n② 蒙皮里法线不做逆转置——三家一致。\n尽管 RTR4 §4.4 明说\u0026quot;骨骼被拉伸压扁时可能需要 $M^{-\\mathsf T}$\u0026quot;， 但实测：UE 的三个蒙皮着色器里 InverseTranspose 出现 0 次， Unity 的顶点蒙皮宏和 compute 也都是直接用混合后的矩阵变换法线再归一化。 知道规则，但在蒙皮里选择不执行。\n③ 三家都不在运行时把权重重新归一化（只有一个例外，见第五节第 3 条）。\n它们只做定点→浮点解码（uint8/255、uint16/65535）。这隐含一个契约：导入时就归一化好了。\n一些前置说明 本文引用的引擎代码都是逐字核对的，不是转述。三份源码的位置：\nUE 5.8 发布版源码（Engine/Shaders/Private/…）\rGodot 官方仓库（servers/rendering/…）\rUnity 本机安装的 2022.3.62 LTS 分支（团结引擎 2022.3.62t15）\r—— 内置着色器在 Editor/Data/CGIncludes/builtin_shaders.zip 里\r为什么强调版本：Unity 6 的 MeshDeformation 三档枚举（CPU / GPU / GPUBatched） 在 2022.3 里根本不存在——2022.3 只有一个 PlayerSettings.gpuSkinning 布尔开关。 拿 Unity 6 的文档去讲 2022.3 的实现，会讲错。\n一、蒙皮在算什么 想象一条胳膊。用刚体变换把上臂和前臂当成两个独立物体来动，肘部就会露馅——两个物体在关节处是重叠的， 不是连起来的。蒙皮解决的就是这件事：让\u0026quot;皮肤\u0026quot;在关节处连续地过渡。\n数学形式是 RTR4 的 Eq 4.59（p.84）：\n$$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$$三个符号的含义值得逐个说清，因为搞混顺序是最常见的错误来源：\n符号 含义 $p$ 顶点的绑定姿势坐标（建模时那个静态模型里的位置） $M_i$ 把绑定姿势下的顶点送进骨骼 $i$ 的坐标系的变换 $B_i(t)$ 骨骼 $i$ 当前的世界变换（随时间变） $w_i$ 骨骼 $i$ 对这个顶点的影响权重 为什么要 $M_i^{-1}$，而且为什么在后面？ 因为它负责\u0026quot;把顶点搬进骨骼自己的坐标系\u0026quot;。 骨骼的动画变换 $B_i(t)$ 是在骨骼自己的空间里描述的（比如\u0026quot;前臂绕肘部转 90°\u0026quot;）， 所以必须先把顶点送进那个空间，让变换作用完，再出来。连贯起来就是：\n顶点（模型空间） → M⁻¹ → 骨骼空间 → B 作用 → 模型空间\r每帧真正要上传的矩阵是 $B_i(t)M_i^{-1}$，业界叫蒙皮矩阵。$M_i^{-1}$ 通常在导入时就算好、 和模型一起存着，运行时只做一次矩阵乘。Godot 的代码里能直接看到这个式子 （scene/3d/skeleton_3d.cpp:405）：\nrs-\u0026gt;skeleton_bone_set_transform(skeleton, i, bonesptr[bone_index].global_pose * skin-\u0026gt;get_bind_pose(i)); global_pose 就是 $B_i(t)$，skin-\u0026gt;get_bind_pose(i) 存的是逆绑定姿态（存\u0026quot;逆\u0026quot;这个细节第五节还会说）✓\n权重那两个约束为什么重要？ 和为 1、非负，意味着结果 $u$ 落在几个候选点 $B_i(t)M_i^{-1}p$ 的凸包里——换句话说，顶点是在几个位置之间\u0026quot;插值\u0026quot;，不是在\u0026quot;外推\u0026quot;。 RTR4 特别指出：权重超出 $[0,1]$ 或不求和为 1，只在配合 morph target 这类叠加式混合时才成立。\nLBS 的固有缺陷：两个旋转矩阵加权求和，得到的不是一个旋转——它的行列式会小于 1， 也就是\u0026quot;缩了\u0026quot;。这在关节处表现为体积塌陷；如果两根骨骼之间是扭转关系，表现为\u0026quot;糖果纸\u0026ldquo;式的收腰。\n上面那个演示就是在量化这件事。权重 $w$ 处的半径保留率是：\n$$\\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$ 两端为零，在中间最大，所以那条\u0026quot;收腰\u0026quot;正好出现在混合区正中。\nRTR4 给的替代方案是双四元数蒙皮：它混合的是旋转本身而不是矩阵，结果仍是刚体变换， 半径分毫不损。代价是\u0026quot;计算量不到线性混合的 1.5 倍\u0026rdquo;（RTR4 原话），以及可能出现的膨胀。\n二、整体流程 抛开引擎，整条链路是五步。这张图值得记，后面讲三家差异时全靠它定位：\n┌─ ① 建模时（离线）────────────────────────────────┐\r│ 顶点 p、每顶点的骨骼索引/权重、每骨骼的逆绑定矩阵 M⁻¹ │ → 存进资源\r└──────────────────────────────────────────────┘\r┌─ ② 每帧 CPU：骨骼层级求值 ───────────────────────┐\r│ local pose（动画曲线/IK/重定向的结果） │\r│ ↓ 逐级乘（父先算） │\r│ global pose B_i │ → 纯 CPU，与 GPU 无关\r└──────────────────────────────────────────────┘\r┌─ ③ 每帧 CPU：算蒙皮矩阵 ─────────────────────────┐\r│ S_i = B_i · M_i⁻¹ │ → 每根骨骼一个矩阵\r└──────────────────────────────────────────────┘\r┌─ ④ 每帧 GPU：顶点混合 ───────────────────────────┐\r│ u = Σ w_i · S_i · p │ ← ★ 三家差异都在这一步\r└──────────────────────────────────────────────┘\r┌─ ⑤ 之后：当普通静态网格走完剩下的管线 ─────────────┐\r│ 法线/切线处理 → 投影 → 光栅化 → 着色 │\r└──────────────────────────────────────────────┘\r三点值得强调：\n②③ 是纯 CPU 的，和 GPU 无关。骨骼层级求值（含 IK、重定向、状态机）全部发生在这一步， 代价随骨骼数增长，而不是顶点数。这就是为什么\u0026quot;骨骼数量\u0026quot;和\u0026quot;顶点数量\u0026quot;是两个独立的性能维度。 第 ④ 步才是\u0026quot;蒙皮\u0026quot;这个名字真正指的那一步，也是唯一需要 GPU 的一步。 第 ⑤ 步是\u0026quot;先算一遍写进顶点缓冲\u0026quot;这个设计能成立的前提：蒙皮完的顶点，和静态网格顶点 在后续管线里没有任何区别。 再看数据规模，就知道为什么三家都往 GPU 挪：\n步骤 规模 每帧变化 ③ 算蒙皮矩阵 骨骼数（几十到几百） × 一个 3×4 矩阵 每根骨骼一次矩阵乘 ④ 顶点混合 顶点数（几万到几百万） × 4 次矩阵乘 每个顶点都得算 差三到四个数量级。所以骨骼层级求值留在 CPU（要跑游戏逻辑、要读动画曲线）， 顶点混合赶去 GPU——这个分工三家完全一致。\n三、最小可抄的实现 抛开引擎，这件事的全部核心代码不到 20 行。\nCPU 侧：把骨骼层级算成蒙皮矩阵\ndef compute_skin_matrices(skeleton, pose): \u0026#34;\u0026#34;\u0026#34;skeleton: 父索引 + 逆绑定矩阵；pose: 每个骨骼的局部变换\u0026#34;\u0026#34;\u0026#34; global_pose = [None] * len(skeleton) for i in skeleton.topo_order(): # 保证父先算 p = skeleton.parent[i] global_pose[i] = (pose[i] if p \u0026lt; 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] 的顺序：是父的全局变换左乘本骨的局部变换。\nGPU 侧：每顶点混合（GLSL）\n// 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); 两处最容易写错的地方：\n① 矩阵顺序。 $B M^{-1}$ 是对的，$M^{-1} B$ 是错的。差别在这个逆矩阵是\u0026quot;进骨骼空间\u0026quot;还是\u0026quot;出骨骼空间\u0026quot;。 用 2 骨骼的具体例子验一下：顶点在 $p$、骨骼 $i$ 的绑定变换是 $M_i$， 那 $M_i^{-1}p$ 给出的是\u0026quot;这个顶点在骨骼坐标系里的位置\u0026quot;， 再被 $B_i$ 搬回世界——这是对的。\n② 权重不是\u0026quot;几个位置的加权平均\u0026quot;这么简单。 严格写是 $\\sum w_i (S_i p)$ ——先把顶点变换到几个位置，再插值。 但因为它对 $p$ 是线性的，可以等价地先算矩阵加权和 $\\left(\\sum w_i S_i\\right) p$。 shader 里都是后者（省 3 次矩阵-向量乘）。两种写法数学上等价，但只有在线性混合下等价—— 一旦换成双四元数，就不能这么交换了。\n四、三个引擎怎么落地 先给结论性的对照表（细节在下一节）：\nUE 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\u0026lt;float4x4\u0026gt;；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）：\nFBoneMatrix 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）：\nfloat3 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 都是同一个套路。三家在这件事上完全一致。\nGPU 蒙皮走的是 GpuSkinCacheComputeShader.usf:194：\n[numthreads(64,1,1)] void SkinCacheUpdateBatchCS(uint3 GroupID : SV_GroupID, uint3 DispatchThreadID : SV_DispatchThreadID, …) 写回两个 UAV——位置（每顶点 3 个 float）与切线（每顶点 2 个 SNORM float4）：\nPositionBufferUAV[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 那句话的实现：\u0026ldquo;UAV 存储允许复用蒙皮结果。\u0026rdquo;\nUnity：着色器里一行蒙皮代码都没有 这是我在核实时最意外的一条。UnityCG.cginc（本机 1242 行）里搜 skin（不区分大小写）——0 命中。 URP 的运行时 ShaderLibrary/（92 个文件）也没有蒙皮实现。\n真正的蒙皮在引擎侧提交的 compute shader 里。 Internal-Skinning.compute 有 16 个 kernel（骨骼数 1/2/4 与法线/切线各一组），核心是：\n[numthreads(64, 1, 1)] void main(uint3 threadID : SV_DispatchThreadID) 每顶点做的事（Internal-Skinning-Util.cginc，4 骨分支）：\nconst 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\u0026lt;float4x4\u0026gt;（没做 3×4 打包）。\n但 Unity 还有一条老式的顶点着色器路径，就在 CGIncludes/VertexShaderSkinning.cginc 里。 骨骼矩阵以 uniform 数组存在，骨数上限是常量：\n#if ENABLE_VS_SKINNING \u0026amp;\u0026amp; !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 \u0026amp;\u0026amp; defined(SHADER_API_METAL)) static const int max_bone_count = 320; 64 还是 320，这个数字就是 RTR4 说的\u0026quot;寄存器上限\u0026quot;——装不下就得换回 compute 或者纹理。 文件里还留着 ENABLE_VS_SKINNING_TEX 分支（骨骼矩阵走纹理），但只有一行 // todo，没实现。\n有意思的是这条路径今天还在被用：URP 的 2D 着色器就调它 （Shaders/2D/RenderAs2D-Flattening.shader:64）：\nDO_VS_SKINNING_PARAM3(v.positionOS, v.boneWeights, v.boneIndices); 所以准确的说法不是\u0026quot;URP 不做蒙皮\u0026quot;，而是：URP 的 3D 着色器不做蒙皮，2D 的反而做。\n顺便，morph target 也是 compute，而且是在蒙皮结果上原地累加 （Internal-BlendShape.compute）：\nMeshVertex vertex = FetchVert(inOutMeshVertices, vertIndex); vertex.pos += blendShapeVert.pos * g_Weight; ——这正是 RTR4 Eq 4.61 的 $M = N + \\sum w_i D_i$。顺序也清楚了： 先蒙皮，再在结果上叠加 morph 位移。\nGodot：一个专职的 compute shader RD 渲染器里没有顶点着色器蒙皮。场景着色器虽然声明了骨骼属性，但实际只给粒子拖尾用 （scene_forward_clustered.glsl:63）：\n#if defined(BONES_USED) || defined(USE_PARTICLE_TRAILS) layout(location = 10) in uvec4 bone_attrib; #endif 真正的蒙皮在 servers/rendering/renderer_rd/shaders/skeleton.glsl——一个专职的 compute：\n#[compute] #version 450 layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in; 3D 的混合（:248 起，含 8 骨分支）：\nuint 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 \u0026amp; 0xFFFF, bones.x \u0026gt;\u0026gt; 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; // … 结尾那几行注意一下乘法方向：\n//reverse order because its transposed vertex = (vec4(vertex, 1.0) * m).xyz; normal = normalize((vec4(normal, 0.0) * m).xyz); 是行向量右乘，因为矩阵按转置存的——和 UE 那句注释说的是同一件事，只是写法相反。\ndispatch 在 mesh_storage.cpp:1283，每个顶点一个线程：\nRD::get_singleton()-\u0026gt;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）。这是\u0026quot;移动端不能假定有 compute\u0026quot;时代的遗产，今天仍然健在。\n五、只有翻源码才知道的五件事 ① 三家都不在场景顶点着色器里蒙皮。\n这不是巧合，而是同一个性能判断的必然结果：顶点数比骨骼数大几个数量级， \u0026ldquo;每帧算一遍写回顶点缓冲\u0026quot;比\u0026quot;每次绘制都在 VS 里重算\u0026quot;省——尤其是同一个网格被多个 pass （阴影、深度、GBuffer、速度）画的时候，蒙皮只算一次，后面全是读。\n这也解释了几个平时会遇到的现成现象：\nUE 为什么能看到 GPUSkinCache 的独立统计项（它是个独立的 pass，不是绘制的一部分）\rUnity 为什么 MeshDeformation 要分三档（CPU / GPU / GPU Batched）来权衡 dispatch 开销\rGodot 为什么 profiler 里能看到 compute 项，且 GLES3 后端行为不一样\r② 法线不做逆转置。\nRTR4 §4.4 明说骨骼被拉伸压扁时可能需要 $M^{-\\mathsf T}$。但三家都没做。 实测 UE 的三个蒙皮文件里 InverseTranspose 出现 0 次；\n为什么会这样？ 因为骨骼变换绝大多数时候是\u0026quot;旋转 + 平移\u0026rdquo;（刚体）， 非均匀缩放在骨骼动画里很少见——通常只有美术手动拉过骨骼长度，或者某些夸张形变才会出现。 每顶点做一次逆转置的代价不值，所以规则留着、执行权交给使用者。 真出现拉伸变形时，正确做法是在导入/导出阶段把缩放烘进绑定姿势，而不是在 shader 里补救。\n③ 权重不在运行时重新归一化——除了 Unity 的 2 骨分支。\nUE 的 GetBlendIndicesAndWeights 只做 UnpackRGBA8（÷255）和 UnpackUnorm2x16（÷65535）； Godot 的 compute 只有加权求和，没有除以权重和。但 Unity 的顶点蒙皮宏里有一个例外：\nelse 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 骨分支不归一化——注释写的是\u0026quot;假定前两个权重之和不为零\u0026quot;， 所以它是个防御性除法（避免 0 除），不是通用归一化。这也说明引擎作者清楚权重要求和为 1， 只是把这件事交给了导入器。\n④ UE 的 skin cache key 是「骨骼缓冲指针 + 版本号」，不是变换的哈希。\nGPUSkinCache 判断\u0026quot;这个网格需不需要重新蒙皮\u0026quot;，靠的是 (骨骼缓冲对象指针, RevisionNumber) 这个二元组，而不是比较变换数值。 代价是：骨骼缓冲被重新分配时会误判（指针变了但内容没变）， 所以代码里专门有个 UpdatePreviousBoneBuffer 去修正指针。\n为什么这样更快：比较几百个骨骼的矩阵哈希要读几百 KB；比较一个指针和一个计数器是常数时间。 蒙皮的失效判断发生在每帧、每个网格上，所以这个选择一点都不奇怪。\n⑤ Godot 把 bind pose 存成「逆」。\nskin-\u0026gt;set_bind_pose(i, skin-\u0026gt;get_bind_pose(i).affine_inverse()); 于是上传时直接 global_pose * get_bind_pose(i) 就得到蒙皮矩阵（见第一节那段代码）， 省掉了一次运行时求逆。这也解释了为什么读 Godot 代码时会困惑\u0026quot;这个 bind_pose 到底是哪个方向\u0026quot;—— 它存的是 $M^{-1}$，不是 $M$。\n小结 三句话：\n蒙皮就是\u0026quot;把绑定姿势下的顶点搬到当前姿势下\u0026quot;：$u = \\sum w_i B_i M_i^{-1} p$，每帧变的只有 $B_i$ \u0026ldquo;谁在算\u0026quot;有三个层次，三家的默认路径都是\u0026quot;独立 compute + 写回顶点缓冲\u0026rdquo;， 场景顶点着色器只读结果——这解释了 UE 的 SkinCache 统计、Unity 的三档模式、Godot 的 compute profiler 项 教科书里的规则（法线逆转置、权重归一化）在工业实现里被两种不同方式绕过了： 该做的留给使用者和导入器，运行时只做必要的事 行动点：下次你看到角色的膝关节处塌陷、或者某个网格在蒙皮时莫名变细， 先算一下这个部位的权重是不是接近 0.5——因为体积损失最大正是发生在\u0026quot;一半一半\u0026quot;的地方 （$\\text{retention} = \\sqrt{1-2w(1-w)(1-\\cos\\varphi)}$）。 那不是美术权重刷错了，是 LBS 本身的数学。\n附录一：可运行的验证脚本 纯 Python 标准库。就算法本身、以及第一篇里那个\u0026quot;法线到底该用哪个矩阵\u0026quot;的问题， 都能在这里独立复现：\n#!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34;骨骼蒙皮核心：蒙皮矩阵、LBS 的体积损失、DQS 为什么没有损失。纯标准库。\u0026#34;\u0026#34;\u0026#34; 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): \u0026#34;\u0026#34;\u0026#34;列主序：(行 r, 列 c) 存在 M[c*4+r]\u0026#34;\u0026#34;\u0026#34; 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): \u0026#34;\u0026#34;\u0026#34;刚体矩阵求逆：旋转转置 + 平移取负（比通用求逆快也稳）\u0026#34;\u0026#34;\u0026#34; 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): \u0026#34;\u0026#34;\u0026#34;两个相差 phi 的旋转、权重 w 混合后，半径的保留比例\u0026#34;\u0026#34;\u0026#34; return math.sqrt(max(0.0, 1 - 2*w*(1-w)*(1 - math.cos(phi)))) print(\u0026#34;=== ① 蒙皮矩阵就是 B·M⁻¹（用一个绕 X 轴转 90° 的骨骼验） ===\u0026#34;) 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\u0026#34; 顶点 {p} 经蒙皮矩阵 → {tuple(round(v,6) for v in mat_apply(S, p))}\u0026#34;) print(f\u0026#34; 直接用 B 作用 → {tuple(round(v,6) for v in mat_apply(B, p))}\u0026#34;) print(\u0026#34; （绑定姿势是单位变换，所以 M⁻¹=I，两者相同）\u0026#34;) print(\u0026#34;\\n=== ② 绑定姿势非平凡时，先把顶点送进骨骼空间 ===\u0026#34;) 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\u0026#34; 顶点 {p2} 在骨骼坐标系里 = {tuple(round(v,6) for v in mat_apply(Minv2, p2))}\u0026#34;) print(f\u0026#34; 经蒙皮矩阵 → {tuple(round(v,6) for v in mat_apply(S2, p2))}\u0026#34;) print(\u0026#34; （绕骨骼自身原点转 90°：(1,1,0) → (1,0,1)；注意不是绕世界原点）\u0026#34;) print(\u0026#34;\\n=== ③ LBS 的半径保留率：w=0.5 时退化为 |cos(φ/2)| ===\u0026#34;) print(f\u0026#34; {\u0026#39;φ\u0026#39;:\u0026gt;6} {\u0026#39;w=0.5 通用式\u0026#39;:\u0026gt;14} {\u0026#39;|cos(φ/2)|\u0026#39;:\u0026gt;12} {\u0026#39;误差\u0026#39;:\u0026gt;10}\u0026#34;) 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\u0026#34; {deg:\u0026gt;5}° {a:\u0026gt;14.6f} {b:\u0026gt;12.6f} {abs(a-b):\u0026gt;10.1e}\u0026#34;) print(\u0026#34;\\n=== ④ 损失在两端为零、中间最大 ===\u0026#34;) phi = math.radians(120) for w in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0): print(f\u0026#34; w={w:\u0026lt;5} → 保留率 {lbs_retention(w, phi):.6f}\u0026#34;) print(\u0026#34;\\n=== ⑤ 对比：DQS 混合的是旋转，半径无损 ===\u0026#34;) # 两个同轴旋转（相差 phi）的对偶四元数混合 = 旋转 w·phi，半径不变 for w in (0.25, 0.5, 0.75): phi = math.radians(120) print(f\u0026#34; w={w:\u0026lt;5} → DQS 保留率 1.000000（旋转 {w*120:.1f}°）\u0026#34; f\u0026#34; LBS 保留率 {lbs_retention(w, phi):.6f}\u0026#34;) 运行输出的关键几行（下面这些是实际跑出来的，不是示意）：\n顶点 (0.0, 1.0, 0.0) 经蒙皮矩阵 → (0.0, 0.0, 1.0)\r顶点 (1.0, 1.0, 0.0) 在骨骼坐标系里 = (0.0, 1.0, 0.0)\r顶点 (1.0, 1.0, 0.0) 经蒙皮矩阵 → (1.0, 0.0, 1.0)\rφ w=0.5 通用式 |cos(φ/2)| 误差\r0° 1.000000 1.000000 0.0e+00\r30° 0.965926 0.965926 0.0e+00\r60° 0.866025 0.866025 1.1e-16\r90° 0.707107 0.707107 0.0e+00\r120° 0.500000 0.500000 0.0e+00\r150° 0.258819 0.258819 1.7e-16\r170° 0.087156 0.087156 1.7e-16\rw=0.0 → 保留率 1.000000\rw=0.5 → 保留率 0.500000\rw=1.0 → 保留率 1.000000\r最后三行就是这个毛病的全貌：权重在两端时毫无损失，在\u0026quot;一半一半\u0026quot;的地方损失最大。\n附录二：三引擎速查 想找什么 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\u0026lt;float4x4\u0026gt; 3×vec4/骨（storage buffer） 顶点着色器还会碰蒙皮吗 只有 GPUSkin 顶点工厂 只有老式 VS 宏（URP 2D 在用） 不碰 控制开关 r.SkinCache.* PlayerSettings.gpuSkinning（Unity 6 起是 MeshDeformation） 无（RD 恒走 compute） 附录三：这条链上的常见追问 蒙皮的公式是什么\r→ u = Σ wᵢ BᵢMᵢ⁻¹ p，每帧变的只有 Bᵢ\r→ M⁻¹ 为什么要放后面（先把顶点送进骨骼自己的坐标系）\r→ 为什么三家都把 4x4 存成 3 行（第四行恒为 0,0,0,1）\r→ 权重和必须为 1 吗（是，除非配合 morph target 这类叠加式混合）\r→ 蒙皮在哪算（三个层次；三家默认都在独立 compute）\r→ 法线要不要逆转置（教科书说可能需要，三家都没做，为什么）\r→ 权重要不要每次归一化（三家都不做，契约在导入器；Unity 2 骨分支那个是防 0 除）\r→ 关节塌陷怎么救（双四元数；代价是膨胀与近反极点的处理）\r→ 移动端怎么办（Godot 给 GLES3 留了 transform feedback + 骨骼纹理那条路）\r参考 RTR4 §4.4 Vertex Blending（书内 p.84–87）：Eq 4.59、权重约束与凸包、GPU 适用性、 \u0026ldquo;UAV 存储允许复用蒙皮结果\u0026rdquo;、双四元数的代价（\u0026lt;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 本文所有数值（半径保留率、蒙皮矩阵算例）由附录一脚本算出，交互演示里可实时对照 ","permalink":"https://quanpu.wang/posts/%E9%AA%A8%E9%AA%BC%E8%92%99%E7%9A%AE/","summary":"骨骼蒙皮的公式只有一条，但“在哪算”有三个层次——UE、Unity、Godot 的主流路径都不在场景顶点着色器里。逐字核对三家的蒙皮代码后，说清整条流程、关键实现，以及五个只有翻源码才知道的设计决定。","title":"骨骼蒙皮：三个引擎都不在顶点着色器里做"},{"content":"\n图形程序开发者，桌面与移动两端的实时渲染都做。\n这个博客记录三件事：\n读论文 —— 实时渲染方向的论文精读与脉络梳理（ReSTIR 系列、SIGGRAPH 课程、全局光照） 读源码 —— UE / Godot 渲染器的实现剖析，看工业界怎么把理论落地 写渲染器 —— 自己从零写一个渲染器的过程，包括踩过的坑 写作原则：能贴代码就贴代码，能给数字就给数字，能复现就写清复现步骤。 不写\u0026quot;某某技术浅析\u0026quot;这类读完什么也没留下的文章。\n关注方向 实时全局光照（ReSTIR / 路径追踪 / 采样与重采样） GPU 架构与性能（统一着色器、warp / occupancy、可见性优化） 跨平台渲染（桌面 IMR 与移动 TBDR 的差异与取舍） 图元管线与几何处理（Nanite 类虚拟化几何、LOD、加速结构） 联系 GitHub: Froler27 ","permalink":"https://quanpu.wang/about/","summary":"\u003cp\u003e\u003cimg alt=\"幻域 · 渲染笔记\" loading=\"lazy\" src=\"/images/brand/banner-1600x400.png\"\u003e\u003c/p\u003e\n\u003cp\u003e图形程序开发者，桌面与移动两端的实时渲染都做。\u003c/p\u003e\n\u003cp\u003e这个博客记录三件事：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e读论文\u003c/strong\u003e —— 实时渲染方向的论文精读与脉络梳理（ReSTIR 系列、SIGGRAPH 课程、全局光照）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e读源码\u003c/strong\u003e —— UE / Godot 渲染器的实现剖析，看工业界怎么把理论落地\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e写渲染器\u003c/strong\u003e —— 自己从零写一个渲染器的过程，包括踩过的坑\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003e写作原则\u003c/strong\u003e：能贴代码就贴代码，能给数字就给数字，能复现就写清复现步骤。\n不写\u0026quot;某某技术浅析\u0026quot;这类读完什么也没留下的文章。\u003c/p\u003e\n\u003ch2 id=\"关注方向\"\u003e关注方向\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e实时全局光照（ReSTIR / 路径追踪 / 采样与重采样）\u003c/li\u003e\n\u003cli\u003eGPU 架构与性能（统一着色器、warp / occupancy、可见性优化）\u003c/li\u003e\n\u003cli\u003e跨平台渲染（桌面 IMR 与移动 TBDR 的差异与取舍）\u003c/li\u003e\n\u003cli\u003e图元管线与几何处理（Nanite 类虚拟化几何、LOD、加速结构）\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"联系\"\u003e联系\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eGitHub: \u003ca href=\"https://github.com/Froler27\"\u003eFroler27\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e","title":"关于"},{"content":"\r先玩一下：拖三个滑块，盯住两根轴箭头（不是盯环）。把俯仰拉到 90°，看红色滚转轴怎么滑到青色偏航轴上——那一下就是万向锁的全部。在新窗口打开完整演示 ↗（画面可拖动换视角）\n结论 ① 万向锁丢的是「参数」的自由度，不是「姿态」的自由度。\n偏航 30° 俯仰 90° 滚转 40° ┐\r偏航 60° 俯仰 90° 滚转 70° ┘ 三个数字全变了 → 姿态完全相同（矩阵最大差 1.1e-16）\r物体没有被锁住：它照样能转到任何姿态。是三个数字里有一个变得多余了。\n② 判据是 $\\cos(\\text{俯仰}) = 0$，不是\u0026quot;俯仰等于 90°\u0026quot;。\n退化是连续的：$1/\\cos$ 型的条件数随俯仰角无界增长——60° 时 3.8 倍，85° 时 23 倍， 89° 时 117 倍，90° 时无穷。所以\u0026quot;业务上注意别转到 90°\u0026ldquo;没有可操作的安全边界。\n③ 锁死时姿态只取决于一个组合角，而且换欧拉角顺序躲不掉。\n+90°：姿态只取决于 (偏航 − 滚转) −90°：姿态只取决于 (偏航 + 滚转)\rRTR4 原话：\u0026ldquo;There is no perfect sequence that avoids gimbal lock.\u0026rdquo;\n提到万向锁，多数资料会给你一段陀螺仪动画，然后说： 「当两个转轴重合时，就失去一个自由度，物体转不动了。」\n这句话有两个地方经不起追问，答案就是上面那两条结论：\n物体真的转不动了吗？没有——它照样能转到任何姿态，是三个数字里有一个变多余了。 那个「一个自由度」，是谁的自由度？是三个参数的，不是姿态的。 这篇文章回答这两个问题。文中的每个数字都能用文末附录的脚本复现，也能在开头那个演示里拖着滑块自己看。\n一、万向锁看起来是什么样 欧拉角是三层嵌套的旋转：外层带动中层、中层带动内层。这一点必须先立住，后面的「搬轴」全靠这个结构。\n外层（偏航，绕世界 Z）\r└─ 中层（俯仰，绕自己那一层转 90° 就出事）\r└─ 内层（滚转）\r└─ 物体\r看图注意两点：左图三个环两两垂直，三根转轴指向三个不同方向；右图内环与外环变成了共面的，红色滚转轴箭头与青色偏航轴箭头落在同一条直线上。\n轴被搬到了哪里，可以精确算出来（身体 X 轴就是滚转轴）：\n俯仰 滚转轴（身体 X）的去向 与偏航轴（世界 Z）的关系 +90° 世界 $-\\mathbf{Z}$ 共线但反向 −90° 世界 $+\\mathbf{Z}$ 共线且同向 「反向」还是「同向」会在第三节变成一个符号，先记住这一点。\n把环隐藏掉再看一次，避免环的形状干扰判断。右图那两根箭头是一条笔直的线——这就是万向锁的全部外观。\n二、它到底锁住了什么：参数塌缩，不是姿态塌缩 先给一个反例。俯仰固定在 90°：\n偏航 30° 俯仰 90° 滚转 40° → 姿态 A\r偏航 60° 俯仰 90° 滚转 70° → 姿态 B\r|A − B| 的最大元素差 = 1.1e-16\r三个数字全变了，姿态一模一样。所以「转不动」是错的：姿态空间毫发无伤，是参数到姿态的映射塌缩了。\n顺带说清：雅可比矩阵是什么 这是全文唯一一个\u0026quot;外来工具\u0026rdquo;，值得花三分钟。一句话：雅可比矩阵就是多变量函数的导数矩阵。\n一维时，导数 $f'(x)$ 回答\u0026quot;输入动一点点，输出动多少\u0026quot;，确切地说是 $f(x+\\delta) \\approx f(x) + f'(x)\\delta$。 多维时这句话照样成立，只是\u0026quot;乘一个数\u0026quot;变成了\u0026quot;乘一个矩阵\u0026quot;：\n$$f(\\mathbf{x}+\\boldsymbol{\\delta}) \\approx f(\\mathbf{x}) + J(\\mathbf{x})\\,\\boldsymbol{\\delta}$$对 $f:\\mathbb{R}^n \\to \\mathbb{R}^m$，$J$ 是 $m\\times n$ 矩阵，第 $i$ 行第 $j$ 列就是 $\\partial f_i/\\partial x_j$。 它把「参数空间里的一小步」线性地翻译成「结果空间里的一小步」。\n具体到本文：函数 $f$ 是「$(\\text{偏航}, \\text{俯仰}, \\text{滚转}) \\to$ 姿态」，$n=m=3$，所以 $J$ 是 $3\\times3$ 的。 （严格说姿态不是三维向量——但任何旋转都能在局部用\u0026quot;一根转轴 + 一个角度\u0026quot;表示，也就是旋转向量， 所以取 $f$ 的值域为 $\\mathbb{R}^3$ 是合法的。）\n有了它，前面那几个说法就都有了精确形式：\n说法 精确形式 「三个旋钮各管一个方向」 $J$ 的秩 = 3（秩 = 这个线性映射的像空间维数） 「有一个方向变多余了」 秩掉到 2 「参数化病态」 $J$ 的条件数 $\\sigma_{\\max}/\\sigma_{\\min}$ 变大 「误差被放大」 反过来的放大倍数就是 $1/\\sigma_{\\min}$ 名称由来：以 19 世纪德国数学家 Carl Gustav Jacob Jacobi 命名，他研究多元微积分时引入了这套偏导矩阵。\n现在可以算了。把姿态对三个角度的偏导组成矩阵，看它的秩：\n俯仰 秩 含义 0° / 30° / 60° / 85° / 89° 3 三个旋钮各管一个独立方向 89.999° / 90° 2 三个旋钮只产生两个独立方向 秩在这里的直观含义：它数的是「三个旋钮能产生的瞬时转动方向里有几个是独立的」。秩 3 就是三个都独立；秩 2 就是有一个方向重复了——转它等于转另一个。\n一句话收束：姿态空间一直是三维的，是我们用三个数字去标它，在某处标重了。\n三、为什么「加」会变成「减」 既然锁死时少了一个独立方向，那姿态到底由什么决定？实测两条等价关系：\n+90°：姿态只取决于 (偏航 − 滚转)\r−90°：姿态只取决于 (偏航 + 滚转)\r为什么符号会翻？ 回看第一节那张表：内层轴被搬到世界 $-\\mathbf{Z}$ 时与偏航轴反向，两个旋钮的效果互相抵消，所以取差；被搬到 $+\\mathbf{Z}$ 时同向，效果叠加，所以取和。\n还有一个漂亮的旁证：让偏航和滚转反向各转 $d$（这会改变姿态），姿态变化的角度恰好是 $2d$：\n反向各 10° → 姿态差 20.000°\r反向各 30° → 姿态差 60.000°\r反向各 60° → 姿态差 120.000°\r恰好是 $2d$，因为两个旋钮在绕同一根轴转，转角直接相加。\n这不是我一个人的结论。RTR4 §4.2.2（书内 p.73）原文：\n\u0026ldquo;With such a value of p, we have lost one degree of freedom since the matrix only depends on one angle, r + h or r − h (but not both at the same time).\u0026rdquo;\n书里用的符号是 $h/p/r$（head/pitch/roll），结论与前文完全一致。\n四、这不是一个「坏点」，而是一条连续退化的路 很多人对万向锁的心智模型是「90° 是个坏点，绕开它就行」。这个模型错在把奇点当成了孤立的点。\n把「姿态里的一点点变化，翻译回角度需要多少变化」算出来，就是雅可比矩阵的条件数 $\\sigma_{\\max}/\\sigma_{\\min}$。实测：\n$$\\sigma_{\\min} \\approx 0.769 \\cos(\\text{俯仰}) \\quad\\Longrightarrow\\quad \\text{条件数} \\propto \\frac{1}{\\cos(\\text{俯仰})}$$ 俯仰 60° 80° 85° 89° 89.9° 90° 条件数 3.8 11.7 23 117 1173 $\\infty$ 误差放大 $1/\\sigma_{\\min}$ 2.6× 7.6× 15× 75× 745× $\\infty$ 看图注意：纵轴是对数刻度。曲线在两端垂直上冲，说明放大倍数没有上界。\n这条曲线的实际后果是：想让误差放大不超过 10 倍，俯仰就得夹在 $\\pm 78°$ 以内；想不超过 100 倍，夹在 $\\pm 88°$ 以内。这个阈值是业务拍的，不是数学给的——数学只告诉你\u0026quot;越靠近越糟，而且没有尽头\u0026quot;。\n顺带修正一个常见的做法：\u0026ldquo;把相机俯仰夹在 ±89°\u0026ldquo;对数值病态几乎帮不上忙——那里误差放大还有 75 倍。它主要解决的是控制手感与视线翻转，别把它当成奇点的解法。\n第二类后果更直观：两个旋钮的效果越来越分不开。让偏航和滚转各转 30°：\n俯仰 60° → 物体实际转 15.364°\r俯仰 85° → 物体实际转 2.587°\r俯仰 89.9° → 物体实际转 0.052°\r俯仰 90° → 物体实际转 0.000°\r看图注意：两端归零。靠近 ±90° 时，两个旋钮越来越在做同一件事、互相抵消。\n第三类后果出在代码里。从旋转矩阵反解欧拉角时（RTR4 Eq 4.25）：\n$$p = \\arcsin(e_{21}), \\qquad h = \\operatorname{atan2}(-e_{20},\\ e_{22}), \\qquad r = \\operatorname{atan2}(-e_{01},\\ e_{11})$$$\\cos p \\to 0$ 时，后两个式子的分子分母同时趋零。atan2(0, 0) 是没有定义的——这不是\u0026quot;算得不准\u0026rdquo;，是\u0026quot;根本没定义\u0026rdquo;，必须先判断再取一个角置零（RTR4 Eq 4.26 处理的就是这件事）。\n三条合起来看：判据是 $\\cos(\\text{俯仰}) = 0$，但\u0026quot;躲开 90°\u0026ldquo;不是一个可操作的对策。 你想多安全，就得夹得多紧，而那个阈值是拍出来的。要解决就得换表示法。\n五、工业界怎么处理 一句话总纲：欧拉角是「人机接口」，四元数是「内部表示」。 搞混这一点是多数万向锁 bug 的来源。\n场景 做法 关键帧动画 动画师用曲线编辑器调欧拉角（可读可调），导出前转四元数做插值 相机控制 把俯仰夹住以绕开奇点与视线翻转（注意第四节：这解决的是手感，不是数值） 姿态解算 手机传感器融合直接输出四元数（Android 的 TYPE_ROTATION_VECTOR 前四个分量就是四元数），从源头不产生欧拉角 为什么\u0026quot;换个欧拉角顺序\u0026quot;救不了？ 因为奇点只是被挪走了，没有被消除。有些资料推荐 $z/x/z$ 顺序，说它只在绕 $x$ 转 $\\pi$ 时才锁——这是事实，但换个方向又会锁。RTR4 p.73 说得很直接：\n\u0026ldquo;There is no perfect sequence that avoids gimbal lock.\u0026rdquo;\n根本原因：用 3 个参数表示 3 维旋转，奇点必然存在。要覆盖住这个空间，需要 4 个分量（四元数）或更多；3 个不够。所以这不是选哪个顺序的问题，是表示法本身的问题。\n桌面与移动的差异主要落在代价上：\n桌面 算力充裕，四元数与矩阵之间来回转换、归一化、球面插值 slerp 都是标配\r移动 传感器融合直接给出四元数，省掉一次反解；四元数归一化用 rsqrt，\r比矩阵重正交化便宜，对低端设备友好\r小结 三句话：\n万向锁锁的是参数——三个数字里有一个变多余了，姿态空间一直是三维的 判据是 $\\cos(\\text{俯仰}) = 0$，而且退化是连续的：躲角度不是解法 出路是换表示法（四元数），并把欧拉角降格成\u0026quot;给人看的接口\u0026rdquo; 行动点：下次相机或角色转到某个角度突然不听话，先算一下 $\\cos(\\text{俯仰})$ 是不是快接近 0 了。如果是，别去调控制器参数——那是表示法的问题。\n附录一：可直接跑的验证脚本 纯 Python 标准库，无依赖。复制下来直接运行，输出的每个数字都能和正文对上。\n#!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34;万向锁的数值验证：秩、条件数、等价关系。纯标准库，无依赖。\u0026#34;\u0026#34;\u0026#34; import math D2R, R2D = math.pi / 180, 180 / math.pi def mm(A, B): return [[sum(A[i][k] * B[k][j] for k in range(3)) for j in range(3)] for i in range(3)] def Rz(a): c, s = math.cos(a), math.sin(a); return [[c, -s, 0.], [s, c, 0.], [0., 0., 1.]] def Ry(a): c, s = math.cos(a), math.sin(a); return [[c, 0., s], [0., 1., 0.], [-s, 0., c]] def Rx(a): c, s = math.cos(a), math.sin(a); return [[1., 0., 0.], [0., c, -s], [0., s, c]] def ap(M, v): return [sum(M[i][k] * v[k] for k in range(3)) for i in range(3)] def euler(a, b, g): \u0026#34;\u0026#34;\u0026#34;外旋 XYZ ≡ 内旋 ZYX：偏航 a → 俯仰 b → 滚转 g\u0026#34;\u0026#34;\u0026#34; return mm(mm(Rz(a), Ry(b)), Rx(g)) def rotvec(M): \u0026#34;\u0026#34;\u0026#34;旋转矩阵 → 旋转向量（log map）\u0026#34;\u0026#34;\u0026#34; tr = M[0][0] + M[1][1] + M[2][2] th = math.acos(max(-1., min(1., (tr - 1) / 2))) if th \u0026lt; 1e-9: return [0., 0., 0.] v = [M[2][1] - M[1][2], M[0][2] - M[2][0], M[1][0] - M[0][1]] return [x * th / (2 * math.sin(th)) for x in v] def jacobian(a, b, g, h=1e-6): \u0026#34;\u0026#34;\u0026#34;姿态对三个角度的偏导（中心差分）\u0026#34;\u0026#34;\u0026#34; cols = [] for d in ((h, 0, 0), (0, h, 0), (0, 0, h)): p = rotvec(euler(a + d[0], b + d[1], g + d[2])) m = rotvec(euler(a - d[0], b - d[1], g - d[2])) cols.append([(p[i] - m[i]) / (2 * h) for i in range(3)]) return [[cols[j][i] for j in range(3)] for i in range(3)] def sym_eig3(A): \u0026#34;\u0026#34;\u0026#34;对称 3×3 的特征值（Jacobi 旋转）\u0026#34;\u0026#34;\u0026#34; a = [r[:] for r in A] for _ in range(60): if max(abs(a[0][1]), abs(a[0][2]), abs(a[1][2])) \u0026lt; 1e-14: break for p, q in ((0, 1), (0, 2), (1, 2)): if abs(a[p][q]) \u0026lt; 1e-16: continue th = 0.5 * math.atan2(2 * a[p][q], a[q][q] - a[p][p]) c, s = math.cos(th), math.sin(th) for k in range(3): ap_, aq_ = a[k][p], a[k][q] a[k][p] = c * ap_ - s * aq_; a[k][q] = s * ap_ + c * aq_ for k in range(3): ap_, aq_ = a[p][k], a[q][k] a[p][k] = c * ap_ - s * aq_; a[q][k] = s * ap_ + c * aq_ return sorted(a[i][i] for i in range(3)) def singular_values(J): \u0026#34;\u0026#34;\u0026#34;JᵀJ 的特征值 → 奇异值（升序）\u0026#34;\u0026#34;\u0026#34; JtJ = [[sum(J[k][i] * J[k][j] for k in range(3)) for j in range(3)] for i in range(3)] return [math.sqrt(max(v, 0.0)) for v in sym_eig3(JtJ)] def rank(M, tol=1e-4): A = [r[:] for r in M]; r = 0 for c in range(3): piv = max(range(r, 3), key=lambda i: abs(A[i][c])) if abs(A[piv][c]) \u0026lt; tol: continue A[r], A[piv] = A[piv], A[r] for i in range(3): if i != r and A[i][c] != 0: f = A[i][c] / A[r][c] for j in range(3): A[i][j] -= f * A[r][j] r += 1 return r def geo_angle(A, B): \u0026#34;\u0026#34;\u0026#34;两个旋转矩阵之间的夹角（度）\u0026#34;\u0026#34;\u0026#34; tr = sum(A[i][k] * B[i][k] for i in range(3) for k in range(3)) return math.degrees(math.acos(max(-1., min(1., (tr - 1) / 2)))) # ── ① 秩、σ_min、条件数 ── print(\u0026#34;俯仰 秩 σ_min 条件数\u0026#34;) for d in (0, 30, 60, 80, 85, 89, 89.9, 89.99, 90): J = jacobian(0.3, d * D2R, 0.7) s = singular_values(J) c = (s[2] / s[0]) if s[0] \u0026gt; 1e-15 else float(\u0026#34;inf\u0026#34;) print(f\u0026#34;{d:\u0026gt;5}° {rank(J)} {s[0]:.3e} {c:\u0026gt;10.1f}\u0026#34;) # ── ② 等价关系：锁死时姿态由什么决定 ── for sign, lbl in ((+1, \u0026#34;+90°\u0026#34;), (-1, \u0026#34;-90°\u0026#34;)): b = sign * math.pi / 2 r0 = euler(30 * D2R, b, 40 * D2R) for tag, r1 in ((\u0026#34;同向各 +30°\u0026#34;, euler(60 * D2R, b, 70 * D2R)), (\u0026#34;反向各 ±30°\u0026#34;, euler(60 * D2R, b, 10 * D2R))): md = max(abs(r0[i][j] - r1[i][j]) for i in range(3) for j in range(3)) hit = \u0026#34; ← 姿态不变\u0026#34; if md \u0026lt; 1e-9 else \u0026#34;\u0026#34; print(f\u0026#34;俯仰 {lbl} {tag} → 最大差 {md:.2e}{hit}\u0026#34;) # ── ③ 内层轴（身体 X）被搬到哪 ── for d in (90, -90): v = ap(Ry(d * D2R), [1, 0, 0]) print(f\u0026#34;俯仰 {d:\u0026gt;4}°：身体 X 轴 → [{v[0]:+.3f} {v[1]:+.3f} {v[2]:+.3f}]（偏航轴 = [0 0 1]）\u0026#34;) # ── ④ 两个旋钮各转 30°，物体实际转了多少 ── for d in (60, 85, 89.9, 90): b = d * D2R print(f\u0026#34;偏航滚转各 +30° @ 俯仰 {d:\u0026gt;5}° → 姿态差 \u0026#34; f\u0026#34;{geo_angle(euler(30*D2R, b, 40*D2R), euler(60*D2R, b, 70*D2R)):\u0026gt;8.3f}°\u0026#34;) # ── ⑤ 反向各转 d → 姿态变化恰好 2d ── for d in (10, 30, 60): b = math.pi / 2 print(f\u0026#34;反向各 {d:\u0026gt;2}° @ 俯仰 90° → 姿态差 \u0026#34; f\u0026#34;{geo_angle(euler(30*D2R, b, 40*D2R), euler((30+d)*D2R, b, (40-d)*D2R)):\u0026gt;8.3f}°\u0026#34;) 运行输出的关键几行（下面是实际跑出来的，不是示意）：\n俯仰 秩 σ_min 条件数\r60° 3 3.795e-01 3.8\r80° 3 1.316e-01 11.7\r85° 3 6.646e-02 23.4\r89° 3 1.339e-02 117.3\r89.9° 3 1.342e-03 1172.9\r89.99° 3 1.342e-04 11729.6\r90° 2 8.904e-09 176791469.8\r俯仰 +90° 同向各 +30° → 最大差 1.11e-16 ← 姿态不变\r俯仰 -90° 反向各 ±30° → 最大差 5.55e-17 ← 姿态不变\r俯仰 90°：身体 X 轴 → [+0.000 +0.000 -1.000]（偏航轴 = [0 0 1]）\r偏航滚转各 +30° @ 俯仰 60° → 姿态差 15.364°\r偏航滚转各 +30° @ 俯仰 85° → 姿态差 2.587°\r偏航滚转各 +30° @ 俯仰 89.9° → 姿态差 0.052°\r反向各 30° @ 俯仰 90° → 姿态差 60.000°\r最后那一行 90° 值得停一下：理论上 $\\sigma_{\\min} = 0$、条件数应为 $\\infty$，脚本却给出 $8.9\\times10^{-9}$ 与 $1.77\\times10^8$。 这不是算错了，而是中心差分在奇点上的必然结果——$\\sigma_{\\min}$ 在那里对浮点误差的敏感度本身就是无穷。这也顺带说明：\u0026ldquo;奇点处数值不可靠\u0026quot;是字面意义上的不可靠，不是\u0026quot;精度差一点\u0026rdquo;。\n注意 euler() 用的是外旋 XYZ ≡ 内旋 ZYX（偏航 → 俯仰 → 滚转）。换别的顺序结论的形式会变（哪个角跟哪个角组合、符号怎么取），但\u0026quot;秩掉到 2、姿态只取决于一个组合角\u0026quot;这个结构不变。\n附录二：这条链上的常见追问 万向锁是什么\r→ 为什么中间轴转到 90° 就锁（内层轴被搬到与外层轴共线）\r→ 怎么证明\u0026quot;丢了一个自由度\u0026quot;（雅可比矩阵秩 3 → 2）\r→ 那 85° 有没有影响（有，但有分寸：条件数 23，误差放大 15 倍 —— 说\u0026quot;85° 已经锁死了\u0026quot;同样是错的）\r→ 3 个参数为什么必然有奇点（3 维旋转空间的参数化，需要 4 个分量才能覆盖住）\r→ 四元数为什么能解决（单位四元数是 4 维空间里的球面，覆盖旋转空间两次，处处无奇点）\r→ 那欧拉角为什么没被淘汰（动画师的曲线编辑器；它是接口，不是内部表示）\r→ 手机上怎么处理的（传感器融合直接输出四元数，从源头不产生欧拉角）\r参考 RTR4 §4.2.1–4.2.2（书内 p.71–73），Real-Time Rendering, 4th Edition §4.2.1 末尾点出欧拉角的两个局限：两组不同欧拉角可给出同一姿态（插值不该转）、gimbal lock Eq 4.22（$E(h,p,r) = R_z(r)R_x(p)R_y(h)$）、Eq 4.25（反解）、Eq 4.26（锁死时的特例） p.73：\u0026quot;There is no perfect sequence that avoids gimbal lock.\u0026quot; RTR4 参考文献 [499]：Frykholm, What Is Gimbal Lock and Why Do We Still Have to Worry about It? 本文所有数值均由附录脚本在本机算出 ","permalink":"https://quanpu.wang/posts/%E4%B8%87%E5%90%91%E9%94%81%E5%88%B0%E5%BA%95%E9%94%81%E4%BD%8F%E4%BA%86%E4%BB%80%E4%B9%88/","summary":"万向锁丢的是参数的自由度，不是姿态的自由度。从姿态雅可比矩阵的秩和条件数出发，说清锁死时的精确等价关系、为什么符号会变，以及为什么“别转到 90°”不是解法。","title":"万向锁到底锁住了什么？"},{"content":"结论 法线要用 $M^{-\\mathsf T}$ 变换，而不是 $M$。记住这一句就够了。\n① 只有存在非均匀缩放或切变时，这件事才有意义。 如果变换只由平移、旋转、均匀缩放组成，直接用 $M$ 就是对的。\n② 矩阵可逆时，$M^{-\\mathsf T}$ 就够了。 伴随矩阵的转置 $\\operatorname{adj}(M)^{\\mathsf T}$ 是更一般的写法（奇异矩阵也有定义）， 但可逆时它给出的方向与 $M^{-\\mathsf T}$ 完全一致，只在 $\\det M = 0$（退化变换）时才多出什么。 用 $M^{-\\mathsf T}$ 不必额外操心符号——符号已经含在里面了。\n③ 工业界的取向是\u0026quot;尽量不在 shader 里算\u0026quot;。 Unity 和 UE 靠 CPU 端预计算；Godot 给每个实例打标记，只有非均匀缩放时才在顶点着色器里算一次。 真正的效率排序是：不算 \u0026gt; 用 CPU 预计算 \u0026gt; 换乘法顺序 \u0026gt; 在 shader 里算。\n一、问题：法线为什么不能用同一个矩阵变换 你的顶点着色器里大概率有这么一行：\nmat3 normalMatrix = transpose(inverse(mat3(model))); 它是对的。但为什么位置、切线、纹理坐标都用同一个 model，法线却要单独算一个矩阵？\n一个矩阵可以一致地变换点、线、三角形，甚至切向量——因为这些量都跟着几何走。\n法线不行。原因在于法线的定义方式和其他量根本不同：\n法线不是\u0026quot;空间里指向某个方向的箭头\u0026quot;，而是\u0026quot;垂直于切平面的那个量\u0026quot;。写出来就是\n$$\\mathbf{n} \\cdot \\mathbf{t} = 0$$对所有切于该点的向量 $\\mathbf{t}$ 成立。即：\n模型经过了一串变换，这些变换可以合并成一个矩阵 $M$。切线随之变成 $\\mathbf{t}' = M\\mathbf{t}$。 请找出一个矩阵 $N$，使变换后的法线 $\\mathbf{n}' = N\\mathbf{n}$ 仍然满足 $\\mathbf{n}' \\cdot \\mathbf{t}' = 0$。\n满足的解就是 $N = M^{-\\mathsf T}$。\n二、推导：从\u0026quot;保持垂直\u0026quot;到 $M^{-\\mathsf T}$ 跟随约束 \u0026ldquo;垂直\u0026quot;就是点乘为零。写成矩阵乘法就是：\n$$\\mathbf{n}'^{\\mathsf T}\\mathbf{t}' = 0$$ 记号约定：本文统一按列向量处理（与 RTR4 一致），所以 $\\mathbf{n}^{\\mathsf T}$ 是行向量， 行向量乘列向量得到一个标量——这正是点乘。这里特意用 $\\mathsf T$ 写成矩阵形式，是为了下一步能直接代入矩阵。 （注意约定差异：HLSL / UE 里是行向量，代码写成 mul(v, M)；GLSL 是列向量，写成 M * v。 数学内容完全一样，只是乘法顺序反过来。）\n代入 $\\mathbf{n}' = N\\mathbf{n}$、$\\mathbf{t}' = M\\mathbf{t}$，并注意 $(N\\mathbf{n})^{\\mathsf T} = \\mathbf{n}^{\\mathsf T}N^{\\mathsf T}$：\n$$\\mathbf{n}^{\\mathsf T} N^{\\mathsf T} M\\, \\mathbf{t} = 0$$我们希望它对所有满足 $\\mathbf{n}^{\\mathsf T}\\mathbf{t} = 0$ 的向量对都成立。取 $N^{\\mathsf T}M = I$ 显然能满足，于是\n$$N = (M^{-1})^{\\mathsf T} = M^{-\\mathsf T}$$这就是\u0026quot;逆转置\u0026rdquo;。2000 组随机矩阵（含拉伸与切变）里，直接用 $M$ 变换法线的垂直性违反率是 100%， 而 $M^{-\\mathsf T}$ 是 0%——因为随机矩阵几乎不会恰好落在\u0026quot;旋转 + 均匀缩放\u0026quot;那个特殊子集里。\n模型经过一串变换时不必逐级求逆：合并成 $M$ 后求一次即可，因为 $(M_1M_2)^{-\\mathsf T} = M_2^{-\\mathsf T}M_1^{-\\mathsf T}$。\n但这个解是唯一的吗 上面只证明了充分性。要谈必然性，令 $A = N^{\\mathsf T}M$。 固定 $\\mathbf{t}$：要求 $\\mathbf{n}^{\\mathsf T}(A\\mathbf{t}) = 0$ 对所有 $\\mathbf{n} \\perp \\mathbf{t}$ 成立， 这意味着 $A\\mathbf{t}$ 平行于 $\\mathbf{t}$。\n而这个性质必须对每个 $\\mathbf{t}$ 都成立。一个把每个向量都映成自身倍数的矩阵，只能是标量矩阵：\n$$A = \\lambda I \\quad\\Longrightarrow\\quad N = \\lambda\\,M^{-\\mathsf T}$$结论：法线变换矩阵唯一到一个标量因子。\n这一步不是数学洁癖，它直接解释了两件事：\n为什么变换后必须重新归一化——那个 $\\lambda$ 让长度不可信，而方向才是法线的全部 为什么可以用伴随矩阵顶替逆矩阵——两者差的正是这样一个标量因子（见下文\u0026quot;更准确的答案\u0026quot;） 三、更准确的答案：伴随矩阵 RTR4 4.1.7 提到一个更一般的写法：把 $M^{-\\mathsf T}$ 换成 $\\operatorname{adj}(M)^{\\mathsf T}$（伴随矩阵的转置）。 它对任何方阵都有定义，不要求可逆；两者只差一个标量因子：\n$$\\operatorname{adj}(M)^{\\mathsf T} = (\\det M)\\,M^{-\\mathsf T}$$但矩阵可逆时它没有任何优势——给出的方向与 $M^{-\\mathsf T}$ 完全一致 （2000 组随机矩阵：$\\det\u003e0$ 时全部相同，$\\det\u003c0$ 时全部相反，乘上符号后一致）； 至于性能，把两种写法都展开成实际运算后是基本持平（见附录二）。\n它真正派得上用场的地方只有 $\\det M = 0$（退化变换：物体被压成平面或线）。这时 $M^{-\\mathsf T}$ 要除以 0、 直接报废，而伴随仍能给出方向：\nM = diag(1,2,0)（把物体压成 z = 0 平面），n = (0,0,1)：\rM⁻ᵀ 版 cofactor(M)/det → 除以 0 → inf / NaN ✗ 报废\r伴随版 (det\u0026lt;0 ? -1 : 1)·C·n = (0,0,2) → 归一化 (0,0,1) ✓ 正确\r顺便澄清一个常见说法：投影矩阵是可逆的——四种常见配置的行列式都非零，2000 组视图空间点 正投影再反投影的最大误差是 4.6e-13。\u0026ldquo;投影不可逆\u0026quot;说的是透视除法那一步（NDC 只剩 3 个分量， 解不回 4 维）；真正不可逆的是退化变换。\n真要在 shader 里用（GPU skinning、程序化变形），可直接抄的写法在文末附录一里， 那里也有一个只在退化变换上触发的符号陷阱。\n四、什么时候你根本不用这么麻烦 RTR4 4.1.7 给了一个常被忽略的省略条件：变换只由平移 + 旋转 + 均匀缩放组成时（不含拉伸、挤压、切变），\n平移不影响法线（法线是向量） 均匀缩放只改变长度，不改变方向 旋转矩阵的转置就是它的逆 → 两次转置（或两次求逆）相互抵消 所以原矩阵 $M$ 可以直接用来变换法线。\n归一化同样能省：\n只有平移 + 旋转：长度不变，根本不需要归一化 再加均匀缩放 $s$：长度变成 $s$ 倍，除以 $s$ 即可 决策表 变换组成 法线变换 归一化 平移 不需要变换 — 平移 + 旋转 直接用 $M$ 不需要 平移 + 旋转 + 均匀缩放 直接用 $M$ 除以缩放因子 含非均匀缩放 / 切变 必须 $M^{-\\mathsf T}$（或 $\\operatorname{adj}^{\\mathsf T}$） 必须 退化变换（$\\det M = 0$） 必须 $\\operatorname{adj}^{\\mathsf T}$ 必须 切向量 T / B 永远直接用 $M$ 视情况 「退化变换」具体指什么：某个轴的缩放为 0——物体被压成一张平面或一条线。 实际会遇到的有：缩放动画的插值经过 0（不是两端为 0，而是中间插出 0 的那一帧）、 美术为了临时隐藏把某个轴设成 0、形变动画把网格压塌。 这时 $M^{-\\mathsf T}$ 要除以 0、直接报废，只有 $\\operatorname{adj}(M)^{\\mathsf T}$ 还能给出方向。 注意它不是\u0026quot;无害的错\u0026rdquo;：哪怕那一帧物体小到看不见，NaN 法线照样会被写进缓冲、污染后面的 pass。\n只想拿结论的话，这张表就够了。\n五、工业界怎么做 三个主流引擎的实现都能对上，思路也一致：想办法不算，而不是想办法算得更快。\nUnity（CGIncludes/UnityCG.cginc，UnityObjectToWorldNormal）：\ninline float3 UnityObjectToWorldNormal(in float3 norm) { #ifdef UNITY_ASSUME_UNIFORM_SCALING return UnityObjectToWorldDir(norm); // 假设均匀缩放 → 直接变换 #else // mul(IT_M, norm) =\u0026gt; mul(norm, I_M) =\u0026gt; {dot(norm, I_M.col0), ...} return normalize(mul(norm, (float3x3)unity_WorldToObject)); #endif } 注意那行官方注释：他们把\u0026quot;用逆转置\u0026quot;改写成了\u0026quot;用逆矩阵、把向量放到乘法左边\u0026quot;。两者等价 （$\\mathrm{mul}(\\mathbf{n}, I_M) = I_M^{\\mathsf T}\\mathbf{n}$），但省掉一次显式转置—— 3×3 转置要做 component shuffle，而换乘法顺序是免费的。UNITY_ASSUME_UNIFORM_SCALING 走的 就是前面那条\u0026quot;可以直接用 $M$\u0026ldquo;的捷径。\nGodot（renderer_rd/shaders/forward_clustered/scene_forward_clustered.glsl:255）：\nmat3 model_normal_matrix; if (bool(instances.data[instance_index].flags \u0026amp; INSTANCE_FLAGS_NON_UNIFORM_SCALE)) { model_normal_matrix = transpose(inverse(mat3(model_matrix))); // 只有这时才算 } else { model_normal_matrix = mat3(model_matrix); // 否则直接用 } 那 flag 由 CPU 侧设置（INSTANCE_DATA_FLAGS_NON_UNIFORM_SCALE = 1 \u0026lt;\u0026lt; 4，定义在 render_forward_clustered.h:276，设置处 render_forward_clustered.cpp:974）。三家里唯一真在 shader 里算的——因为绝大多数物体 根本走不到那个分支。\nUE（Engine/Shaders/Private/VertexFactoryCommon.ush:134）：\nfloat3 RotateLocalToWorld(float3 LocalDirection, float3x3 LocalToWorld, float3 InvScale) { return InvScale.x * LocalToWorld[0] * LocalDirection.xxx + InvScale.y * LocalToWorld[1] * LocalDirection.yyy + InvScale.z * LocalToWorld[2] * LocalDirection.zzz; } InvScale 是每实例预计算好的逆非均匀缩放（InvNonUniformScale）。这行同时做两件事： 用\u0026quot;按行加权求和\u0026rdquo;（等价于乘转置）省掉转置，再用逐分量的 InvScale 抵消矩阵里的缩放。\n真正的效率排序是：不算 \u0026gt; 用 CPU 预计算 \u0026gt; 用乘法顺序代替转置 \u0026gt; 在 shader 里算。\n什么时候躲不掉 \u0026ldquo;想办法不算\u0026quot;有个前提：CPU 得先拿到那个矩阵。下面这些情况没这个前提：\n变换在 GPU 侧合成（GPU-driven 渲染、把层级关系放进 compute shader 逐级相乘）。 矩阵从没到过 CPU，也就无从预计算它的逆转置。\n程序化变形（绕轴扭转、风吹弯折、顶点位移）。这类变形不是一个矩阵，而是\u0026quot;位置 → 新位置\u0026rdquo; 的函数；法线要乘它雅可比矩阵的逆转置，而雅可比逐顶点都不同——没有常量矩阵可以先算好。\n顶点动画带非均匀缩放（骨骼缩放、morph target 的缩放通道）。混合出来的矩阵是逐顶点的：\nM = Σ wᵢ·Mᵢ 而 invT(Σ wᵢ·Mᵢ) ≠ Σ wᵢ·invT(Mᵢ)\r实测最大方向差 179°（随机矩阵的极端情形；实际问题通常小得多，但两者不是同一个量，没有\u0026quot;误差可忽略\u0026quot;的保证）。想用对，就必须先混合、再求逆转置。 （UE 的 GPU skinning 实际上把法线当切线一起变换，等于默认骨骼不带非均匀缩放； 一旦骨骼上有缩放，这个误差就会显出来。）\n实例数据只在 GPU 上可见（indirect draw、GPU culling 之后才知道要画哪些实例）。\n前两条是\u0026quot;矩阵拿不到\u0026quot;，后两条是\u0026quot;矩阵逐顶点不同\u0026quot;。出路是同一个：在 shader 里算， 但把范围压到最小——Godot 那套标记就是这个思路：分支在 CPU 侧设好，只有真正需要的物体才走进去。\n\u0026ldquo;手写余子式更省\u0026quot;这个说法并不成立。 两者的真实执行成本基本持平，而手写版还多担一个坑。 把两种写法各自原样编译（顶点着色器）看：\n写法 SPIR-V 指令数 关键指令 transpose(inverse(mat3(M))) 952 字节 17 MatrixInverse ×1（语义指令）+ 矩阵×向量 + Normalize 手写 cofactorMatrix + det + 判断 2072 字节 54 Cross ×3 + Determinant ×1 + Select ×1 注意第一行那个 17 条是假象：MatrixInverse 把展开工作推给了驱动，执行前还得展开成 $C/\\det M$（约 52 条），和手写版的 54 条几乎一样——手写省下的那次除法，被多出来的判断指令抵消了。\n所以写 transpose(inverse(...))。 它更短、意图更清楚，而且把\u0026quot;怎么展开\u0026quot;的决定留给驱动 （驱动可能用上硬件特性或更合适的算法）。手写展开等于把这个决定从编译器手里拿走。\n（顺带：$C/\\det M$ 编译出来只有 1 次除法——编译器先算一次倒数再做向量乘标量； 另外 transpose(inverse(m)) * v 里的转置是免费的，被 MatrixInverse 一起吸收了。）\n小结 法线的身份由\u0026quot;与切向量的关系\u0026quot;定义，所以它按 $M^{-\\mathsf T}$ 而不是 $M$ 变换 这个解唯一到一个标量因子——于是伴随矩阵能顶替逆矩阵，而归一化不能省 但在\u0026quot;平移 + 旋转 + 均匀缩放\u0026quot;下原矩阵就是对的（均匀缩放会改长度，除一下缩放因子即可） 下次写下 transpose(inverse(...)) 之前先问一句：这个物体的变换里有非均匀缩放吗？ 没有就可以省掉；有，就知道为什么省不掉。\n附录一：可直接抄的实现 日常写法：法线矩阵每个物体算一次，CPU 端算好传 uniform。绝大多数项目到此为止。\n// 顶点着色器 vec3 n_world = normalize(normalMatrix * n_object); // normalMatrix 由 CPU 传入 // CPU 端（GLM） glm::mat3 N = glm::transpose(glm::inverse(glm::mat3(model))); 要在 GPU 上算时（GPU skinning 里每根骨骼一个矩阵、程序化变形）：3×3 的 $\\operatorname{adj}(M)^{\\mathsf T}$ 就是余子式矩阵 $C$，它的三列是原矩阵两两列的循环叉积。\n// GLSL 的 mat3(a,b,c) 是「列为 a,b,c」；m[0] 是第一列 mat3 cofactorMatrix(mat3 m) { return mat3(cross(m[1],m[2]), cross(m[2],m[0]), cross(m[0],m[1])); } mat3 M = mat3(model); float det = determinant(M); // ⚠ 只有\u0026#34;负\u0026#34;才取反 —— 不能写成 sign(det)： // det = 0 时 GLSL 的 sign 返回 0，会把法线乘成零向量，normalize 之后就是 NaN。 float s = (det \u0026lt; 0.0) ? -1.0 : 1.0; vec3 n_world = normalize(s * cofactorMatrix(M) * n_object); 两个注意点：三对叉积必须循环移位（顺序写错会拿到转置或错的列）；叉积含大数相减， 极端缩放下会掉精度。\n附录二：这条链上的常见追问 为什么法线不能直接用模型矩阵变换\r→ 非均匀缩放破坏垂直性（用 n·t = 0 这个约束论证）\r→ 那正确的矩阵是什么（M⁻ᵀ，并指出它唯一到标量因子）\r→ 为什么既要求逆又要转置（切线是\u0026quot;跟着坐标系走\u0026quot;的量，法线是\u0026quot;反着走\u0026quot;的量）\r→ 什么时候可以偷懒（平移 + 旋转 + 均匀缩放）\r→ 矩阵奇异、逆不存在怎么办（伴随矩阵：$​\\det M = 0$ 时它仍能给出方向）\r→ 切向量为什么不一样（同一个变换，两类量的变换规则相反：逆变 / 协变）\r→ 负缩放下法线贴图为什么会错\r参考：Tomas Akenine-Möller 等，《Real-Time Rendering》第 4 版，4.1.7 节 Normal Transform（p.65–67）。 文中所有算例与压力测试数据均可用 3×3 矩阵运算复现。\n","permalink":"https://quanpu.wang/posts/%E6%B3%95%E7%BA%BF%E7%9F%A9%E9%98%B5/","summary":"从 n·t = 0 这个约束出发推导法线变换矩阵，说清它唯一到什么程度、什么时候可以省掉、以及矩阵奇异时该怎么办。","title":"为什么法线不能直接用模型矩阵变换？"},{"content":"为什么要写 学了几年渲染，我发现一个规律：能讲清楚的东西，才是真的会了。\n很多知识停留在\u0026quot;我知道有这么回事\u0026quot;的状态——面试被追问两层就露馅，写代码时踩到坑才发现理解是错的。 写文章是成本最低的自检方式：写不出来，就说明还没想明白。\n写三件事 一、读论文 实时渲染的论文，尤其是全局光照与采样方向。会尽量给出： 脉络（这篇在解决什么问题、前面的人怎么做、它改了什么）、关键公式的直觉解释、以及我自己的疑问。\n二、读源码 UE、Godot 这类引擎的渲染器实现。论文里的方法到工业代码之间通常有一大段距离， 这段距离里全是工程取舍——那才是最值得看的部分。\n三、写渲染器 从零写一个渲染器的过程。包括架构怎么分层、每个 pass 为什么这么设计、以及踩过的坑。\n一条原则 每篇都要有能被验证的东西。\n能贴 shader 就贴 shader，能给实测数字就给数字，能复现就写清复现步骤。 我会尽量做到——如果某篇只有形容词没有证据，那说明我还没真懂，请直接质疑我。\n\u0026ndash; 2026.09\n","permalink":"https://quanpu.wang/posts/hello/","summary":"三件事：读论文、读源码、写渲染器。以及一条写作原则——每篇都要有能被验证的东西。","title":"这个博客要写什么"}]