Model Viewer 示例深度解析:用 Slang 的 ParameterBlock 与 interface/泛型构建可静态特化的着色器库
【免费下载链接】slangMaking it easier to work with shaders项目地址: https://gitcode.com/GitHub_Trending/sl/slang
Model Viewer 是 Slang 仓库中一个承上启下的实战示例:它在 Hello World 示例的基础上,真正加载并渲染一个.obj模型,并配以极简的表面材质与光照着色。它演示了两件在 Slang 中"换一种思路做事"的核心能力——用ParameterBlock以图形 API 无关的方式组织着色器参数绑定(同时保留 D3D12/Vulkan 的高性能路径),以及用interface+ 泛型在不使用 C 预处理器、不写任何显式绑定修饰符的前提下完成着色器的静态特化。读完本文,你将掌握:如何组织一个可复用的 Slang 着色器库、如何用反射信息驱动参数块分配、以及如何按需特化泛型类型来切换材质与光照实现。
示例概览:从 Hello World 到 Model Viewer
与仅演示"编译一个 compute shader 并在 Vulkan 中运行"的 hello-world 示例 不同,model-viewer 示例引入了完整的渲染管线:顶点着色器、片元着色器、深度测试、多网格模型、材质与多光源光照。示例全部源码位于 examples/model-viewer/ 目录:
shaders.slang——着色器库与渲染 pass 的全部 Slang 代码;main.cpp——C++ 应用端,展示 Slang API 的高级集成方式;cube.obj/cube.mtl——随仓库提交的简易模型与材质文件(无需外部资源即可运行);CMakeLists.txt——通过example(model-viewer WIN32_EXECUTABLE)注册为可执行示例(见 examples/model-viewer/CMakeLists.txt)。
需要说明的是,main.cpp开头的注释表明该示例的代码形态偏旧,当前构建中的gfx层已经重构出更简洁的 shader-object 模型(见 examples/model-viewer/main.cpp)。但示例中展示的参数块组织方式、接口/泛型抽象与按需特化思想至今仍是理解 Slang shader-object 绑定模型的最佳教材。
应用从 example-base.h 继承窗口、设备、队列等基础设施:WindowedAppBase提供IDevice、ICommandQueue、ISurface以及renderFrame()虚函数,ExampleResources负责在examples/model-viewer/目录下解析资源文件(cube.obj、shaders.slang),EXAMPLE_MAIN宏则生成平台相关的main入口(examples/example-base/example-base.h)。窗口标题为 "Model Viewer",初始尺寸 1024×768,支持测试模式下的离屏渲染。
着色器库:用接口与泛型分层抽象(shaders.slang)
shaders.slang刻意把"可复用的着色器库"与"具体的渲染 pass"分成上下两半:上半部分是材质/光照抽象库,下半部分才是vertexMain/fragmentMain两个入口点。库部分按如下层次逐级构建。
1. 表面几何:SurfaceGeometry
材质评估的输入被统一为SurfaceGeometry结构体,携带世界空间位置、法线与一组 UV(examples/model-viewer/shaders.slang)。源码注释指出切线向量(各向异性材质所需)与多套 UV 是自然的下一步扩展,但受限于当前极简的模型加载代码而未引入——这体现了用struct表达"接口数据契约"的做法。
2. 反射模型:IBRDF 接口与 BlinnPhong
着色器库以interface IBRDF定义反射函数的契约(examples/model-viewer/shaders.slang),只有一个方法evaluate(float3 wo, float3 wi, float3 N)。注释特别说明:虽然示例本身不是 PBR 的,但这种"将反射模型独立成接口"的分解方式正是真实 PBR 渲染器在 Slang 中的组织思路。
BlinnPhong : IBRDF给出第一个实现(examples/model-viewer/shaders.slang),字段为漫反射系数kd、镜面反射系数ks与高光指数specularity。其evaluate采用教科书式 Blinn-Phong 公式,并刻意把 N·L 项折入反射函数求值中,以便编译器获得代数化简的机会。源码中的 TODO 还记录了一个现实问题:当时的模型加载 bug 会让ks/specularity变成无效垃圾值,导致结果非有限,因此示例临时只返回kd*nDotL——这是理解"示例刻意保持简单"的旁证。
3. 材质:IMaterial 接口与关联类型
interface IMaterial是本示例最具教学价值的部分(examples/model-viewer/shaders.slang):
interface IMaterial { associatedtype BRDF : IBRDF; BRDF prepare(SurfaceGeometry geometry); }这里用到了 Slang 的关联类型(associated type):接口要求实现方提供一个名为BRDF的类型,且该类型必须满足IBRDF约束,而不是要求某个具体类型。源码注释以 C++ 类比说明:如同每个容器都提供iterator类型、但不同容器的迭代器类型不同,不同材质可以产生不同类型的 BRDF。这与 Slang 官方用户指南中关联类型的定义一致——接口中的associatedtype Iterator : IIterator;要求所有实现类型在自身作用域内提供一个满足约束的同名类型(见 docs/user-guide/06-interfaces-generics.md),其语义与 Swiftprotocol的 associated type、Rusttrait的关联类型相同。
SimpleMaterial : IMaterial演示了如何满足该要求(examples/model-viewer/shaders.slang):用typedef BlinnPhong BRDF;声明关联类型,然后实现prepare()把均匀的diffuseColor/specularColor/specularity填入一个BlinnPhong实例。代码注释还指出:SimpleMaterial在整个文件中没有任何静态引用,完全由应用端在运行时"插拔"进来——这正是为按需特化埋下的伏笔。
4. 光照环境:泛型方法、LightArray 与 LightPair
interface ILightEnv定义了"能照亮表面"的对象契约(examples/model-viewer/shaders.slang):
interface ILightEnv { float3 illuminate<B:IBRDF>(SurfaceGeometry g, B brdf, float3 wo); }注意illuminate是一个泛型方法(接受满足IBRDF的任意B)。注释特别指出:Slang 允许接口要求中包含泛型方法,这与 C++ 中"模板方法不能是虚函数"形成鲜明对比——接口约束下的泛型方法会随具体类型静态特化,而不是走动态分发。
随后是两个平凡实现:DirectionalLight返回intensity * brdf.evaluate(...);PointLight按距离平方衰减intensity / (d*d)(examples/model-viewer/shaders.slang)。
真实场景通常有多个不同类型的光源,shaders.slang给出了两条组合路线:
LightArray<L : ILightEnv, let N : int> : ILightEnv——同质光源数组(examples/model-viewer/shaders.slang)。它同时拥有类型参数L与泛型值参数let N : int(表示最大光源数)。count记录实际光源数,L lights[N]为定长数组,illuminate用一个for循环累加所有光源贡献。源码注释提醒:泛型值参数目前在 Slang 中仍属实验性特性,语法可能变化。LightPair<T : ILightEnv, U : ILightEnv> : ILightEnv——异质光源组合子(examples/model-viewer/shaders.slang)。它聚合两个光照环境,illuminate把两者贡献相加。由于T/U本身可以是LightArray或LightPair,任意复杂的场景光照结构都能由这两个类型递归组合出来。源码注释强调:Slang 的泛型不是C++ 模板,错误信息总体可读,这也不是任何形式的"模板元编程"。
最后,EmptyLightEnv作为退化情形返回零贡献(examples/model-viewer/shaders.slang),用于"无光源"场景的类型折叠基准。
ParameterBlock:API 无关的高性能参数绑定
示例的渲染 pass 只声明了四个全局参数,全部通过ParameterBlock或接口类型组织(examples/model-viewer/shaders.slang):
struct PerView { float4x4 viewProjection; float3 eyePosition; }; ParameterBlock<PerView> gViewParams; struct PerModel { float4x4 modelTransform; float4x4 inverseTransposeModelTransform; }; ParameterBlock<PerModel> gModelParams; ILightEnv gLightEnv; IMaterial gMaterial;关于ParameterBlock<T>需要理解三件事:
它是一组绑定参数的容器,且能隐藏内部细节。与
ConstantBuffer<T>类似,ParameterBlock<T>将元素T中的参数聚合成一个分组;ParameterBlock<>会隐藏其元素使用的 bindings/registers/slots(见 docs/user-guide/09-reflection.md)。PerView注释指出:这样写与 HLSLcbuffer差别不大,但关键在于后续往PerView里加环境贴图等资源时代码无需改动。它专门为现代图形 API 设计。与
ConstantBuffer不同,ParameterBlock<T>会为T中定义的资源/采样器句柄引入新的 descriptor set ID,其设计目标正是 D3D12/Vulkan/Metal/WebGPU:例如在 Vulkan 下,一个不含嵌套参数块的ParameterBlock恒映射为单个 descriptor set,资源从 binding 0 开始连续排布,应用可以预先创建并填充 descriptor set 后在命令编码期间复用,无需逐个参数指定 binding 索引(见 docs/user-guide/a2-01-spirv-target-specific.md)。这正是 README 所说"利用 D3D12 和 Vulkan 带来的性能机会"。它同时也是"代码选择器"。这是本示例最有意思的一点:
gLightEnv与gMaterial声明为接口类型(ILightEnv/IMaterial),意味着编译期它们的具体实现未知;而当应用端用某个具体类型(如SimpleMaterial,或特化后的LightPair<...>)创建 shader object 并绑定进来时,编译器就针对该具体类型生成特化版本。fragmentMain中对gMaterial.prepare(g)与gLightEnv.illuminate(...)的调用因此会静态分发到正确的材质/光照实现,而不是在 GPU 上做动态分支。
入口点用 HLSL Shader Model 6.x 引入的[shader("vertex")]/[shader("fragment")]属性标记(examples/model-viewer/shaders.slang)。该属性告诉编译器哪些函数是入口点及对应阶段,应用端(或slangc命令行)就无需再指定入口点名称与阶段。与 HLSL 仅在新版本支持不同,Slang 编译器在所有目标(DXBC、DXIL、SPIR-V)上均支持该属性。在编译器源码中,该属性对应EntryPointAttribute(见 source/slang/slang-ast-modifier.h),并在语义检查阶段用于确定入口点的 capability(如 source/slang/slang-check-shader.cpp 通过findModifier<EntryPointAttribute>()读取)。
顶点着色器把POSITION/NORMAL/UV组装成世界空间位置与法线,片元着色器则构造SurfaceGeometry、调用gMaterial.prepare(g)得到关联类型gMaterial.BRDF(具体即BlinnPhong),再交给gLightEnv.illuminate(g, brdf, V)积分光照,最后加一个常数环境项并输出。注释将此流程类比为 RenderMan Shading Language 的illuminance循环——片元着色器完全不关心光源如何组织。
应用端:反射、参数块分配与按需特化(main.cpp)
main.cpp相对 Hello World 展示了三处更高级的 API 集成。
1. 加载模块、组合组件类型、获取反射布局
RendererContext::init首先通过device->getSlangSession()->loadModule(...)加载整个shaders.slang模块(而非单个入口点),再分别用shaderModule->findEntryPointByName("vertexMain" / "fragmentMain")找到两个入口点(examples/model-viewer/main.cpp)。
关键一步是把三者组合成"组件类型":slang::IComponentType既可以表示模块,也可以表示入口点,而createCompositeComponentType把它们合并为一个完整着色器程序(examples/model-viewer/main.cpp)。源码注释说明:一个 Slang 模块可以包含大量入口点(多个顶点/片元/计算着色器),组合操作把"要一起生成代码的入口点集合"固定下来。随后:
slangReflection = composedProgram->getLayout(); // 反射布局 perViewShaderType = slangReflection->findTypeByName("PerView"); perModelShaderType = slangReflection->findTypeByName("PerModel");getLayout()返回的ShaderReflection描述了整个程序的参数布局——这正是 README 强调的"不指定具体入口点、直接对类型做反射":应用在生成任何目标代码之前,就能通过类型名查询PerView、PerModel、SimpleMaterial等类型的布局信息。
2. 用反射信息分配图形 API 对象
findTypeByName返回的slang::TypeReflection*可以直接交给device->createShaderObject(...),由gfx层根据布局信息自动分配对应的 descriptor set/table、常量缓冲区等底层资源:
auto shaderType = program->findTypeByName("SimpleMaterial"); shaderObject = context->device->createShaderObject(shaderType); ShaderCursor cursor(shaderObject); cursor["diffuseColor"].setData(&diffuseColor, sizeof(diffuseColor)); cursor["specularColor"].setData(&specularColor, sizeof(specularColor)); cursor["specularity"].setData(&specularity, sizeof(specularity));(见 examples/model-viewer/main.cpp)ShaderCursor提供按成员名定位写入的句柄式 API。渲染循环中,每帧还会创建PerView与PerModel的 shader object 并填充viewProjection/eyePosition、modelTransform/inverseTransposeModelTransform(examples/model-viewer/main.cpp),随后在根对象上一次性绑定:
rootCursor["gViewParams"].setObject(viewShaderObject); rootCursor["gModelParams"].setObject(modelShaderObject); rootCursor["gLightEnv"].setObject(lightShaderObject); rootCursor["gMaterial"].setObject(mesh->material->shaderObject);(examples/model-viewer/main.cpp)材质参数块在模型加载时一次性创建、随后跨帧复用;注释点明:绑定材质参数块同时完成了两件事——既选择材质参数的值,也选择实现IMaterial的具体 shader 类型所对应的求值代码。
3. 灯光环境的按需特化:LightEnvLayout 与 LightEnv
应用端用两个 C++ 类对齐 shader 侧的光照抽象(examples/model-viewer/main.cpp):
LightEnvLayout声明每种光源允许的类型与最大数量。当某类光源最大数量 ≤ 1 时对应 shader 类型就是X本身,否则是LightArray<X, maximumCount>(源码通过拼接类型名字符串再findTypeByName查找,见 examples/model-viewer/main.cpp)。LightEnv由 layout 构造,为每类光源维护一个数组,并提供getShaderType():对 N 类光源做反向"折叠",生成形如LightPair<L0, LightPair<L1, ... LN>>的嵌套类型名;零光源时回退为EmptyLightEnv(examples/model-viewer/main.cpp)。createShaderObject()每帧从特化类型新建一个"瞬态" shader object,按类型结构填充first/second或count/lights。
示例初始化时创建了一个可容纳10 个点光源 + 2 个方向光的布局,但场景中只激活一个点光源(examples/model-viewer/main.cpp)。源码注释解释了背后的工程权衡:我们希望能够按场景中光源的具体类型特化 shader,但也不愿"过度特化"——99 个点光源与 100 个点光源不应生成两套不同的特化代码,所以用"类型 + 最大数量"的布局做量化。这种"把整个场景/帧的光源集合上限固定下来、再按类型特化"的思路,同样适用于延迟渲染或 Forward+ 渲染器。README 中"按需特化 Slang 泛型"指的正是这一套:用参数块携带的类型信息驱动特化。
4. 模型加载与渲染循环
模型从cube.obj加载(platform::ModelLoader提供回调式 C++ 接口,应用注册createMaterial/createMesh/createModel回调来构建自己的表示,见 examples/model-viewer/main.cpp)。输入布局声明为POSITION(RGB32Float)、NORMAL(RGB32Float)、UV(RG32Float)三个元素(examples/model-viewer/main.cpp)。渲染状态包含TriangleList图元、LessEqual深度测试与 RGBA8 颜色目标(examples/model-viewer/main.cpp)。摄像机为极简 6DOF 第一人称模型:WASD 平移、鼠标拖拽旋转(examples/model-viewer/main.cpp)。渲染循环对每个模型的每个网格执行drawIndexed,注释也坦承更高级的做法应"按材质排序以减少状态切换"。
示例刻意不做的事:无预处理器、无显式绑定
README 特意列出两个"反其道而行"的设计决策,这也是本示例的教学价值所在:
不使用 C 风格预处理器。传统 DX/GL 生态常靠
#if defined(...)+ 宏重编译来生成着色器变体;本示例用interface+ 泛型 + 关联类型表达变体,变体由编译器在特化时静态生成,shaders.slang中没有任何预处理指令。不使用显式参数绑定修饰符(如 HLSL
register、GLSLlayout修饰符)。所有绑定槽位、descriptor set 编号都由 Slang 编译器在ParameterBlock<T>的基础上自动分配,应用侧通过反射布局信息创建并填充底层资源。这说明无需手写绑定布局也能获得高性能的着色器参数绑定——这正是ParameterBlock面向 D3D12/Vulkan 的设计初衷(见 docs/user-guide/a2-01-spirv-target-specific.md)。
小结
Model Viewer 示例用一套约 480 行的 Slang 代码 + 约 1000 行的 C++ 应用代码,完整展示了 Slang 区别于传统着色器生态的三个核心主张:
- 参数绑定交给编译器:
ParameterBlock<T>自动完成 D3D12/Vulkan 的 descriptor 布局,应用只负责通过反射 +ShaderCursor填值; - 变体表达用类型系统而非预处理器:
interface、泛型、关联类型与泛型值参数组合出可递归嵌套的光照环境类型,所有分发在特化期静态完成; - 反射驱动的按需特化:
findTypeByName+createShaderObject让应用在运行时依据具体类型创建参数块,同一份 shader 库即可服务不同的材质与光照组合。
当前仓库中,gfx层的 shader-object 模型(见 examples/example-base/example-base.h 与slang-rhi.h)正是在这一思想上演进出的新一代绑定 API;而ParameterBlock的布局细节与反射 API 的完整参考,可继续阅读 docs/user-guide/09-reflection.md 与 docs/user-guide/09-targets.md。
【免费下载链接】slangMaking it easier to work with shaders项目地址: https://gitcode.com/GitHub_Trending/sl/slang
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考