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建的是FLOAT,PSO里写的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而不是乘以PI
PBR的公式看起来复杂,但核心就一句话:出射辐射度等于入射辐射度乘以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。这三个函数的选择直接影响材质的表现。
| 函数 | 常用选择 | 特点 |
|---|---|---|
| D | GGX/Trowbridge-Reitz | 高光尾部更长,更接近真实金属 |
| G | Smith-Schlick | 计算便宜,和GGX配合好 |
| F | Schlick近似 | 简单且足够准确 |
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_FLOAT,UNORM会丢失精度。
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里的实现,说到底是一个"细节决定成败"的事。公式抄对了只是开始,真正让画面好看的是那些参数背后的物理意义和调试经验。我在这个过程中最大的体会是:不要怕慢,先把一个球调对,再扩展到场景。一个球上的问题,在场景里会被放大十倍。