在DX12中落地PBR:金属度/粗糙度工作流的工程实践
发布时间:2026/10/2 4:57:12来源:尧图网络
最近把场景渲染器从 D3D11 往 D3D12 迁折腾完基础的命令列表、同步和资源状态管理之后第一反应就是哦终于把三角形画出来了但随后马上陷入了一个尴尬的境地画面还是 D3D11 时代的老一套Blinn-Phong 高光硬边界塑料感扑面而来。于是就有了这篇加入 PBR的系列第二篇。这篇文章不是泛泛讲 PBR 理论也不是贴一段 DX12 Hello World 代码而是记录我把基于物理的渲染真正塞进 DX12 管线时踩过的坑、做过的选择和最终落地的一套方案坐标锁定在金属度/粗糙度工作流Metallic-Roughness Workflow适合已经能用 DX12 画出网格、想往真实渲染迈进的人。PBRPhysically Based Rendering在 DX12 里落地涉及的不只是 Shader 里多几个函数它动的是整个数据流材质参数怎么摆进 GPU、纹理描述符怎么组织、根签名怎么设计、管线状态对象PSO怎么配渲染目标。更直接的问题是DX12 把所有资源绑定和状态切换的压力都甩给了开发者PBR 引入的大量子纹理和常量数据如果还用 D3D11 那种随手 Bind 一下的思路性能和可维护性会同时崩盘。这篇内容正好把这两件事的核心痛点一起解决掉。1. 为什么 PBR 在 DX12 里要从数据流开始重新设计1.1 先弄明白 PBR 到底改了什么很多人以为 PBR 就是把高光公式从 Blinn-Phong 换成 Cook-Torrance换完就完事了。实际上换成 PBR 意味着整个渲染假设都变了。Blinn-Phong 里高光大小靠粗糙度一个参数硬调所有材质共享同一个经验公式视觉结果靠美术反复试。而 PBR 的金属度/粗糙度工作流假设每一个表面点都同时存在两种反射行为漫反射光进入表面散射后射出和镜面反射光在表面直接弹走。金属材质的漫反射几乎为零非金属则两者都有。这个区分直接决定了材质贴图的通道含义。在 DX12 里引入这套东西第一件事不是写 Shader而是想清楚你往 GPU 里塞的材质描述是什么。我用的是一个常规四通道方案Albedo基础色本质是漫反射率但有金属度修正、Metallic金属度、Roughness粗糙度、AO环境光遮蔽。再加上法线贴图每个物件至少五张纹理。这和之前只绑一张漫反射贴图的 D3D11 老项目有本质区别——资源数量、描述符数量、SRV 绑定逻辑全部要重新设计。1.2 DX12 的绑定模型为什么反而适合 PBRD3D11 的绑定模型是管线随手可绑Shader 里声明一个 t0 的 Texture2DCPU 端 BindSRV 一下就完事。但每帧每物体 Bind 多次CPU 开销很大而且引擎层很难做批量绘制。DX12 换成了描述符堆Descriptor Heap和根签名Root SignatureShader 看到的资源是从一个巨大的表里按索引取数据。这听起来复杂但对 PBR 这种需要大批量纹理和常量的场景反而是优势你可以把场景里所有材质的所有纹理放到同一个着色器可见的描述符堆里DrawCall 之间只换索引不换描述符表。我最终把根签名设计成了三张表第一张放场景级常量相机矩阵、光照参数、曝光等第二张放物体级常量模型矩阵、物体 ID 等第三张是一大堆 SRV把所有用到的贴图全部按顺序塞进去。纹理在堆里的起始偏移DescriptorHeap offset随每个物件的 Per-Draw 数据传下去。这套结构后来实测下来批量绘制同材质物体时几乎零额外绑定开销每帧 CPU 侧的最大瓶颈反而变成了命令列表的记录。注意Root Signature 不允许在命令列表录制中途随意变更所以设计阶段就想清楚哪些是场景级、哪些是物体级、哪些是绘制级后期改动的成本是最大。我第一版图省事把物体矩阵放进了根常量后来加入大批量实例化时被迫重构这坑踩得印象深刻。2. 根签名与描述符堆布局PBR 数据的骨架2.1 把材质参数塞进 GPU 的两种姿势对比材质参数有两种塞法一种是放进常量缓冲区Constant Buffer每帧按需上传另一种是做成结构化的 BufferStructuredBuffer把整个场景的所有材质数组传下去Shader 按 MaterialID 取。PBR 的材质参数不算多常规就 BaseColor、Metallic、Roughness、AO、NormalScale 五个 float 加上若干标记位单体 32 字节左右但场景里材质数量可以轻松上千。我做了对比全部走常量缓冲区意味着每帧要创建一个至少几十 KB 的 upload buffer 并做 CPU 端 Memcpy而且遇到材质变化比如特效切换还要单独更新局部区域逻辑分散。走 StructuredBuffer整个材质数组一份数据全场景通用GPU 端按索引取更新只需要重建那个 buffer 或局部拷贝而且可以跟 GPU 剔除、间接绘制这类高级功能天然兼容。我选了后者——一个StructuredBufferMaterialData gMaterialBuffer所有材质参数一口气传下去。用 DX12 的组织方式核心代码长这样HLSL 侧struct MaterialData { float4 BaseColor; float Metallic; float Roughness; float AO; float NormalScale; uint Flags; float3 Padding; // 对齐到16字节 }; StructuredBufferMaterialData gMaterialBuffer : register(t0, space0);这里有个对齐的细节HLSL 常量/结构化 buffer 的成员偏移按 16 字节对齐规则排我的结构体里 Flags 后面必须补三个 float 到 16 字节否则 D3D12 报布局错误或者数据错位。这种错误只在运行时冒出来调试器还不会主动提示只能自己对照D3D12_RESOURCE_BARRIER和布局检查慢慢排。我的建议是写一个小的 helperCPU 端用memcpy塞数据之前先用一个静态断言确保结构体大小是 16 的倍数。2.2 纹理SRV描述符堆的组织方案纹理这块更值得上心。像前面说的PBR 动辄五张纹理起步如果一个物件一个描述符范围堆的增长会非常快。我最后采用的是纹理数组全局打平策略所有 Texture2D 按固定顺序排好Albedo、Metallic、Roughness、AO、Normal 依次排列每个物件占用 5 个连续的 SRV通过MaterialID * 5 offset索引。放进根签名的样子// 描述符堆一个Shader可见堆支持上万SRV D3D12_DESCRIPTOR_HEAP_DESC heapDesc {}; heapDesc.Type D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; heapDesc.Flags D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; heapDesc.NumDescriptors 8192;然后在根签名里把这个堆绑定到t0, space0这一整段。Shader 里只需做一次gTextureArray.GetDimensions之类的操作确认索引范围实际采样时通过Texture2D gMaterialTextures[]数组或Texture2Dfloat4 gMaterialTextures[MAX_TEXTURE_COUNT]声明。这样的好处是切换材质时只改一个 RootConstantMaterialID描述符表本身不用重绑。提示DX12 的 Texture2D 数组最大元素个数与特性级别有关几乎所有现代 GPU 都支持非常大。但为了兼容性建议把 MAX_TEXTURE_COUNT 控制在 2048 以下超出就考虑分批次否则老显卡或 WARP 设备可能 Texture 数组上限不够编译 Shader 时直接报错。2.3 静态采样器与各向异性过滤的坑采样器Sampler在 D3D11 里随意创建但在 DX12 里分静态和动态两种。静态采样器直接在根签名里声明不占描述符堆动态采样器放在堆里每次绘制切换。PBR 的贴图大部分需要各项异性过滤Anisotropic如果为每个纹理动态绑一个采样器堆开销和切换成本都不小。实际做法是在根签名里定义一组常用静态采样器比如线性/最近/各向异性各一个按纹理用途选择采样器索引。不过各向异性采样器有个陷阱你想要各向异性效果除了采样器要配D3D12_FILTER_ANISOTROPIC纹理资源本身D3D12_RESOURCE_DESC.MipLevels必须完整或至少保留合适的 Mip 链。否则采样器虽设了各向异性实际上退化到双线性画面看着糊性能却一点没省。我在项目里踩过这个坑法线贴图为了省显存只保留了 4 级 Mip设各向异性后在高光变化剧烈的表面出现明显锯齿排查了很久才想起是 Mip 链不完整。3. PBR Shader 核心实现Cook-Torrance 光照模型实战3.1 BRDF 公式拆解不是背公式是知道每项在渲染里管什么我用的就是行业最主流的 Cook-Torrance 反射模型高光项由三部分组成法线分布函数NDFD、几何遮蔽函数 G、菲涅尔项 F。这三项分别负责高光形状、微表面自遮挡、反射强度随视角变化。这里列一下最终落在 Shader 里的版本GGX Smith-Schlick 近似// DGGX法线分布 float D_GGX(float NdotH, float roughness) { float a roughness * roughness; float a2 a * a; float d (NdotH * a2 - NdotH) * NdotH 1.0; return a2 / (PI * d * d); } // GSmith可见性项Schlick-GGX近似 float G_Smith(float NdotV, float NdotL, float roughness) { float k (roughness 1.0) * (roughness 1.0) / 8.0; float gv NdotV / (NdotV * (1.0 - k) k); float gl NdotL / (NdotL * (1.0 - k) k); return gv * gl; } // FSchlick菲涅尔近似 float3 F_Schlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }这些公式网上都能抄到但真正让渲染结果像那么回事的是理解它们各自在什么时候起作用。GGX 的 roughness 参数控制的是高光中心的尖锐度和衰减速度roughness 越小高光越集中越大则越扩散成粗糙漫反射状Smith 的几何项会在掠射角grazing angle时让高光明显变暗模拟微表面互相遮挡菲涅尔项让任何材质在接近 90 度视角时反射率趋近于 1。如果你调了参数却发现画面没有符合直觉变化大概率是这三项里某些项被写成常量或者用错了夹角。3.2 金属度工作流的核心F0 的处理金属度工作流最关键也最容易错的地方是 F0垂直入射的菲涅尔反射率。常见错误是直接把 F0 设为常量 0.04 或者写成lerp(0.04, albedo, metallic)。正确的做法是float3 F0 lerp(float3(0.04, 0.04, 0.04), BaseColor.rgb, Metallic); float3 diffuseColor lerp(BaseColor.rgb, float3(0.0, 0.0, 0.0), Metallic);这里每个通道都可能不同所以 F0 必须也是 float3 的而不是一个标量。金属的 Albedo 通道保存的就是它的反射色非金属的 Albedo 则保存漫反射色和基础反射的折算值。很多金属表面看起来脏或塑料感问题往往就出在 F0 处理成了统一标量——金属的每个通道反射率差异巨大统一处理会让金色和铝色看起来一样。3.3 法线贴图和切线空间的坑PBR 没法线贴图基本上就是 2015 年的渲染水平所以法线贴图必须加。DX12 这边实现法线贴图要处理的不是 Shader 本身而是顶点数据的切线Tangent和副切线Bitangent是否准备好了。我最初图方便顶点只输出 Normal法线贴图采到的切线空间法线没有变换到世界空间结果所有凹凸效果都是歪的。推荐的方式是在顶点阶段把 Normal、Tangent 都变换到世界空间再用cross(Normal, Tangent)算出 Bitangent注意 DX12 的手性约定左手坐标系cross 顺序与 OpenGL 相反。Shader 核心就几行float3 normalTS UnpackNormal(normalMap.Sample(samplerLinear, uv).xyz); float3 N normalize(input.NormalW); float3 T normalize(input.TangentW); float3 B normalize(cross(N, T)) * input.TangentSign; float3 N_World normalize( T * normalTS.x B * normalTS.y N * normalTS.z );这里UnpackNormal把 [0,1] 的法线贴图像素重映射到 [-1,1]乘 2 减 1 就行。NormalScale 参数最佳放置点是控制法线 X/Y 分量的强度比如normalTS.xy * NormalScale而不是调 Z 分量调 Z 会让法线整体偏移光照失真。这个细节是调了 N 个晚上才悟出来的直接抄走省事。另一个隐藏坑法线贴图的 Mip 链必须完整Mip 本身也能破坏法线的凹凸效果。法线贴图 mip 后如果直接采样没有重新归一化法线长度变化会导致光照强度波动。较新的做法是采样后手动重归一化normalTS normalize(normalTS)但这只能解决方向漂移不能解决 mip 后整体细节丢失。最实际的方案是别让法线贴图 mip 太激进保留至少到 4 级或者用各向异性线性组合的采样器。3.4 光照循环与能量守恒PBR 的一个隐含前提是能量守恒反射出的光不能超过入射光。Cook-Torrance 模型天然满足大部分守恒要求但实现时要注意几个坑高光项和漫反射项不能简单相加后再乘以一个任意常数否则某些角度高光爆掉先在 BRDF 中计算镜面反射项再把漫反射项与1 - F相乘以保证总反射率不超过 1。我用的是直接光照版本的半程向量循环for (each light) { float3 L normalize(lightPos - worldPos); float3 H normalize(V L); float NdotL saturate(dot(N, L)); float NdotV saturate(dot(N, V)); float NdotH saturate(dot(N, H)); float VdotH saturate(dot(V, H)); float3 radiance lightColor * lightIntensity; float D D_GGX(NdotH, roughness); float G G_Smith(NdotV, NdotL, roughness); float3 F F_Schlick(VdotH, F0); float3 spec (D * G * F) / (4.0 * NdotV * NdotL 0.001); float3 diffuse diffuseColor / PI; float3 Lo (diffuse * (1.0 - F) spec) * radiance * NdotL; result Lo; }分母里的0.001是防黑点神器。掠射角或粗糙度极大时NdotV * NdotL会趋近于零不加 epsilon 会出现大量高亮噪点这个在 DX12 的浮点精度下尤其明显。调试时如果画面在边缘出现白色碎点优先检查分母是否加了 epsilon。4. 实操管线状态对象、Shader 编译与渲染流程4.1 Shader 编译的配置细节DX12 里 Shader 编译不再像 D3D11 那样运行时自动处理必须在创建 PSO 前把编译好的字节码准备好。我用的是 DXCDirectX Shader Compiler它对较新的 SM6.x 支持好而且可以在编译期做许多优化。关键配置有两点一是/E指定入口函数二是/T指定目标 profile例如ps_6_4。材质多的项目建议用编译缓存把编译结果存成 .cso 文件否则每次启动都要重新编译几百个变体启动时间直奔 10 秒以上。ComPtrIDxcBlob compileShader(const std::string hlsl, const char* entry, const char* profile) { // 用DXC编译 // 记住Debug配置下加 /Zi 和 /OdRelease下 /O3 }值得一提的是PBR Shader 的变体数量会爆炸。我的基础 shader 有是否使用法线贴图、是否使用AO贴图、是否使用天空盒IBL等开关如果每个都用编译宏排列组合PSO 数量轻松超过一百个。我的做法是尽量少用编译期分支把带开关的贴图采样放在运行时判断比如没有 AO 贴图时在材质数据里标一个 flagShader 里直接采样但不使用这样性能损失极小PSO 数量却大幅下降。4.2 PSO 配置呈现场景与 Shader 匹配DX12 的 PSOPipeline State Object把顶点布局、Shader、渲染状态、混合模式全封装进一个对象切换 PSO 的开销远比 D3D11 的 SetRenderState 组合要小。对 PBR 来说最重要的是混合模式和深度模板状态。PBR 场景通常是不透明渲染和透明渲染混合透明物体的混合模式是 Alpha Blend不透明物体是 Opaque。一个被忽略的点是 PSO 的PrimitiveTopologyType。如果你用三角形列表TriangleList又偶尔要画线框调试必须单独创建一个线框 PSO而不是在同一个 PSO 里动态切换。我之前为了图省事把线框渲染硬塞进三角形列表 PSO结果画出来的是三角网而非线框排查了半天。后来老老实实建了三个 PSOOpaquePBR、AlphaBlendPBR、WireframeDebug共用同一个根签名和 Shader切换成本只在一个SetPipelineState调用。管线状态设置的一段典型代码D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc {}; psoDesc.pRootSignature rootSignature.Get(); psoDesc.VS { vsBytecode-GetBufferPointer(), vsBytecode-GetBufferSize() }; psoDesc.PS { psBytecode-GetBufferPointer(), psBytecode-GetBufferSize() }; psoDesc.RasterizerState CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT); psoDesc.RasterizerState.CullMode D3D12_CULL_MODE_BACK; psoDesc.RasterizerState.FillMode D3D12_FILL_MODE_SOLID; psoDesc.BlendState CD3DX12_BLEND_DESC(D3D12_DEFAULT); psoDesc.DepthStencilState CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT); psoDesc.SampleMask UINT_MAX; psoDesc.PrimitiveTopologyType D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE; psoDesc.NumRenderTargets 1; psoDesc.RTVFormats[0] DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; psoDesc.DSVFormat DXGI_FORMAT_D32_FLOAT;注意DXGI_FORMAT_R8G8B8A8_UNORM_SRGB和普通 UNORM 格式的差异。PBR 要求 Albedo 贴图以 sRGB 编码存储在采样后由硬件自动转换到线性空间。如果你把渲染目标也设成 sRGB那么输出时硬件会自动把线性值转回 sRGB这个流程是对的。如果渲染目标格式与 shader 输出不匹配会出现整体颜色过暗或过亮表现完全不像 PBR。4.3 命令列表录制的正确节奏PBR 引入后的绘制代码核心不是画一个物体而是以批次为单位录制命令。理论上每帧的 CPU 工作应该是更新场景常量缓冲、更新材质缓冲如果有变化、逐物体往描述符堆里填纹理、设置根签名和描述符表、对每个批次设置 PSO 和绘制参数。实际执行时要注意描述符堆在录制过程中不能重复分配因为 Shader 可见描述符堆在 GPU 执行命令之前就必须是稳定的。我的做法是每帧维护一个临时的描述符堆每帧开始从堆顶往下写绘制完直接整帧重置而不是零散回收。这个模式避免了很多堆碎片和指针失效的问题。5. 调试实录与性能优化心得5.1 典型渲染问题的排查思路PBR 在 DX12 里跑起来之后最先遇到的三个问题非常有代表性。第一个是整体画面过暗。这个十有八九是线性空间转换没做对。Albedo 贴图如果是 sRGB 编码但你用UNORM格式采样而没用UNORM_SRGB那么采样回来的值是经过 gamma 编码的颜色算出来的光照结果偏暗。解法就是贴图格式设成DXGI_FORMAT_R8G8B8A8_UNORM_SRGB或者采样后手动做pow(color, 2.2)但强烈建议用格式位去解决省得每个采样点都多一条指令。第二个是金属表面出现大面积亮斑或假发光。这个我在前面提过多半是 F0 处理错误。金属和非金属的 F0 差异极大非金属约 0.04金属的反射率可以到 0.9 以上。如果 F0 用统一 0.04金属面就只能靠高光强撑会出现一块一块的亮斑。正确做法是把BaseColor作为金属的 F0漫反射部分为 0否则金属不像金属像涂了清漆的塑料。第三个是掠射角的边缘出现噪点或暗纹。这要分两类排查一类是 BRDF 分母缺 epsilon前面说过了另一类是几何遮蔽项 G 没用对 Smith 形式用了简单形式导致掠射角的衰减太剧烈。排查方法很直接——把 roughness 固定到一个中间值用相机视角从垂直看向掠射角看高光是否平滑过渡。5.2 性能优化从哪下手DX12 引入 PBR 后性能瓶颈往往不出在你以为的地方。我实测下来的经验是先把 GPU 侧的瓶颈摸清楚再优化 CPU。PBR 场景常见的 GPU 瓶颈是像素填充率和显存带宽尤其是同时开启很多张高分辨率纹理加实时阴影的情况下。可以通过降低 Albedo 贴图分辨率、对距离远的物体使用更低 Mip 链、或者对法线贴图采样降频来做。CPU 侧反而容易出问题的地方是描述符堆和命令列表的创建频率。如果每帧都创建一个新的描述符堆或者每个物体都单独 Record 一条命令列表帧时间会急剧上涨。我优化后把同材质、同 PSO、同状态的物体合并为一个 DrawIndexedInstanced 调用PBR 的材质参数进材质缓冲数组后每帧只更新少量数据命令列表数量下降一个数量级。5.3 一个可复用的调试辅助工具说个我自己的小习惯在刚把 PBR 接好时先别急着调真实美术资源。做一个 Debug 模式让整个世界使用纯色材质把所有光照参数固定用几个简单的几何体球、立方体、平面做视觉验证。金属球粗糙度渐变球是渲染圈公认的 PBR 测试场景因为球体各个视角的光照行为都能看清。我在球面上用网格纹理辅助观察反射方向确认法线、切线、双切线变换正确后再导入真实模型和材质能省下大量调试时间。6. 后续扩展与底层思考PBR 接到 DX12 里目前已经能跑通直接光照Directional Point Spot但离真实的物理渲染还差临门一脚——间接光照。环境光遮蔽、漫反射辐照度、镜面反射预滤波贴图IBL这些才是让金属表面反射出周围环境的关键。IBL 实现上要预计算环境贴图的辐照度和预滤波结果存成 CubeMap 或 2D 展开运行时用 BRDF 积分查表这块又涉及 CubeMap 的采样、采样器配置和额外的描述符堆布局是下一篇内容的主线。不过现在这套基础已经够稳了根签名设计留了空间材质缓冲是结构化组织描述符堆是全局打平后面加 IBL 只需要在第三张 SRV 表里追加几个 CubeMap 描述符根签名完全不用动。这个扩展性设计当初花了不少心思现在看是值得的。最后分享一个我在实际调试中的心得PBR 在 DX12 里跑通不难但要跑得对光看公式和文档是不够的。你必须亲手把线性空间、描述符组织、采样器配置、法线变换这些基础环节都摸一遍然后回头调一个金属球的粗糙度观察高光边缘的过渡是否连续。如果能看到粗糙度从 0.1 到 0.9 时高光从一个锐利亮点平滑退化成宽泛弥散那这套管线就基本合格了。我当时看着那个金属球从塑料感一点点变成金属感的过程比跑通一个静态阴影还开心。后面再做 IBL本质上就是给这个球补上环境反射让它不再像在纯黑房间里被打光而是真正和场景融为一体。
网站建设高端定制企业官网