GPU Gems 1 · Chapter 32. An Introduction to Shader Interfaces

用 Cg 1.2 的 shader interfaces、unsized arrays 和运行时绑定,把归一化、可变数量光源以及 Material/Texture 材质树组合成可复用且可专门化的 shader。

学习目标

  • 能用接口声明、实现对象和 Cg runtime 绑定解释 shader 如何在运行时组合并在绑定后编译
  • 能实现一个支持可变数量光源的 Light interface 与 lights[],并说明为什么需要先设置数组长度
  • 能用 MaterialTexture interface 组织可替换的材质树,而不为每一种纹理来源复制材质 shader
  • 能判断手动编译、constant 参数和多光源组合对编译时成本、最终 GPU 指令和代码维护性的影响

GPU Gems 1 第 32 章介绍 Cg 1.2 的 shader interfaces。它解决的是“同一份 shader 如何容纳多种实现”的组合问题:归一化可以走数值计算或 cube-map 查表,光源可以有不同类型且数量未知,材质又可以由纹理节点和其他材质节点组成。接口把这些变化从主 shader 的分支和字符串拼接中移出,交给 Cg runtime 在运行时连接对象并生成最终程序。

shader interface:抽象调用,运行时组合generic shadernormalizer.nrm(v)lights[i].illuminate(P)material.color(...)不依赖具体实现Cg runtimecreateParameterconnectParameterset array sizebind → compileimplementationsStd · Cube · Lightfinal GPU programspecialized instructionsGPU 运行没有接口动态分派成本;代价主要在绑定后的 runtime/compiler 工作
接口把“调用什么”与“具体怎么做”分开;绑定实现后才生成最终程序,应用无需在字符串层拼接专用 shader 源码。

的价值不是像 C++ 一样为大型软件层次提供抽象,而是让 shader 可以由多个模块在运行时组合。主程序只依赖方法契约;用户提供实现;runtime 把这些实现放进最终 shader。由于具体类型在编译前已确定,GPU 执行阶段不需要为接口调用支付动态分派成本。

1. 接口的最小形态:先声明能力,再提供实现

Cg 的接口声明包含名称和方法签名,结构体通过继承接口名来承诺实现所有方法。归一化例子足够小,却能展示完整路径:fragment shader 接收一个 Normalizer,对光向量和插值法线都调用 nrm(),却不关心归一化来自数学运算还是纹理查表。

interface Normalizer {
  float3 nrm(float3 v);
};
 
float4 main(float3 Pworld, float3 Nworld,
            uniform float3 Plight,
            uniform Normalizer normalizer) : COLOR {
  float3 L = normalizer.nrm(Plight - Pworld);
  float3 C = Kd * max(0, dot(L, normalizer.nrm(Nworld)));
  return float4(C, 1);
}

可以是 StdNormalizerCubeNormalizer。前者实现 normalize(v);后者在结构体中持有 samplerCUBE,用 texCUBE() 读取已经编码好的归一化向量。主 shader 不需要两份,也不需要把 hardware profile 判断写进每个 fragment program。

Normalizer:同一调用,不同实现fragment shadernormalizer.nrm(L)normalizer.nrm(N)只知道 interfaceNormalizerfloat3 nrm(float3 v)runtime bindingone selected instanceStdNormalizernormalize() mathCubeNormalizertexCUBE lookup换实现只需重新 connectParameter,再手动 compile;不用复制整份 fragment shader
接口让硬件能力选择留在绑定阶段:主 shader 只调用 nrm,应用可以在运行时选择数值归一化或 cube-map 查表。

接口替换的边界很清楚:实现的输入、输出和所有方法必须满足声明;实现内部可以持有自己的 sampler、常量和中间数据;应用需要在程序运行前把某个实现绑定给接口参数。类型安全来自这个契约,而不是来自主 shader 自己解析字符串。

2. Cg runtime:绑定完成后才生成最终程序

负责把源码里的抽象参数变成已连接的程序。推荐的手动编译顺序如下:

cgSetAutoCompile(context, CG_COMPILE_MANUAL);
CGprogram prog = cgCreateProgramFromFile(
    context, CG_SOURCE, "frag.cg", profile, NULL, NULL);
 
