GPU Gems 3 · Chapter 3. DirectX 10 Blend Shapes: Breaking the Limits
从顶点属性上限出发,对比 stream-out 多 pass 与 buffer-template 单次 shader 循环,让复合表情突破四个 active blend shapes 的旧限制。
学习目标
- 能解释 blend shape 如何由 neutral mesh 的 per-vertex delta 组成,并说明为什么同时绑定很多表达式会撞上顶点属性预算
- 能修改 Blend Shape Lab 的 active shapes、delta 通道、压缩和缓存选项,比较 stream-out 与 buffer-template 的 pass、读取和调用信号
- 能回答:表情数量较少、需要缓存最终网格,或表情数量较多、希望一次 draw 完成时,应该怎样选累积策略
先看一张会叠加的脸
想象一张中性的脸:嘴角、眼皮、眉毛都是可以单独推拉的小纸片。一次只动嘴角很简单;但如果同时眨眼、挑眉、微笑和说话,真正困难的不是“能不能相加”,而是“这些小纸片怎样送到 GPU”。
本章解决的问题是:如何让几十个表情组件参与同一次变形,而不把所有数据都塞进有限的顶点输入槽?如果不解决它,程序要么把合成工作退回 CPU,要么把表情限制在很小的集合;两者都会牺牲响应速度或表现力。
1. blend shape:neutral 加上带权重的差分
↡相对于 neutral pose 的逐顶点差分;它可以改变位置、法线和切线,再由一个权重控制加入多少。一个表情不是一张完整的新网格,而是 neutral mesh 到目标姿势的差分向量。对一个顶点来说,可以把它写成:deltaᵢ = poseᵢ - neutral;最终位置则是 neutral + weight₁ × delta₁ + weight₂ × delta₂ + ...。法线和切线也可以采用同样的差分思路,UV 等没有变化的属性继续从 neutral mesh 复用。
这一定义让“复合表情”自然出现:眨眼、微笑和挑眉各自只影响自己的 delta,权重可以同时非零。动画曲线只需更新当前有效的权重和索引,不必每帧重新制作一张完整脸。
2. attribute budget:为什么四个 active shapes 不够用
旧的 GPU 路线把每个 active blend shape 的位置、法线和切线差分都作为顶点属性绑定。neutral mesh 已经占据位置、法线、切线和 UV;再加四个表达式,很快就接近 DirectX 10 的 16 个输入属性预算。把 54 个表达式全部同时绑定,会产生远超输入槽的属性宽度。
这里的限制不是“艺术家只能做四种表情”,而是“一次 vertex shader invocation 只能直接看到很窄的一组输入”。因此本章的两条路线都保持同一个目标:静态数据可以很多,但每次处理只让当前需要的 delta 进入计算。
3. stream-out:用多 pass 拆开属性宽度
↡让 vertex shader 处理一批顶点后把结果写回 buffer,当前 pass 可以不继续光栅化;下一轮把结果 buffer 当作新的输入。stream-out 方法把 active 表情分组,每轮最多合并一到四个 delta。第一轮从 neutral 开始,输出“neutral 加第一组表达式”;第二轮读取上一轮结果,再叠加下一组。两个 vertex buffer 交替作为输入和输出,直到所有 active shapes 都被累积,最后把结果当作普通 mesh 绘制。
↡两个 GPU buffer 交替承担输入与输出的布局;一轮写 A 读 B,下一轮写 B 读 A,避免同一 buffer 同时读写。如果每轮能处理 k 个表达式,active shapes 为 n 时,累积轮数大致是 ceil(n / k)。这增加了 pass 和中间 buffer 流转,但有一个重要优点:最终顶点可以留在 stream-out buffer 中。角色定制完成后,可以缓存结果,后续帧不必再次合成全部表情。
4. buffer template:把迭代留在 vertex shader
↡HLSL 中用于读取无固定大小 buffer 的资源模板;blend shape 数据作为 shader resource view,由 Load 按元素访问。第二条路线把所有静态 blend shape 放进一个大 buffer,neutral mesh 仍由 input assembler 提供,delta 则通过 shader resource view 在顶点着色器中显式 Load()。另有两个动态的小 buffer 保存 active indices 和 weights,numBS 决定本帧循环多少项。
SV_VertexID 让每个顶点知道自己在 mesh 中的编号。着色器用 shapeOffset + 3 × vertexID + channel 定位位置、法线和切线差分,再乘权重累加到当前顶点。静态数据可以远超输入属性槽,单次 draw 便能完成 active list 的迭代;代价是每个顶点要执行更多 Load 和循环。
float3 pos = input.position;
float3 normal = input.normal;
float3 tangent = input.tangent;
for (uint i = 0; i < numBS; ++i) {
uint base = bsPitch * bsOffsets.Load(i);
float weight = bsWeights.Load(i);
float3 dp = bsVertices.Load(base + 3 * vertexID + 0);
float3 dn = bsVertices.Load(base + 3 * vertexID + 1);
float3 dt = bsVertices.Load(base + 3 * vertexID + 2);
pos += dp * weight;
normal += dn * weight;
tangent += dt * weight;
}注意“无限”只描述地址空间相对输入槽更宽,不代表 GPU 工作量无限免费:active list 越长,Load 越多;如果每个 delta 还包含切线,带宽和 shader 循环都会增加。
5. 两条路线的取舍:调用、读取与缓存
stream-out 的性能会随着表达式组数增加而下降,因为 CPU 需要驱动更多轮 buffer 流转;buffer-template 把迭代保留在 vertex shader,通常能减少中间流转和 API 调用。官方样例报告,在 6 个表达式时第二种方法约为第一种的 1.34 倍,在 50 个表达式时约为 2.4 倍;这些是该样例和旧硬件上的相对结果,不是跨引擎承诺。
但 buffer-template 并非永远取胜:如果表情很少、最终结果要跨多帧复用,stream-out 可以把合成后的 vertex buffer 缓存下来;如果表达式很多、每帧都变且不想反复 stream-out,buffer-template 更适合。还要检查 buffer view 的元素宽度:不同宽度的属性需要 padding 或 shader 重组,不能把任意结构体直接当作模板元素。
6. 把实验信号和角色需求对齐
官方 Dawn 样例把 54 个表情分成多个组,用动画曲线驱动一部分权重,同时保留手动滑杆来叠加其他表达式。下面的实验把这个思路缩成可观察信号:active shapes 代表当前非零表达式,delta channels 代表每个表达式需要改变的位置、法线和切线的宽度,压缩代表更紧的输入预算,缓存代表是否值得保留最终结果。
第 1 / 4 步 · 准备 neutral 与 per-vertex delta
多 pass 与单 pass 不是绝对优劣,而是缓存、提交和 shader 读取之间的取舍。
先猜一猜:把 active shapes 从 18 调到 54,stream-out 的 blend passes 和 API calls 会怎样变化?切到 buffer-template 后,哪一项保持为 1,哪一项会随表达式数增加?
调整表情预算:先预测 pass 还是 shader Load 上升
当前可检查信号
单次 draw 把迭代留在 vertex shader,active list 决定 Load 次数,不受输入槽数量直接限制。
提高 active shapes 会增加表情自由度;是否变慢取决于 pass、Load 循环和是否需要跨帧复用。
三步验收:从 delta 定义到策略选择
第一步:把表情写成 neutral 加 delta
先区分不变的 UV 与会改变的位置、法线、切线,再用权重把多个 delta 叠加。之后检查一次输入最多能容纳多少 active shapes,而不是把硬件槽位误写成艺术限制。
本章小结
- blend shape 是 neutral 到目标姿势的逐顶点 delta,可按权重组合复合表情。
- 顶点属性预算限制一次输入的宽度,不限制可制作的表情总数。
- stream-out 用多 pass 和 ping-pong buffer 拆开属性压力,也能缓存最终网格。
- buffer template 用 shader resource buffer、SV_VertexID 和 Load 循环支持更宽的 active set。
- 选型要同时看 pass、draw calls、delta reads、带宽和跨帧缓存需求。
练习
问题 1|计算属性预算。 neutral mesh 占 4 个属性,每个 active blend shape 需要位置、法线和切线 3 个属性,输入上限是 16。一次最多能直接绑定多少个 active shapes?如果把切线移出这一轮,预算会怎样变化?
问题 2|修改 Demo 代码。 给 Blend Shape Lab 增加 maxShapesPerPass 控件,并让 stream-out 模式直接使用它计算 ceil(activeShapes / maxShapesPerPass)。切到 buffer-template 模式时,这个控件应该改变 pass 数吗?为什么?
问题 3|场景选型。 一个角色有 50 个表情:加载时生成一次定制脸,之后数百帧不再改变;另一个角色每帧都由语音驱动 20 个权重。分别优先选择哪条路线?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- blend shape
- stream-out
- ping-pong buffer
- buffer template
- SV_VertexID
- active list