
1. 从零搭建DX12渲染框架后为什么下一步必须上PBR很多人在学完DX12的第一部分之后手里已经能跑出一个三角形或者一个带贴图的立方体了。那种感觉确实不错——设备初始化、命令队列、交换链、根签名、PSO、围栏同步这一整套流程跑通之后你会觉得“我已经入门DX12了”。但接下来如果继续用Lambert或者Phong那套光照模型往上堆很快就会发现一个问题不管怎么调参数金属看起来像塑料塑料看起来像纸片粗糙表面的高光永远是一坨死白。这不是你代码写得不好而是光照模型本身的天花板到了。PBRPhysically Based Rendering基于物理的渲染就是用来捅破这层天花板的。它不是什么新鲜概念离线渲染领域早就用烂了但实时渲染里真正大规模普及也就是最近这些年的事。DX12作为底层图形API天然适合承载PBR这套需要大量常量缓冲区、纹理资源和精确数学计算的渲染管线。这篇文章要聊的就是怎么在已有的DX12框架上把PBR加进去。我会从设计思路开始拆然后一步步讲清楚每个环节在干什么、为什么这么干、参数怎么算、坑在哪里。适合已经跑通DX12基础框架、想往真实感渲染方向走的人。如果你还在跟命令列表和描述符堆搏斗建议先把第一部分吃透再来看这篇。2. PBR核心思路与DX12框架的适配设计2.1 为什么选金属度-粗糙度工作流而不是镜面度-光泽度PBR有两套主流工作流金属度-粗糙度Metallic-Roughness和镜面度-光泽度Specular-Glossiness。前者用一张Metallic贴图和一张Roughness贴图控制材质后者用Specular和Glossiness。我选前者原因有三个。第一金属度工作流的参数更少美术容易理解。Metallic就两个值——0是绝缘体1是金属中间值只在过渡区域用。Roughness从0到1线性控制微观表面粗糙程度。相比之下Specular工作流需要美术自己调F0颜色很容易调出物理上不可能存在的材质。第二金属度工作流的纹理内存占用更低。Metallic和Roughness可以打包到同一张纹理的B通道和G通道加上AO塞进R通道一张RGB纹理就搞定三个参数。镜面度工作流至少需要两张独立纹理。第三也是最重要的一点金属度工作流和IBL基于图像的光照配合得更好。金属的反射颜色直接来自环境贴图不需要额外的镜面颜色输入整个计算链路更干净。在DX12里这意味着我的根签名需要多一个SRV描述符表来放这三张纹理常量缓冲区里要多几个float来传Metallic和Roughness的缩放系数。这些改动都不大但设计的时候要提前想好。2.2 DX12资源绑定模型对PBR管线的影响DX12和DX11在资源绑定上的最大区别就是描述符堆Descriptor Heap和根签名Root Signature的显式管理。DX11你创建一个Shader Resource View往Context上一绑就完事了。DX12你得自己管理描述符堆的分配、根签名的布局、以及命令列表里SetGraphicsRootDescriptorTable的调用时机。对于PBR来说这意味着几件事。首先材质参数不能像DX11那样随手改你得把每个材质的常量缓冲区安排好要么用动态常量缓冲区每帧更新要么用描述符数组一次性绑定多个材质。我选的是后者——把所有材质的常量数据打包到一个大的结构化缓冲区里用StructuredBuffer索引访问。这样根签名里只需要一个SRV指向这个缓冲区切换材质的时候只需要改一个索引值不用重新绑定描述符表。其次纹理数组的管理要提前规划。PBR场景里通常有BaseColor、Normal、Metallic-Roughness、AO、Emissive这几类纹理。如果每个材质单独绑定描述符堆很快就爆了。我的做法是创建一个大的纹理数组所有材质的纹理按顺序排进去然后用纹理索引在Shader里采样。这样描述符堆里只需要一个SRV根签名也简洁。注意纹理数组的Slice数量有上限D3D12的Tier 3硬件保证至少2048个Slice一般场景够用了。但如果你的场景材质特别多还是要做分页加载。2.3 常量缓冲区的数据结构设计PBR的常量缓冲区设计直接影响到Shader的读取效率和CPU端的更新开销。我踩过的坑是一开始把每个材质的参数单独放一个Constant Buffer结果每帧要Map/Unmap几十次CPU开销直接爆炸。后来改成两级结构一个全局常量缓冲区放相机矩阵、光照方向、曝光参数这些每帧只更新一次的数据另一个结构化缓冲区放每个材质的参数用StructuredBuffer 声明。MaterialData的结构体对齐到256字节这是DX12常量缓冲区的最小对齐要求。struct MaterialData { float4 baseColorFactor; float metallicFactor; float roughnessFactor; float normalScale; float occlusionStrength; float3 emissiveFactor; uint baseColorIndex; uint normalIndex; uint metallicRoughnessIndex; uint occlusionIndex; uint emissiveIndex; float2 padding; };这个结构体在CPU端和GPU端必须完全一致否则会出现参数错位。我建议用static_assert在编译期检查大小省得运行时出鬼。3. PBR核心数学原理与Shader实现细节3.1 渲染方程在实时渲染里的简化路径PBR的理论基础是渲染方程但完整的渲染方程在实时渲染里没法直接解。我们做的所有简化本质上都是在“物理正确”和“跑得动”之间找平衡。渲染方程长这样出射辐射度等于入射辐射度乘以BRDF再对半球积分。实时渲染里我们把它拆成两部分直接光照和间接光照。直接光照就是解析光源平行光、点光、聚光的贡献间接光照用IBL近似。直接光照部分每个光源的贡献是光源辐射度乘以BRDF乘以入射角余弦。BRDF用Cook-Torrance模型包含法线分布函数D、几何遮蔽函数G、菲涅尔项F三个部分。这三个函数各有多种实现我选的是业界最常用的组合D用GGX/Trowbridge-ReitzG用Smith-SchlickF用Schlick近似。为什么选这套组合因为GGX在粗糙表面的高光拖尾更自然Smith遮蔽和GGX是配套的Schlick菲涅尔计算量小且精度够用。这套组合在Unreal Engine 4和很多商业引擎里都验证过了属于“抄作业不会错”的选择。3.2 法线分布函数D的选型与参数计算法线分布函数描述的是微观表面法线的统计分布。GGX的公式是D α² / (π * ((N·H)² * (α² - 1) 1)²)其中α是粗糙度的平方N是宏观法线H是半角向量。这里有个细节粗糙度不能直接用Roughness贴图的值要先平方再参与计算。原因是感知粗糙度和实际微观粗糙度之间不是线性关系平方之后高光的变化更符合视觉直觉。我在Shader里是这么写的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); }注意分母里的π不能漏漏了之后整体亮度会偏。还有a2的计算有些教程写成aa有些写成roughnessroughness其实是一样的但为了可读性我建议分开写。3.3 几何遮蔽函数G与菲涅尔项F的配合几何遮蔽函数描述的是微观表面互相遮挡导致的光能损失。Smith-GGX的公式是G G1(V) * G1(L)其中G1是单向遮蔽函数。Schlick-GGX的近似是G1 (N·V) / ((N·V) * (1 - k) k)k对于直接光照是α/2对于IBL是α²/2。这个区别很重要用错了会导致IBL和直接光照的亮度不匹配。菲涅尔项用Schlick近似F F0 (1 - F0) * (1 - V·H)^5F0对于绝缘体是0.04对于金属是BaseColor。这里有个常见的错误金属的F0直接用BaseColor的RGB值但绝缘体的F0是固定的0.04。我在Shader里用Metallic插值float3 F0 lerp(float3(0.04, 0.04, 0.04), baseColor, metallic);这样金属和绝缘体用同一套代码路径只是F0不同。3.4 直接光照的完整计算流程把D、G、F组合起来Cook-Torrance的镜面BRDF是Specular D * G * F / (4 * N·V * N·L)分母的4是几何衰减因子不能漏。然后加上漫反射项Diffuse (1 - F) * baseColor / π注意漫反射项里有个(1-F)这是能量守恒的要求——被镜面反射掉的能量不能再参与漫反射。最后乘以光源辐射度和N·L累加所有光源。我在Shader里把直接光照封装成一个函数输入是N、V、L、光源颜色、材质参数输出是辐射度。这样加新光源类型的时候只需要改调用部分核心BRDF不用动。4. DX12下的PBR实操流程与关键环节4.1 根签名与PSO的重新设计加入PBR之后根签名需要增加几个绑定。我的根签名布局是这样的槽位类型内容更新频率0CBV全局常量相机、光照每帧1SRV材质结构化缓冲区每帧2SRV纹理数组加载时3Sampler各向异性采样器加载时根签名用D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT因为PBR的顶点着色器还是需要输入布局的。PSO的创建和之前差不多但像素着色器换成了PBR版本渲染目标格式要注意用DXGI_FORMAT_R16G16B16A16_FLOAT做HDR渲染最后再Tonemap到LDR。提示如果你的交换链是DXGI_FORMAT_R8G8B8A8_UNORM直接输出PBR结果会过曝。要么用HDR中间目标要么在Shader里手动Tonemap。4.2 纹理数组的加载与描述符管理纹理数组的加载我用的是DirectXTex库。每个材质的BaseColor、Normal、MetallicRoughness、AO、Emissive纹理按顺序加载到CPU内存然后创建一个大的纹理数组资源用UpdateSubresources一次性上传。描述符堆我创建了一个Shader Visible的CBV_SRV_UAV堆大小是纹理数量加材质缓冲区加全局常量。每个纹理创建一个SRV指向纹理数组的对应Slice。材质缓冲区创建一个SRV格式是DXGI_FORMAT_UNKNOWN因为StructuredBuffer不需要指定格式。这里有个坑纹理数组的所有纹理必须尺寸和格式一致。如果美术给的贴图尺寸不统一要么在加载时统一缩放到2的幂次要么用纹理图集。我选的是前者加载时用Resize函数统一到1024x1024虽然损失了一点精度但省去了运行时分支。4.3 材质数据的组织与上传材质数据我存在一个std::vectorMaterialData里每帧或者材质变化时上传到GPU。上传用的是上传堆Upload Heap因为材质数据每帧都可能变。上传堆的大小是材质数量乘以256字节Map之后memcpy然后Unmap。命令列表里在设置PSO之后、Draw之前调用SetGraphicsRootConstantBufferView绑定全局常量SetGraphicsRootDescriptorTable绑定材质缓冲区和纹理数组。注意描述符表的绑定顺序必须和根签名里定义的顺序一致否则会绑错。4.4 IBL环境贴图的预计算直接光照搞定之后间接光照用IBL。IBL需要三张预计算贴图Irradiance Map漫反射环境、Prefiltered Environment Map镜面反射环境、BRDF LUTBRDF积分查找表。Irradiance Map用卷积计算每个像素对半球积分。Prefiltered Environment Map用GGX重要性采样每个粗糙度级别单独卷积。BRDF LUT用解析近似把N·V和粗糙度映射到F0的缩放和偏移。这三张贴图的预计算我在CPU端用计算着色器做因为DX12的计算着色器比图形管线更适合这种并行积分。计算着色器的线程组大小设成16x16每个线程处理一个输出像素。预计算只需要在加载时做一次运行时直接采样。// BRDF LUT的解析近似 float2 IntegrateBRDF(float NdotV, float roughness) { float3 V float3(sqrt(1.0 - NdotV * NdotV), 0.0, NdotV); float A 0.0, 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; }这段代码在计算着色器里跑输出到一张512x512的R16G16_FLOAT纹理。采样数我用的1024再高收益不明显。5. 常见问题排查与性能优化实录5.1 PBR渲染结果偏暗或偏亮的排查思路PBR最容易出的问题就是亮度不对。我遇到过几次排查下来无非几个原因。第一颜色空间搞错了。BaseColor贴图通常是sRGB空间采样之后要转线性。DX12里创建SRV的时候指定DXGI_FORMAT_R8G8B8A8_UNORM_SRGB硬件会自动转换。如果忘了指定颜色会偏亮。反过来如果对已经是线性的贴图指定了sRGB格式颜色会偏暗。第二光源单位不对。PBR的光照计算用的是物理单位平行光的辐射度单位是lux点光的是candela。如果直接拿0-1的值当光源颜色结果会偏暗。我的做法是给光源一个强度系数平行光默认3.0点光默认10.0根据场景调整。第三Tonemap曲线不对。PBR输出的是HDR值直接截断到LDR会过曝。我用的是ACES近似Tonemap能保留高光细节。float3 ACESFilm(float3 x) { float a 2.51f; float b 0.03f; float c 2.43f; float d 0.59f; float e 0.14f; return saturate((x * (a * x b)) / (x * (c * x d) e)); }5.2 金属表面出现噪点或条纹的处理金属表面的噪点通常来自法线贴图的精度问题或者IBL采样不足。法线贴图如果是8位精度在粗糙度低的时候会出现明显的阶梯。解决办法是用16位法线贴图或者在Shader里对法线做滤波。IBL采样不足的话Prefiltered Environment Map的粗糙度级别之间会有跳变。我的做法是在采样的时候对相邻两个粗糙度级别做插值用SampleLevel手动指定Mip Level然后线性混合。注意Prefiltered Environment Map的Mip Level和粗糙度的对应关系不是线性的通常是roughness mip / (mipCount - 1)。如果直接用roughness当Mip Level高粗糙度区域会采样不足。5.3 性能瓶颈定位与优化手段PBR的性能开销主要在像素着色器。我用PIX抓帧之后发现像素着色器的耗时占了总渲染时间的70%以上。优化手段有几个。第一减少光源数量。每个光源都要跑一遍BRDF光源多了直接爆炸。我的做法是限制同时影响一个像素的光源数量用光源剔除把远处的小光源干掉。第二降低IBL采样数。Prefiltered Environment Map的采样数从1024降到256视觉上几乎看不出区别但预计算时间少了四分之三。第三用半精度浮点。常量缓冲区和纹理可以用16位浮点精度够用带宽减半。DX12里创建资源的时候指定DXGI_FORMAT_R16G16B16A16_FLOAT就行。优化手段性能提升视觉损失光源剔除30-50%几乎无IBL采样降到256预计算时间减75%极小半精度浮点带宽减半极小降低阴影分辨率20-30%边缘略糊5.4 描述符堆溢出的预防措施描述符堆溢出是DX12新手最容易踩的坑。PBR场景里纹理多、材质多描述符堆很快就满了。我的经验是提前算好最大描述符数量留20%余量。具体做法是加载场景之前先统计材质数量和纹理数量然后创建描述符堆的时候大小设为(纹理数 材质数 全局常量数) * 1.2。如果还是不够就要考虑用描述符数组或者动态索引了。还有一个坑是描述符堆的释放。DX12的描述符堆不会自动回收必须手动调用Release。我在切换场景的时候忘了释放旧的描述符堆跑了几次之后显存直接爆了。后来加了一个资源管理器统一管理描述符堆的生命周期。6. 从直接光照到IBL的完整链路打通6.1 Irradiance Map的卷积计算与采样Irradiance Map是漫反射环境的预计算。理论上要对每个法线方向做半球积分但实际实现里用球面谐波Spherical Harmonics或者余弦卷积近似。我用的是余弦卷积在计算着色器里对每个输出像素采样环境贴图的多个方向加权平均。采样方向用Hammersley序列生成保证分布均匀。权重是max(N·L, 0)因为漫反射的余弦项。采样数用1024输出纹理尺寸64x64就够了因为漫反射环境是低频信号。运行时采样Irradiance Map的时候直接用法线方向采样不需要考虑粗糙度。因为漫反射没有镜面反射的方向性。6.2 Prefiltered Environment Map的Mip链生成Prefiltered Environment Map是镜面反射环境的预计算。每个Mip Level对应一个粗糙度值Mip 0是粗糙度0完美镜面Mip N是粗糙度1完全漫反射。生成的时候对每个Mip Level用对应的粗糙度做GGX重要性采样。采样数随Mip Level增加而减少因为高粗糙度需要的采样数更少。Mip 0用1024个采样Mip 5用64个采样。这里有个细节重要性采样的PDF要转成Mip Level的权重。因为不同Mip Level的采样方向分布不同直接平均会导致能量不守恒。我的做法是用pdf / sampleCount作为权重然后归一化。6.3 BRDF LUT的生成与使用BRDF LUT是一张2D纹理横轴是N·V纵轴是粗糙度存储的是F0的缩放和偏移。生成的时候用解析积分不需要环境贴图。运行时采样BRDF LUT得到两个值A和B然后镜面IBL的贡献是SpecularIBL (F0 * A B) * prefilteredColor其中prefilteredColor是从Prefiltered Environment Map采样的颜色。这个公式是IBL的标准做法A和B分别对应菲涅尔的缩放和偏移。6.4 间接光照的合成与最终输出把漫反射IBL和镜面IBL加起来再加上直接光照就是最终的PBR输出。DiffuseIBL irradiance * baseColor * (1 - metallic) SpecularIBL (F0 * A B) * prefilteredColor FinalColor DirectLighting DiffuseIBL SpecularIBL注意漫反射IBL要乘以(1 - metallic)因为金属没有漫反射。这个细节很容易漏漏了之后金属会看起来像塑料。最后Tonemap到LDR输出到交换链。Tonemap之前可以做曝光调整我用的是自动曝光根据场景平均亮度调整曝光系数。7. 我个人在实际操作中的几点体会PBR这套东西理论看一遍觉得都懂真上手写代码的时候才发现到处都是坑。我最大的体会是不要试图一次把所有特性都加上。我一开始就想把直接光照、IBL、阴影、后处理全塞进去结果调了一个星期亮度还是不对。后来拆开来先只做直接光照确认BRDF没问题再加IBL再加阴影每一步都验证反而快得多。另一个体会是参考实现很重要。Khronos的glTF Sample Viewer、Unreal的材质编辑器、Filament的源码都是很好的参考。遇到不确定的地方看看别人怎么写的比自己在那边推导快得多。但注意不要照抄要理解为什么这么写。还有一个坑是浮点精度。PBR的中间结果动态范围很大从0.001到10000都有可能。如果用16位浮点高光区域会出现明显的色带。我的做法是中间渲染目标用32位浮点只有最终输出才降到8位。最后分享一个小技巧用参考球验证BRDF。在场景里放一排球粗糙度从0到1金属度从0到1然后跟Khronos的参考图对比。如果对不上说明BRDF实现有问题。这个方法帮我省了很多调试时间。