Skip to main content

Chapter 89: HLSL Shader System

HLSL Shader System 讨论的对象是一套从 .hlsl 源文件到 GPU 执行结果的工程链路。读完本章后,读者应能追踪一个 HLSL shader 如何声明输入输出、如何通过编译进入 DXIL、如何和 root signature / pipeline state 对齐,以及如何用 GPU capture 把黑屏、错误材质或 shader 性能瓶颈定位到具体阶段。

本章以一个 Direct3D 12 材质 pass 作为贯穿材料:顶点 shader 把 mesh 顶点从模型空间变换到裁剪空间,像素 shader 根据材质索引采样 albedo / normal / roughness 贴图,compute shader 负责一个可选的亮度直方图 pass。这个材料足够小,可以覆盖 HLSL 语法、stage entry point、resource binding、Shader Model 特性和调试路径;它也足够接近真实引擎,因为真实项目中的 shader 问题多数发生在“源码能编译,但绑定、语义、变体或 GPU 执行结果和预期不一致”的位置。

Microsoft 的 HLSL 文档把 HLSL 定位为 DirectX 可编程 shader 使用的 C-like 高层语言。工程上更准确的理解是:HLSL 源码描述某个 shader stage 内每个 invocation 的计算逻辑;Direct3D 运行时和驱动根据编译产物、资源绑定、pipeline state、draw / dispatch 参数共同决定这些逻辑在 GPU 上如何执行。HLSL 文件本身无法单独决定一帧图像,它必须进入编译、绑定和 pipeline integration 之后才产生稳定图形结果。

本章的核心结论是:HLSL 系统的正确性要同时检查五条契约。第一条是语言契约,类型、函数、intrinsic 和控制流要能被目标 shader profile 接受。第二条是 stage 契约,entry point 的输入输出语义要和管线阶段匹配。第三条是资源契约,b / t / u / s register 和 space 要和 root signature 兼容。第四条是编译契约,宏、include、profile、DXIL 目标和 cache key 要保持可复现。第五条是工具契约,GPU capture 中看到的 PSO、绑定表、shader source / disassembly 和 counter 要能解释视觉结果或性能症状。

89.1 HLSL 语言基础与语法特点

HLSL 的语言基础可以从“每次 shader invocation 看到什么输入、执行什么代码、写出什么输出”理解。vertex shader 的一次 invocation 通常对应一个顶点,pixel shader 的一次 invocation 通常对应一个 fragment 或 sample,compute shader 的一次 invocation 对应一个 thread。HLSL 语法接近 C 系语言,但它围绕 GPU 数据路径增加了向量类型、矩阵类型、资源对象、采样器、语义标记和 shader intrinsic。

下面的最小材质 pass 展示本章贯穿材料的主体。代码用于把 HLSL 文件中最常见的对象放到同一个上下文中:constant buffer 提供矩阵和材质参数,SRV 贴图提供像素输入,sampler 决定过滤方式,vertex shader 输出 SV_Position 和插值属性,pixel shader 写入 SV_Target0

struct FrameConstants
{
float4x4 viewProj;
float3 cameraPosWS;
float exposure;
};

struct ObjectConstants
{
float4x4 model;
uint materialIndex;
float3 padding;
};

ConstantBuffer<FrameConstants> gFrame : register(b0, space0);
ConstantBuffer<ObjectConstants> gObject : register(b1, space0);
Texture2D<float4> gAlbedoTextures[] : register(t0, space1);
SamplerState gLinearSampler : register(s0, space0);

struct VSInput
{
float3 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
};

struct VSOutput
{
float4 positionCS : SV_Position;
float3 normalWS : NORMAL;
float2 uv : TEXCOORD0;
nointerpolation uint materialIndex : MATERIAL_INDEX;
};

VSOutput VSMain(VSInput input)
{
VSOutput output;
float4 positionWS = mul(float4(input.positionOS, 1.0), gObject.model);
output.positionCS = mul(positionWS, gFrame.viewProj);
output.normalWS = normalize(mul(float4(input.normalOS, 0.0), gObject.model).xyz);
output.uv = input.uv;
output.materialIndex = gObject.materialIndex;
return output;
}

