DX12实战:从Phong到PBR,基于物理的渲染管线改造指南
发布时间:2026/9/30 9:53:37来源:尧图网络
1. 从光照立方体到PBR为什么这一步非走不可很多人跟着教程把DX12的三角形画出来、把贴图贴上去之后就卡在了下一步该做什么上。我自己当初也是这样手里有一个能跑的光照模型看起来还行但总觉得哪里不对——金属不像金属塑料不像塑料同一个光源下不同材质的反应几乎一模一样。这个问题的根源在于传统的光照模型比如Phong、Blinn-Phong本质上是在模拟看起来像什么而不是模拟光到底怎么跟物体作用。PBRPhysically Based Rendering基于物理的渲染要解决的就是这件事。这篇文章是学一下DX12系列的第二篇核心目标很明确在已有的DX12渲染管线里把光照模型从经验模型切换到PBR。我不会只给你一堆公式让你抄而是会把每个参数为什么这么定、代码为什么这么写、踩过哪些坑都讲清楚。适合已经能用DX12画出东西、但对PBR还停留在听说过阶段的读者。如果你连根签名和PSO都还没搞明白建议先把第一篇的内容消化掉再来看这篇不然会非常痛苦。先说结论PBR不是某一个具体的算法而是一套约束和框架。它要求你遵守能量守恒、使用物理量纲的参数、把光照计算拆成漫反射和镜面反射两部分分别处理。在DX12里实现PBR难点不在数学而在于常量缓冲区的布局、纹理资源的组织、以及着色器编译时那些让人抓狂的报错。下面我会按实际开发的顺序一步步拆开讲。2. PBR的数学底子别被公式吓住先搞懂它在算什么2.1 渲染方程到底在说什么PBR的理论基础是渲染方程完整形式看起来吓人但在实时渲染里我们做的是它的简化版本。核心思想就一句话一个着色点最终反射出来的光等于它接收到的所有方向的光乘以它在这个方向上反射出去的比例再积分。实时渲染没法真的做积分所以我们把它离散化——对每个光源单独算一次贡献然后加起来。这个反射出去的比例就是BRDF双向反射分布函数。PBR的核心工作就是选一个物理上合理的BRDF然后用它来计算每个像素的颜色。目前业界最通用的是Cook-Torrance BRDF它把反射拆成漫反射项和镜面反射项漫反射项用Lambert模型简单说就是光线打到粗糙表面后向各个方向均匀散射。镜面反射项用微表面模型包含法线分布函数D、几何遮蔽函数G、菲涅尔项F三个部分。我一开始看这些公式的时候也觉得头大但实际写代码的时候你会发现真正需要你理解的只有每个函数在控制什么视觉效果。D控制高光的形状和集中程度G控制掠射角下的能量损失F控制不同角度下的反射率变化。把这三个搞明白公式只是翻译问题。2.2 金属度与粗糙度两个参数撑起整个材质系统PBR工作流里最常见的参数化方式是金属度Metallic和粗糙度Roughness。这两个参数之所以好用是因为它们能覆盖绝大多数真实材质材质类型MetallicRoughness典型表现塑料0.00.3-0.6有彩色高光漫反射明显纯金属1.00.1-0.4高光带材质本色几乎无漫反射生锈铁0.8-1.00.7-0.9高光散开颜色偏暗陶瓷0.00.1-0.2高光锐利表面光滑这里有个关键规则金属的漫反射为零。因为金属的自由电子会吸收折射进内部的光所以金属只有镜面反射。很多新手做出来的金属看起来像塑料就是因为没有把漫反射项按金属度正确插值掉。我在实际项目里用的插值方式是float3 diffuseColor albedo * (1.0 - metallic); float3 specularColor lerp(0.04, albedo, metallic);0.04是大多数非金属材质的基准反射率F0这个值不是随便定的是从实际测量数据里来的。介电质非金属的F0通常在0.02到0.05之间取0.04是个通用近似。2.3 能量守恒PBR的底线能量守恒听起来很学术但它的实际意义很简单一个表面反射出去的光不能比它接收到的多。违反这条规则的直接后果就是材质看起来发光或者过曝。在Cook-Torrance里漫反射项需要乘以(1-F)来保证镜面反射和漫反射加起来不超过入射能量。同时镜面反射的D、G、F三项组合起来也要满足能量约束。很多简化实现里会忽略这些细节结果就是高光特别亮、暗部特别黑整体看起来很不自然。我踩过的一个坑是一开始没做漫反射的能量补偿金属度调到1的时候整个物体还是有一层灰蒙蒙的漫反射看起来像蒙了层灰。后来加上(1-metallic)的乘法之后金属的质感立刻就对了。3. DX12管线里塞进PBR常量缓冲和纹理怎么改3.1 常量缓冲区的重新设计第一篇里我们的常量缓冲区可能只有一个世界矩阵加一个光照方向。加入PBR之后每个材质需要传递的参数变多了albedo颜色、金属度、粗糙度、环境光强度、相机位置等等。这时候如果还把所有东西塞在一个CBV里会非常难维护。我的做法是把常量缓冲拆成两个每帧更新相机位置、光照方向、光照颜色、环境光强度、时间。每材质更新albedo、metallic、roughness、法线强度、AO强度。在DX12里这意味着你需要两个CBV分别绑定到不同的寄存器。根签名也要相应调整。这里有个容易忽略的点常量缓冲区的大小必须是256字节的整数倍。我一开始没注意结构体只有200字节结果GPU读到的数据全是乱的排查了半天才发现是对齐问题。struct FrameConstants { XMFLOAT4X4 viewProj; XMFLOAT4 cameraPos; XMFLOAT4 lightDir; XMFLOAT4 lightColor; XMFLOAT4 ambientColor; float exposure; float padding[3]; // 补齐到256字节 }; struct MaterialConstants { XMFLOAT4 albedo; float metallic; float roughness; float normalStrength; float aoStrength; float padding[4]; // 同样补齐 };注意HLSL里的cbuffer布局默认是16字节对齐但C结构体不是。如果你在C里用了float3在HLSL里对应float3中间可能会差4字节导致错位。最稳妥的做法是全部用float4或者手动加padding。3.2 纹理资源的组织方式PBR材质通常需要这几张贴图albedo、normal、metallic-roughness合并在一张图的R和G通道、AO可以放在B通道。我习惯把metallic、roughness、AO打包到一张贴图里这样能省显存带宽也能减少纹理采样次数。在DX12里创建这些纹理资源的时候有几个细节值得注意sRGB问题albedo贴图是sRGB空间normal和metallic-roughness是线性空间。如果你在创建SRV的时候搞错了格式颜色会明显偏亮或偏暗。我的经验是albedo用DXGI_FORMAT_R8G8B8A8_UNORM_SRGB其他用DXGI_FORMAT_R8G8B8A8_UNORM。mipmap生成DX12不像DX11那样有自动生成mipmap的辅助方法你需要自己写compute shader或者用工具预生成。我一开始偷懒没做mipmap结果远处物体闪烁得非常厉害。描述符堆管理每个材质需要多个SRV如果每个材质单独开一个描述符堆很快就会耗尽。建议用一个大的描述符堆统一管理用偏移量来索引。3.3 根签名的调整根签名是DX12里比较烦人的部分但理解之后其实很直接。加入PBR之后我用的根签名大概长这样// 根参数0每帧常量缓冲 CD3DX12_ROOT_PARAMETER1 param[3]; param[0].InitAsConstantBufferView(0, 0, D3D12_ROOT_DESCRIPTOR_FLAG_NONE, D3D12_SHADER_VISIBILITY_ALL); // 根参数1每材质常量缓冲 param[1].InitAsConstantBufferView(1, 0, D3D12_ROOT_DESCRIPTOR_FLAG_NONE, D3D12_SHADER_VISIBILITY_PIXEL); // 根参数2材质纹理表 CD3DX12_DESCRIPTOR_RANGE1 ranges[1]; ranges[0].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 4, 0, 0); param[2].InitAsDescriptorTable(1, ranges[0], D3D12_SHADER_VISIBILITY_PIXEL);这里我把四个纹理albedo、normal、metallic-roughness、AO放在同一个描述符表里用t0到t3的寄存器。这样切换材质的时候只需要改变描述符表的基地址不需要重新绑定根签名。踩过的坑根签名里的D3D12_SHADER_VISIBILITY如果设错了着色器里会读不到数据但不会有任何报错只是结果全黑。我建议一开始全部设成ALL跑通之后再按需优化。4. 着色器实现Cook-Torrance BRDF的HLSL落地4.1 法线分布函数的选择与实现法线分布函数NDF描述的是微表面法线朝向某个方向的概率密度。常用的有GGX也叫Trowbridge-Reitz和Beckmann。我选GGX因为它的高光尾部更长看起来更接近真实材质。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 nom a2; float denom (NdotH2 * (a2 - 1.0) 1.0); denom PI * denom * denom; return nom / max(denom, 0.0001); }这里有个细节roughness在传入之前通常要做一次平方因为感知上的粗糙度和物理上的粗糙度不是线性关系。很多教程直接拿roughness用结果就是粗糙度调到0.5的时候看起来还是很光滑。我一般会在材质参数里直接存roughness的平方值或者在使用前做一次roughness * roughness。4.2 几何遮蔽函数的坑几何遮蔽函数G描述的是微表面之间互相遮挡导致的光能损失。Smith-GGX是最常用的组合。这里有个容易出错的地方直接光和IBL用的G函数不一样。直接光用Smith-GGXIBL用Smith-Schlick或者Karis的近似。float GeometrySchlickGGX(float NdotV, float roughness) { float r (roughness 1.0); float k (r * r) / 8.0; float nom NdotV; float denom NdotV * (1.0 - k) k; return nom / 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; }我一开始把直接光和IBL的k值搞混了直接光应该用(roughness1)^2/8IBL用roughness^2/2。搞混的后果是环境反射的高光边缘特别硬看起来很不自然。4.3 菲涅尔项与F0菲涅尔项描述的是不同视角下反射率的变化。Schlick近似是最常用的float3 FresnelSchlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); }F0就是垂直入射时的反射率。对于非金属F0约等于0.04对于金属F0就是albedo本身。这就是为什么金属的高光带颜色而非金属的高光通常是白色的。4.4 把BRDF组装起来最终的直接光计算大概是这样float3 N normalize(input.normal); float3 V normalize(cameraPos - input.worldPos); float3 L normalize(-lightDir); float3 H normalize(V L); float NdotL max(dot(N, L), 0.0); float NdotV max(dot(N, V), 0.0); float3 F0 lerp(0.04, albedo, metallic); float3 F FresnelSchlick(max(dot(H, V), 0.0), F0); float D DistributionGGX(N, H, roughness); float G GeometrySmith(N, V, L, roughness); float3 numerator D * G * F; float denominator 4.0 * NdotV * NdotL 0.0001; float3 specular numerator / denominator; float3 kS F; float3 kD (1.0 - kS) * (1.0 - metallic); float3 diffuse kD * albedo / PI; float3 Lo (diffuse specular) * lightColor * NdotL;这段代码我建议你逐行理解不要直接抄。特别是kD和kS的关系以及为什么漫反射要除以PI因为Lambert BRDF的定义就是albedo/PI。5. 环境光照没有IBL的PBR是不完整的5.1 为什么直接光不够只做直接光的PBR物体背光面会完全死黑看起来像在太空里。真实场景里物体还会接收到来自周围环境的反射光。这就是IBLImage Based Lighting要解决的问题。IBL的核心思路是把环境贴图当成一个巨大的光源对每个着色点计算来自半球所有方向的入射光。实时渲染里没法真的积分所以我们预计算两张贴图一张是辐照度图irradiance map用于漫反射一张是预过滤环境图prefiltered environment map用于镜面反射。5.2 辐照度图的生成辐照度图可以用球谐函数SH来近似也可以用卷积计算。我选的是卷积方式因为实现起来更直观。在DX12里这通常是一个compute shader的工作把环境贴图作为输入对每个输出像素在半球上采样若干方向累加颜色。// 简化的辐照度卷积 float3 irradiance float3(0, 0, 0); float3 up float3(0, 1, 0); float3 right normalize(cross(up, N)); up normalize(cross(N, right)); float sampleDelta 0.025; float nrSamples 0.0; for (float phi 0.0; phi 2.0 * PI; phi sampleDelta) { for (float theta 0.0; theta 0.5 * PI; theta sampleDelta) { float3 tangentSample float3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); float3 sampleVec tangentSample.x * right tangentSample.y * up tangentSample.z * N; irradiance envMap.SampleLevel(linearSampler, sampleVec, 0).rgb * cos(theta) * sin(theta); nrSamples; } } irradiance PI * irradiance / nrSamples;这段代码的采样密度是0.025输出贴图我一般用32x32就够了因为辐照度图本身是低频信息。5.3 预过滤环境图的mipmap策略预过滤环境图需要为每个粗糙度级别生成一张mipmap。roughness0对应最清晰的反射roughness1对应最模糊的反射。我一般生成5到6级mipmap每级对应不同的roughness。这里有个关键点采样时的mipmap级别要根据roughness来选。如果直接用SampleLevel指定mip级别过渡会很不平滑。更好的做法是用Sample让硬件自动做三线性插值但需要把roughness映射到mip级别。float mipLevel roughness * maxMipLevel; float3 prefilteredColor prefilteredMap.SampleLevel(linearSampler, R, mipLevel).rgb;5.4 BRDF LUT那个容易被忽略的小贴图IBL的镜面反射部分还需要一张BRDF积分贴图LUT它把NdotV和roughness映射到F0的缩放和偏移。这张图只需要生成一次可以离线做好存成文件也可以在初始化时用compute shader生成。float2 IntegrateBRDF(float NdotV, float roughness) { float3 V float3(sqrt(1.0 - NdotV * NdotV), 0.0, NdotV); float A 0.0; float B 0.0; float3 N float3(0.0, 0.0, 1.0); for (uint i 0; i SAMPLE_COUNT; i) { float2 Xi Hammersley(i, SAMPLE_COUNT); float3 H ImportanceSampleGGX(Xi, N, roughness); float3 L normalize(2.0 * dot(V, H) * H - V); float NdotL max(L.z, 0.0); float NdotH max(H.z, 0.0); float VdotH max(dot(V, H), 0.0); if (NdotL 0.0) { float G GeometrySmith(N, V, L, roughness); float G_Vis (G * VdotH) / (NdotH * NdotV); float Fc pow(1.0 - VdotH, 5.0); A (1.0 - Fc) * G_Vis; B Fc * G_Vis; } } return float2(A, B) / SAMPLE_COUNT; }这张LUT我建议用128x128的分辨率再大也看不出区别。6. 调试与验证怎么判断你的PBR是对的6.1 用标准材质球做基准测试PBR实现完之后最有效的验证方式是渲染一组标准材质球金属度从0到1粗糙度从0到1排成一个网格。如果实现正确你应该看到左下角metallic0, roughness0应该是非常锐利的白色高光像镜子一样的塑料。右下角metallic0, roughness1应该是完全漫反射的哑光表面。左上角metallic1, roughness0应该是带颜色的镜面反射颜色就是albedo。右上角metallic1, roughness1应该是模糊的金属反射颜色偏暗。如果金属球看起来像塑料检查漫反射有没有按metallic正确消除。如果粗糙度变化不明显检查roughness有没有做平方。6.2 常见问题排查表现象可能原因排查方法金属看起来像塑料漫反射没按metallic消除检查kD计算高光过亮/过暗F0值不对非金属应为0.04粗糙度变化不明显roughness没做平方检查D函数输入背光面死黑没有IBL加环境光或IBL法线贴图无效切线空间不对检查TBN矩阵颜色偏亮/偏暗sRGB格式错误检查SRV格式6.3 性能优化的几个方向PBR的计算量比Phong大不少在移动端或者低端GPU上需要优化。我常用的手段有预计算BRDF LUT不要每帧算离线做好。降低IBL采样数辐照度图用32x32就够了预过滤图用128x128。合并纹理采样metallic、roughness、AO打包到一张图。使用half精度在支持的平台上颜色计算可以用half。避免分支着色器里的if尽量用lerp代替。7. 我踩过的那些坑和最后的建议第一个大坑是常量缓冲区的对齐问题。我一开始用float3定义光照方向结果在HLSL里读出来全是乱的。后来全部改成float4问题消失。DX12对内存布局的要求比DX11严格得多任何不对齐都会导致静默错误。第二个坑是描述符堆的释放时机。PBR需要更多的SRV如果每帧都创建新的描述符堆很快就会耗尽。我的做法是预分配一个足够大的堆用freelist管理空闲槽位。第三个坑是法线贴图的绿色通道方向。不同工具生成的法线贴图绿色通道可能是反的OpenGL和DirectX的约定不同。如果法线贴图看起来凹凸相反把绿色通道取反试试。最后一个建议不要一开始就追求完美。我见过很多人卡在IBL的预计算上结果连直接光的PBR都没跑通。正确的顺序是先做直接光PBR验证材质球正确再加简单的环境光最后才上完整的IBL。每一步都确保能看到正确的结果再往下走。PBR这个东西看公式觉得复杂但真正写出来之后你会发现核心代码也就一百多行。难的是理解每个参数背后的物理意义以及在不同场景下怎么调。我到现在也不敢说完全吃透了但至少现在做出来的金属像金属、塑料像塑料这比什么都重要。
网站建设高端定制企业官网