CGtype nrmType = cgGetNamedUserType(prog, "StdNormalizer");
CGparameter stdNorm = cgCreateParameter(context, nrmType);
CGparameter normIface = cgGetNamedParameter(prog, "normalizer");
cgConnectParameter(stdNorm, normIface);
 
cgCompileProgram(prog);

先手动关闭自动编译,是因为程序创建时接口还没有实例。cgGetNamedUserType() 找到实现类型,cgCreateParameter() 创建实现对象,cgGetNamedParameter() 找到主 shader 的接口参数,cgConnectParameter() 建立连接,最后 cgCompileProgram() 才能生成完整代码。换实现时可以重新连接并再次编译;同一个实现对象也能被同一 context 中的多个程序共享。

自动编译并非永远错误:如果连接变化很少且工具希望简化调用,可以让 runtime 自动重编译。但要把“连接导致的隐式编译”当成明确的成本边界,尤其是在材质编辑器或大量对象同时变更时。

3. Light interface 与可变数量光源

多光源是接口真正有用的场景。若把 light type 当作普通参数,主 shader 必须为 point、spot、projective 和 shadow-map light 写一串判断;若用预处理器,则应用需要手工拼接并维护大量专用源码。接口可以把每种光源的方向 L 和到达强度交给实现:

interface Light {
  float3 illuminate(float3 P, out float3 L);
};
 
struct SpotLight : Light {
  sampler2D shadow;
  samplerCUBE distribution;
  float3 Plight, Clight;
 
  float3 illuminate(float3 P, out float3 L) {
    L = normalize(Plight - P);
    return Clight * tex2D(shadow, P).xxx * texCUBE(distribution, L).xyz;
  }
};

让主 shader 声明 uniform Light lights[],并用 lights.length 遍历当前集合。runtime 端需要先设置长度,再创建每个 Light 的实现参数并连接到数组槽位:

cgSetArraySize(lightsParam, lightCount);
for (int i = 0; i < lightCount; ++i) {
  CGparameter lightInstance = createLightParameter(lightTypes[i]);
  cgConnectParameter(lightInstance, cgGetArrayParameter(lightsParam, i));
}
cgCompileProgram(prog);
Light interface + unsized arrayfragment shaderfor i < lights.lengthlights[i].illuminate(P)C += Kd × Cl × N·L没有硬编码数量Cg runtimecgSetArraySizecgCreateParametercgConnectParameterlength → instances → compilebound lightspoint lightspot lightshadow light …最终 GPU 指令在具体数量和类型确定后生成
unsized array 把光源数量从 shader 源码中移出;运行时先设长度、再绑定每个接口实例,最终程序才被编译。

这样主 shader 只需要做一次纹理读取、法线归一化和光源累加循环,而不是每个 light pass 都重复运行整套 fragment program。最终 GPU 指令在光源数量和具体类型确定之后生成,因此接口和 unsized array 本身不会引入执行阶段的动态分派成本;它们把成本放在 runtime/compiler 的绑定阶段。

4. Material 与 Texture:把材质库组织成树

材质树的关键是区分职责:Material 描述表面如何响应光照,Texture 提供材质所需的某个点值。Material.color() 接收位置、法线、视线方向、纹理坐标和 Light[],而 Texture.eval() 可以来自图片、常量或程序生成函数。

interface Texture {
  float3 eval(float3 P, float3 N, float2 uv);
};
 
interface Material {
  float3 color(float3 P, float3 N, float3 I,
               float2 uv, Light lights[]);
};

允许 DiffuseMaterial 只依赖 Texture,而不区分 ImageTextureConstantTextureBlendTextureBlendTexture 又可以持有三个 Texture:两个输入值和一个混合量。FogMaterial 可以持有另一个 Material,先调用 base 的颜色,再混入雾颜色。新增一种纹理或装饰材质时,不需要修改已经存在的 DiffuseMaterial 源码。

Material tree:正交组合 shader 功能main → Materialcolor(P, N, I, uv, lights)DiffuseMaterialuses Texture + Light[]FogMaterialdecorates base MaterialImageTexturesampler2DBlendTextureTexture + Texturebase Materialany implementation
接口组合把材质库组织成树或网络:DiffuseMaterial 不需要知道纹理来自图片、常量还是程序生成。