float4 PSMain(VSOutput input) : SV_Target0
{
uint textureIndex = NonUniformResourceIndex(input.materialIndex);
float4 albedo = gAlbedoTextures[textureIndex].Sample(gLinearSampler, input.uv);
float lighting = saturate(input.normalWS.z * 0.5 + 0.5);
return float4(albedo.rgb * lighting * gFrame.exposure, albedo.a);
}

这段代码里,HLSL 类型首先承担数据布局和执行语义。float3float4float4x4 描述 shader 内的向量和矩阵计算;ConstantBuffer<T> 表示 GPU 读取的常量数据;Texture2D<float4> 表示可采样二维纹理;SamplerState 表示采样状态。CPU 侧会把矩阵、贴图 descriptor 和 sampler descriptor 绑定到对应 register 位置,shader 执行时按声明读取它们。

HLSL 的函数入口由 entry point 名称和 target profile 共同确定。同一个 .hlsl 文件可以同时包含 VSMainPSMainCSMain,编译命令通过 -E 选择入口,通过 -T 选择 stage 与 Shader Model,例如 vs_6_6ps_6_6。入口函数的参数和返回值形成 stage 边界:vertex shader 读取 input assembler 提供的顶点属性,输出光栅化需要的位置和插值属性;pixel shader 读取插值结果,输出 render target 或 depth 值;compute shader 通过 SV_DispatchThreadID 等系统值定位自己的 thread。

语义(semantic)是 HLSL stage 之间传递数据的标签。Microsoft 的 Semantics 文档说明 semantic 是附加在 shader 输入或输出上的字符串,用来表达参数用途。普通 semantic 例如 TEXCOORD0NORMAL 用于应用自定义数据传递;系统值 semantic 以 SV_ 开头,例如 SV_PositionSV_TargetSV_DispatchThreadID,会被 Direct3D 管线解释为固定含义。排查 shader 问题时,semantic 先回答“这份数据是否穿过了正确的管线阶段”。

HLSL 的控制流和函数调用需要放在 GPU 执行粒度里理解。ifloop、函数封装和宏组合都能提升表达能力,但像素 shader 中的分支可能让同一 wave 内不同 lane 走不同路径,纹理采样和导数计算也会受控制流影响。一个稳定做法是把材质模式、贴图开关、透明裁剪等分支分成两层处理:跨 draw 或跨 material 稳定的条件放入 shader permutation;每像素连续变化的数据放入 branchless math、纹理数据或统一分支。这样能把 shader 源码可读性和 GPU 执行一致性同时纳入设计。

HLSL 与 C/C++ 相似的外观容易掩盖一个事实:HLSL 变量多数服务于 GPU 寄存器、资源句柄、插值寄存器或输出 attachment。局部变量主要影响编译器寄存器分配和指令生成;全局资源变量代表可绑定资源范围;结构体字段代表 stage signature 或 buffer layout。阅读 HLSL 时应先标出资源、入口、语义和输出,再看函数内部算术,这个顺序能快速判断代码是否处在正确的管线位置。

89.2 Shader 模块组织与编译流程

Shader 模块组织解决的是可复现编译问题。真实工程中的 HLSL 很少是单文件:公共数学函数、BRDF 函数、packing 函数、材质定义、debug visualization 会分散在多个 .hlsli.hlsl 文件中。编译流程要把这些文件、宏、entry point、target profile、include search path、优化级别、debug 信息和输出二进制统一到一个稳定 cache key 中。

一个可维护的材质 shader 目录通常按责任拆分。CommonTypes.hlsli 放置 CPU / GPU 需要对齐的数据结构,Lighting.hlsli 放置光照函数,MaterialResources.hlsli 放置 descriptor 声明,MaterialPass.hlsl 放置 stage entry point。.hlsli 的作用是让多个 entry point 共享代码;.hlsl 的作用是提供可编译入口。这个边界能让编译系统清楚知道“哪个文件发生变化会影响哪些 shader blob”。

// MaterialResources.hlsli
ConstantBuffer<FrameConstants> gFrame : register(b0, space0);
Texture2D<float4> gAlbedoTextures[] : register(t0, space1);
SamplerState gLinearSampler : register(s0, space0);

// MaterialPass.hlsl
#include "CommonTypes.hlsli"
#include "MaterialResources.hlsli"
#include "Lighting.hlsli"

float4 PSMain(VSOutput input) : SV_Target0
{
MaterialSample sample = LoadMaterial(input.materialIndex, input.uv);
return ShadeMaterial(sample, input.normalWS, gFrame.cameraPosWS);
}

