3.3 蒙皮集成与性能基准
3.2 结束时我们手里有一份姿态:每个节点一份平移/旋转/缩放。但姿态还只是浮点数组,离「动起来的人」差最后一步——把顶点贴到骨骼上。这就是蒙皮。蒙皮原理(LBS 的加权、权重归一、双四元数)在站内 /graphics-skeletal-animation/
已经讲透,本节不重推,只做三件事:把姿态算成蒙皮矩阵、写出 CPU 参考实现、在 GPU 上跑同一段数学并与 CPU 逐顶点比对,最后量化「蒙皮比静态网格贵多少」。
骨骼、绑定逆与蒙皮矩阵
骨骼是「关节数组 + 父子关系 + 绑定逆矩阵」,数据布局对齐 glTF 的 skin。每帧只有两条链:先把局部 TRS 复合成局部矩阵 M = T·R·S,再按拓扑序 global[i] = global[parent] · local[i] 链乘出全局矩阵;然后乘上预先算好的绑定逆矩阵,得到顶点着色器真正要的蒙皮矩阵 skin[i] = global[i] · invBind[i]。invBind[i] 是绑定姿态下全局矩阵的逆——因为它只有旋转加平移(无缩放),用「转置的 3×3 + 反平移」就能精确求逆,比通用 4×4 求逆稳定得多。这些逻辑落在 src/subsys/anim/skeleton.ts(含 quatToMat4、composeLocal、computeGlobalMatrices、computeSkinMatrices、invertRigid),本节不逐行展开。
关键性质只有一条:绑定姿态下 skin[i] 恰好是单位矩阵,蒙皮不改变任何顶点。这正是正确性的第一个检验点。
本节用的「关节链 + 窄带」就是最小化的 glTF 骨骼模型:链上每个关节对应 glTF 的一个 node(带 translation),inverseBindMatrices 是 skin 上的一串 4×4,窄带网格的每个顶点带 JOINTS_0(uint16x4)与 WEIGHTS_0(float32x4)两组属性。真实角色只是这条链分叉成树、关节数从 8 涨到几十上百,算法一字不改——这也是为什么值得先用最小模型把管线从头跑通,再拿它去接导入器产出的真模型。
从零实现:CPU 线性混合蒙皮
CPU 版 LBS 是正确性基准,也是性能对照里「慢的那一侧」。每个顶点带 4 组「关节下标 + 权重」,变形位置就是 Σ wⱼ · (skinⱼ · p)。零权重跳过是这里唯一的优化。同文件里还有两件配套:静态变换 transformPositions(每个顶点只乘一个模型矩阵,对照用),以及一条沿 +Y 的关节链与它上面的竖直窄带网格——这是最小可蒙皮模型,弯曲任一关节,窄带对应段随之弯折。
// file: src/subsys/anim/skinning.ts
// tier: L1
// verify: npx vitest run src/subsys/anim/skinning.test.ts
//
// helio 子系统卷 3.3:CPU 线性混合蒙皮(LBS)参考实现。每个顶点携带 4 组
// 「关节下标 + 权重」,变形位置 = Σ wⱼ · (skinⱼ · p)。这份 CPU 版既是最小 glTF
// 骨骼模型的正确性基准,也是 3.3 性能对照里的「慢的那一侧」。
import { type Mat4 } from '../../core/mat4.ts';
import {
computeGlobalMatrices,
defaultLocalPose,
invertRigid,
type Joint,
type LocalPose,
type Skeleton,
} from './skeleton.ts';
export interface SkinnedMesh {
readonly positions: Float32Array;
readonly joints: Uint16Array;
readonly weights: Float32Array;
readonly vertexCount: number;
}
/**
* CPU 线性混合蒙皮:把每个顶点按 4 组关节权重加权求和。
* out 与 mesh.positions 同长;返回 out。零权重跳过,是这里唯一的优化。
*/
export function skinVerticesCPU(
mesh: SkinnedMesh,
skinMatrices: readonly Mat4[],
out: Float32Array = new Float32Array(mesh.positions.length),
): Float32Array {
const { positions, joints, weights, vertexCount } = mesh;
for (let v = 0; v < vertexCount; v++) {
const x = positions[v * 3]!;
const y = positions[v * 3 + 1]!;
const z = positions[v * 3 + 2]!;
let px = 0;
let py = 0;
let pz = 0;
for (let k = 0; k < 4; k++) {
const w = weights[v * 4 + k]!;
if (w === 0) continue;
const m = skinMatrices[joints[v * 4 + k]!]!;
px += w * (m[0]! * x + m[4]! * y + m[8]! * z + m[12]!);
py += w * (m[1]! * x + m[5]! * y + m[9]! * z + m[13]!);
pz += w * (m[2]! * x + m[6]! * y + m[10]! * z + m[14]!);
}
out[v * 3] = px;
out[v * 3 + 1] = py;
out[v * 3 + 2] = pz;
}
return out;
}
/** 静态网格变换:每个顶点只乘一个模型矩阵。这是蒙皮对照里的「快的那一侧」。 */
export function transformPositions(
positions: Float32Array,
m: Mat4,
out: Float32Array = new Float32Array(positions.length),
): Float32Array {
const n = positions.length / 3;
for (let v = 0; v < n; v++) {
const x = positions[v * 3]!;
const y = positions[v * 3 + 1]!;
const z = positions[v * 3 + 2]!;
out[v * 3] = m[0]! * x + m[4]! * y + m[8]! * z + m[12]!;
out[v * 3 + 1] = m[1]! * x + m[5]! * y + m[9]! * z + m[13]!;
out[v * 3 + 2] = m[2]! * x + m[6]! * y + m[10]! * z + m[14]!;
}
return out;
}
/**
* 造一条沿 +Y 的关节链:第 j 个关节相对父关节平移 (0, segLen, 0)。
* 返回骨骼与绑定姿态(绑定姿态即局部平移本身,无旋转、单位缩放)。
*/
export function buildChainSkeleton(jointCount: number, segLen: number): { skeleton: Skeleton; bindPose: LocalPose } {
const bindPose = defaultLocalPose(jointCount);
for (let j = 0; j < jointCount; j++) {
bindPose.translation[j * 3 + 1] = j === 0 ? 0 : segLen;
}
// 先用占位 inverseBind 建骨架,再用绑定姿态的全局矩阵求逆填回
const joints: Joint[] = [];
for (let j = 0; j < jointCount; j++) {
joints.push({ name: `j${j}`, parent: j === 0 ? -1 : j - 1, inverseBind: new Float32Array(16) });
}
const bindGlobals = computeGlobalMatrices({ joints }, bindPose);
for (let j = 0; j < jointCount; j++) {
joints[j] = { name: `j${j}`, parent: j === 0 ? -1 : j - 1, inverseBind: invertRigid(bindGlobals[j]!) };
}
return { skeleton: { joints }, bindPose };
}
/**
* 沿关节链生成一条竖直窄带网格:顶点均匀分布在 [0, chainLength],
* 每个顶点把权重摊给最近的上下两个关节(线性混合)。
* 这是最小可蒙皮模型——弯曲任一关节,窄带对应段随之弯折。
*/
export function buildStripMesh(jointCount: number, segLen: number, verts: number): SkinnedMesh {
const chainLen = (jointCount - 1) * segLen;
const positions = new Float32Array(verts * 3);
const joints = new Uint16Array(verts * 4);
const weights = new Float32Array(verts * 4);
for (let v = 0; v < verts; v++) {
const u = verts === 1 ? 0 : v / (verts - 1);
const y = u * chainLen;
positions[v * 3] = 0;
positions[v * 3 + 1] = y;
positions[v * 3 + 2] = 0;
const f = segLen > 0 ? y / segLen : 0;
const j0 = Math.min(jointCount - 1, Math.max(0, Math.floor(f)));
const j1 = Math.min(jointCount - 1, j0 + 1);
const frac = j0 === j1 ? 0 : Math.min(1, Math.max(0, f - j0));
joints[v * 4] = j0;
joints[v * 4 + 1] = j1;
weights[v * 4] = 1 - frac;
weights[v * 4 + 1] = frac;
}
return { positions, joints, weights, vertexCount: verts };
}
src/subsys/anim/skinning.test.ts 有 6 条断言,其中两条是骨架正确性的关键:绑定姿态下 skin[i] 为单位矩阵、蒙皮复现原顶点;关节 1 绕 Z 转 90° 后,链顶顶点 (0,2,0) 落到 (-1,1,0)。跑法 npx vitest run src/subsys/anim/skinning.test.ts。
GPU 蒙皮:同一段数学搬进顶点着色器
把 Σ wⱼ · (skinⱼ · p) 原样搬进 WGSL 就是 GPU 蒙皮。每帧 CPU 只需算出 skin 矩阵数组并上传,顶点着色器读关节下标与权重就地变换。下面这份 shaders/skinning.wgsl 同时提供两条路径:skin_compute 把全部顶点算进 storage buffer(供精确回读比对),vs_skin 在顶点着色器里蒙皮后光栅化。有一个 WGSL 陷阱必须记住:array<vec3<f32>> 的元素步长是 16 字节(对齐到 16),不是 12——位置统一用 vec4 承载就不会踩这个坑。
// file: shaders/skinning.wgsl
// tier: L2
// verify: node tools/run-l2-ch3.mjs src/subsys/anim/skinning-l2.ts
// helio 子系统卷 3.3:GPU 蒙皮。同一段 LBS 数学(skin_one)被两条路径复用——
// compute 路径把全部顶点算进 storage buffer 供精确回读,渲染路径在顶点着色器里
// 直接蒙皮后光栅化。skin_mats 是每帧由 CPU 算好的 skin[i] = global[i]·invBind[i]。
struct SkinU { count: u32, _pad0: u32, _pad1: u32, _pad2: u32 };
struct ViewU { scale: f32, offset_y: f32, half_width: f32, _pad: f32 };
@group(0) @binding(0) var<storage, read> skin_mats: array<mat4x4<f32>>;
// 注意:WGSL 里 array<vec3<f32>> 的元素步长是 16 字节(对齐到 16),不是 12。
// 为免踩坑,位置统一用 vec4 承载,w 分量留空。
@group(0) @binding(1) var<storage, read> src_pos: array<vec4<f32>>;
@group(0) @binding(2) var<storage, read> src_joints: array<vec4<u32>>;
@group(0) @binding(3) var<storage, read> src_weights: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read_write> dst_pos: array<vec4<f32>>;
@group(0) @binding(5) var<uniform> su: SkinU;
@group(0) @binding(6) var<uniform> view: ViewU;
// 线性混合蒙皮:Σ wⱼ · (skinⱼ · p)。4 组影响权重由导入器保证归一。
fn skin_one(p: vec3<f32>, j: vec4<u32>, w: vec4<f32>) -> vec3<f32> {
var acc = vec3<f32>(0.0);
acc += w.x * (skin_mats[j.x] * vec4<f32>(p, 1.0)).xyz;
acc += w.y * (skin_mats[j.y] * vec4<f32>(p, 1.0)).xyz;
acc += w.z * (skin_mats[j.z] * vec4<f32>(p, 1.0)).xyz;
acc += w.w * (skin_mats[j.w] * vec4<f32>(p, 1.0)).xyz;
return acc;
}
@compute @workgroup_size(64)
fn skin_compute(@builtin(global_invocation_id) gid: vec3<u32>) {
let i = gid.x;
if (i >= su.count) { return; }
dst_pos[i] = vec4<f32>(skin_one(src_pos[i].xyz, src_joints[i], src_weights[i]), 0.0);
}
// 渲染路径:顶点属性直接带 pos / joints / weights,顶点着色器里蒙皮后投影到 NDC。
struct VOut { @builtin(position) pos: vec4<f32>, @location(0) tint: f32 };
@vertex
fn vs_skin(
@location(0) p: vec3<f32>,
@location(1) j: vec4<u32>,
@location(2) w: vec4<f32>,
) -> VOut {
let sp = skin_one(p, j, w);
var o: VOut;
o.pos = vec4<f32>(sp.x / view.half_width, sp.y * view.scale + view.offset_y, 0.0, 1.0);
o.tint = f32(j.x) / 8.0;
return o;
}
@fragment
fn fs_skin(in: VOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.tint, 1.0 - in.tint, 0.5, 1.0);
}
驱动 src/subsys/anim/skinning-l2.ts 在 headless Chromium(本地 http 服务)里跑:compute 路径把整条窄带回读,与 CPU skinVerticesCPU 逐顶点比对;渲染路径光栅化后回读像素,确认几何画出且弯关节后画面改变。本机实跑(node tools/run-l2-ch3.mjs src/subsys/anim/skinning-l2.ts):
PASS wgsl 0 error 0
PASS GPU 蒙皮与 CPU LBS 一致 4.77e-7
PASS 绑定姿态下 GPU 蒙皮复现原顶点 4.77e-7
PASS 弯关节后 GPU 仍与 CPU 一致 4.77e-7
PASS 弯关节确实改变了顶点位置 2.598
PASS 渲染路径画出几何(绑定姿态有覆盖) 768
PASS 弯关节后渲染画面改变 1629
GPU 与 CPU 的最大逐分量误差是 4.77e-7——就是 float32 的舍入量级,说明两条路径算的是同一件事。这里刻意分成两条验证路径:compute 路径把全部顶点算进 storage buffer 再回读,能与 CPU 的 skinVerticesCPU 做逐顶点精确比对,这是「两条路径算同一件事」的硬证据;渲染路径只能回读像素,无法精确定位单个顶点,于是退一步做特征断言——绑定姿态有覆盖(几何确实画出来了)、弯关节后画面改变(变换确实生效)。前者证明正确,后者证明可用,缺一不可。本节 L2 驱动需在浏览器中运行,命令行不可复现(navigator.gpu 只在安全上下文可用)。
性能基准:蒙皮相对静态网格的代价
蒙皮到底比静态网格贵多少?本机实测:20 万顶点、64 关节,对照三档——静态(1 个模型矩阵)、蒙皮 1 影响、蒙皮 4 影响。每档预热到中位数稳定(变化 < 3%)再采 15 个样本取中位数(node src/subsys/anim/skinning-bench.ts):
档位 中位数(ms) 每顶点(ns) 相对静态
静态变换 0.85 4.3 1.0×
蒙皮 1 影响 2.36 11.8 2.8×
蒙皮 4 影响 5.37 26.9 6.3×
(表中为 5 次独立运行的中位数;同机复跑单次中位数浮动约 ±3%。)
结论有两层。其一,蒙皮相对静态网格的实测代价约 2.8×(1 影响)到 6.3×(4 影响)——多出来的开销来自每个顶点要查 4 个关节的蒙皮矩阵并做 4 次矩阵-向量乘,而静态网格只乘一次。其二,4 影响相对 1 影响只慢约 2.3×(而非 4×),因为顶点取数、写出等固定开销被摊薄了——这解释了为什么工程上普遍接受 4 影响:相比 1 影响多花的绝对值不算大,却换来了关节处明显更顺滑的形变。
为什么 4 影响的成本不是 1 影响的 4 倍,还能从访存角度看:蒙皮矩阵的下标由顶点携带,是间接寻址——每个顶点的 4 个 skinⱼ 散布在调色板里,命中率取决于相邻顶点的关节是否相近。蒙皮网格通常按骨骼分段,局部性很好,于是调色板能常驻缓存。这正是卷一 11.1 缓存与内存访问模式
那条「把一起访问的数据放一起」的原则在蒙皮上的落地:把权重归一、把同一骨骼的顶点排在一起,就能把间接寻址的代价压下去。
这份基准读的是中位数而非均值:每档先预热到「滚动中位数 5 批内变化 < 3%」再采样,以排除 JIT 编译与 CPU 调频造成的前几十次偏慢;采 15 个样本后取中位数与 IQR。即便如此,同机复跑仍有约 ±3% 的浮动,来源是后台进程抢占、缓存热度与分配抖动。所以本节的比值(2.8× / 6.3× / 2.3×)比绝对毫秒数更可信——绝对值只在同一台机器、同一次会话内可比,比值才反映算法本身的工作量差异。
(本节所有数字均来自本机实跑,运行环境见附录 C;skinning-bench.ts 为 tier B 基准,skinning-l2.ts 需在浏览器中运行,命令行不可复现。)
调色板、骨骼数与渲染管线挂接
上面量的是「每顶点」的成本。还有一块只与骨骼数有关的开销:每帧要算出 skin[i] = global[i] · invBind[i] 这整块蒙皮矩阵调色板(skin matrix palette),再上传给顶点着色器。它和顶点数无关,单独量一下(node src/subsys/anim/palette-bench.ts):
关节数 调色板构建(ms) 调色板字节
16 0.010 1024
64 0.015 4096
256 0.058 16384
调色板构建是 O(关节数):256 关节也只有 0.058 ms,相对 20 万顶点的蒙皮开销(5.4 ms)可以忽略。真正要留意的是上传:64 关节是 4 KB、256 关节是 16 KB,每帧整块传。WebGPU 的单块 uniform buffer 上限是 64 KB,256 关节用掉 16 KB 看似宽裕,但一块 uniform 还要和相机、光照等共用;多角色场景里把调色板塞进 storage buffer(array<mat4x4<f32>>,正是本节 WGSL 采用的形式)更省心,没有 64 KB 的心理负担,还能把整帧所有角色的调色板拼成一个大 buffer 一次上传。
由此带出两条工程判断。其一,蒙皮成本几乎只由顶点数决定,与骨骼数解耦——骨架再细也不会拖慢顶点阶段,代价只转移到 O(骨骼) 的调色板构建与上传上,而这两项都比顶点阶段小一个量级。其二,GPU 蒙皮与 CPU 蒙皮的分界线在顶点规模:CPU 版 20 万顶点、4 影响要 5.4 ms,已经吃掉 60 fps 预算(16.7 ms)的三分之一;GPU 版把同一份工作塞进顶点着色器,与光栅化并行、几乎不占额外预算(本节 L2 实测已证明两条路径结果一致)。所以《微光岛》角色一多就必须走 GPU 蒙皮,CPU 版只保留作基准与无 GPU 环境的回退。与之配套的是动画 LOD:远处的角色降到 2 影响、甚至整帧跳过求值,屏幕上看不出差别,却省下同比例的顶点开销。
上传还有个容易踩的坑:调色板每帧都变,若 CPU 直接写进 GPU 正在读的同一块 buffer,就会踩到「写后读」的同步问题。工程做法是双缓冲——这一帧写 A 块、渲染读 B 块,下一帧交换;或者干脆依赖 queue.writeBuffer 的队列语义,把上传排在提交之前,让驱动替你处理时序。本节的 L2 是单帧离线跑,简化成「先算完再提交」,真机每帧动画则需要按帧号轮转 buffer。
最后是挂接点。蒙皮不是独立管线,它插在卷二渲染管线的顶点阶段之前:顶点布局在 position / normal / uv 之外多出 joints(uint16x4)与 weights(float32x4)两条属性(对应卷二 6.1 顶点格式、属性布局与程序化网格
的顶点格式表);调色板作为 storage buffer 绑进顶点着色器的 bind group;每个角色的世界矩阵与卷二 7.1 场景图与变换
共用同一套「局部 → 世界」链乘——区别只是蒙皮先在模型空间里做一遍关节变换,再乘世界矩阵。3.1 求值器产姿态、3.2 状态机选剪辑、3.3 蒙皮把姿态贴上骨骼,动画系统到此闭环。
阅读导航:上一节:3.2 状态机与混合树 · 下一节:4.1 引擎与物理库的边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。