这种正交性正是接口组合的工程收益:材质实现管理反射模型,纹理实现管理取值来源,光源实现管理照明行为。应用只负责创建对象并连接节点;shader 库不必为每一种组合预先生成一份巨大的专用文件。Material tree 也为 content-creation 工具提供了自然的节点图表达方式。

5. constant 参数:让编译器看到稳定事实

Cg 1.2 还允许把 shader 参数标记为 。若一个 if 的条件只依赖常量,编译器可以在编译阶段求值:删掉永远不会走到的分支,或把必然成立的代码直接展开。这里的收益发生在最终程序生成阶段,不是让运行时接口调用更快。

因此,常量标记要和绑定时机一起设计:在实现类型、Light[] 长度和 constant 参数都确定后编译,编译器才有足够事实做专门化;如果同一个程序还要频繁更换这些值,就要比较重编译成本与运行时分支成本。接口提供组合能力,constant 提供优化信息,两者解决的是不同问题。

Shader interfaces lab

绑定组件,再生成专用程序

实验把官方三类例子压缩到同一条路径:Normalizer 选择实现,Light[] 在运行时确定长度,Material 再组合 Texture。数字是关系示意,不是某个 GPU 的 benchmark。

interfaces → bindings → final GPU codegeneric main()DiffuseMaterial + Light[]Cg runtime5 bindingscompile1 passStdNormalizer · 3 lights · DiffuseMaterialL1L2L3runtime 1.75 · instruction factor 0.82

先完成接口连接再编译,避免未绑定接口导致编译失败。

6. 什么时候该用接口,什么时候不该用

shader interface 并不能表达以前无法表达的数学程序;它改变的是组织和生成方式。适合使用的信号包括:实现类型会随硬件或内容变化、光源数量不固定、材质节点要递归组合、主 shader 已经开始出现大量类型分支或字符串拼接。

不应盲目抽象的信号包括:只有一个永不替换的实现、绑定变化频繁却无法承受重编译、profile 对接口相关语法支持不一致,或简单的静态代码已经足够清楚。官方章节强调的成本边界是 compile-time 的 runtime/compiler overhead;发布前仍应检查目标 profile 的生成代码、编译次数和实际 shader 资源。

小结

  • shader interface 声明抽象方法,具体 implementation 在运行时连接,绑定后生成专用 GPU 程序。
  • 手动编译时应先关闭自动编译,创建用户类型参数、连接接口,再调用 cgCompileProgram()
  • Light interface 与 unsized array 让主 shader 支持不同光源类型和运行时数量,避免重复 pass 与源码拼接。
  • MaterialTextureLight 可以组成可递归的 material tree;新增节点不必修改已有节点。
  • constant 参数让编译器利用稳定事实做分支消除;接口和 unsized array 的主要成本位于绑定与编译阶段,而不是 GPU 执行阶段。

练习

问题 1|绑定顺序 为什么程序创建后不能立刻编译一个带 Normalizer interface 的 Cg program?请写出从创建程序到得到可执行 GPU 程序的最小调用顺序。

问题 2|可变光源 一个 fragment shader 需要支持任意数量的 point light 和 spot light。为什么 Light lights[] 比“每个光源渲染一次”更适合这个抽象?runtime 必须完成哪两类工作?

问题 3|材质树 请设计一个支持图片纹理、常量颜色和雾装饰的组合。哪些 interface 节点需要实现?为什么 DiffuseMaterial 不应该直接判断纹理来源?

名词解释

本章出现的专业名词,用大白话再讲一遍。

shader interface
声明抽象方法、由 runtime 绑定实现并在编译时组合进最终 shader 的 Cg 接口机制。
interface implementation
遵守接口方法契约的具体 Cg 结构体,例如 StdNormalizer 或 SpotLight。
Cg runtime
创建程序和用户类型参数、连接接口实例、设置数组长度并触发编译的运行时 API 层。
unsized array
声明时不固定长度、由 runtime 在执行前设置长度并绑定元素的数组。
material tree
以 Material、Texture 和 Light 等接口节点递归组合表面描述的层次结构。
constant parameter
被标记为稳定值、允许编译器在生成程序时进行分支求值和代码优化的 shader 参数。

资料与写作方式声明

本章以GPU Gems 系列权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…