编译流程的核心输入至少包含六类信息:源文件路径、entry point、target profile、宏集合、include 搜索路径、编译器版本。对于同一个 PSMainUSE_NORMAL_MAP=1USE_NORMAL_MAP=0 应产生不同 cache key;ps_6_0ps_6_6 也应产生不同 cache key;DXC 版本升级后,即使源码未变,也应能触发一次重新编译或兼容性验证。Microsoft 的 DirectX Shader Compiler说明 DXC 用于把 HLSL 编译为 DXIL,并提供 dxc.exedxcompiler.dll、DXIL validator 等组件;工程里的 shader cache 应把编译器和 validator 视为构建输入。

下面的命令展示一个离线编译的关键路径。这种写法把模块组织、entry point、profile、debug 信息和输出文件绑定到一次可复查动作中。

dxc shaders/MaterialPass.hlsl \
-E PSMain \
-T ps_6_6 \
-I shaders/include \
-D USE_NORMAL_MAP=1 \
-Fo build/shaders/MaterialPass.PSMain.normal.dxil \
-Fd build/shaders/MaterialPass.PSMain.normal.pdb \
-Zi

这条命令回答四个工程问题。-E 确定当前 blob 来自哪个入口;-T 确定目标 stage 和 Shader Model;-I-D 确定源码展开结果;-Fo-Fd 让 runtime blob 和调试信息可以被工具关联。若 GPU capture 中一个 draw 使用了错误 shader,先根据 PSO 中的 shader identifier 或文件名回查 cache key,再查 entry point、宏集合和 profile,比直接阅读所有源码更可靠。

Shader permutation 是编译系统的主要复杂度来源。permutation key 应表达会改变编译结果的条件,例如 normal map、alpha test、skinning、instancing、shadow receiver、debug mode、wave path。每个 key 都会增加编译时间、磁盘缓存、PSO 数量和测试矩阵。设计 key 时应区分三类条件:会删除大段代码和资源声明的条件适合做宏;会在 draw 之间稳定变化的小开关适合做 root constant 或 material constant;会在像素之间变化的值适合放入纹理或 buffer 数据。

Reflection 用来把编译产物里的资源绑定、输入输出签名和常量布局反馈给引擎。它可以用于校验 shader 声明是否和 root signature、input layout、material resource table 对齐。Reflection 结果适合作为构建阶段的验证工具:检查 space1t0 是否落在 material texture table,检查 b0 是否保留给 frame constants,检查 pixel shader 是否输出了当前 render pass 需要的 SV_Target0

一个稳定的 shader 编译缓存可以按下面的路径组织:源码依赖图计算 hash,entry point / profile / macro 进入 key,DXC 版本进入 key,编译输出 DXIL 和调试信息,reflection 输出 JSON 或二进制元数据,PSO 构建阶段读取元数据做契约校验。这个路径的结果是:shader 文件变化、宏变化、编译器变化、binding 变化都能被定位到构建产物,运行时随机黑屏的排查范围也会明显收缩。

89.3 HLSL Shader Stage Resource Binding and Pipeline Integration

HLSL 与管线集成的核心是把 shader 声明映射到 API 状态。对于本章材质 pass,vertex shader 需要 input layout 和顶点 buffer,pixel shader 需要 descriptor table 与 render target,compute shader 需要 UAV 或 SRV,所有 stage 都依赖 root signature 和 PSO。HLSL 代码中的 register(b0, space0)Texture2DSV_PositionSV_Target0 只有和这些 API 对象对齐后才产生正确图像。

Direct3D 12 的 HLSL 资源绑定使用虚拟 register 空间。Microsoft 的 Resource binding in HLSL说明 D3D12 HLSL 中常见 register 类型包括 t 对应 SRV、s 对应 sampler、u 对应 UAV、b 对应 CBV,space 用来划分逻辑 register 空间。工程中可以把 space0 分给 frame / pass / object 常量,把 space1 分给 material textures,把 space2 分给 bindless scene resources。这样的划分能让 shader 资源声明和 root signature 形成固定协议。

下面的简化 root signature 片段表达了本章材质 pass 的资源布局。它的目标是说明映射关系:b0b1 由 root CBV 或 descriptor table 提供,space1t0 起始范围由材质贴图 descriptor table 提供,s0 由 sampler table 或 static sampler 提供。

