1. 从光照模型到PBR:为什么DX12项目绕不开这一步
很多人在DX12里跑通第一个三角形、把纹理贴上去之后,下一步就卡住了——画面看起来“能跑”,但就是不对劲。金属像塑料,塑料像纸片,光照要么死白要么死黑。这不是DX12的问题,是你还在用Lambert或者Blinn-Phong那套经验模型。PBR(Physically Based Rendering,基于物理的渲染)就是来解决这个“不对劲”的。
PBR的核心思路其实一句话能说清:用物理上合理的参数来描述材质和光照,让渲染结果在不同光照环境下都保持一致。传统Phong模型里,你调一个高光强度参数,换个场景就得重调;PBR里,材质的粗糙度和金属度是固定的物理属性,环境变了画面自然跟着变,不需要手调。
在DX12里做PBR,和DX11时代有本质区别。DX11时期很多人用现成的引擎或者框架,PBR管线是封装好的。DX12把管线控制权完全交给你,描述符堆、根签名、PSO(Pipeline State Object)全要自己管。好处是你对GPU的控制粒度更细,坏处是每一步都得想清楚。这篇文章就围绕“在DX12里从零加入PBR”这件事,把关键决策点、容易踩的坑、以及实际调参经验讲透。
适合谁看?如果你已经能用DX12画出带纹理的模型,理解顶点着色器和像素着色器的基本流程,但对PBR只有模糊概念,或者照着教程抄了一遍但不知道为什么这么写,那这篇就是给你准备的。我会尽量用“为什么这么做”来串起每个步骤,而不是丢一堆公式让你自己悟。
2. PBR的数学底子:别被公式吓到,先搞懂它在算什么
2.1 渲染方程到底在描述什么
PBR的理论根基是渲染方程,完整形式长这样:
Lo(p, ωo) = ∫Ω fr(p, ωi, ωo) Li(p, ωi) (n · ωi) dωi看着吓人,拆开就三块东西。Li是来自某个方向的光,fr是材质表面把光反射到观察方向的比率,(n · ωi)是入射光与表面法线的夹角余弦——光斜着照到表面,单位面积接收的能量就少,这个余弦就是在做这个衰减。最后对整个半球积分,把所有方向的贡献加起来。
实时渲染不可能真的做积分,所以实际做法是把光照拆成直接光和间接光两部分,各自用近似公式算。直接光就是光源直接照到表面的部分,用解析公式算;间接光就是环境反射、全局光照那部分,通常用IBL(Image Based Lighting)来近似。这个拆分是整个PBR实现的主干,后面所有代码都是围绕它展开的。
2.2 Cook-Torrance BRDF的三个核心项
实时PBR最常用的BRDF是Cook-Torrance模型,它的镜面反射部分由三个函数相乘再归一化:
fr = D · F · G / (4 · (n·ωi) · (n·ωo))D项(法线分布函数)描述微表面法线朝向的统计分布。最常用的是GGX/Trowbridge-Reitz:
D = α² / (π · ((n·h)² · (α² - 1) + 1)²)其中α = roughness²。这里有个关键细节:很多教程直接写α = roughness,但正确的做法是α = roughness²。这个平方关系是Disney在原始论文里提出的,目的是让粗糙度的感知变化更线性。我一开始照抄教程没注意这点,结果粗糙度在0.5到0.7之间变化时画面几乎没区别,调到0.9突然全糊了。改成平方之后,整个0到1区间的过渡就均匀多了。
F项(菲涅尔方程)描述不同角度下反射率的差异。Schlick近似最常用:
F = F0 + (1 - F0) · (1 - (h·ωo))⁵F0是垂直入射时的反射率。对于非金属(电介质),F0约0.04;对于金属,F0就是材质的基色。这就是金属度参数的核心作用——它决定了F0取0.04还是取albedo。
G项(几何遮蔽函数)描述微表面之间的自遮挡。Smith-GGX是最常见的组合,把入射和出射方向的遮蔽分别算再相乘。实际实现中常用Schlick-GGX近似来降低计算量。
2.3 为什么金属度和粗糙度是两个独立参数
这是PBR材质系统设计里最容易被误解的地方。金属度(metallic)和粗糙度(roughness)不是一回事,也不能互相替代。
金属度决定的是反射的性质:金属的反射有颜色(F0 = albedo),非金属的反射几乎无色(F0 ≈ 0.04)。粗糙度决定的是反射的清晰程度:粗糙度低,反射像镜子;粗糙度高,反射模糊扩散。
一个常见错误是把金属度当“光泽度”来用,觉得调高金属度就更亮。实际上金属度调高会让漫反射消失(金属没有漫反射),如果光照环境不够亮,画面反而更暗。我见过有人在场景里把所有材质金属度拉到1,结果整个画面像蒙了一层灰,就是因为没有环境反射来支撑金属的镜面反射。
实操建议:金属度参数尽量只用0或1两个值,中间值只在材质过渡区域(比如生锈的边缘)使用。这是业界普遍遵循的原则,因为现实中很少有“半金属”材质。
3. DX12管线改造:把PBR着色器接进现有框架
3.1 根签名怎么改才不浪费性能
DX12的根签名决定了着色器能访问哪些资源。加入PBR之后,你需要传给GPU的数据变多了:材质参数(albedo、metallic、roughness、normal贴图)、光照参数、相机参数。这些数据怎么组织,直接影响性能。
我的做法是把常量缓冲区分成三块:
| 缓冲区 | 更新频率 | 内容 |
|---|---|---|
| 每帧常量 | 每帧一次 | 相机矩阵、光照方向、光照颜色 |
| 每物体常量 | 每物体一次 | 世界矩阵、法线矩阵 |
| 每材质常量 | 每材质一次 | albedo值、metallic、roughness |
根签名里,每帧常量用root constant直接塞进去(64个DWORD以内),每物体和每材质用CBV描述符。这样做的理由是:每帧常量变化最频繁,放根常量里避免描述符堆的间接访问开销;每材质常量变化最少,放描述符堆里可以批量更新。
根签名的具体配置:
CD3DX12_ROOT_PARAMETER rootParams[4]; rootParams[0].InitAsConstantBufferView(0); // 每帧 rootParams[1].InitAsConstantBufferView(1); // 每物体 rootParams[2].InitAsConstantBufferView(2); // 每材质 rootParams[3].InitAsDescriptorTable(1, &srvRange); // 贴图这里有个坑:根签名的版本要选1.1,1.0版本不支持某些特性,而且1.1在驱动层面有更好的优化。创建根签名时记得检查D3D12_FEATURE_D3D12_OPTIONS里的HighestRootSignatureVersion。
3.2 PSO里需要改哪些状态
PSO是DX12里把着色器、混合状态、光栅化状态等打包在一起的对象。加入PBR之后,PSO本身不需要大改,但有几个地方要注意。
渲染目标格式:如果你打算用HDR渲染再后期色调映射,RTV格式要从DXGI_FORMAT_R8G8B8A8_UNORM改成DXGI_FORMAT_R16G16B16A16_FLOAT。原因是PBR计算出的亮度值可能超过1.0,用8位UNORM会直接截断,高光区域全变成死白。我一开始没改这个,调了半天光照参数都觉得高光不对劲,换成FP16之后瞬间正常了。
深度模板状态:PBR对深度测试的要求和之前一样,但如果你要用屏幕空间反射或者SSAO,可能需要额外的深度纹理。这个看具体需求,不是PBR必须的。
光栅化状态:把CullMode设成BACK,FrontCounterClockwise根据你的模型坐标系决定。这个和PBR无关,但很多人在这里翻车——模型看起来正常但光照全反了,往往是法线方向或者绕序搞错了。
3.3 描述符堆的管理策略
PBR需要至少四张贴图:albedo、normal、metallic-roughness(通常打包在一张图的不同通道)、以及可选的AO。加上阴影贴图、环境贴图,描述符堆很快就满了。
我的策略是用两个描述符堆:一个专门放SRV(着色器资源视图),一个放CBV和UAV。SRV堆的大小按最大贴图数量乘以2来分配(留出余量),CBV堆按最大物体数量分配。堆类型用D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV,标志加上D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE。
更新描述符时,不要在每帧都重新创建堆,那是性能杀手。正确做法是初始化时创建好堆,每帧只更新堆里对应位置的数据。如果物体数量动态变化,用环形缓冲区的方式复用描述符槽位。
// 更新材质描述符的正确方式 D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc = {}; cbvDesc.BufferLocation = materialCB->GetGPUVirtualAddress() + offset; cbvDesc.SizeInBytes = sizeof(MaterialConstants); device->CreateConstantBufferView(&cbvDesc, heap->GetCPUDescriptorHandleForHeapStart());注意SizeInBytes必须是256的倍数,这是DX12的硬性要求。我第一次写的时候没对齐,调试层直接报错,查了半天才发现是这个原因。
4. 着色器实现:从HLSL代码看PBR的每个计算步骤
4.1 顶点着色器要传哪些数据到像素着色器
PBR的像素着色器需要世界空间的位置、法线、切线、副切线、UV坐标。顶点着色器的工作就是把这些准备好。
struct VSOutput { float4 pos : SV_POSITION; float3 worldPos : POSITION; float3 normal : NORMAL; float3 tangent : TANGENT; float3 bitangent : BITANGENT; float2 uv : TEXCOORD; };法线变换要用法线矩阵(世界矩阵的逆转置),不能直接用世界矩阵。如果模型有非均匀缩放,直接用世界矩阵变换法线会导致法线方向错误,光照看起来“歪”了。切线变换用世界矩阵就行,因为切线是沿着表面的方向向量。
副切线有两种算法:cross(normal, tangent) * tangent.w或者直接传进来。我推荐用叉乘加handedness的方式,减少顶点数据量。但要注意切线空间的handedness,如果模型导出时没带这个信息,法线贴图的方向可能会反。
4.2 像素着色器里的直接光照计算
直接光照的计算流程:
float3 N = normalize(input.normal); float3 V = normalize(cameraPos - input.worldPos); float3 L = normalize(-lightDir); float3 H = normalize(V + L); float NdotL = saturate(dot(N, L)); float NdotV = saturate(dot(N, V)); float NdotH = saturate(dot(N, H)); float VdotH = saturate(dot(V, H));然后算F0、D、G、F,组合成BRDF值,乘以光照颜色和NdotL。
这里有个容易忽略的点:NdotV在掠射角附近会趋近于0,导致BRDF分母爆炸。实际实现中要加一个小的epsilon,或者用max(NdotV, 0.0001)来避免除零。我在调试时遇到过画面边缘出现异常亮斑,就是因为这个。
另一个坑是H向量的计算。如果V和L方向相反(比如光源在相机后面),V+L可能接近零向量,normalize会得到NaN。加一个判断:
float3 H = (length(V + L) > 0.0001) ? normalize(V + L) : N;4.3 IBL的实现细节:环境贴图怎么用
直接光照只解决了光源直接照射的部分,环境反射需要IBL。基本思路是用一张环境立方体贴图,预计算成两张图:辐照度图(用于漫反射)和预过滤环境图(用于镜面反射)。
辐照度图是把环境贴图在半球上积分,得到每个方向上的平均入射光。预过滤环境图是对不同粗糙度做不同程度的模糊,粗糙度越高越模糊。再加上一张BRDF LUT(查找表),把F0和粗糙度映射到预计算的积分结果。
在像素着色器里:
float3 irradiance = irradianceMap.Sample(linearSampler, N).rgb; float3 diffuseIBL = irradiance * albedo * (1 - metallic); float3 R = reflect(-V, N); float mipLevel = roughness * maxMipLevel; float3 prefilteredColor = prefilteredMap.SampleLevel(linearSampler, R, mipLevel).rgb; float2 brdf = brdfLUT.Sample(linearSampler, float2(NdotV, roughness)).rg; float3 specularIBL = prefilteredColor * (F0 * brdf.x + brdf.y);mipLevel的计算是关键。预过滤环境图的每个mip对应一个粗糙度级别,mip 0是镜面反射,mip越大越模糊。用roughness * maxMipLevel来映射,但要注意maxMipLevel是log2(环境图分辨率),不是mip数量减一。我一开始搞错了这个,导致粗糙度到0.8以上时反射突然变成纯色,就是因为采样到了不存在的mip。
BRDF LUT的生成可以在初始化时用计算着色器做,也可以直接加载预生成的文件。我建议预生成,因为实时计算需要跑一次积分,启动时会卡一下。
5. 调试与调参:那些教程不会告诉你的实战经验
5.1 画面发灰、发暗、过曝的排查顺序
PBR调参最让人头疼的就是画面不对但不知道哪一步错了。我总结了一个排查顺序,按这个走基本能定位到问题。
第一步:检查法线。把像素着色器的输出改成N * 0.5 + 0.5,看看法线颜色是否正常。如果法线看起来像彩虹或者方向明显不对,先解决法线问题。常见原因是法线贴图的绿色通道方向(OpenGL和DirectX的Y轴相反),或者切线空间计算错误。
第二步:检查F0。把输出改成F0,非金属应该是接近0.04的暗灰色,金属应该是albedo的颜色。如果非金属显示成亮色,说明F0计算错了。
第三步:检查直接光照。把IBL关掉,只留直接光。如果直接光下画面正常,说明问题在IBL;如果直接光下就不对,问题在BRDF计算。
第四步:检查色调映射。PBR的输出是线性HDR值,需要色调映射才能显示到LDR屏幕。如果没做色调映射,高光区域会直接截断成白色。常用的有ACES、Reinhard、Filmic。我推荐ACES,它的高光滚降比较自然。
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 画面整体发灰 | 缺少IBL或环境光 | 检查辐照度图是否绑定 |
| 金属像塑料 | F0计算错误 | 输出F0可视化检查 |
| 高光死白 | 未做色调映射 | 检查RTV格式和后期 |
| 边缘异常亮斑 | NdotV除零 | 加epsilon保护 |
| 粗糙度无变化 | α未平方 | 检查roughness²计算 |
5.2 粗糙度贴图的通道打包问题
大多数PBR工作流把metallic和roughness打包在一张贴图里,通常是B通道放metallic,G通道放roughness。但不同来源的贴图打包方式可能不同,有的把roughness放A通道,有的把AO也塞进来。
我的做法是在材质加载时做一个通道重映射,把不同来源的贴图统一到自己的标准格式。具体来说,在CPU端读取贴图后,用计算着色器或者CPU端处理,把需要的通道提取出来重新打包。这样着色器里只需要按固定方式采样,不用为每个材质写不同的采样逻辑。
注意:如果贴图是sRGB格式,metallic和roughness通道不能用sRGB采样。sRGB是给颜色用的,metallic和roughness是线性值。正确做法是把贴图以UNORM格式加载,或者在着色器里手动做sRGB到线性的转换。我见过有人把metallic-roughness贴图设成sRGB,结果粗糙度整体偏暗,怎么调都不对。
5.3 性能优化:哪些计算可以预计算
PBR的像素着色器计算量不小,尤其是GGX的D项和Smith的G项,涉及多次除法和幂运算。在移动端或者集成显卡上,这些计算可能成为瓶颈。
可以做的优化:
- 预计算BRDF LUT:前面提过,把NdotV和roughness的二维积分预计算成一张贴图,运行时只做一次纹理采样。
- 简化G项:用Schlick-GGX近似代替完整的Smith模型,精度损失很小但计算量减半。
- 降低环境贴图分辨率:辐照度图64x64就够了,预过滤图256x256也足够。环境贴图本身不需要4K。
- 用half精度:如果目标平台支持,像素着色器里的颜色计算可以用half(float16),带宽和计算量都能降。
我在一台老笔记本上测试过,把BRDF LUT预计算加上G项简化之后,帧率从45提到了60左右,画面质量肉眼几乎看不出区别。
6. 从直接光到IBL:完整光照管线的组装
6.1 光照数据的组织方式
一个场景里可能有多个光源:方向光、点光源、聚光灯。PBR对每种光源的处理方式略有不同,但核心BRDF是一样的,区别在于光的方向和衰减计算。
方向光最简单,L就是光源方向的负方向,没有衰减。点光源需要算距离衰减,物理正确的衰减是1 / (distance²),但实际中直接用平方反比会导致近处过亮远处过暗,通常加一个平滑处理:
float distanceAttenuation(float dist, float range) { float d = dist / range; float d2 = d * d; return saturate(1.0 / (d2 + 1.0)) * saturate(1.0 - d2 * d2); }这个公式在距离为0时返回1,在距离等于range时返回0,中间平滑过渡。比纯平方反比好用得多。
聚光灯额外需要一个角度衰减,用cosine的幂来算:
float spotAttenuation(float3 L, float3 spotDir, float innerCos, float outerCos) { float cd = dot(-L, spotDir); float attenuation = saturate((cd - outerCos) / (innerCos - outerCos)); return attenuation * attenuation; }6.2 多光源的循环与性能权衡
在像素着色器里循环处理多个光源是最直接的做法,但光源数量多了之后性能下降明显。优化思路有几种:
光源裁剪:在CPU端计算每个物体受哪些光源影响,只把相关的光源传给着色器。对于大场景,这个优化效果很明显。
Tile-based光照:把屏幕分成若干tile,每个tile计算受哪些光源影响,然后着色时只遍历相关光源。这个实现复杂但效果好,适合光源密集的场景。
Clustered光照:在tile的基础上增加深度维度,把视锥体分成三维簇。这是目前最先进的做法,但实现难度也最高。
对于大多数项目,光源裁剪加上限制同时活跃的光源数量(比如最多8个)就够用了。我在实际项目里用的是每物体最多4个影响最大的光源,超过的光源用IBL近似,效果和性能的平衡比较好。
6.3 IBL的烘焙流程
IBL需要三张预计算贴图:辐照度图、预过滤环境图、BRDF LUT。烘焙流程可以离线做,也可以在引擎启动时做。
离线烘焙的步骤:
- 加载环境立方体贴图(HDR格式)
- 用计算着色器把环境图卷积成辐照度图(半球积分)
- 对每个粗糙度级别,用重要性采样生成预过滤环境图
- 用计算着色器生成BRDF LUT
如果启动时烘焙,要注意不要在渲染循环里做,否则会卡顿。正确做法是在加载画面时用异步计算队列跑烘焙,烘焙完成后再进入主循环。
预过滤环境图的重要性采样有个细节:采样数要足够多。我一开始用64个采样,结果粗糙度高的mip有明显的噪点。加到256之后噪点基本消失,但烘焙时间从0.5秒涨到了2秒。实际项目中可以在质量和时间之间取平衡,128个采样是个不错的折中。
7. 收尾:几个我踩过的坑和对应的解法
PBR在DX12里的实现,说难不难,说简单也不简单。核心公式就那么几个,但要把它们正确地串起来,中间有太多细节容易出错。
第一个坑是颜色空间。albedo贴图是sRGB,metallic-roughness是线性,光照计算在线性空间做,最后输出到sRGB。这条链上任何一步搞错,画面颜色就会不对。我的建议是在代码里明确标注每个变量的颜色空间,贴图加载时就做好转换,不要留到着色器里做。
第二个坑是法线贴图的强度。法线贴图的XY分量通常需要乘以一个强度系数再归一化。如果直接用原始值,法线贴图的效果可能过强或过弱。我一般把强度设成1.0,然后在材质层面调整,这样美术调起来更直观。
第三个坑是PSO的创建时机。DX12的PSO创建是同步的,如果在渲染循环里创建会卡帧。正确做法是在加载阶段把所有需要的PSO都创建好,运行时只做切换。如果材质系统支持动态变化,可以预创建一组PSO变体,用的时候直接选。
最后一个建议:先用一个简单的球体测试PBR,不要一上来就往复杂场景里塞。球体上光照的变化最直观,调参数的时候反馈最快。等球体上的效果满意了,再应用到实际模型上。我在球体上调了大概两天,把各种边界情况都摸清楚了,后面往场景里加的时候基本没再出问题。