
1. 从光照模型到物理渲染为什么DX12项目绕不开PBR很多人在学完DX12的基础三角形绘制、常量缓冲区、根签名之后会卡在一个很尴尬的位置——场景能跑起来了但画面看起来像塑料玩具。光照要么是硬邦邦的Lambert要么是随便凑的Phong高光金属和木头在屏幕上长得一模一样。这时候下一步该做什么答案基本只有一个上PBR。PBR全称Physically Based Rendering中文一般叫基于物理的渲染。它不是一个具体的算法而是一套约定和框架。核心思想是材质的表现应该由物理参数驱动而不是美术凭感觉调出来的魔法数字。你在DX12里加入PBR本质上是在做三件事——换一套光照计算公式、换一套材质参数体系、换一套纹理输入规范。为什么DX12项目特别适合在这个阶段引入PBR因为DX12给了你显式的资源管理和管线控制权。在DX11时代你写PBR和写传统光照在API层面的差异不大驱动帮你兜了很多底。但DX12里描述符堆怎么排、根签名怎么设计、常量缓冲区怎么对齐这些都会直接影响你PBR着色器的性能和可维护性。换句话说DX12让你有机会把PBR做“对”而不只是做“出来”。这篇文章面向的是已经跑通DX12基础渲染管线、能画出带纹理的立方体、但对PBR还停留在“听说过”阶段的开发者。我会从光照模型的演进讲起拆解PBR的核心公式然后落到DX12的具体实现——根签名怎么改、描述符怎么分配、着色器里哪些参数必须严格对齐。中间会穿插我在实际项目里踩过的坑比如法线贴图的绿通道方向问题、金属度工作流和镜面度工作流的取舍、以及DX12描述符堆溢出导致整个画面变黑这种让人抓狂的情况。注意本文假设你已经掌握了DX12的CommandList、PSO、RootSignature基本用法。如果这些概念对你来说还很陌生建议先补完基础再来看PBR部分否则很多设计决策你会看不懂背后的原因。2. PBR的核心公式拆解从渲染方程到可落地的Shader代码2.1 渲染方程到底在说什么PBR的理论根基是渲染方程但直接看那个积分公式很容易劝退。我用一个生活化的类比来解释想象你站在一个房间里眼睛看到某个点的亮度取决于所有光源从这个点反射到你眼睛里的光的总和。每个光源的贡献又取决于三个因素——光源本身的强度、这个点朝向光源的程度、以及这个点本身的材质属性。渲染方程把这个过程写成了一个积分但在实时渲染里我们不可能真的去积分。所以PBR在工程上的做法是把光源简化成点光源、方向光、聚光灯这些解析形式然后对每种光源单独计算贡献最后累加。这就是所谓的直接光照。间接光照则用IBL或者球谐函数来近似。关键点在于PBR把材质属性拆成了两个独立的部分漫反射项和镜面反射项。漫反射项描述的是光线进入材质内部、经过多次散射后从表面均匀射出的部分。镜面反射项描述的是光线在表面直接反射的部分。这两项的权重由金属度参数控制。2.2 漫反射项为什么除以π漫反射项的标准形式是 albedo / π * NdotL * lightColor。很多人第一次看到这个除以π会困惑——为什么要除原因在于能量守恒。一个理想的漫反射表面从半球空间接收的所有入射光能量应该等于它向半球空间反射出去的总能量。如果你把漫反射的BRDF设成常数albedo那么对半球空间积分之后得到的是albedo * π。为了让反射能量等于入射能量乘以albedoBRDF必须是albedo / π。在实际写Shader的时候这个π经常被合并到光源强度里。比如你把方向光的颜色直接设成(1,1,1)那最终亮度可能偏暗因为少了π这个因子。我的习惯是在CPU端计算光源强度时就乘上π这样Shader里写起来更直观。// 漫反射项 float3 diffuse albedo * NdotL * lightColor * (1.0 / PI); // 如果lightColor已经包含了π因子这里就可以省略1/PI2.3 镜面反射项Cook-Torrance的三个函数镜面反射项是PBR里最复杂的部分标准形式是Cook-Torrance BRDF由三个函数组成法线分布函数D、几何遮蔽函数G、菲涅尔函数F。法线分布函数D描述的是微表面法线朝向半角向量的概率密度。常用的有GGX/Trowbridge-Reitz。这个函数决定了高光的形状和拖尾。GGX的特点是高光核心很亮、边缘有较长的拖尾看起来比较真实。几何遮蔽函数G描述的是微表面之间互相遮挡的程度。常用的有Schlick-GGX和Smith方法。这个函数在掠射角下会显著降低镜面反射避免边缘过亮。菲涅尔函数F描述的是不同角度下反射率的变化。所有材质在掠射角下都会趋近于全反射这就是为什么你从侧面看水面会像镜子。Schlick近似是最常用的形式F F0 (1 - F0) * pow(1 - VdotH, 5)。// GGX法线分布 float D_GGX(float NdotH, float roughness) { float a roughness * roughness; float a2 a * a; float d NdotH * NdotH * (a2 - 1.0) 1.0; return a2 / (PI * d * d); } // Schlick-GGX几何遮蔽 float G_SchlickGGX(float NdotV, float roughness) { float r roughness 1.0; float k (r * r) / 8.0; return NdotV / (NdotV * (1.0 - k) k); } // Schlick菲涅尔 float3 F_Schlick(float VdotH, float3 F0) { return F0 (1.0 - F0) * pow(1.0 - VdotH, 5.0); }这三个函数乘在一起再除以4 * NdotV * NdotL就得到了镜面反射项。那个分母是BRDF的归一化因子保证能量守恒。2.4 金属度工作流 vs 镜面度工作流PBR有两套主流的工作流金属度工作流和镜面度工作流。金属度工作流用三个参数描述材质——albedo、metallic、roughness。镜面度工作流用albedo、specular、glossiness。我强烈建议在DX12项目里用金属度工作流。原因有三个第一金属度工作流的参数更少纹理打包更紧凑一张RGB纹理就能存albedo一张RG纹理就能存metallic和roughness。第二金属度工作流的物理约束更强非金属的F0固定为0.04金属的F0就是albedo本身美术不容易调出物理上不可能存在的材质。第三主流引擎和素材库都默认金属度工作流资源迁移成本低。镜面度工作流虽然给了美术更大的控制权但也给了他们更大的犯错空间。我见过太多项目因为美术把specular调得乱七八糟导致整个场景的光照看起来不统一。3. DX12管线改造根签名、描述符堆与常量缓冲区的重新设计3.1 根签名怎么改才能容纳PBR参数在DX11时代你加几个Shader参数就是改一下常量缓冲区的事。但在DX12里根签名是你和GPU之间的契约改参数意味着改契约。PBR需要哪些参数至少包括光源方向、光源颜色、光源强度、相机位置、材质albedo、材质metallic、材质roughness、以及各种纹理的SRV。如果场景里有多个光源还需要一个光源数组。我的做法是把根签名分成三部分第一部分是常量缓冲区存每帧变化的参数相机矩阵、光源数组、时间。第二部分是另一个常量缓冲区存每个DrawCall变化的参数世界矩阵、材质参数。第三部分是描述符表存纹理SRV。// 根签名布局示意 // RootParameter 0: CBV b0 - 每帧常量 // RootParameter 1: CBV b1 - 每物体常量 // RootParameter 2: DescriptorTable - SRV t0-t3 (albedo, normal, metallic, roughness)这里有个坑根签名里的常量缓冲区有256字节的对齐要求。如果你把光源数组直接塞进根常量里很容易超限。根常量最多64个DWORD也就是256字节。一个光源至少需要方向(3 float) 颜色(3 float) 强度(1 float) 7个float 28字节。最多塞9个光源就到头了。所以我的建议是少量光源用根常量大量光源用常量缓冲区加描述符堆。3.2 描述符堆的分配策略DX12的描述符堆分两种CBV_SRV_UAV堆和Sampler堆。PBR材质通常需要四张纹理——albedo、normal、metallic、roughness。如果场景里有100个物体那就是400个SRV描述符。描述符堆的大小在创建时就要确定不能动态扩展。所以你需要预估最大纹理数量。我的经验是按场景物体数量的1.5倍来分配留出余量给后期可能加入的阴影贴图、环境贴图、LUT等。// 描述符堆创建 D3D12_DESCRIPTOR_HEAP_DESC srvHeapDesc {}; srvHeapDesc.Type D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; srvHeapDesc.NumDescriptors 1024; // 预分配1024个 srvHeapDesc.Flags D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; device-CreateDescriptorHeap(srvHeapDesc, IID_PPV_ARGS(srvHeap));还有一个容易忽略的点描述符堆的句柄增量。不同GPU架构的句柄大小可能不同必须用GetDescriptorHandleIncrementSize来获取不能硬编码。3.3 常量缓冲区的对齐陷阱DX12的常量缓冲区有一个著名的坑每个常量缓冲区的大小必须是256字节的整数倍。如果你定义了一个结构体大小是200字节上传到GPU时会被自动填充到256字节。如果你在CPU端按200字节的步长去写数据第二个物体的数据就会错位。// 错误做法按实际大小步进 struct ObjectConstants { float4x4 world; float3 albedo; float metallic; float roughness; // 大小可能是80字节 }; // 上传时按80字节步进 - 第二个物体数据错位 // 正确做法按256字节对齐 struct ObjectConstants { float4x4 world; float3 albedo; float metallic; float roughness; float padding[44]; // 填充到256字节 };我一般会写一个辅助函数来计算对齐后的大小避免手动数padding数错。3.4 PSO里需要调整的状态PBR的PSO和传统光照的PSO差异不大但有几个地方需要注意。首先是渲染目标格式如果你要做HDR渲染RTV格式应该是DXGI_FORMAT_R16G16B16A16_FLOAT而不是R8G8B8A8_UNORM。因为PBR的高光很容易超过1.0用UNORM格式会被截断导致高光区域变成一片死白。其次是深度模板状态PBR通常需要深度测试开启、深度写入开启。如果你要做透明物体深度写入要关掉但那是另一个话题了。最后是采样器。PBR的纹理采样通常需要各向异性过滤尤其是法线贴图和粗糙度贴图。采样器可以放在根签名里作为静态采样器也可以放在采样器堆里。4. 纹理与材质数据准备从美术资源到GPU可读格式4.1 Albedo贴图的sRGB问题Albedo贴图存储的是颜色信息必须用sRGB格式读取。在DX12里这意味着你要么把纹理格式设成DXGI_FORMAT_R8G8B8A8_UNORM_SRGB要么在Shader里手动做sRGB到线性的转换。我推荐用_SRGB格式因为硬件转换是免费的而且不容易出错。如果你在Shader里手动转换很容易忘记某张纹理导致颜色偏亮或偏暗。但要注意法线贴图、金属度贴图、粗糙度贴图绝对不能做sRGB转换。它们存储的是几何或物理数据不是颜色。如果你不小心把法线贴图设成了_SRGB格式法线会变得乱七八糟光照完全不对。4.2 法线贴图的绿通道方向法线贴图有一个经典的方向问题OpenGL和DirectX的绿通道方向是相反的。OpenGL是Y向上DirectX是Y向下。如果你用了一张为OpenGL生成的法线贴图在DirectX里光照会看起来凹凸颠倒。解决方法有两个要么在导入时翻转绿通道要么在Shader里做normal.y -normal.y。我倾向于在导入时处理因为运行时翻转会多几条指令而且容易忘记。// 如果法线贴图是OpenGL格式需要翻转绿通道 float3 normalTS normalTex.Sample(sampler, uv).rgb; normalTS.y -normalTS.y; // 仅在需要时 normalTS normalTS * 2.0 - 1.0; // 从[0,1]映射到[-1,1]4.3 金属度与粗糙度贴图的打包金属度和粗糙度都是单通道数据没必要各占一张纹理。标准做法是把它们打包到一张纹理的B通道和G通道。通常的约定是R通道存环境光遮蔽G通道存粗糙度B通道存金属度。这样一张RGB纹理就搞定了三个参数节省了显存和带宽。在Shader里采样一次然后分别取不同通道即可。float4 metalRoughAO metalRoughTex.Sample(sampler, uv); float ao metalRoughAO.r; float roughness metalRoughAO.g; float metallic metalRoughAO.b;注意不同美术工具导出的通道顺序可能不同。Substance Painter默认是RAO、GRoughness、BMetallic但有些工作流会不一样。拿到资源后一定要先确认通道顺序否则调半天光照都调不对。4.4 纹理上传的DX12细节DX12上传纹理比DX11麻烦得多。你需要创建一个上传堆、一个默认堆、然后用CopyTextureRegion把数据从上传堆拷贝到默认堆。对于PBR材质通常有四张纹理要上传如果每张都单独走一遍上传流程代码会很冗长。我的做法是写一个TextureLoader类封装上传堆的创建、资源屏障的转换、以及拷贝命令的录制。所有纹理的上传命令录制到同一个CommandList里最后一次性提交。// 简化的纹理上传流程 // 1. 创建默认堆纹理资源 // 2. 创建上传堆缓冲区 // 3. 映射上传堆内存拷贝纹理数据 // 4. 录制CopyTextureRegion命令 // 5. 转换资源状态从COPY_DEST到PIXEL_SHADER_RESOURCE // 6. 提交命令队列还有一个细节纹理数据的行对齐。DX12要求每行数据按256字节对齐。如果你的纹理宽度是1920每像素4字节一行就是7680字节刚好是256的倍数。但如果宽度是1000一行4000字节不是256的倍数就需要手动填充。5. 着色器实现把PBR公式翻译成HLSL5.1 顶点着色器需要输出什么PBR的顶点着色器比传统光照多输出几个量世界空间位置、世界空间法线、世界空间切线、以及纹理坐标。切线是用来构建TBN矩阵的法线贴图需要TBN矩阵才能从切线空间变换到世界空间。struct VSOutput { float4 posH : SV_POSITION; float3 posW : POSITION; float3 normalW : NORMAL; float3 tangentW : TANGENT; float2 uv : TEXCOORD; };切线空间的计算有两种方式一种是在顶点着色器里算一种是在像素着色器里用屏幕空间导数算。前者更准确但需要顶点数据里包含切线信息。后者不需要切线数据但在UV接缝处会有问题。我推荐用前者因为PBR对法线精度要求比较高。5.2 像素着色器的主循环像素着色器是PBR的核心。流程大致是采样纹理、构建TBN矩阵、计算NdotV和NdotL、计算漫反射和镜面反射、累加所有光源、最后做色调映射。float3 PBR(float3 posW, float3 normalW, float3 tangentW, float2 uv) { // 采样纹理 float3 albedo albedoTex.Sample(sampler, uv).rgb; float3 normalTS normalTex.Sample(sampler, uv).rgb * 2.0 - 1.0; float4 metalRoughAO metalRoughTex.Sample(sampler, uv); float metallic metalRoughAO.b; float roughness metalRoughAO.g; float ao metalRoughAO.r; // 构建TBN矩阵 float3 N normalize(normalW); float3 T normalize(tangentW - dot(tangentW, N) * N); float3 B cross(N, T); float3x3 TBN float3x3(T, B, N); N normalize(mul(normalTS, TBN)); // 计算F0 float3 F0 lerp(0.04, albedo, metallic); // 视线方向 float3 V normalize(cameraPos - posW); float NdotV max(dot(N, V), 0.0); // 累加光源 float3 Lo float3(0, 0, 0); for (int i 0; i numLights; i) { float3 L normalize(lightDir[i]); float3 H normalize(V L); float NdotL max(dot(N, L), 0.0); float NdotH max(dot(N, H), 0.0); float VdotH max(dot(V, H), 0.0); // 漫反射 float3 diffuse albedo * (1.0 - metallic) / PI; // 镜面反射 float D D_GGX(NdotH, roughness); float G G_SchlickGGX(NdotV, roughness) * G_SchlickGGX(NdotL, roughness); float3 F F_Schlick(VdotH, F0); float3 specular D * G * F / (4.0 * NdotV * NdotL 0.001); Lo (diffuse specular) * lightColor[i] * NdotL; } // 环境光近似 float3 ambient albedo * ao * 0.03; return ambient Lo; }5.3 色调映射与伽马校正PBR的输出是HDR的直接显示会过曝。所以最后要做色调映射把HDR值压缩到LDR范围。常用的有Reinhard、ACES、Filmic。我一般用ACES因为它的高光滚降比较自然。float3 ACESToneMapping(float3 color) { float a 2.51; float b 0.03; float c 2.43; float d 0.59; float e 0.14; return saturate((color * (a * color b)) / (color * (c * color d) e)); }色调映射之后还要做伽马校正把线性颜色转回sRGB。如果RTV是_SRGB格式硬件会自动做否则需要手动pow(color, 1/2.2)。5.4 性能优化哪些计算可以预计算PBR的像素着色器计算量不小尤其是多光源场景。有几个优化方向第一把不随视角变化的项预计算比如F0。第二用近似公式替代复杂函数比如用球谐函数近似环境光。第三把光源按影响范围剔除只计算影响当前像素的光源。我在实际项目里发现最大的性能瓶颈往往不是PBR公式本身而是纹理采样。四张纹理各采样一次加上阴影贴图和环境贴图采样次数很容易上两位数。所以纹理压缩和mipmap生成很重要。6. 调试与验证怎么确认你的PBR是对的6.1 用标准材质球做基准测试PBR调不对的时候最有效的调试方法是渲染一组标准材质球。通常包括纯金属球metallic1, roughness0.1、纯粗糙金属球metallic1, roughness0.9、纯电介质球metallic0, roughness0.1、纯粗糙电介质球metallic0, roughness0.9。如果这四个球看起来符合直觉——金属球有清晰的环境反射、粗糙金属球反射模糊、电介质球高光锐利但整体偏暗——那你的PBR基本是对的。如果金属球看起来像塑料或者电介质球高光过亮那说明F0或者能量守恒有问题。6.2 常见错误排查表现象可能原因排查方法画面整体偏暗缺少π因子或光源强度不足检查漫反射项是否除以π光源强度是否乘了π金属看起来像塑料F0计算错误确认metallic1时F0等于albedo高光过曝一片白没有做色调映射检查是否在输出前做了ACES或Reinhard法线贴图效果颠倒绿通道方向错误尝试翻转normal.y粗糙度变化不明显粗糙度贴图通道取错确认采样的是G通道而不是R通道物体边缘过亮几何遮蔽函数缺失检查G项是否计算并乘入画面全黑描述符堆溢出或根签名不匹配检查描述符数量是否超限根签名参数是否对齐6.3 用RenderDoc抓帧分析DX12的调试离不开RenderDoc。抓一帧之后你可以看到每个DrawCall的输入输出、每个Shader的常量缓冲区内容、每个纹理的采样结果。我一般会重点看三个地方常量缓冲区里的材质参数是否正确、纹理采样结果是否符合预期、以及最终输出的颜色值是否在合理范围。有一次我遇到画面全黑的问题用RenderDoc抓帧后发现描述符堆的SRV句柄指向了错误的位置。原因是创建描述符堆时分配的数量不够后面的纹理覆盖了前面的。这种问题在DX11里很少见因为驱动帮你管理了资源绑定但在DX12里你必须自己保证描述符堆不溢出。6.4 数值验证手算一个像素的颜色最可靠的验证方法是手算。选一个简单的场景——一个方向光、一个纯白电介质球、metallic0、roughness0.5、albedo(1,1,1)。然后手动计算某个像素的漫反射和镜面反射值和Shader输出的值对比。如果手算结果和Shader输出一致说明公式实现正确。如果不一致逐项检查哪个函数出了问题。这个方法虽然笨但非常有效尤其是当你怀疑某个数学函数写错的时候。7. 从能跑到好用PBR在DX12项目里的进阶方向7.1 环境光照IBL的接入直接光照做完之后下一步自然是环境光照。没有环境光的PBR场景金属球看起来是黑的因为金属没有漫反射全靠镜面反射而镜面反射需要环境贴图提供反射源。IBL的核心是预计算一张环境贴图把它卷积成漫反射辐照度图和镜面反射预过滤图。漫反射辐照度图用球谐函数或者低分辨率立方体贴图存储。镜面反射预过滤图按粗糙度分级每级对应不同的模糊程度。在DX12里接入IBL需要额外的描述符和根签名参数。我通常把环境贴图的SRV放在描述符堆的固定位置然后在根签名里加一个描述符表指向它。7.2 多光源与阴影PBR场景通常需要多个光源才能看出效果。但多光源意味着多份阴影贴图显存和带宽压力会急剧上升。我的做法是主方向光用高分辨率阴影贴图点光源和聚光灯用低分辨率或者干脆不投影。阴影的计算和PBR是正交的但阴影的软硬程度会影响PBR的观感。硬阴影配低粗糙度材质看起来比较锐利软阴影配高粗糙度材质看起来比较柔和。7.3 材质系统与资源管理当场景里的材质数量超过几十个之后手动管理描述符和常量缓冲区就不现实了。你需要一个材质系统把材质参数和纹理句柄封装成对象然后统一上传到GPU。我的做法是用一个大的常量缓冲区数组存所有物体的材质参数用一个大的描述符堆存所有纹理。每个物体记录自己的常量缓冲区偏移和描述符堆偏移。渲染时只需要更新偏移量不需要重新创建资源。7.4 性能分析与优化PBR的性能开销主要在像素着色器。优化手段包括减少纹理采样次数、用更便宜的近似函数、剔除不可见光源、降低着色频率比如用可变速率着色。DX12的可变速率着色是一个很有用的特性。它允许你在屏幕的不同区域用不同的着色速率。比如画面中心用全速率边缘用半速率。对于PBR这种像素级计算密集的渲染VRS可以带来明显的性能提升。我在实际项目里测过开启2x2的VRS之后PBR的像素着色器开销降低了大约30%而画面质量的损失在大多数场景下几乎看不出来。当然如果场景里有大量高频细节VRS可能会导致边缘模糊需要根据具体情况调整。7.5 常见问题与解决思路最后列几个我在DX12 PBR项目里反复遇到的问题和解决思路。第一个是描述符堆溢出症状是画面随机变黑或者纹理错乱解决方法是预分配足够的描述符并加断言检查。第二个是常量缓冲区对齐错误症状是物体变换错乱或者材质参数不对解决方法是统一按256字节对齐。第三个是法线贴图方向错误症状是光照凹凸颠倒解决方法是翻转绿通道。第四个是色调映射缺失症状是高光过曝解决方法是加ACES或Reinhard。这些问题看起来简单但在实际调试的时候往往要花几个小时才能定位。我的建议是每加一个新特性就用RenderDoc抓一帧确认输入输出都符合预期不要等到问题积累多了再一起查。