// Simplified D3D12-style intent, not full production code.
// Root parameter 0: CBV b0 space0, frame constants.
// Root parameter 1: CBV b1 space0, object constants.
// Root parameter 2: SRV descriptor table, t0..unbounded space1, material textures.
// Root parameter 3: Sampler s0 space0.

这份布局要和 HLSL 声明逐项对齐。gFrame : register(b0, space0) 对应 frame constant buffer,gObject : register(b1, space0) 对应 object constant buffer,gAlbedoTextures[] : register(t0, space1) 对应 descriptor array,gLinearSampler : register(s0, space0) 对应 sampler。若 root signature 中材质贴图表放在 space0,而 HLSL 使用 space1,shader 可以成功编译,PSO 也可能创建成功,但 draw 执行时读取的 descriptor 位置会偏离预期。GPU capture 中的 root signature 和 shader reflection 能直接暴露这种错位。

Stage signature 也需要对齐。Vertex shader 输入的 POSITIONNORMALTEXCOORD0 要和 input layout 的 semantic name、format、offset、input slot 对应。Vertex shader 输出的 SV_Position 必须进入光栅化路径;pixel shader 输入的 TEXCOORD0 来自光栅化插值;pixel shader 输出的 SV_Target0 要和 render pass 的 RTV format 对齐。若 input layout 把 normal 当作 R16G16B16A16_FLOAT,shader 期望 float3,结果可能表现为法线方向错误、光照变黑或高光漂移。

下面的 mermaid 图把 HLSL 与管线状态的关系压缩成一次 draw 的检查路径。它用于排查“shader 源码看起来正确,但画面错误”的情况。

图中的关键路径是 PSO → Root Signature → Resource Binding → Shader Signature → Render Target。渲染故障排查时,先确认当前 draw 使用的 PSO 是否包含预期 shader blob;再确认 root signature 中的参数是否覆盖 shader 声明的 register / space;然后确认 input layout 是否匹配 vertex shader 输入;接着确认 pixel shader 输出数量和 render target 数量、格式、blend state 是否匹配;最后再进入 shader 内部计算。这个顺序能减少在源码中盲目修改的概率。

Compute shader 的集成路径相似,但它没有 input assembler、rasterizer 和 render target 语义。它依赖 Dispatch(x, y, z)numthreads、SRV / UAV / CBV、barrier 和 resource state。一个亮度直方图 compute pass 可以读取 HDR color texture 的 SRV,写入 histogram buffer 的 UAV。HLSL 中的 RWStructuredBuffer<uint> 必须对应 UAV descriptor;dispatch 前 texture 要处于可读状态,histogram buffer 要处于 UAV 可写状态;dispatch 后若图形 pass 继续读取 histogram,需要正确的 resource barrier。

RWStructuredBuffer<uint> gHistogram : register(u0, space0);
Texture2D<float4> gHdrColor : register(t0, space0);

[numthreads(8, 8, 1)]
void CSMain(uint3 dispatchId : SV_DispatchThreadID)
{
float3 color = gHdrColor.Load(int3(dispatchId.xy, 0)).rgb;
float luminance = dot(color, float3(0.2126, 0.7152, 0.0722));
uint bin = min((uint)(luminance * 64.0), 63u);
InterlockedAdd(gHistogram[bin], 1u);
}

这段 compute shader 的资源路径和材质 pixel shader 不同。pixel shader 的主要风险是插值、采样、render target 输出和 early / late depth;compute shader 的主要风险是 thread group 覆盖范围、越界访问、UAV 原子操作、barrier 和读写状态。若同一帧中 compute pass 生成的数据又被 pixel shader 读取,调试时要把两个 pass 的资源状态和执行顺序连起来看。

Descriptor array 与 bindless 风格资源是 HLSL 绑定系统里最容易引入边界问题的部分。Microsoft 的资源绑定文档说明,descriptor array 的索引在默认情况下有 wave 内一致性限制;若索引在 draw / dispatch 内变化,应使用 NonUniformResourceIndex。本章材质 pass 中 input.materialIndex 来自 object constant,并通过 nointerpolation 传到 pixel shader;若一个 draw 内多个像素访问不同材质 descriptor,就要明确使用 NonUniformResourceIndex,同时接受相关硬件上可能出现的额外执行成本。

