DX12实现PBR渲染:从Blinn-Phong到物理光照的完整实践
1. 为什么第二篇就急着上PBR——旧光照模型的痛点在哪里第一篇把DX12的命令队列、同步、SwapChain理顺之后渲染出来的小球其实很惨。Blinn-Phong高光像糊上去的塑料灯光一强就整片过曝成白斑灯光一暗球面直接像死掉一样。半夜调了几个小时最后意识到问题不在参数而在光照模型本身就不守恒高光强度完全是拍脑袋给的漫反射和高光之间没有约束金属和塑料在算法里没有本质区别。那一刻就决定第二篇不补别的先把PBR接进来。很多学渲染的人会把“加PBR”理解成“换一个更复杂的shader”实际上它是整个渲染流程的范式转换。DX12的难度在于资源管理和绑定方式PBR的难度在于光照模型和材质参数的物理含义两者同时展开确实让人头大但这一步躲不开。只要后面要做IBL、做天气变化的场景、做不同粗糙度表面互相反射旧模型的天花板肉眼可见。这一篇适合两类人一类是把DX12的DrawCall跑通了、但光照还停在Blinn-Phong系列的读者另一类是已经在用其他图形API做PBR、想迁移到DX12并理解描述符堆与纹理资源之间关系的读者。我会给出一条可复现的路线并尽量说清楚每一步背后的理由而不是丢一个shader让你抄完就完事。1.1 从Blinn-Phong迁到PBR换掉的不只是公式Blinn-Phong的问题是它建立在“看起来顺手”的假设上。specular乘一个指数diffuse乘一个系数整个模型没有能量守恒概念一个很粗糙的表面理论上反射的光很少但Blinn-Phong可以同时让高光很亮、漫反射也很亮总出射能量超过入射能量于是画面就会产生那种“加个灯就亮瞎”的失真。PBR的核心是两个方向上的努力。宏观层面它遵守能量守恒表面反射出去的辐射度不能超过入射的辐射度能量要么被漫反射消耗要么被镜面反射走了要么被吸收。微观层面它用微表面模型代替经验高光每个像素点被想象成无数微观小镜面的集合粗糙度决定了这些微表面法线方向的统计分布金属度决定了反射率基准。实际最直观的变化体现在参数语义上。Blinn-Phong里的“shininess”指数拉到128、256你只能凭感觉知道“嗯亮一点”PBR里的roughness是0到1的物理量0.03基本代表光滑镜面0.8代表水泥墙面。每个参数都能在现实世界找到对应物这才是材质的可复用性。我最初写shader时还是忍不住想加一个“强度系数”去微调高光亮度后来发现多余PBR的高光亮度由Fresnel项自动算出来加系数只是掩盖错误资产。1.2 金属工作流与能量守恒先立规矩再写代码PBR有金属工作流和Specular工作流两种组织方式。现在引擎里更通用的是金属工作流用Albedo贴图描述非金属的漫反射颜色、用Metallic贴图描述金属占比、用Roughness贴图描述粗糙度。这套约定的好处是资产制作统一美术不需要理解底层公式。金属与非金属的分界要严格记住非金属的F0大约0.04对应我们熟知的4%反射率金属没有漫反射F0直接取Albedo颜色。代码里这两者是靠metallic来lerp的这个细节后面我会贴完整的着色器代码。金属工作流还要求漫反射项乘以(1 - metallic)否则金属材质会添上一层不该有的漫反射能量。能量守恒落实到Cook-Torrance BRDF上对应公式是 f kd * diffuse ks * specular其中漫反射项是albedo / PI镜面项是D * G * F / (4 * NoV * NoL)。D是法线分布G是几何遮蔽F是Fresnel。kd与ks之间不能随便各自定义一个亮度的缩放它们的总和在各个方向上都要有物理约束稍后我会在实现部分对照代码讲。2. 资产先行把贴图和资源状态理清楚DX12绑定才不糊涂写PBR着色器本身不是最难的真正卡住我的是资产和DX12资源绑定的衔接。我自己踩过一轮后发现PBR对贴图通道、颜色空间、资源状态的要求比Blinn-Phong严格得多这一步不先理清楚后面全是无头苍蝇式的试错。2.1 通道组织Albedo、法线、Metallic/Roughness/AO 的分工先约定一套资产通道。我采用的方案是Albedo一张RGB贴图金属工作流下它既是非金属的漫反射颜色也是金属的反射率颜色。法线一张切线空间法线贴图RG通道存储XYB通道由重建得到。Metallic与Roughness两张单通道贴图经常合并进同一张纹理的R通道和G通道B通道留给AO。AO环境光遮蔽单通道只在环境项里使用不应在直接光照里重复乘除。这套通道组合在实现时对应一组SRV绑定。真正容易出问题的不是“有几张贴图”而是每张贴图该用什么格式、什么颜色空间。Albedo一定是sRGB法线必须严格在线性空间被采样否则切线空间的法线方向会被扭曲成奇怪的角度偏差渲染出来的高光会整体偏掉一个方向。2.2 线性空间与sRGBDX12没有帮你做的转换第一次在DX12工程里贴Albedo时我以为像OpenGL那样让采样器把纹理当作sRGB自动解码就行但DX12的SRV创建是显式指定格式的这里反而给了你更强的控制力。具体来说Albedo纹理在上传并创建SRV时格式可以用DXGI_FORMAT_R8G8B8A8_UNORM_SRGB这会让硬件在采样时自动把sRGB编码的存储值解码为线性空间shader里拿到的就是线性颜色不用手动做pow。法线贴图则要使用DXGI_FORMAT_R8G8B8A8_UNORM不声明sRGB因为法线向量不是颜色不需要伽马解码。这一点如果搞反效果不是“颜色变淡一点”而是整个BRDF的入射角计算全乱球面会呈现诡异的双色高光。选格式之前还有一个大前提渲染目标必须在线性空间。也就是说中间的GBuffer、光照结果都不要做任何伽马校正只保留到最终输出到SwapChain之前才做一次色彩空间转换。如果中途某些pass启动了sRGB写入那段光照就是非线性空间里算的结果只有“凑出来的正确”没法复用卡。2.3 描述符堆、上传堆和资源Barrier把贴图交到着色器手上DX12里一个老的坑是CPU创建纹理和GPU使用纹理之间隔着一大堆状态管理PBR一下子要五张贴图状态转换量与对象数量一起增长。做法上我先把所有材质纹理从一个STB加载器读进来创建默认堆资源再做一个上传堆用于暂存像素数据随后用CopyTextureRegion把像素数据拷贝到默认堆再把资源状态从D3D12_RESOURCE_STATE_COPY_DEST转换成D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE。这个过程每张纹理都要做一次但可以在加载阶段统一循环处理。描述符堆则集中管理而不是每个纹理对象各自带描述符。我的实现里只维护一个CBV_SRV_UAV堆每个材质纹理连续占据5个SRV描述符分别是albedo、normal、metallicRoughness、ao、一个预留深度/自定义数据槽。这样纹理表在GPU侧是紧凑的一段内存后续要做材质排序时也会方便。根签名上我用了两个描述符范围范围0绑定每帧的常量缓冲区范围1绑定材质纹理表的起点。如果一口气把5个SRV和CBV全塞在同一个范围里代码虽然简单但场景里不同材质纹理数量多了以后每次切换材质都要重新绑定整个表时间开销会变大。分开设计后B0是每帧常量t0到t4是当前物体的材质SRV改材质只要重新设置一个描述符表起点。3. 着色器落地把Cook-Torrance拆成三个可以独立调试的部件写shader之前先把Cook-Torrance模型拆成三个独立函数每个函数都能单独输出成可视化结果这样做的好处后面排错时会体现。整段代码用HLSL写在像素着色器里直接光源循环在shader里展开。3.1 NDF、几何遮蔽、菲涅尔的HLSL实现三个核心函数的实现如下// D项GGX/Trowbridge-Reitz法线分布函数 float D_GGX(float NoH, float roughness) { float a roughness * roughness; float a2 a * a; float NoH2 NoH * NoH; float d NoH2 * (a2 - 1.0) 1.0; return a2 * rcp(PI * d * d); } // 几何遮蔽项Smith height-correlated配合GGX float V_SmithGGXCorrelated(float NoV, float NoL, float roughness) { float a2 roughness * roughness; a2 a2 * a2; float gV NoL * sqrt(NoV * NoV * (1.0 - a2) a2); float gL NoV * sqrt(NoL * NoL * (1.0 - a2) a2); return 0.5 * rcp(gV gL); } // F项Schlick近似Fresnel float3 F_Schlick(float3 F0, float VoH) { float f pow(1.0 - VoH, 5.0); return F0 (1.0 - F0) * f; }D_GGX里的roughness平方再平方是为了配合迪士尼提出的粗糙度重映射因为美术给的roughness线性变化时感知上的粗糙度并不是线性的。这个细节教科书不一定会拿出来讲但直接影响手感直接拿roughness去算a2粗糙度0.9和0.8的差别会很小画面看起来要么整片模糊死黑要么高光碎成噪点。V项用Smith height-correlated这个版本可以处理View和Light互相遮挡相关的部分比把G_V和G_L独立相乘的版本更准确。在一些引擎里它被写作Vis因为实现里把分母4NoVNoL已经并进去了我这个版本返回的就是已经合并了分母的可见性项调用时直接乘D和F即可。F项用Schlick近似F0是入射光角度为0时的反射率。代码里F0的计算这样写float3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float3 kd (1.0 - metallic) * albedo;这是整个金属工作流最核心的一行非金属永远从0.04起步金属用albedo本身中间值用metallic做过渡。kd乘以(1 - metallic)保证金属没有漫反射项。用lerp而不是直接乘albedo这样的小细节可以让半金属材质过渡时反射和漫反射的能量分担尽量自然不至于出现“金属但颜色很暗还自带漫反射”的错误结果。3.2 直接光照的循环主体与IBL的边界像素着色器主体遍历场景里的点光源每个光源都算一遍完整的BRDFfloat3 directLighting 0; for (uint i 0; i pointLightCount; i) { float3 L normalize(pointLightPos[i].xyz - worldPos); float3 V normalize(cameraPos - worldPos); float3 H normalize(L V); float NoL saturate(dot(N, L)); float NoV max(dot(N, V), 1e-4); float NoH saturate(dot(N, H)); float VoH saturate(dot(V, H)); float D D_GGX(NoH, roughness); float Vis V_SmithGGXCorrelated(NoV, NoL, roughness); float3 F F_Schlick(F0, VoH); float3 specular D * Vis * F; float3 diffuse kd * rcp(PI); float distance length(pointLightPos[i].xyz - worldPos); float attenuation 1.0 / (1.0 distAttenuation * distance * distance); directLighting (diffuse specular) * pointLightColor[i].rgb * NoL * attenuation; }这一版里我没有写IBL。不是偷懒而是IBL需要预滤波环境贴图、法线分布采样和BRDF LUT工程量直接翻倍并且在DX12里还要额外处理CubeMap的SRV和采样器。很多初学者一步冲进PBRIBL结果环境光和高光叠加后惨不忍睹很难定位到底是哪一项没做对。我建议先只做直接光让金属、粗糙度、光源方向这三个参数全部正确了再加IBL才有意义。如果后面要扩展IBL注意预滤波环境贴图要和D_GGX使用相同的NDFBRDF LUT也可以直接用当前这一套F和Vis函数离线生成。至少过渡起来是平滑的不必推翻重写。4. 接入DX12管线后踩到的三个坑从黑屏到噪点的排错链代码写好后真正的考验在接入DX12渲染器时。这一章我把自己实际排查过的三个问题完整记录下来。这三个坑分别发生在根签名、贴图格式、描述符堆三个层面基本覆盖了新手最容易摔的位置。4.1 坑一根签名与静态采样器之间的不一致我最初的方案是纹理用描述符表采样器也用描述符表也就是在RootSignature里同时声明一个SRV范围和一个Sampler范围。后来为了让纹理过滤统一改成在根签名里声明静态采样器CD3DX12_STATIC_SAMPLER_DESC samplerDesc( 0, D3D12_FILTER_ANISOTROPIC, D3D12_TEXTURE_ADDRESS_MODE_WRAP, D3D12_TEXTURE_ADDRESS_MODE_WRAP, D3D12_TEXTURE_ADDRESS_MODE_WRAP);结果渲染全黑而且DX12调试层只提示一句描述符类型不匹配。排查半天才发现shader里的register(s0)仍然要求一个描述符表中的采样器而根签名提供的是静态采样器。DX12与OpenGL的一大不同是绑定状态必须和根签名严格一致哪怕多一个静态采样器声明、少一个桌面描述符都可能静默失败。排错链是这样的先确认根签名与着色器反射里的register布局是否一致再打开调试层看是否存在“Descriptor range type mismatch”之类的提示最后在RenderDoc里逐帧对比PIX的Pipeline State和Root Signature绑定。最终解法是彻底移除描述符表里的Sampler范围让所有采样都走静态采样器。对于PBR这种纹理数量多的场景静态采样器还可以减少每帧描述符堆内存占用。4.2 坑二法线贴图被sRGB解读之后的“青绿色高光”第一次把法线贴图接入后球面高光整体泛着一层不正常的青绿色。这不是着色器数学的问题而是法线贴图格式创建错误加载器默认把RGBA8的存储值当作sRGB来创建SRV硬件自动做了伽马解码使得法线向量的分量不再是(0,1,0)附近的中性值而被抬到0.5左右的偏移量。排查时我把法线贴图的输出可视化直接输出RGB颜色到渲染目标看到整张贴图确实呈现偏亮和绿色的色偏说明问题出在采样层的格式解释。修复方法很简单法线贴图的SRV创建格式用DXGI_FORMAT_R8G8B8A8_UNORM不要带_SRGB后缀。同理Metallic/Roughness/AO贴图也都要在线性空间读取它们的单通道值代表物理参数不是颜色。这个坑给出一个很重要的思路PBR资产里每一张贴图是什么语义就要用与之匹配的格式语义去创建SRV。Albedo是sRGB法线和材质参数是线性数据混在一起就是色偏和光斑。4.3 坑三描述符堆“刚好不够”的静态故障PBR需要的SRV数量增加之后我遭遇过一次很隐蔽的黑屏场景里前几个物体正常多加载一个角色之后整帧开始闪烁再往后直接设备移除。排查到的原因很简单也很蠢——描述符堆的容量是初始化时写死的我只预留了少量材质槽位场景里物体数量超过槽位后创建SRV时CPU句柄一直在往堆外写把相邻内存踩坏。DX12不会像D3D11那样自动扩容描述符堆必须在创建时分配足够空间。我的修正方案是创建描述符堆时把容量直接按“场景最大材质数*每个材质纹理数预留动态物体槽位”来算而不是按当前数量。对于还在学习阶段的渲染器我建议干脆统一分配一个大堆每帧不做CPU和GPU描述符的交替扩容宁可浪费一点显存也要保证稳定性。等将来要做大量动态材质时再考虑GPU描述符堆的搬运和分帧回收。5. 调参、验证和性能上的一点感想PBR接完不等于画面好看。接通之后真正花时间的是调参和验证这里分享几个我实测出来的方法。5.1 从Roughness常量和白炉测试开始调先用常量roughness替掉贴图把值分别设成0.03、0.5、0.9渲染一排小球观察高光扩散和能量变化。这一步能快速验证BRDF实现是否正确。如果粗糙度0.03的小球高光仍然模糊问题多半出在D_GGX的a2重映射或NoH方向向量上如果0.9和0.03高光亮度几乎一样说明能量守恒项没接对。白炉测试是另一个有效的验证方法场景里放一个只有直接光照且各方向入射强度完全一致的“白炉”把物体放进去看它是否保持应有的能量分布。白色非金属球应该在各个方向都呈现均匀的灰色因为不会自发光金属球的高光则应该锐利且总量不超过入射能量。如果球面亮度明显高于环境基准说明diffuse和specular之间的能量分配还不对。5.2 性能开销与调试工具PBR的直接光照在Shader级别并不算昂贵真正的开销在多光源循环和内存带宽。DX12里尤其明显每个光源在循环里会读取光照常量、计算视线向量导致寄存器压力和指令数双双上升。如果你在场景里放了几十个点光源而且没有做任何光源剔除帧时间会直接暴露这个瓶颈。调试工具我强烈推荐RenderDoc它能在单帧里把SRV绑定、描述符堆布局、像素着色器中间值全部掰开看。而PIX虽然能看GPU计时但DX12下的配置成本更高。先用RenderDoc确认SRV绑定和每个像素的中间量确认着色器数学正确后再上PIX做性能剖析。最后一个个人体会不要把PBR和DX12当成两个独立关卡去打。DX12的绑定模式和资源状态设计恰好逼着你把PBR的资产组织做得更规范而PBR对纹理语义的严格要求也会让你更清楚地理解DX12里SRV格式和描述符堆的意义。两件事结合起来学踩坑次数虽然会集中爆发一阵但理顺之后反而比分开学要牢靠得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →