1. 从光照立方体到PBR为什么这一步非走不可很多人跟着教程把DX12的三角形画出来、把纹理贴上去、再搞个简单的Lambert光照就觉得差不多了。但只要你把渲染结果和市面上任何一款正经游戏截图放在一起对比立刻就能看出差距——你的模型看起来像塑料玩具而人家的金属有锈迹、木头有纹理层次、皮肤有次表面散射的透红感。这个差距的核心就是基于物理的渲染Physically Based RenderingPBR。PBR不是某一个具体的Shader技巧而是一整套关于光如何与物质交互的建模思路。它最早在离线渲染领域成熟后来被Disney在2012年的BRDF论文中系统化地引入实时渲染之后UE4、Unity、Frostbite等引擎全面跟进。到了DX12时代PBR几乎成了默认标准——不是因为它是唯一正确的方案而是因为它把美术制作和渲染结果之间的猜测成本降到了最低。我自己的经历很典型早期用传统Phong模型做项目美术调一个金属材质得反复改高光颜色、高光强度、环境光贡献改完在场景里一看又不对因为换个光照角度整个感觉就崩了。换成PBR之后美术只需要给金属度、粗糙度、基础色三张图剩下的交给Shader和光照环境一致性好了不止一个档次。这篇文章面向的是已经能用DX12画出东西、但还没系统上PBR的开发者。我会从DX12的管线配置讲起把PBR的数学原理拆开揉碎再给出可以直接抄的HLSL代码和C常量缓冲区布局最后分享几个我在实际集成中踩过的坑。你不需要有离线渲染背景但需要了解DX12的基本流程——命令列表、根签名、PSO这些概念得先有。提示本文假设你已经有一个能渲染带纹理模型的DX12工程。如果还没有建议先把纹理映射跑通再来看PBR否则调试时会分不清是管线问题还是光照问题。2. PBR的数学骨架从渲染方程到可落地的BRDF2.1 渲染方程到底在算什么PBR的理论根基是Kajiya在1986年提出的渲染方程L_o(p, ω_o) ∫_Ω f_r(p, ω_i, ω_o) L_i(p, ω_i) (n · ω_i) dω_i这个公式看着吓人但拆开就三件事出射光等于所有入射方向的光乘以BRDF双向反射分布函数再乘以入射方向和法线的夹角余弦最后对整个半球积分。BRDF描述了从某个方向来的光有多少被反射到观察方向。实时渲染不可能真的做半球积分所以工程上做了两步简化一是把光源限制为点光、方向光、聚光灯这些解析光源积分变成求和二是把环境光部分用**IBLImage Based Lighting**预计算成 cubemap运行时只做查表。这两步是理解后续所有代码的前提。2.2 Cook-Torrance BRDF的三个组成部分实时PBR普遍采用Cook-Torrance模型它把BRDF拆成漫反射项和镜面反射项f_r k_d * (c / π) k_s * (D * F * G) / (4 * (n·l) * (n·v))其中漫反射项就是Lambert的变体c是基础色除以π是为了能量守恒。镜面项由三个函数相乘再归一化D法线分布函数描述微表面法线的朝向分布决定高光的形状和宽度。常用GGX/Trowbridge-Reitz。F菲涅尔项描述不同角度下反射率的變化掠射角反射更强。常用Schlick近似。G几何遮蔽函数描述微表面之间的自遮挡避免能量不守恒。常用Smith方法配合Schlick-GGX。这三个函数的选择直接决定渲染质量。我试过用Blinn-Phong的D项凑合结果金属边缘的高光拖尾特别假换成GGX之后立刻自然了。下面给出我实际项目里用的HLSL实现参数都是调过的。2.3 为什么是GGX而不是其他分布GGX也叫Trowbridge-Reitz相比Beckmann分布最大的优势是长尾特性——它在高光核心之外还有一段缓慢衰减的拖尾这恰好符合真实金属和粗糙表面的观测结果。Beckmann衰减太快高光看起来干瘪。代价是GGX的积分没有解析解必须用数值方法或者近似。Epic在UE4里用的就是GGX配合Smith几何项这套组合现在基本是行业默认。我在项目里对比过同样粗糙度0.3的金属球GGX的高光边缘过渡明显更柔和视觉上更贵。// GGX / Trowbridge-Reitz 法线分布函数 float DistributionGGX(float3 N, float3 H, float roughness) { float a roughness * roughness; float a2 a * a; float NdotH max(dot(N, H), 0.0); float NdotH2 NdotH * NdotH; float num a2; float denom (NdotH2 * (a2 - 1.0) 1.0); denom PI * denom * denom; return num / max(denom, 0.0001); }注意a roughness * roughness这一步很多教程直接写a roughness结果粗糙度参数的手感完全不对。Disney的原始论文里用的是α roughness²这样美术在0到1之间调的时候感知上是线性的。这个细节我踩过坑当时美术抱怨粗糙度调到0.5还是太亮查了半天才发现是这里少了一次平方。2.4 菲涅尔项金属和非金属的分水岭菲涅尔项F决定了材质在掠射角下的反射增强。Schlick近似是实时渲染的标配F F0 (1 - F0) * (1 - (v·h))^5其中F0是垂直入射时的反射率。对于非金属F0大约是0.04也就是4%的反射对于金属F0就是基础色本身。这就是**金属度metallic**参数的物理意义——它其实是在F0的两种取值之间做插值。float3 fresnelSchlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); }这里有个容易忽略的点cosTheta应该用dot(H, V)而不是dot(N, V)。用N·V在粗糙表面上会有明显误差因为微表面的法线才是真正决定反射方向的东西。我一开始就是用的N·V金属球边缘的反射亮得不对劲改成H·V之后正常了。2.5 几何遮蔽别让能量凭空消失几何项G描述微表面之间的相互遮挡。Smith方法把G拆成入射和出射两部分相乘float GeometrySchlickGGX(float NdotV, float roughness) { float r (roughness 1.0); float k (r * r) / 8.0; float num NdotV; float denom NdotV * (1.0 - k) k; return num / denom; } float GeometrySmith(float3 N, float3 V, float3 L, float roughness) { float NdotV max(dot(N, V), 0.0); float NdotL max(dot(N, L), 0.0); float ggx2 GeometrySchlickGGX(NdotV, roughness); float ggx1 GeometrySchlickGGX(NdotL, roughness); return ggx1 * ggx2; }k的取值有讲究直接光照用(roughness1)²/8IBL用roughness²/2。我见过有人混用结果环境光下的粗糙物体边缘发黑。这个差异来自Epic的推导直接光下要考虑光源的立体角IBL下则是无限远光源的近似。3. DX12管线里塞进PBR常量缓冲与根签名的实际布局3.1 常量缓冲区的字段对齐陷阱PBR的Shader参数比传统光照多得多常量缓冲区Constant Buffer的布局必须严格遵守HLSL的打包规则。float3后面必须跟一个float补齐到16字节否则CPU和GPU看到的内存布局会错位表现为参数乱跳或者直接黑屏。我实际用的结构体长这样struct alignas(16) PBRConstants { DirectX::XMFLOAT4X4 world; // 64 bytes DirectX::XMFLOAT4X4 viewProj; // 64 bytes DirectX::XMFLOAT3 cameraPos; // 12 bytes float pad0; // 4 bytes 补齐 DirectX::XMFLOAT3 lightDir; // 12 bytes float pad1; // 4 bytes DirectX::XMFLOAT3 lightColor; // 12 bytes float lightIntensity; // 4 bytes DirectX::XMFLOAT3 baseColorFactor;// 12 bytes float metallicFactor; // 4 bytes float roughnessFactor;// 4 bytes float pad2[3]; // 12 bytes 补齐到16 };alignas(16)是必须的DX12要求常量缓冲区绑定地址256字节对齐结构体本身16字节对齐能省掉很多麻烦。pad字段看着冗余但少一个就等着调半天吧——我当初就是漏了pad0相机位置和光照方向串了画面像迪斯科灯球一样闪。3.2 根签名怎么设计才不浪费PBR需要绑定常量缓冲区、基础色纹理、金属粗糙度纹理、法线纹理、采样器、IBL的irradiance map和prefilter map。如果每个都单独放根参数根签名的256字节限制很快就爆了。我的做法是常量缓冲区放根参数root CBV纹理和采样器走描述符表。根CBV的更新最快每帧都要改纹理基本不变用描述符表批量绑定更划算。CD3DX12_ROOT_PARAMETER rootParams[2]; rootParams[0].InitAsConstantBufferView(0); // b0: PBR常量 CD3DX12_DESCRIPTOR_RANGE texRange; texRange.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 5, 0); // t0-t4 rootParams[1].InitAsDescriptorTable(1, texRange, D3D12_SHADER_VISIBILITY_PIXEL);五个SRV分别是基础色、金属粗糙度、法线、irradiance、prefilter。采样器用静态采样器放在根签名里省一个描述符堆。3.3 PSO配置里容易翻车的点PBR的像素着色器计算量大PSO配置有几个地方要特别注意渲染目标格式如果要做HDRRTV用DXGI_FORMAT_R16G16B16A16_FLOAT别用R8G8B8A8否则高光会截断成一片死白。深度测试PBR对深度精度敏感深度缓冲用D32_FLOAT别用D24S8。混合状态不透明物体关闭混合PBR的BRDF已经考虑了能量守恒再开混合会破坏物理正确性。我遇到过一次高光过曝的问题查了半天Shader最后发现是RTV用了8位格式高光值超过1.0直接被clamp了。换成16位浮点之后金属的高光终于有了层次。4. 直接光照与IBL两套光照路径的代码实现4.1 直接光照的循环结构直接光照部分对每个解析光源做一次BRDF计算。我习惯把光源数据打包成数组传进Shader这样加光源不用改管线float3 CalculatePBRDirect(float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float metallic, float roughness) { float3 H normalize(V L); float3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float NDF DistributionGGX(N, H, roughness); float G GeometrySmith(N, V, L, roughness); float3 F fresnelSchlick(max(dot(H, V), 0.0), F0); float3 numerator NDF * G * F; float denominator 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) 0.0001; float3 specular numerator / denominator; float3 kS F; float3 kD (1.0 - kS) * (1.0 - metallic); float NdotL max(dot(N, L), 0.0); return (kD * albedo / PI specular) * radiance * NdotL; }kD (1 - kS) * (1 - metallic)这一行是能量守恒的关键金属没有漫反射非金属的漫反射要被镜面反射抢走一部分能量。分母那个0.0001是防止除零别省。4.2 IBL的漫反射部分irradiance map环境光的漫反射用预计算的irradiance map它是对环境贴图做半球卷积的结果。运行时只需要用N方向采样float3 irradiance irradianceMap.Sample(samplerLinear, N).rgb; float3 diffuseIBL irradiance * albedo;irradiance map的生成可以在CPU端用计算着色器做也可以离线烘焙。我项目里用的是运行时CS卷积分辨率64x64的cubemap一帧就够开销可以忽略。4.3 IBL的镜面部分prefilter与BRDF LUT镜面IBL复杂一些需要两张预计算图prefilter map按不同粗糙度级别预过滤的环境贴图mip级别对应粗糙度。BRDF LUT把BRDF的积分结果预计算成一张2D查找表横轴是N·V纵轴是粗糙度。float3 PrefilteredColor prefilterMap.SampleLevel(samplerLinear, R, roughness * maxMipLevel).rgb; float2 brdf brdfLUT.Sample(samplerLinear, float2(max(dot(N, V), 0.0), roughness)).rg; float3 specularIBL PrefilteredColor * (F0 * brdf.x brdf.y);BRDF LUT这张图我建议直接生成一次存成DDS运行时加载。每次启动都算一遍纯属浪费而且不同机器上浮点精度差异可能导致结果不一致。4.4 合成最终颜色把直接光和IBL加起来再做色调映射float3 color directLighting (kD * diffuseIBL specularIBL) * ao; color color / (color 1.0); // Reinhard色调映射 color pow(color, 1.0 / 2.2); // Gamma校正AO环境光遮蔽只作用于IBL部分直接光不该被AO影响因为直接光的遮挡关系由阴影贴图处理。这个细节很多人搞混把AO乘到整个结果上导致直接光区域也变暗。5. 集成过程中踩过的坑与排查链路5.1 金属球看起来像塑料F0的插值错误第一次跑通PBR金属球完全没有金属感灰蒙蒙的像磨砂塑料。排查过程先确认metallic贴图有没有正确采样——打印出来是1.0没问题。检查F0 lerp(0.04, albedo, metallic)发现albedo采样的是sRGB纹理但我在Shader里没做sRGB到线性的转换。基础色纹理的格式是R8G8B8A8_UNORM_SRGB硬件会自动转换但我手动又做了一次pow等于转了两次。修复方法要么用_SRGB格式让硬件转要么用UNORM格式手动转绝对不能两个都做。这个坑我见过至少三个人踩症状都是颜色发灰、对比度低。5.2 高光边缘出现黑边几何项的参数用错金属球的高光边缘有一圈黑边特别明显。查了GGX和菲涅尔都没问题最后定位到几何项的k值。我直接光用了roughness²/2这是IBL的公式。直接光应该用(roughness1)²/8。改过来之后黑边消失。这个错误的本质是直接光下光源有立体角微表面遮挡的统计分布和无限远光源不同。Epic的推导里明确区分了两种情况抄公式的时候一定要看清楚上下文。5.3 环境光下的物体整体偏暗prefilter的mip计算IBL的镜面反射明显偏暗尤其是粗糙物体。原因是prefilter map的mip级别计算错了// 错误写法 float mip roughness * maxMipLevel; // 正确写法 float mip roughness * (maxMipLevel - 1);prefilter map的mip0对应粗糙度0mipN对应粗糙度1但采样时如果直接用roughness * maxMipLevel粗糙度1会采样到不存在的mipN1硬件clamp到mipN导致粗糙物体的反射被过度模糊。减1之后正常。5.4 法线贴图的切线空间问题法线贴图用错切线空间光照方向会完全乱掉。DX12里没有内置的切线生成需要自己算。我的做法是在模型加载时用MikkTSpace算法生成切线和大部分DCC工具保持一致。如果法线贴图看起来反了先检查绿通道需不需要翻转OpenGL和DirectX的Y轴方向相反再检查切线的手性handedness。这两个问题症状相似但原因不同我一般先翻转绿通道试不行再查切线。6. 性能优化与参数调校的实战心得6.1 分支预测把粗糙度低的像素聚在一起PBR的Shader里有很多if比如metallic 0.5走金属路径。GPU是SIMD架构同一个warp里如果有像素走不同分支两条路都要执行。优化方法是按材质排序绘制让金属物体连续绘制非金属连续绘制。我在一个场景里试过排序前帧时间18ms排序后降到14ms提升超过20%。这个优化不需要改Shader只是调整绘制顺序性价比极高。6.2 预计算图的精度取舍irradiance map和prefilter map的分辨率直接影响显存和带宽。我的经验值贴图分辨率格式显存占用irradiance32x32 cubemapR11G11B10约12KBprefilter128x128 cubemap6级mipR16G16B16A16约1.5MBBRDF LUT512x512R16G16约1MBirradiance用32x32足够了因为漫反射本来就是低频信息。prefilter用128起步再低粗糙物体的反射会有明显块状。BRDF LUT 512x512是甜点256会有可见的条带。6.3 粗糙度与金属度的美术调校PBR的参数虽然物理正确但美术调的时候还是需要一些作弊空间。我的做法是粗糙度给美术一个0.05到0.95的可用范围别让他们调到0或1。完全光滑的物体在实时渲染里会有严重的走样完全粗糙的物体看起来像纸。金属度尽量用0或1中间值只用于过渡区域比如磨损的金属边缘。中间值会让F0插值出物理上不存在的材质。基础色金属的基础色就是它的反射色非金属的基础色是漫反射色。这两个的物理意义不同美术经常搞混需要提前沟通。6.4 调试视图把中间结果可视化PBR的参数多出问题很难定位。我在Shader里加了一个调试模式用常量控制输出哪个中间量#if DEBUG_VIEW if (debugMode 0) return float4(N * 0.5 0.5, 1.0); if (debugMode 1) return float4(albedo, 1.0); if (debugMode 2) return float4(metallic.xxx, 1.0); if (debugMode 3) return float4(roughness.xxx, 1.0); if (debugMode 4) return float4(F0, 1.0); if (debugMode 5) return float4(specularIBL, 1.0); #endif这个功能帮我省了无数时间。比如有一次金属反射不对切到F0视图一看发现F0是纯黑——说明metallic贴图没绑上采样返回了0。没有调试视图的话得从光照一路查到纹理绑定。6.5 移动端的降级策略如果项目要兼顾移动端PBR需要降级。我的方案是去掉prefilter map用irradiance map近似镜面反射。BRDF LUT降到256x256或者用解析近似代替。几何项用更便宜的近似比如Karis的解析拟合。降级之后视觉损失大概20%但性能能提升一倍以上。具体取舍看项目定位主机和PC可以全开移动端必须砍。7. 从PBR出发还能往哪走PBR跑通之后下一步通常是加阴影。方向光的阴影用CSM级联阴影贴图点光源用cubemap阴影。阴影和PBR结合的时候要注意阴影只影响直接光IBL部分不受阴影影响——因为IBL代表的是无限远的环境光局部遮挡由AO处理。再往后可以上延迟渲染。PBR的G-Buffer需要存基础色、法线、金属度、粗糙度正好适合延迟管线。DX12的延迟渲染可以用R16G16B16A16_FLOAT存基础色和法线R8G8B8A8存金属粗糙度带宽压力比前向渲染小很多。还有一个方向是屏幕空间反射SSR用来补充IBL的镜面反射。IBL的反射是无限远的近处的物体反射不到SSR能补上这一块。SSR和PBR的prefilter结果混合的时候需要按粗糙度做权重粗糙度高的像素更多依赖IBL粗糙度低的更多依赖SSR。我自己在实际项目里的体会是PBR最大的价值不是好看而是可预测。美术给一组参数在任何光照环境下结果都是一致的不需要为每个场景重新调材质。这个特性在大型项目里能省下巨量的沟通和返工成本。刚开始上手会觉得参数多、公式复杂但一旦跑通后面加内容的速度会快很多。最后分享一个小技巧如果你在调试PBR的时候觉得哪里都不对先把所有纹理去掉用纯色常量代替只留直接光照。如果纯色金属球看起来正常再逐个加纹理。这样能把问题范围缩小到具体的纹理或采样环节比对着完整Shader瞎猜高效得多。