89.4 Shader Model 最新版本特性

Shader Model 应作为“编译目标和硬件能力契约”理解。ps_6_6cs_6_6lib_6_6 这样的 profile 并不只改变语法开关,它还决定 DXIL 指令能力、可用 intrinsic、资源模型和工具链要求。工程中选择 Shader Model 时应同时检查四项:DXC 是否支持目标 profile,Windows SDK / runtime 是否支持对应 DXIL,目标 GPU 和 driver 是否支持相关硬件能力,fallback shader 是否覆盖低能力设备。

Shader Model 6.0 引入 wave-level operation,是 HLSL 从“每个 invocation 独立表达”扩展到“同一 wave 内显式协作”的重要节点。Microsoft 的 HLSL Shader Model 6.0 文档说明 wave 是同时在处理器上执行的一组 lane,并提供 Wave Query、Wave Vote、Wave Broadcast、Wave Reduction、Wave Scan 等 intrinsic。它们适合 stream compaction、reduction、binning、排序片段和部分 FFT 等任务。

下面的例子把 wave reduction 放到 compute pass 中。它展示的是执行模型变化:每个 lane 先计算局部亮度,再用 WaveActiveMax 得到当前 wave 的最大亮度。这样可以减少部分 group shared memory 和 barrier 使用;它依赖 wave 语义,wave 宽度应通过能力和内建查询处理,固定厂商宽度会降低可移植性。

float luminance = dot(color, float3(0.2126, 0.7152, 0.0722));
float waveMax = WaveActiveMax(luminance);

if (WaveIsFirstLane())
{
uint bin = min((uint)(waveMax * 64.0), 63u);
InterlockedAdd(gHistogram[bin], 1u);
}

Ray tracing shader 改变的是 shader stage 组织方式。传统 graphics pipeline 围绕 vertex / raster / pixel 路径展开;DXR 路径围绕 ray generation、miss、closest hit、any hit、intersection、callable shader 和 shader table 展开。HLSL 中的 ray tracing 代码常通过 TraceRay 发起查询,结果由 hit / miss shader 写入 payload。它的工程难点在于 shader table、acceleration structure、local root signature 和 payload layout 要同时对齐。调试时应先确认 ray tracing pipeline state、shader table record、TLAS / BLAS resource 和 local root binding,再看 hit shader 内部材质计算。

Mesh shader 和 amplification shader 改变的是几何提交方式。传统 vertex shader 从 input assembler 获取顶点;mesh shader 可以在 shader 内生成一批 primitives 和 vertices,更接近 GPU-driven geometry path。它适合 meshlet、GPU culling、LOD 和 procedural geometry,但它把顶点拉取、可见性判断和 primitive 输出放入 shader 逻辑,排查时要同时观察 dispatch 参数、meshlet buffer、输出 primitive 数量和 rasterizer 输入。这个特性通常和 GPU-driven rendering、indirect dispatch、visibility buffer 等系统一起设计。

Bindless resource 和 descriptor indexing 改变的是材质系统的资源选择方式。传统绑定模型更接近“当前 draw 绑定当前材质所需的少量贴图”;bindless 风格更接近“shader 持有一个大 descriptor table,通过 material id 索引具体资源”。它能减少 CPU 侧频繁重绑和 draw 分组压力,但会把错误从 CPU 绑定阶段转移到 descriptor residency、索引合法性、non-uniform indexing 和资源生命周期管理上。稳定做法是让 material record 中的 descriptor index 由资源系统统一分配,并在 debug build 中保留越界材质、缺失纹理和默认纹理路径。

DXIL 是现代 HLSL 工具链的关键中间表示。DXC 把 HLSL 编译为 DXIL,validator 验证编译产物是否符合平台规则,driver 再把 DXIL 转成硬件可执行形式。对工程调试来说,DXIL 的意义不在于要求程序员手写中间表示,而在于让 shader blob、reflection、debug info、disassembly、validator error 和 GPU capture 之间有共同对象。若某个 shader 在一台机器失败,保存 HLSL 源码、DXC 版本、DXIL blob、编译参数和 root signature 能让复现路径更完整。

