ARTICLE DETAIL

资讯详情

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

DX12 PBR渲染实战:从管线配置到调试优化的完整指南

DX12 PBR渲染实战:从管线配置到调试优化的完整指南 PBR这东西第一次在DX12里跑通的时候我盯着屏幕上那个金属球看了很久——它终于不再像塑料了。但紧接着问题就来了为什么同样的材质参数在别的引擎里看着正常到我这儿就发灰为什么加了IBL之后高光位置对不上为什么帧率掉了30%这些问题在教程里通常不会讲因为它们不属于能跑通的范畴而属于跑得对的范畴。这篇内容就是围绕这些跑通之后的事展开的从DX12的管线状态配置到PBR的数学推导再到实际调试中踩过的坑我会尽量把每个决策背后的原因说清楚。适合已经能画出三角形、对描述符堆和根签名有基本概念、想把画面质量往上提一个档次的开发者。1. 从Blinn-Phong切到PBR时管线状态要改哪些东西很多人以为PBR只是换一套shader公式实际上在DX12里它牵动的是整个管线状态对象的重新配置。Blinn-Phong时代你可能只用一个PSO走天下但PBR的材质参数变多了渲染目标的格式也可能从UNORM换成FLOAT这些都会影响PSO的创建。1.1 渲染目标格式与精度选择PBR的中间结果对精度很敏感。如果你还在用DXGI_FORMAT_R8G8B8A8_UNORM作为HDR渲染目标高光部分会被截断到1.0金属表面的反射细节直接丢失。我建议至少用DXGI_FORMAT_R16G16B16A16_FLOAT如果显卡支持R11G11B10_FLOAT也是个好选择——它在保持足够精度的同时省带宽。这里有个容易忽略的点DX12不会自动帮你做格式转换。你创建RTV时的格式必须和PSO里RTVFormats数组完全一致否则调试层会直接报错。我见过有人RTV建的是FLOATPSO里写的UNORM结果画面一片黑查了半天以为是shader问题。// PSO创建时的渲染目标格式配置 D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc {}; psoDesc.RTVFormats[0] DXGI_FORMAT_R16G16B16A16_FLOAT; psoDesc.NumRenderTargets 1; psoDesc.DSVFormat DXGI_FORMAT_D32_FLOAT;深度缓冲建议用D32_FLOAT而不是D24S8因为PBR的深度测试在远距离物体上对精度更敏感尤其是做SSAO或者屏幕空间反射的时候。1.2 根签名里常量缓冲的重新划分Blinn-Phong的常量缓冲可能就一个WorldViewProj矩阵加几个光照参数。PBR需要的数据量大了不少基础颜色、金属度、粗糙度、法线强度、环境光强度、曝光参数……这些如果全塞一个cbuffer更新频率不一致会导致不必要的上传。我的做法是拆成两个cbuffer一个PerFrame放相机矩阵、光照方向、环境贴图索引这些每帧变一次的数据另一个PerMaterial放材质参数只在切换材质时更新。根签名里用两个CBV分别绑定到不同的寄存器。// 根签名配置 CD3DX12_ROOT_PARAMETER rootParams[3]; rootParams[0].InitAsConstantBufferView(0); // PerFrame rootParams[1].InitAsConstantBufferView(1); // PerMaterial rootParams[2].InitAsDescriptorTable(1, srvTableRange); // 纹理注意根签名里的CBV数量不要超过硬件限制。有些老卡只支持最多64个DWORD的根常量用CBV而不是根常量更稳妥。1.3 输入布局的变化PBR通常需要切线空间来做法线贴图。如果你的顶点格式里还没有切线和副切线现在得加上。但切线这东西不是随便算的——它必须和UV方向一致否则法线贴图会歪。我一般用XMFLOAT3存切线XMFLOAT3存副切线加上原有的位置、法线、UV一个顶点大概占56字节。如果模型面数高可以考虑用R10G10B10A2压缩法线和切线但调试阶段不建议因为解压错了很难查。2. PBR的数学核心为什么是除以PI而不是乘以PIPBR的公式看起来复杂但核心就一句话出射辐射度等于入射辐射度乘以BRDF再乘以余弦项。问题在于很多人抄公式的时候把1/PI抄丢了或者抄反了结果画面要么过亮要么过暗。2.1 漫反射项的归一化因子Lambertian漫反射的BRDF是albedo / PI。这个1/PI不是随便加的它来自半球积分∫(albedo/PI) * cosθ dω albedo。如果你忘了除PI能量就不守恒物体看起来会比实际亮π倍。我在第一次实现时就把这个PI搞错了金属球看起来像自发光。后来用了一个简单的验证方法拿一个纯白漫反射球在单位强度的平行光下正对光源的那一点亮度应该接近albedo * lightIntensity / PI。如果算出来是albedo * lightIntensity那肯定漏了PI。// 正确的漫反射BRDF float3 F_Diffuse(float3 albedo) { return albedo / PI; }2.2 微表面理论的三个函数Cook-Torrance BRDF由三部分组成法线分布函数D、几何遮蔽函数G、菲涅尔函数F。这三个函数的选择直接影响材质的表现。函数常用选择特点DGGX/Trowbridge-Reitz高光尾部更长更接近真实金属GSmith-Schlick计算便宜和GGX配合好FSchlick近似简单且足够准确GGX的D项公式是α² / (π * ((N·H)² * (α² - 1) 1)²)其中α roughness²。注意这里roughness要平方很多人直接拿roughness当α用结果粗糙度0.5的材质看起来像0.25。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); }2.3 菲涅尔项的F0从哪来F0是垂直入射时的反射率。对于非金属F0大约是0.04对于金属F0就是基础颜色。这就是为什么金属的albedo应该存F0而不是漫反射颜色。我在实际项目里见过有人把金属的albedo设成黑色然后奇怪为什么金属不反光。因为F0是0菲涅尔项就永远是0当然不反光。正确的做法是金属的albedo直接作为F0漫反射项设为0。float3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float3 F F0 (1.0 - F0) * pow(1.0 - VdotH, 5.0);3. IBL环境光照从 cubemap 到球谐的取舍直接光照只能照亮物体的一面没有环境光照的PBR场景看起来会很干。IBL基于图像的光照是让PBR材质真正活起来的关键但在DX12里实现IBL有几个绕不开的决策点。3.1 预计算辐照度图的两种路径漫反射环境光需要一张辐照度图。传统做法是用卷积对cubemap做预计算生成一张低分辨率的irradiance map。这个过程可以在CPU做也可以在GPU做。CPU做的好处是简单用DirectXTex库里的ComputeIrradiance就行。缺点是慢一张256x256的cubemap可能要几秒。GPU做的话需要写compute shader但可以实时更新适合动态环境。我一般是在离线工具里预计算好运行时直接加载DDS。这样启动快而且可以用更高的采样数。如果你要做动态时间变化那就得走GPU路径但要注意compute shader的线程组划分别让每个线程算太多像素。3.2 镜面反射的预过滤与LUT镜面IBL需要两张图一张预过滤的环境图不同粗糙度对应不同mip一张BRDF积分LUT。预过滤环境图的生成用GGX重要性采样每个mip对应一个粗糙度级别。BRDF LUT是一张2D纹理横轴是NdotV纵轴是roughness存的是菲涅尔项的积分结果。这张图可以预计算一次所有材质共用。// 采样IBL的镜面反射 float3 GetSpecularIBL(float3 N, float3 V, float roughness, float3 F0) { float NdotV max(dot(N, V), 0.0); float3 R reflect(-V, N); float mip roughness * MAX_REFLECTION_LOD; float3 prefilteredColor texCubelod(envMap, float4(R, mip)).rgb; float2 brdf tex2D(brdfLUT, float2(NdotV, roughness)).rg; return prefilteredColor * (F0 * brdf.x brdf.y); }提示BRDF LUT的分辨率不用太高256x256足够。但格式要用R16G16_FLOATUNORM会丢失精度。3.3 球谐光照作为轻量替代如果目标平台性能有限或者场景里没有明显的镜面反射球谐光照是个不错的替代方案。它用9个系数二阶就能表示整个环境的光照分布采样成本几乎为零。球谐的缺点是只能表示低频信息高光会糊。但对于漫反射为主的场景效果可以接受。我在一个移动端项目里就用球谐代替了irradiance map省了一张纹理和一次采样。4. 调试PBR时那些让人抓狂的瞬间PBR的调试比Blinn-Phong难得多因为参数之间会互相影响。一个参数错了可能表现为完全不同的症状。下面是我踩过的几个典型坑。4.1 金属度与粗糙度的视觉耦合金属度和粗糙度在视觉上是耦合的。金属度高但粗糙度也高看起来像磨砂金属金属度低但粗糙度低看起来像塑料。如果你发现材质不对劲先检查这两个参数是不是反了。我遇到过一个情况美术给的材质里金属度存的是0或1粗糙度存的是0到1。但我在shader里把粗糙度当成了光滑度用结果所有材质都反了。后来加了一个roughness 1.0 - smoothness的转换才正常。4.2 法线贴图的绿通道方向法线贴图的绿通道有OpenGL和DirectX两种约定方向是反的。如果你从网上下的贴图直接拿来用很可能光照方向是错的。判断方法很简单找一个有明显凹凸的贴图如果光照看起来是凹的而不是凸的那就是绿通道反了。解决办法是在shader里normal.y -normal.y或者在导入时翻转。// 处理OpenGL法线贴图 float3 normalTS tex2D(normalMap, uv).xyz * 2.0 - 1.0; normalTS.y -normalTS.y; // 翻转绿通道4.3 曝光与色调映射的顺序PBR的输出是HDR的需要经过色调映射才能显示。但曝光应该在色调映射之前还是之后答案是之前。曝光是线性缩放色调映射是非线性的顺序反了会导致高光细节丢失。我一般用ACES色调映射它在保留高光细节方面表现不错。但ACES有个问题它会稍微降低饱和度。如果你觉得颜色太灰可以试试Reinhard或者自定义的曲线。// ACES色调映射的简化版 float3 ACESFilm(float3 x) { float a 2.51; float b 0.03; float c 2.43; float d 0.59; float e 0.14; return saturate((x * (a * x b)) / (x * (c * x d) e)); }4.4 描述符堆的绑定时机DX12里描述符堆的绑定是个容易出错的地方。如果你在每帧开始时绑定了一次堆但中间又创建了新的描述符那新的描述符可能不会生效因为堆的绑定状态没更新。我的做法是把所有需要的描述符在初始化时全部创建好运行时只做SetGraphicsRootDescriptorTable不再动态创建。如果确实需要动态更新那就用CopyDescriptors更新堆里的内容而不是重新创建堆。5. 性能优化PBR不是免费的午餐PBR的计算量比Blinn-Phong大不少尤其是IBL的采样。如果不做优化帧率可能会掉一半。5.1 预计算与运行时采样的平衡IBL的预计算可以在离线做但运行时采样还是要花时间。镜面IBL需要采样cubemap和LUT两次纹理采样加上一些数学运算在低端GPU上可能成为瓶颈。优化方法有几个一是降低cubemap的分辨率二是用更少的mip级别三是把BRDF LUT的采样结果缓存到常量缓冲里如果材质不变的话。我在一个项目里把BRDF LUT的采样结果按粗糙度分档缓存减少了大约15%的像素着色器开销。5.2 多光源下的BRDF计算复用如果场景里有多个光源每个光源都要算一遍BRDF。但D项和G项只和N、H、roughness有关和光源方向无关。所以可以先算好D和G然后对每个光源只算F和余弦项。// 预计算D和G float D D_GGX(NdotH, roughness); float G G_Smith(NdotV, NdotL, roughness); // 对每个光源 for (int i 0; i lightCount; i) { float3 F F_Schlick(VdotH, F0); float3 specular (D * G * F) / (4.0 * NdotV * NdotL); // ... }这个优化在多光源场景下效果明显因为D和G的计算量不小。5.3 半精度浮点的使用边界在支持16位浮点的硬件上用half代替float可以省带宽和计算资源。但PBR里有些地方不能用半精度世界坐标、深度、以及任何可能超过65504的值。我的经验是颜色、粗糙度、金属度可以用half法线、切线、视角方向最好用float因为归一化的时候半精度误差会被放大。6. 从单个球到完整场景的过渡跑通一个PBR球之后下一步是把它放到完整场景里。这时候会遇到一些新问题阴影、多材质、LOD过渡。6.1 阴影贴图与PBR的配合阴影贴图本身不区分PBR和Blinn-Phong但PBR对阴影的软硬更敏感。硬阴影在PBR材质上看起来会很假因为真实世界的阴影边缘是有过渡的。PCF是最简单的软阴影方案采样几次阴影贴图取平均。但PCF的采样数不能太多否则性能吃不消。我一般用4x4的PCF配合泊松盘采样效果和性能比较平衡。6.2 多材质场景的描述符管理一个场景里可能有几十种材质每种材质需要不同的纹理和常量缓冲。如果每个材质都建一个描述符堆内存会爆。正确的做法是用一个大的描述符堆所有材质的SRV都放进去渲染时只切换根参数。// 所有材质的SRV放在一个堆里 D3D12_DESCRIPTOR_HEAP_DESC heapDesc {}; heapDesc.NumDescriptors materialCount * TEXTURES_PER_MATERIAL; heapDesc.Type D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; heapDesc.Flags D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;注意描述符堆的大小要在创建时确定不能动态扩展。所以初始化时要预估好最大材质数。6.3 LOD切换时的材质参数插值LOD切换时如果材质参数突变会出现明显的跳变。解决办法是在LOD之间插值材质参数比如粗糙度和金属度。但插值会增加shader的复杂度需要权衡。我在一个开放世界项目里用了简单的线性插值在LOD过渡区域把两个LOD的材质参数按距离混合。效果不错但要注意插值后的参数可能超出合理范围需要clamp。7. 一些不那么显然的实操心得最后分享几个我在实际项目里总结的小技巧它们不在任何教程里但能省不少时间。7.1 用参考球验证BRDF实现在场景里放一排参考球金属度从0到1粗糙度从0到1形成一个网格。每次改shader后看一眼这排球就能快速判断BRDF有没有问题。这个方法帮我抓到了好几次PI错误和F0错误。7.2 调试视图的快速切换在shader里加一个调试模式可以单独输出D项、G项、F项、漫反射、镜面反射。用常量缓冲里的一个标志位控制。这样不用改代码就能定位问题出在哪个环节。if (debugMode 1) return float4(D, D, D, 1.0); if (debugMode 2) return float4(G, G, G, 1.0); if (debugMode 3) return float4(F, 1.0);7.3 环境贴图的mip生成要手动控制DX12不会自动生成cubemap的mip需要自己用compute shader或者GenerateMips。但PBR的预过滤环境图需要特定的mip对应特定的粗糙度所以不能直接用GenerateMips得自己写预过滤的compute shader。我一般用DirectXTex的ComputePremultipliedRadiance来生成预过滤环境图它支持GGX重要性采样效果比简单的mip生成好很多。7.4 注意sRGB与线性的转换时机PBR的所有计算都在线性空间进行但纹理通常是sRGB的。所以采样纹理后要转成线性输出前再转回sRGB。DX12里可以用DXGI_FORMAT_*_SRGB格式让硬件自动转换但要注意法线贴图、粗糙度贴图这些数据纹理不能用sRGB格式否则会出错。我在一个项目里把粗糙度贴图设成了sRGB结果粗糙度全偏了查了两天才发现。记住只有基础颜色贴图用sRGB其他都用UNORM。7.5 帧率突然下降时先查描述符堆DX12的调试层会报描述符堆的警告但有时候警告不明显。如果帧率突然下降先检查是不是每帧都在创建描述符堆或者PSO。这两个操作都很慢应该只在初始化时做。我有一次在渲染循环里创建PSO帧率从120掉到15找了半天才发现。后来把所有PSO创建移到初始化阶段问题解决。PBR在DX12里的实现说到底是一个细节决定成败的事。公式抄对了只是开始真正让画面好看的是那些参数背后的物理意义和调试经验。我在这个过程中最大的体会是不要怕慢先把一个球调对再扩展到场景。一个球上的问题在场景里会被放大十倍。
返回列表