ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DX12渲染框架进阶:从零实现PBR物理渲染与BRDF

DX12渲染框架进阶:从零实现PBR物理渲染与BRDF 1. 从零搭建DX12渲染框架后为什么下一步必须上PBR很多人在学完DX12的第一部分之后手里已经能跑出一个三角形或者一个带贴图的立方体了。那种感觉确实不错——设备初始化、命令队列、交换链、根签名、PSO这些概念终于从文档里走进了代码屏幕上出现了实实在在的画面。但紧接着就会遇到一个很尴尬的问题画面看起来特别“假”。一个金属球看起来像塑料一个粗糙的石头表面反光却像镜子光照稍微调一下整个场景就崩了。这不是你的DX12写得不对而是你用的光照模型太老了。传统的那套光照方案不管是Lambert还是Blinn-Phong本质上都是经验模型。它们把光的行为简化成几个可以手调的参数漫反射一个系数高光一个系数再加一个高光指数。这套东西在二十年前的游戏里够用因为那时候美术资源本身精度就低玩家也不会盯着一个材质看半天。但现在不一样了PBR材质贴图已经是行业标准美术在Substance Painter里做出来的东西你用一个Phong模型去渲染等于把人家精心调制的金属度、粗糙度全扔了。PBR也就是基于物理的渲染核心思路是把光照计算建立在能量守恒和微表面理论之上。它不再让你去猜“高光应该多亮”而是让你输入两个物理意义明确的参数——金属度Metallic和粗糙度Roughness剩下的交给公式去算。这样带来的直接好处是同一个材质在不同光照环境下表现一致美术调整材质时不需要反复跟程序对参数整个管线的可预测性大大提升。这篇文章要聊的就是怎么在已有的DX12框架里把PBR接进去。我会从整体设计思路讲起然后拆解BRDF的数学原理和代码实现接着走一遍完整的实操流程最后把我在调试过程中踩过的坑和排查经验整理出来。如果你已经能跑通DX12的基础渲染管线但对怎么把PBR落地还有疑惑那这篇内容应该能帮到你。如果你还在用DX11其实思路完全一样只是资源绑定和管线配置的方式不同核心的BRDF部分可以直接搬。2. 整体设计思路与方案选型2.1 为什么选Cook-Torrance BRDF而不是其他方案PBR发展到现在BRDF模型有好几种流派。最主流的是Cook-Torrance模型它把镜面反射项拆成三个部分法线分布函数D、几何遮蔽函数G、菲涅尔项F。这三个函数各自有多个变体比如D可以用GGX、Beckmann、Blinn-PhongG可以用Smith、SchlickF可以用Schlick近似或者更精确的版本。我选的是GGX Smith Schlick这个组合。原因很直接GGX在粗糙度较高时的高光拖尾更自然金属表面的表现明显好于BeckmannSmith几何项和GGX是配套的两者结合能保证能量守恒Schlick菲涅尔近似计算量小精度在实时渲染里完全够用。这套组合基本上是目前实时渲染的默认答案Unreal、Unity、Godot的默认PBR管线都是这个方案。另一个选择是Disney BRDF它更偏向离线渲染参数更多实时跑起来开销偏大。对于DX12入门到中阶的项目来说Cook-Torrance是性价比最高的选择。2.2 直接光照还是IBL先做哪个PBR的光照来源分两块直接光照和基于图像的光照IBL。直接光照就是平行光、点光源、聚光灯这些IBL是用环境贴图来做漫反射和高光的环境反射。我的建议是先把直接光照的PBR做通再上IBL。原因在于直接光照的BRDF计算是PBR的核心你把这部分调通了理解每个参数对最终画面的影响后面接IBL的时候才知道预计算出来的那张贴图到底在干什么。如果一上来就搞IBL很容易变成“抄了一堆代码画面出来了但不知道为什么”。而且直接光照的调试成本低得多。你只需要一个平行光调调方向、颜色、强度就能看到金属度和粗糙度的变化。IBL需要预计算辐照度贴图、预过滤环境贴图、BRDF LUT每一步都有坑不适合和BRDF本身的学习混在一起。2.3 材质参数的存储方案PBR需要每像素的金属度和粗糙度。常见的存储方式有两种一种是单独一张贴图存金属度和粗糙度另一种是打包进其他贴图的通道里。我采用的是金属度存B通道、粗糙度存G通道的方案和glTF的标准一致。具体来说一张贴图的R通道可以放遮蔽AOG放粗糙度B放金属度。这样一张贴图搞定三个参数节省显存带宽。基础颜色单独一张贴图法线单独一张。这里有个细节要注意粗糙度贴图里存的值和实际使用的粗糙度之间通常要做一个映射。很多美术工具导出的粗糙度是线性值但有些引擎会做一个平方或者别的变换来让滑条的手感更好。我这边直接用原始值因为Substance导出的已经是物理正确的了。2.4 常量缓冲区的组织DX12里常量缓冲区需要256字节对齐这个在第一部分应该已经处理过了。PBR新增的参数包括基础颜色因子、金属度因子、粗糙度因子、法线强度。这些可以打包进一个材质常量缓冲区。光照参数方面平行光需要方向、颜色、强度。如果要做多光源还需要光源数组。我建议把材质常量和光照常量分开因为材质是逐Draw调用的光照是逐帧或者逐Pass的分开之后更新频率不同的数据不会互相干扰。3. Cook-Torrance BRDF核心细节与代码实现3.1 法线分布函数DGGX的数学形式和实现法线分布函数描述的是微表面法线朝向某个方向的概率密度。GGX的公式是这样的D(h) α² / (π * ((n·h)² * (α² - 1) 1)²)其中α是粗糙度的平方n是宏观法线h是半程向量。这里有个关键点α等于粗糙度的平方不是粗糙度本身。很多教程直接拿粗糙度当α用结果就是粗糙度在中间值的时候高光形状不对。这个平方操作是为了让粗糙度的感知变化更线性美术在调参的时候手感更好。代码实现上我习惯把α的计算放在函数外面避免每个像素重复算float3 CalculateGGX(float NdotH, float roughness) { float a roughness * roughness; float a2 a * a; float d NdotH * NdotH * (a2 - 1.0f) 1.0f; return a2 / (PI * d * d); }注意这里返回的是float3虽然D本身是标量但后面要和F相乘提前转成向量可以省一次类型转换。另外分母里的π不能漏漏了之后整体亮度会偏你会发现自己怎么调光强都不对。3.2 几何遮蔽函数GSmith-GGX的联合形式几何遮蔽函数描述的是微表面之间互相遮挡导致的光能损失。Smith模型把遮蔽和阴影分开算然后相乘。GGX对应的Smith形式是G1(v) 2(n·v) / ((n·v) sqrt(α² (1-α²)(n·v)²)) G G1(v) * G1(l)这个公式看起来复杂但实现起来就是一个函数调两次。我把它写成float CalculateSmithG1(float NdotX, float roughness) { float a roughness * roughness; float a2 a * a; float denom NdotX sqrt(a2 (1.0f - a2) * NdotX * NdotX); return (2.0f * NdotX) / denom; } float CalculateSmithG(float NdotV, float NdotL, float roughness) { return CalculateSmithG1(NdotV, roughness) * CalculateSmithG1(NdotL, roughness); }这里有个常见的坑NdotX不能为0。如果视线方向和法线垂直分母会变成0结果就是NaN。实际渲染中这种情况很少见但一旦出现就是一个黑点或者白点。稳妥的做法是在外面加一个max(NdotX, 0.001)。3.3 菲涅尔项FSchlick近似与金属度的工作流菲涅尔项描述的是光线在不同角度下的反射率。Schlick近似是最常用的F F0 (1 - F0) * (1 - (v·h))^5其中F0是垂直入射时的反射率。对于非金属F0大约是0.04对于金属F0就是基础颜色本身。这就是金属度工作流的核心金属度作为一个0到1的插值因子在0.04和基础颜色之间插值。float3 CalculateFresnel(float VdotH, float3 F0) { float fresnel pow(1.0f - VdotH, 5.0f); return F0 (1.0f - F0) * fresnel; }F0的计算在像素着色器里做float3 F0 lerp(float3(0.04f, 0.04f, 0.04f), baseColor, metallic);这里有个细节基础颜色在参与漫反射计算之前要乘以(1 - metallic)。因为金属没有漫反射所有入射光都被反射或者吸收了。如果忘了这一步金属表面会看起来像发光的塑料。3.4 漫反射项Lambert与能量守恒PBR里的漫反射通常还是用Lambert但要除以π来做能量归一化kd (1 - F) * (1 - metallic) diffuse kd * baseColor / πkd是漫反射系数它等于(1 - F)乘以(1 - metallic)。这个(1 - F)保证了能量守恒反射出去的能量加上折射进去再散射出来的能量不能超过入射能量。float3 kd (1.0f - F) * (1.0f - metallic); float3 diffuse kd * baseColor / PI;注意这里的F是菲涅尔项的计算结果不是F0。有些实现直接用F0代替F在粗糙度低的时候差别不大但粗糙度高的时候会有明显偏差。3.5 直接光照的完整BRDF组合把上面四部分组合起来直接光照的BRDF就是specular D * G * F / (4 * NdotV * NdotL) finalColor (diffuse specular) * lightColor * NdotL分母里的4 * NdotV * NdotL是Cook-Torrance的归一化因子不能漏。漏了之后高光强度会随视角变化得很奇怪。完整的像素着色器代码大概长这样float3 CalculatePBRDirect( float3 N, float3 V, float3 L, float3 baseColor, float metallic, float roughness, float3 lightColor, float3 lightIntensity) { float3 H normalize(V L); float NdotV max(dot(N, V), 0.001f); float NdotL max(dot(N, L), 0.001f); float NdotH max(dot(N, H), 0.0f); float VdotH max(dot(V, H), 0.0f); float3 F0 lerp(float3(0.04f, 0.04f, 0.04f), baseColor, metallic); float D CalculateGGX(NdotH, roughness); float G CalculateSmithG(NdotV, NdotL, roughness); float3 F CalculateFresnel(VdotH, F0); float3 specular (D * G * F) / (4.0f * NdotV * NdotL); float3 kd (1.0f - F) * (1.0f - metallic); float3 diffuse kd * baseColor / PI; return (diffuse specular) * lightColor * lightIntensity * NdotL; }这段代码可以直接用但要注意几个地方NdotV和NdotL都做了clamp防止除零H是半程向量要先归一化lightIntensity是光的强度和lightColor分开是为了方便调参。4. DX12管线中的实操落地4.1 根签名的调整与常量缓冲区绑定在第一部分的框架里根签名可能只有一个CBV。加入PBR之后需要至少两个常量缓冲区一个存逐帧的相机和光照数据一个存逐Draw的材质数据。根签名的设计有两种方案一种是全部用根常量另一种是用CBV。我推荐用CBV因为材质参数比较多根常量有256字节的限制而且根常量的更新需要重新录制命令列表不如CBV灵活。根签名大概长这样CD3DX12_ROOT_PARAMETER rootParams[3]; rootParams[0].InitAsConstantBufferView(0); // 逐帧数据 rootParams[1].InitAsConstantBufferView(1); // 逐Draw材质 rootParams[2].InitAsConstantBufferView(2); // 光照数据对应的着色器寄存器cbuffer PerFrame : register(b0) { float4x4 viewProj; float3 cameraPos; }; cbuffer PerMaterial : register(b1) { float4 baseColorFactor; float metallicFactor; float roughnessFactor; float normalScale; }; cbuffer PerLight : register(b2) { float3 lightDir; float3 lightColor; float lightIntensity; };这里有个DX12特有的坑常量缓冲区的大小必须是256字节的整数倍。如果你的结构体只有100字节也要分配256字节。我一般会在结构体后面加一个padding数组确保sizeof是256的倍数。4.2 纹理资源的加载与SRV堆管理PBR需要至少四张贴图基础颜色、法线、金属粗糙度、遮蔽。这些贴图需要在SRV堆里各占一个描述符。DX12的SRV堆管理在第一部分应该已经搭好了这里只需要在创建纹理的时候多创建几个。加载贴图我用的是DirectXTex库它支持DDS、PNG、JPG等格式。对于PBR贴图基础颜色和金属粗糙度贴图要用线性空间采样法线贴图也是线性空间。只有基础颜色在最后输出的时候需要做伽马校正或者如果你的交换链是sRGB格式硬件会自动处理。// 创建SRV D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc {}; srvDesc.Shader4ComponentMapping D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; srvDesc.Format textureFormat; srvDesc.ViewDimension D3D12_SRV_DIMENSION_TEXTURE2D; srvDesc.Texture2D.MipLevels mipLevels; device-CreateShaderResourceView(texture, srvDesc, srvHandle);法线贴图的采样要注意法线贴图存的是切线空间法线需要TBN矩阵变换到世界空间。TBN矩阵的构建需要切线、副切线和法线。如果模型没有切线数据可以用屏幕空间导数来算但质量会差一些。我建议在导入模型的时候就生成好切线。4.3 法线贴图的正确采样与TBN构建法线贴图的采样和普通贴图一样但采样出来的值范围是[0,1]需要映射到[-1,1]float3 normalSample normalTex.Sample(sampler, uv).rgb; normalSample normalSample * 2.0f - 1.0f; normalSample.xy * normalScale;然后构建TBN矩阵float3 T normalize(tangent); float3 N normalize(normal); float3 B cross(N, T) * tangentSign; float3x3 TBN float3x3(T, B, N); float3 worldNormal normalize(mul(normalSample, TBN));这里有个细节副切线的方向由切线的手性决定。如果模型导入的时候切线手性不对法线贴图会看起来凹凸颠倒。我一般会在导入的时候检查一下或者在着色器里用一个参数来控制。4.4 光照参数的传递与调试平行光的参数包括方向、颜色、强度。方向是从光源指向场景的所以计算NdotL的时候要用-lightDir。颜色和强度我分开传因为调强度的时候不想动颜色。调试的时候我建议先关掉法线贴图用一个纯色材质把金属度设成0粗糙度设成0.5然后调光照方向看漫反射是不是正常。然后再把金属度调到1看高光是不是有金属感。最后再把法线贴图打开看凹凸细节。这样分步调试出了问题容易定位。4.5 伽马校正与输出PBR的计算全部在线性空间进行但显示器的输出是sRGB空间。所以最后需要做一次伽马校正float3 finalColor pow(linearColor, 1.0f / 2.2f);或者把交换链的格式设成DXGI_FORMAT_R8G8B8A8_UNORM_SRGB让硬件自动做。我推荐后者因为硬件做的话性能更好而且不会忘记。这里有个常见的错误贴图采样的时候做了伽马校正输出的时候又做了一次。结果就是画面整体偏亮暗部细节丢失。正确的做法是基础颜色贴图如果是sRGB格式采样的时候硬件会自动转到线性空间法线和金属粗糙度贴图是线性格式采样出来就是线性值最后输出的时候做一次伽马校正。5. 常见问题与排查技巧实录5.1 画面全黑或者全白这是最常见的问题原因通常有几个常量缓冲区没更新、根签名不匹配、SRV堆没绑定、光照方向反了。排查顺序我一般是这样的先用一个固定的颜色输出比如return float3(1,0,0)看能不能显示红色。如果能说明管线是通的问题在光照计算。然后检查NdotL如果NdotL一直是0说明光照方向反了把lightDir取反试试。如果NdotL正常但画面还是黑检查F0和kd的计算看看是不是有NaN。NaN是PBR调试里最烦人的问题因为NaN会传播一个像素出问题可能整个画面都花。我一般会在关键步骤后面加isnan检查定位到具体是哪一步出的问题。5.2 金属表面看起来像塑料这个问题通常是两个原因要么是F0没算对要么是漫反射没乘(1-metallic)。检查F0金属的F0应该是基础颜色本身不是0.04。如果你发现金属表面的高光颜色是白色的说明F0还是0.04lerp没生效。检查漫反射金属没有漫反射kd应该等于0。如果kd不等于0金属表面会有漫反射颜色看起来就像塑料。5.3 高光形状不对或者有硬边高光形状不对通常是粗糙度的处理有问题。记住α等于粗糙度的平方不是粗糙度本身。如果你直接用粗糙度当α高光在中间粗糙度的时候会偏小看起来像硬边。另一个可能是法线分布函数的归一化因子漏了。GGX的分母里有π漏了之后高光强度会偏。5.4 法线贴图导致光照异常法线贴图的问题通常是TBN矩阵不对。检查切线的方向、副切线的手性、法线贴图的通道顺序。有些工具导出的法线贴图是Y轴朝下的OpenGL风格有些是Y轴朝上的DirectX风格。如果凹凸看起来是反的把法线贴图的G通道取反试试。5.5 性能突然下降PBR的计算量比Phong大不少特别是GGX和Smith函数里有sqrt和pow。如果性能下降明显可以考虑几个优化把粗糙度的平方提前算好存在常量里用近似函数代替sqrt把BRDF的预计算做成LUT。不过在入门阶段我建议先保证正确性性能优化后面再说。毕竟你是在学DX12和PBR不是在做一个要上线的3A大作。5.6 常见问题速查表问题现象可能原因排查方法画面全黑常量缓冲区未更新检查Map/Unmap和根签名绑定画面全白伽马校正重复检查贴图格式和输出格式金属像塑料F0未使用基础颜色检查lerp的插值因子高光硬边粗糙度未平方检查α的计算法线凹凸颠倒TBN手性错误取反副切线或G通道边缘黑点NdotV为0导致NaN加max(NdotV, 0.001)整体偏暗漏了π或NdotL检查BRDF的归一化因子6. 从直接光照到IBL的扩展思路直接光照调通之后下一步自然是IBL。IBL的核心思想是用一张环境贴图来模拟周围环境的光照。它分两部分漫反射的辐照度贴图和镜面反射的预过滤环境贴图。漫反射部分相对简单把环境贴图做一次卷积得到一张低频的辐照度贴图采样的时候直接用N采样就行。镜面反射部分复杂一些需要根据粗糙度做预过滤生成一系列不同粗糙度的mipmap然后用反射向量采样根据粗糙度选择mip层级。还有一个BRDF LUT它把菲涅尔项和几何项的积分结果预计算成一张二维贴图运行时用NdotV和粗糙度采样。这张LUT和具体场景无关可以复用。IBL的预计算可以在CPU做也可以在GPU做。CPU做的话加载慢但代码简单GPU做的话需要额外的计算着色器但速度快。我建议先用CPU做把流程跑通后面再优化。从直接光照到IBL的过渡最关键的是理解环境贴图贡献的是间接光它和直接光相加才是最终结果。直接光负责高光的方向性和阴影间接光负责整体的环境氛围。两者缺一不可。我在实际项目里的体会是PBR的调试过程比Phong要长因为参数之间的耦合更强。但一旦调通材质的复用性和一致性会好很多。以前用Phong的时候每个场景都要重新调光照参数用了PBR之后同一套材质放到不同场景里只要光照是物理正确的表现就基本一致。这个收益在项目规模大了之后会非常明显。最后分享一个小技巧如果你在调试的时候不确定某个参数对不对可以把它设成极端值。比如金属度设成0和1粗糙度设成0.05和0.95看画面变化是否符合预期。极端值下的表现最能暴露问题比在中间值反复微调效率高得多。
返回列表