“最新 Shader Model 特性”的使用策略应以能力查询和 fallback 为边界。wave ops 可以提升 reduction 与 subgroup 协作效率,但要查询 wave operation 支持;ray tracing 可以表达反射、阴影和全局光路径,但要查询 DXR tier 和 acceleration structure 支持;mesh shader 可以重构几何路径,但要查询 mesh shader support;bindless resource 可以减少绑定开销,但要管理 descriptor heap、residency 和 non-uniform index。引擎层应把这些能力收敛到 feature profile,例如 D3D12_SM6_BaseD3D12_SM6_WaveD3D12_DXRD3D12_MeshShader,再让材质和 pass 选择对应 permutation。

89.5 Shader 调试与性能分析工具

HLSL 调试应从 frame capture 进入,而非从源码猜测进入。PIX、RenderDoc、Visual Studio Graphics Diagnostics、厂商 profiler 都能提供不同层级的证据:当前 draw / dispatch、PSO、root signature、descriptor table、resource state、shader source / disassembly、输入输出签名、render target 内容、GPU timing 和部分 hardware counter。工具输出的价值在于把“画面黑了”转换成“哪个 pass、哪个 draw、哪个 shader、哪个资源、哪个状态出现偏离”。

调试信息要在编译阶段准备。使用 DXC 时,debug build 可以输出 .pdb 或嵌入 debug 信息,使 PIX 或其他工具更容易把 GPU 指令映射回 HLSL 源行。release build 可以保留可追踪的 shader hash、entry point、profile、macro key 和源码提交号。这样即使线上只保存 GPU crash dump 或用户 capture,工程也能把 shader blob 还原到准确源码版本。

dxc shaders/MaterialPass.hlsl \
-E PSMain \
-T ps_6_6 \
-D USE_NORMAL_MAP=1 \
-Zi \
-Fd build/symbols/MaterialPass.PSMain.normal.pdb \
-Fo build/shaders/MaterialPass.PSMain.normal.dxil

一次 HLSL 故障排查可以按固定顺序推进。先在 capture 中定位异常像素或异常 draw,确认它属于哪个 render pass。再查看该 draw 的 PSO,确认 vertex shader、pixel shader、blend state、depth state 和 render target format。然后查看 root signature 和 descriptor table,确认 b0b1t0 space1s0 等声明都有实际资源。接着查看 input / output signature,确认 semantic 和插值链路成立。最后进入 shader debug 或 disassembly,观察计算值、分支路径、纹理采样结果和输出颜色。

材质变黑的常见证据链可以这样复盘:capture 中 albedo 贴图资源存在,descriptor table 的 space1 t0 指向默认黑贴图,HLSL 中 gAlbedoTextures[] 使用 space1,material record 的 descriptor index 却按另一个 heap 分配。这个问题的根因在资源系统和 shader binding protocol 之间;修改 BRDF 函数、调 gamma 或调光照常量都无法稳定修复。正确动作是修正 descriptor index 分配或 root table 映射,并用 reflection / debug validation 在构建阶段捕获错位。

性能分析要先分清瓶颈类型。若 GPU timing 显示 pixel pass 代价高,同时 overdraw 和 texture bandwidth 高,重点检查 pixel shader 采样数量、mipmap、anisotropic filter、render target format 和 depth pre-pass。若 compute pass 代价高,同时 UAV atomic 数量集中,重点检查 thread group 尺寸、bin 冲突、shared memory reduction 和 wave reduction。若 CPU frame time 高但 GPU 空闲,主因更可能落在 PSO 创建、shader 编译、descriptor 更新或 draw submission。

HLSL disassembly 和 counter 能回答不同问题。Disassembly 能看到指令形态、资源访问、分支和部分编译器优化结果;counter 能看到 GPU 执行层面的 occupancy、wave 数量、cache miss、texture unit 压力、ALU / memory 比例和 stall 类型。两者结合时,先用 GPU timing 找出 pass,再用 draw / dispatch 统计缩小范围,再用 disassembly 看 shader 结构,最后用 counter 判断瓶颈。单独看源码容易高估某段数学代码的成本,也容易低估纹理采样、UAV 原子和带宽压力。

Debug shader 是排查视觉问题的低成本工具。材质 pass 可以提供一组专门的 debug permutation:输出 UV、normalWS、materialIndex、textureIndex、roughness、metallic、shadow factor、mip level。这些 permutation 不追求画面美观,它们把中间量变成 render target 颜色,让读者在 GPU capture 或截图中看到数据是否进入正确范围。若 materialIndex 输出已经错误,问题在 draw / object constants 或插值之前;若 materialIndex 正确但 albedo 错误,问题在 descriptor index、resource binding 或 texture data;若 albedo 正确但最终颜色错误,问题进入光照、tone mapping 或 blend。

