Shader接口与数据流
全库目录221
本文目录18
CPU上传了一组参数,Shader却读到错位的值,类型名字相似并不能保证布局相同。阶段接口、常量对齐、资源视图和采样方式构成一份契约。沿着数据从顶点或缓冲进入Shader,再输出到后续阶段,能把编译问题与运行数据问题分开。
接口与布局
HLSL、GLSL 和 ShaderLab
- HLSL 是 Direct3D 体系常用的着色语言,也被 Unity、Unreal 和现代跨平台编译链广泛使用。
- GLSL 是 OpenGL 体系的着色语言。
- Vulkan 常使用 SPIR-V 中间表示,源语言可以是 GLSL、HLSL 或其他前端。
- Unity ShaderLab 负责声明 SubShader、Pass、状态和属性;其中的 HLSL 才是 GPU 程序。
语言语法不是主要差异。资源绑定、坐标约定、编译目标和引擎生成代码更容易造成跨平台问题。
顶点输入
Vertex Shader 通常读取:
- Position;
- Normal、Tangent;
- UV、Vertex Color;
- Skin Weight 和 Bone Index;
- Instance ID 或自定义实例数据。
数据来自 Vertex Buffer。Input Layout/Vertex Declaration 说明每个属性的格式、步长和位置。Shader 声明与 Buffer 实际布局不一致时,结果可能完全错误,但 API 不一定能替你发现语义问题。
Constant、Uniform 和 Buffer
同一个 Draw 中保持不变的小数据通常放在 Constant/Uniform Buffer:
- 变换矩阵;
- 相机和时间;
- 光源参数;
- 材质参数。
GPU 读取常量时偏好对齐后的连续布局。HLSL传统Constant Buffer按16字节向量边界装填,变量不能跨越该边界;float3后可接一个float填满16字节,而float2若放不下就从下一边界开始。CPU 结构、Shader 结构和 API 绑定必须使用同一布局。
大量结构化数据可以使用 Structured Buffer、Byte Address Buffer 或 Storage Buffer。它们比普通常量灵活,但访问成本、缓存行为和平台支持不同。
HLSL 用 cbuffer 声明常量缓冲区,并可用 register(b0) 指定绑定槽位。CPU 侧需要创建同样大小和布局的 Buffer,把数据写入后绑定到对应 Shader Stage。D3D11动态资源常用Map/Unmap:WRITE_DISCARD 取得一段可重新分配的写入区域,适合整块替换;NO_OVERWRITE 保证不会覆盖 GPU 尚未读取的区间,适合环形缓冲中的追加写入。NO_OVERWRITE在常量缓冲等用途上的支持受D3D11版本与能力标志限制。D3D12上传堆和Vulkan映射内存使用不同的接口;共同要求是避免覆盖GPU仍在读取的区间。
常量应按更新频率分组,而不是把所有参数塞进一个大 Buffer:
- Per-frame:时间、全局环境;
- Per-view:相机、投影和曝光;
- Per-material:材质参数;
- Per-draw:对象矩阵和对象 ID。
频率分组能避免只改一个对象时重新上传全局数据。Unity SRP Batcher 要求材质属性位于 UnityPerMaterial,引擎按对象提供的数据位于 UnityPerDraw,并保持各 Pass 的布局一致。违反布局约定会失去批处理兼容性或读到错误偏移。
对齐示例
下面的 HLSL 布局把相关字段填满 16 字节边界。CPU 侧结构也使用相同字段顺序和总大小:
cbuffer PerView : register(b1)
{
row_major float4x4 ViewProjection;
float3 CameraPositionWS;
float Exposure;
float2 RenderSize;
float2 InvRenderSize;
};struct alignas(16) PerViewConstants
{
Matrix4x4 viewProjection;
Float3 cameraPositionWS;
float exposure;
Float2 renderSize;
Float2 invRenderSize;
};
static_assert(sizeof(PerViewConstants) % 16 == 0);本例32位float和4×4矩阵下,偏移依次为0、64、76、80、88字节,总大小96字节。static_assert仅检查16的倍数并不足够,还要用offsetof和Shader反射核对每个字段。row_major说明缓冲中的矩阵排列;mul的操作数顺序另行决定向量变换约定。GLSL 的 std140、HLSL cbuffer 与结构化 Buffer 使用的布局规则也不能混用。
字节错位的定位
若CPU省掉Exposure字段,把RenderSize写在偏移76,而Shader仍按偏移80读取,CPU上传宽1920、高1080时,Shader的Exposure会把宽度当曝光,RenderSize.x会读到高度,RenderSize.y则来自后续字段。两端字段名称都正确,96字节总大小也可能因尾部填充保持正确,画面仍会异常。
先在CPU把每个字段填成易区分的值,再查看捕获中的原始字节与Shader反射偏移。对应PerView布局,CameraPosition从64开始占12字节、Exposure在76、RenderSize在80,逐字段核对可以直接定位第一处偏移。修正结构体后再检查绑定槽与上传时机;仅改Shader变量名不会改变字节含义。
资源访问
资源视图与访问权限
同一底层资源可以通过不同视图进入管线。Direct3D 常见名称包括:
- SRV(Shader Resource View):Shader 只读访问纹理或 Buffer;
- UAV(Unordered Access View):Shader 随机读写,常用于 Compute 和图像写入;
- RTV(Render Target View):作为颜色输出;
- DSV(Depth Stencil View):作为深度模板输出。
视图决定格式解释、Mip、数组层和允许的访问方式,不等于复制一份资源。资源从 Render Target 写入转为 Shader 读取时,现代显式 API 还需要正确的 Resource State、Barrier 和同步范围。在缺少合法读写模型与同步时,对重叠子资源的并发读写会形成冲突;调试时应同时检查视图、槽位和状态转换。
Texture 和 Sampler
Texture 保存数据。Sampler 描述如何读取:过滤、寻址、LOD 等。
它们在某些 API 中可以独立绑定,也可以组合成一个对象。引擎可能复用 Sampler,避免每张纹理重复创建相同状态。
一次纹理采样包含:
- 计算坐标;
- 选择 Mip;
- 应用寻址;
- 读取并按资源格式解码Texel;
- 对sRGB颜色先转换到线性域,再执行所需过滤;
- 返回采样结果。
因此“只采了一张纹理”并不等于只读一次显存。
阶段数据
Varying 和插值
Vertex Shader 输出会在三角形内部插值,再进入 Pixel Shader。常见数据有 UV、世界位置、法线和颜色。
注意:
- 方向插值后通常需要重新归一化;
- 使用太多 Varying 会增加寄存器和带宽压力;
flat/nointerpolation不做插值,适合 ID 等离散数据;noperspective使用屏幕线性插值,不做透视修正。
Semantic 和 Location
HLSL 常用 POSITION、TEXCOORD0、SV_Position 等 Semantic 连接阶段。GLSL/Vulkan 常用显式 Location。
System Value 表示管线提供的特殊数据,例如 Vertex ID、Instance ID、Position、Depth。它们不是普通顶点属性。
GLSL 接口块与片元内建输入
接口块把跨阶段的多个字段组织在一起。下例OpenGL按名称链接时,块名VertexData必须匹配;vsOut和fsIn是实例名,可以不同。成员名、类型、顺序、数组及限定符也须满足对应链接规则。显式 location 能让接口契约更清楚:
该GLSL代码块并列展示两个阶段的片段,分别放入各自源文件;Vertex Shader还需main函数、变换矩阵及属性赋值。
// Vertex Shader
layout(location = 0) in vec3 positionOS;
out VertexData { vec2 uv; vec3 normalWS; } vsOut;
// Fragment Shader
in VertexData { vec2 uv; vec3 normalWS; } fsIn;
layout(location = 0) out vec4 color;
void main()
{
vec3 normal = gl_FrontFacing ? fsIn.normalWS : -fsIn.normalWS;
float checker = mod(floor(gl_FragCoord.x / 8.0)
+ floor(gl_FragCoord.y / 8.0), 2.0);
color = vec4(normalize(normal) * 0.5 + 0.5, checker);
}gl_FragCoord.xy 是窗口空间片元坐标,原点和像素中心约定应以当前 API 设置为准。gl_FrontFacing 来自图元绕序与正反面判断,双面材质可以用它翻转几何法线,但 Normal Map 的切线手性也要一致处理。
片元 Shader 可以写 gl_FragDepth 覆盖光栅器生成的深度。任意改写深度会限制提前深度测试;能保证写出值只比原深度更近或更远时,可使用保守深度布局向驱动声明约束。验证时同时显示颜色和深度附件:接口错配常表现为属性全零或跳变,深度约定错误会表现为遮挡反转或 Early-Z 效率下降。
数值与控制流
精度
float、half、min16float 的实际位宽和运算方式受目标平台、编译器和 GPU 影响。桌面 GPU 可能把 half 仍按 32 位执行,移动 GPU 则可能真正受益。
低精度容易出问题的位置:
- 大世界坐标;
- 深度和重建;
- 长时间累积;
- HDR 亮度;
- 很小的法线或概率值。
精度优化必须看编译结果和目标硬件,不应只改类型名。
分支
编译期分支
预处理宏会产生不同程序。未选中的代码不会进入当前变体,但组合过多会增加编译、包体和加载成本。
运行时分支
条件在运行时判断。若同一 Wave 中线程走不同路径,GPU 可能依次执行两边并屏蔽不参与的 Lane。
运行时分支不一定慢:
- 条件在整个 Draw 中一致时,通常不会产生像素间发散;
- 分支能跳过很重的工作时可能值得;
- 很短的分支可能被编译器改成无分支选择。
Shader Graph
Shader Graph 是生成 Shader 代码的前端,不是另一种 GPU 管线。节点最终仍会变成采样、算术、分支、Varying 和 Pass。
检查 Shader Graph 性能时,要看生成代码、变体、纹理采样数和最终编译指令,不能只数节点。
调试方法
- 检查编译后的 HLSL、SPIR-V 或 ISA,而不只看源代码。
- 在 RenderDoc 中检查资源绑定、Constant Buffer 和 Shader Input/Output。
- 用纯色或方向 RGB 逐段验证中间结果。
- 对比引擎坐标转换函数和手写矩阵结果。
- 在不同平台检查精度、UV 原点、深度范围和纹理格式。
相关主题
实现与验证:compile_shader.py从正文提取PerView,使用全部字段生成入口,经DXC编译并检查反射偏移0/64/76/80/88和大小96字节。GLSL 330的同名块和不同实例名通过离线链接,成员类型不匹配的反例被拒绝;CPU上传与实际OpenGL上下文仍未运行。
参考资料
- Microsoft HLSL常量装填规则:2026-10-22核读16字节跨界、结构及数组规则。
- Khronos GLSL规范源文件,Interface Blocks:2026-10-22核读块名匹配与实例名可不同的条款。
- 矩阵与字段实际偏移由本库DXC反射探针检查,平台布局不得跨API推定。