GPU Gems 1 · Chapter 32. An Introduction to Shader Interfaces
用 Cg 1.2 的 shader interfaces、unsized arrays 和运行时绑定,把归一化、可变数量光源以及 Material/Texture 材质树组合成可复用且可专门化的 shader。
学习目标
- 能用接口声明、实现对象和 Cg runtime 绑定解释 shader 如何在运行时组合并在绑定后编译
- 能实现一个支持可变数量光源的
Lightinterface 与lights[],并说明为什么需要先设置数组长度 - 能用
Material与Textureinterface 组织可替换的材质树,而不为每一种纹理来源复制材质 shader - 能判断手动编译、constant 参数和多光源组合对编译时成本、最终 GPU 指令和代码维护性的影响
GPU Gems 1 第 32 章介绍 Cg 1.2 的 shader interfaces。它解决的是“同一份 shader 如何容纳多种实现”的组合问题:归一化可以走数值计算或 cube-map 查表,光源可以有不同类型且数量未知,材质又可以由纹理节点和其他材质节点组成。接口把这些变化从主 shader 的分支和字符串拼接中移出,交给 Cg runtime 在运行时连接对象并生成最终程序。
↡在 Cg 中声明一组抽象方法、允许 shader 调用而无需知道具体实现,并由 runtime 在执行前连接实现对象的接口机制 的价值不是像 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);
}↡遵守 shader interface 方法契约的具体 Cg 结构体,例如用 normalize 数学函数或 cube map 查表完成向量归一化 可以是 StdNormalizer 或 CubeNormalizer。前者实现 normalize(v);后者在结构体中持有 samplerCUBE,用 texCUBE() 读取已经编码好的归一化向量。主 shader 不需要两份,也不需要把 hardware profile 判断写进每个 fragment program。
接口替换的边界很清楚:实现的输入、输出和所有方法必须满足声明;实现内部可以持有自己的 sampler、常量和中间数据;应用需要在程序运行前把某个实现绑定给接口参数。类型安全来自这个契约,而不是来自主 shader 自己解析字符串。
2. Cg runtime:绑定完成后才生成最终程序
↡负责创建程序、创建用户类型参数、连接 interface 实例、设置数组长度并触发最终 shader 编译的 Cg 运行时 API 层 负责把源码里的抽象参数变成已连接的程序。推荐的手动编译顺序如下:
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;
}
};↡声明时不固定元素数量、由 Cg runtime 在执行前设置长度并逐项连接 interface 实例的数组 让主 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);这样主 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[]);
};↡用 interface 节点递归描述表面如何响应光照,并把 Texture、Light 和其他 Material 作为可替换子节点的层次化材质结构 允许 DiffuseMaterial 只依赖 Texture,而不区分 ImageTexture、ConstantTexture 或 BlendTexture。BlendTexture 又可以持有三个 Texture:两个输入值和一个混合量。FogMaterial 可以持有另一个 Material,先调用 base 的颜色,再混入雾颜色。新增一种纹理或装饰材质时,不需要修改已经存在的 DiffuseMaterial 源码。
这种正交性正是接口组合的工程收益:材质实现管理反射模型,纹理实现管理取值来源,光源实现管理照明行为。应用只负责创建对象并连接节点;shader 库不必为每一种组合预先生成一份巨大的专用文件。Material tree 也为 content-creation 工具提供了自然的节点图表达方式。
5. constant 参数:让编译器看到稳定事实
Cg 1.2 还允许把 shader 参数标记为 ↡被标记为稳定值、允许编译器在生成程序时进行分支求值和代码优化的 shader 参数。若一个 if 的条件只依赖常量,编译器可以在编译阶段求值:删掉永远不会走到的分支,或把必然成立的代码直接展开。这里的收益发生在最终程序生成阶段,不是让运行时接口调用更快。
因此,常量标记要和绑定时机一起设计:在实现类型、Light[] 长度和 constant 参数都确定后编译,编译器才有足够事实做专门化;如果同一个程序还要频繁更换这些值,就要比较重编译成本与运行时分支成本。接口提供组合能力,constant 提供优化信息,两者解决的是不同问题。
Shader interfaces lab
绑定组件,再生成专用程序
实验把官方三类例子压缩到同一条路径:Normalizer 选择实现,Light[] 在运行时确定长度,Material 再组合 Texture。数字是关系示意,不是某个 GPU 的 benchmark。
先完成接口连接再编译,避免未绑定接口导致编译失败。
6. 什么时候该用接口,什么时候不该用
shader interface 并不能表达以前无法表达的数学程序;它改变的是组织和生成方式。适合使用的信号包括:实现类型会随硬件或内容变化、光源数量不固定、材质节点要递归组合、主 shader 已经开始出现大量类型分支或字符串拼接。
不应盲目抽象的信号包括:只有一个永不替换的实现、绑定变化频繁却无法承受重编译、profile 对接口相关语法支持不一致,或简单的静态代码已经足够清楚。官方章节强调的成本边界是 compile-time 的 runtime/compiler overhead;发布前仍应检查目标 profile 的生成代码、编译次数和实际 shader 资源。
小结
- shader interface 声明抽象方法,具体 implementation 在运行时连接,绑定后生成专用 GPU 程序。
- 手动编译时应先关闭自动编译,创建用户类型参数、连接接口,再调用
cgCompileProgram()。 Lightinterface 与 unsized array 让主 shader 支持不同光源类型和运行时数量,避免重复 pass 与源码拼接。Material、Texture和Light可以组成可递归的 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 参数。