工具边界也要明确。RenderDoc 对跨 API frame inspection 很有用,但具体 D3D12 debug 信息、DXIL 源级调试和 Xbox / Windows 平台诊断通常更依赖 PIX;厂商 profiler 对硬件 counter 更深入,但不同 GPU 的 counter 名称和含义并不完全一致。工程判断应回到可共同验证的对象:draw / dispatch 时间、resource binding、shader profile、指令结构、纹理访问、UAV 写入、barrier 和最终图像。

最小自检任务

给定一个 D3D12 材质 pass:vertex shader 输出 materialIndex,pixel shader 使用 Texture2D<float4> gTextures[] : register(t0, space1),通过 gTextures[input.materialIndex].Sample(...) 采样 albedo。GPU capture 显示当前 draw 使用的 PSO 正确,SV_Position 输出正常,render target 格式正确;画面中同一 mesh 的部分三角形 albedo 正确,部分三角形随机变黑。root signature 的材质 descriptor table 覆盖 t0..unbounded space1,descriptor heap 中也存在对应贴图。请判断优先检查哪些对象,并给出最可能的两个原因。

答案要点

优先检查顺序是:先看 pixel shader 的输入 signature,确认 materialIndex 是否按预期从 vertex shader 传入;再看它是否声明为 nointerpolation,因为材质索引属于离散 ID,经过透视插值会产生无效中间值;接着看 texture descriptor array 的索引是否使用 NonUniformResourceIndex,因为同一 draw / wave 内不同像素访问不同 descriptor 时需要显式标注 non-uniform index;然后检查 material record 中的 index 是否落在 space1 t0 对应 descriptor table 范围内。

最可能的两个原因是:第一,materialIndex 被当作可插值属性传给 pixel shader,三角形内部生成了非整数或错误整数索引,导致采样到错误 descriptor 或默认黑贴图;第二,descriptor array 的索引在 wave 内变化,但 shader 没有使用 NonUniformResourceIndex,在部分硬件和驱动路径上产生未定义采样结果。root signature 和 descriptor heap 看起来正确,只能说明资源表范围存在;每个像素使用的索引合法性、HLSL 的 non-uniform 访问契约仍需单独验证。

本章知识点总结

  • 系统边界:HLSL 系统由源码、编译、资源绑定、pipeline state 和 GPU 执行结果共同构成。
  • 语言对象:HLSL 的类型、资源、函数和 intrinsic 都要放回 shader invocation 的输入输出中理解。
  • Stage 契约:entry point、target profile 和 semantic 共同决定 shader 属于哪个管线阶段。
  • Semantic 作用:普通 semantic 传递自定义插值数据,SV_ semantic 由 Direct3D 管线解释为系统值。
  • 模块组织.hlsli 适合承载共享定义,.hlsl 适合承载可编译 entry point。
  • 编译输入:shader cache key 应包含源码依赖、entry point、profile、宏、include 路径和编译器版本。
  • Reflection 价值:reflection 适合在构建阶段验证资源绑定、输入输出签名和常量布局。
  • Register 映射btus register 和 space 要和 root signature 的 descriptor 范围兼容。
  • PSO 集成:PSO 把 shader blob、input layout、root signature、render target format 和固定功能状态连接到一次 draw。
  • Compute 边界:compute shader 依赖 dispatch、thread group、SRV / UAV、barrier 和 resource state。
  • Wave 特性:wave ops 让同一 wave 内的 lane 可以执行 vote、broadcast、reduction 和 prefix 类协作。
  • Bindless 代价:bindless 风格减少频繁重绑压力,同时把正确性压力转移到 descriptor index、residency 和 non-uniform access。
  • DXIL 角色:DXIL 让 HLSL 编译产物、validator、reflection、debug 信息和 GPU capture 共享同一个可追踪对象。
  • 调试顺序:HLSL 故障应按 pass、draw、PSO、root signature、binding、signature、shader 内部计算的顺序排查。
  • 性能定位:shader 性能判断要结合 GPU timing、disassembly、resource access 和 hardware counter。