拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

DX12实战:从Phong到PBR,基于物理的渲染管线改造指南

DX12实战:从Phong到PBR,基于物理的渲染管线改造指南

1. 从光照立方体到PBR:为什么这一步非走不可

很多人跟着教程把DX12的三角形画出来、把贴图贴上去之后,就卡在了"下一步该做什么"上。我自己当初也是这样,手里有一个能跑的光照模型,看起来还行,但总觉得哪里不对——金属不像金属,塑料不像塑料,同一个光源下不同材质的反应几乎一模一样。这个问题的根源在于,传统的光照模型(比如Phong、Blinn-Phong)本质上是在"模拟看起来像什么",而不是"模拟光到底怎么跟物体作用"。PBR(Physically Based Rendering,基于物理的渲染)要解决的就是这件事。

这篇文章是"学一下DX12"系列的第二篇,核心目标很明确:在已有的DX12渲染管线里,把光照模型从经验模型切换到PBR。我不会只给你一堆公式让你抄,而是会把每个参数为什么这么定、代码为什么这么写、踩过哪些坑都讲清楚。适合已经能用DX12画出东西、但对PBR还停留在"听说过"阶段的读者。如果你连根签名和PSO都还没搞明白,建议先把第一篇的内容消化掉再来看这篇,不然会非常痛苦。

先说结论:PBR不是某一个具体的算法,而是一套约束和框架。它要求你遵守能量守恒、使用物理量纲的参数、把光照计算拆成漫反射和镜面反射两部分分别处理。在DX12里实现PBR,难点不在数学,而在于常量缓冲区的布局、纹理资源的组织、以及着色器编译时那些让人抓狂的报错。下面我会按实际开发的顺序,一步步拆开讲。

2. PBR的数学底子:别被公式吓住,先搞懂它在算什么

2.1 渲染方程到底在说什么

PBR的理论基础是渲染方程,完整形式看起来吓人,但在实时渲染里我们做的是它的简化版本。核心思想就一句话:一个着色点最终反射出来的光,等于它接收到的所有方向的光,乘以它在这个方向上反射出去的比例,再积分。实时渲染没法真的做积分,所以我们把它离散化——对每个光源单独算一次贡献,然后加起来。

这个"反射出去的比例"就是BRDF(双向反射分布函数)。PBR的核心工作就是选一个物理上合理的BRDF,然后用它来计算每个像素的颜色。目前业界最通用的是Cook-Torrance BRDF,它把反射拆成漫反射项和镜面反射项:

  • 漫反射项:用Lambert模型,简单说就是光线打到粗糙表面后向各个方向均匀散射。
  • 镜面反射项:用微表面模型,包含法线分布函数(D)、几何遮蔽函数(G)、菲涅尔项(F)三个部分。

我一开始看这些公式的时候也觉得头大,但实际写代码的时候你会发现,真正需要你理解的只有每个函数在"控制什么视觉效果"。D控制高光的形状和集中程度,G控制掠射角下的能量损失,F控制不同角度下的反射率变化。把这三个搞明白,公式只是翻译问题。

2.2 金属度与粗糙度:两个参数撑起整个材质系统

PBR工作流里最常见的参数化方式是金属度(Metallic)和粗糙度(Roughness)。这两个参数之所以好用,是因为它们能覆盖绝大多数真实材质:

材质类型MetallicRoughness典型表现
塑料0.00.3-0.6有彩色高光,漫反射明显
纯金属1.00.1-0.4高光带材质本色,几乎无漫反射
生锈铁0.8-1.00.7-0.9高光散开,颜色偏暗
陶瓷0.00.1-0.2高光锐利,表面光滑

这里有个关键规则:金属的漫反射为零。因为金属的自由电子会吸收折射进内部的光,所以金属只有镜面反射。很多新手做出来的金属看起来像塑料,就是因为没有把漫反射项按金属度正确插值掉。

我在实际项目里用的插值方式是:

float3 diffuseColor = albedo * (1.0 - metallic); float3 specularColor = lerp(0.04, albedo, metallic);

0.04是大多数非金属材质的基准反射率(F0),这个值不是随便定的,是从实际测量数据里来的。介电质(非金属)的F0通常在0.02到0.05之间,取0.04是个通用近似。

2.3 能量守恒:PBR的底线

能量守恒听起来很学术,但它的实际意义很简单:一个表面反射出去的光,不能比它接收到的多。违反这条规则的直接后果就是材质看起来"发光"或者过曝。

在Cook-Torrance里,漫反射项需要乘以(1-F)来保证镜面反射和漫反射加起来不超过入射能量。同时,镜面反射的D、G、F三项组合起来也要满足能量约束。很多简化实现里会忽略这些细节,结果就是高光特别亮、暗部特别黑,整体看起来很不自然。

我踩过的一个坑是:一开始没做漫反射的能量补偿,金属度调到1的时候整个物体还是有一层灰蒙蒙的漫反射,看起来像蒙了层灰。后来加上(1-metallic)的乘法之后,金属的质感立刻就对了。

3. DX12管线里塞进PBR:常量缓冲和纹理怎么改

3.1 常量缓冲区的重新设计

第一篇里我们的常量缓冲区可能只有一个世界矩阵加一个光照方向。加入PBR之后,每个材质需要传递的参数变多了:albedo颜色、金属度、粗糙度、环境光强度、相机位置等等。这时候如果还把所有东西塞在一个CBV里,会非常难维护。

我的做法是把常量缓冲拆成两个:

  • 每帧更新:相机位置、光照方向、光照颜色、环境光强度、时间。
  • 每材质更新:albedo、metallic、roughness、法线强度、AO强度。

在DX12里,这意味着你需要两个CBV,分别绑定到不同的寄存器。根签名也要相应调整。这里有个容易忽略的点:常量缓冲区的大小必须是256字节的整数倍。我一开始没注意,结构体只有200字节,结果GPU读到的数据全是乱的,排查了半天才发现是对齐问题。

struct FrameConstants { XMFLOAT4X4 viewProj; XMFLOAT4 cameraPos; XMFLOAT4 lightDir; XMFLOAT4 lightColor; XMFLOAT4 ambientColor; float exposure; float padding[3]; // 补齐到256字节 }; struct MaterialConstants { XMFLOAT4 albedo; float metallic; float roughness; float normalStrength; float aoStrength; float padding[4]; // 同样补齐 };

注意:HLSL里的cbuffer布局默认是16字节对齐,但C++结构体不是。如果你在C++里用了float3,在HLSL里对应float3,中间可能会差4字节导致错位。最稳妥的做法是全部用float4,或者手动加padding。

3.2 纹理资源的组织方式

PBR材质通常需要这几张贴图:albedo、normal、metallic-roughness(合并在一张图的R和G通道)、AO(可以放在B通道)。我习惯把metallic、roughness、AO打包到一张贴图里,这样能省显存带宽,也能减少纹理采样次数。

在DX12里创建这些纹理资源的时候,有几个细节值得注意:

  • sRGB问题:albedo贴图是sRGB空间,normal和metallic-roughness是线性空间。如果你在创建SRV的时候搞错了格式,颜色会明显偏亮或偏暗。我的经验是albedo用DXGI_FORMAT_R8G8B8A8_UNORM_SRGB,其他用DXGI_FORMAT_R8G8B8A8_UNORM。
  • mipmap生成:DX12不像DX11那样有自动生成mipmap的辅助方法,你需要自己写compute shader或者用工具预生成。我一开始偷懒没做mipmap,结果远处物体闪烁得非常厉害。
  • 描述符堆管理:每个材质需要多个SRV,如果每个材质单独开一个描述符堆,很快就会耗尽。建议用一个大的描述符堆统一管理,用偏移量来索引。

3.3 根签名的调整

根签名是DX12里比较烦人的部分,但理解之后其实很直接。加入PBR之后,我用的根签名大概长这样:

// 根参数0:每帧常量缓冲 CD3DX12_ROOT_PARAMETER1 param[3]; param[0].InitAsConstantBufferView(0, 0, D3D12_ROOT_DESCRIPTOR_FLAG_NONE, D3D12_SHADER_VISIBILITY_ALL); // 根参数1:每材质常量缓冲 param[1].InitAsConstantBufferView(1, 0, D3D12_ROOT_DESCRIPTOR_FLAG_NONE, D3D12_SHADER_VISIBILITY_PIXEL); // 根参数2:材质纹理表 CD3DX12_DESCRIPTOR_RANGE1 ranges[1]; ranges[0].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 4, 0, 0); param[2].InitAsDescriptorTable(1, &ranges[0], D3D12_SHADER_VISIBILITY_PIXEL);

这里我把四个纹理(albedo、normal、metallic-roughness、AO)放在同一个描述符表里,用t0到t3的寄存器。这样切换材质的时候只需要改变描述符表的基地址,不需要重新绑定根签名。

踩过的坑:根签名里的D3D12_SHADER_VISIBILITY如果设错了,着色器里会读不到数据,但不会有任何报错,只是结果全黑。我建议一开始全部设成ALL,跑通之后再按需优化。

4. 着色器实现:Cook-Torrance BRDF的HLSL落地

4.1 法线分布函数的选择与实现

法线分布函数(NDF)描述的是微表面法线朝向某个方向的概率密度。常用的有GGX(也叫Trowbridge-Reitz)和Beckmann。我选GGX,因为它的高光尾部更长,看起来更接近真实材质。

float DistributionGGX(float3 N, float3 H, float roughness) { float a = roughness * roughness; float a2 = a * a; float NdotH = max(dot(N, H), 0.0); float NdotH2 = NdotH * NdotH; float nom = a2; float denom = (NdotH2 * (a2 - 1.0) + 1.0); denom = PI * denom * denom; return nom / max(denom, 0.0001); }

这里有个细节:roughness在传入之前通常要做一次平方,因为感知上的粗糙度和物理上的粗糙度不是线性关系。很多教程直接拿roughness用,结果就是粗糙度调到0.5的时候看起来还是很光滑。我一般会在材质参数里直接存roughness的平方值,或者在使用前做一次roughness * roughness。

4.2 几何遮蔽函数的坑

几何遮蔽函数(G)描述的是微表面之间互相遮挡导致的光能损失。Smith-GGX是最常用的组合。这里有个容易出错的地方:直接光和IBL用的G函数不一样。直接光用Smith-GGX,IBL用Smith-Schlick或者Karis的近似。

float GeometrySchlickGGX(float NdotV, float roughness) { float r = (roughness + 1.0); float k = (r * r) / 8.0; float nom = NdotV; float denom = NdotV * (1.0 - k) + k; return nom / denom; } float GeometrySmith(float3 N, float3 V, float3 L, float roughness) { float NdotV = max(dot(N, V), 0.0); float NdotL = max(dot(N, L), 0.0); float ggx2 = GeometrySchlickGGX(NdotV, roughness); float ggx1 = GeometrySchlickGGX(NdotL, roughness); return ggx1 * ggx2; }

我一开始把直接光和IBL的k值搞混了,直接光应该用(roughness+1)^2/8,IBL用roughness^2/2。搞混的后果是环境反射的高光边缘特别硬,看起来很不自然。

4.3 菲涅尔项与F0

菲涅尔项描述的是不同视角下反射率的变化。Schlick近似是最常用的:

float3 FresnelSchlick(float cosTheta, float3 F0) { return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); }

F0就是垂直入射时的反射率。对于非金属,F0约等于0.04;对于金属,F0就是albedo本身。这就是为什么金属的高光带颜色,而非金属的高光通常是白色的。

4.4 把BRDF组装起来

最终的直接光计算大概是这样:

float3 N = normalize(input.normal); float3 V = normalize(cameraPos - input.worldPos); float3 L = normalize(-lightDir); float3 H = normalize(V + L); float NdotL = max(dot(N, L), 0.0); float NdotV = max(dot(N, V), 0.0); float3 F0 = lerp(0.04, albedo, metallic); float3 F = FresnelSchlick(max(dot(H, V), 0.0), F0); float D = DistributionGGX(N, H, roughness); float G = GeometrySmith(N, V, L, roughness); float3 numerator = D * G * F; float denominator = 4.0 * NdotV * NdotL + 0.0001; float3 specular = numerator / denominator; float3 kS = F; float3 kD = (1.0 - kS) * (1.0 - metallic); float3 diffuse = kD * albedo / PI; float3 Lo = (diffuse + specular) * lightColor * NdotL;

这段代码我建议你逐行理解,不要直接抄。特别是kD和kS的关系,以及为什么漫反射要除以PI(因为Lambert BRDF的定义就是albedo/PI)。

5. 环境光照:没有IBL的PBR是不完整的

5.1 为什么直接光不够

只做直接光的PBR,物体背光面会完全死黑,看起来像在太空里。真实场景里,物体还会接收到来自周围环境的反射光。这就是IBL(Image Based Lighting)要解决的问题。

IBL的核心思路是把环境贴图当成一个巨大的光源,对每个着色点计算来自半球所有方向的入射光。实时渲染里没法真的积分,所以我们预计算两张贴图:一张是辐照度图(irradiance map),用于漫反射;一张是预过滤环境图(prefiltered environment map),用于镜面反射。

5.2 辐照度图的生成

辐照度图可以用球谐函数(SH)来近似,也可以用卷积计算。我选的是卷积方式,因为实现起来更直观。在DX12里,这通常是一个compute shader的工作:把环境贴图作为输入,对每个输出像素在半球上采样若干方向,累加颜色。

// 简化的辐照度卷积 float3 irradiance = float3(0, 0, 0); float3 up = float3(0, 1, 0); float3 right = normalize(cross(up, N)); up = normalize(cross(N, right)); float sampleDelta = 0.025; float nrSamples = 0.0; for (float phi = 0.0; phi < 2.0 * PI; phi += sampleDelta) { for (float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta) { float3 tangentSample = float3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); float3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N; irradiance += envMap.SampleLevel(linearSampler, sampleVec, 0).rgb * cos(theta) * sin(theta); nrSamples++; } } irradiance = PI * irradiance / nrSamples;

这段代码的采样密度是0.025,输出贴图我一般用32x32就够了,因为辐照度图本身是低频信息。

5.3 预过滤环境图的mipmap策略

预过滤环境图需要为每个粗糙度级别生成一张mipmap。roughness=0对应最清晰的反射,roughness=1对应最模糊的反射。我一般生成5到6级mipmap,每级对应不同的roughness。

这里有个关键点:采样时的mipmap级别要根据roughness来选。如果直接用SampleLevel指定mip级别,过渡会很不平滑。更好的做法是用Sample让硬件自动做三线性插值,但需要把roughness映射到mip级别。

float mipLevel = roughness * maxMipLevel; float3 prefilteredColor = prefilteredMap.SampleLevel(linearSampler, R, mipLevel).rgb;

5.4 BRDF LUT:那个容易被忽略的小贴图

IBL的镜面反射部分还需要一张BRDF积分贴图(LUT),它把NdotV和roughness映射到F0的缩放和偏移。这张图只需要生成一次,可以离线做好存成文件,也可以在初始化时用compute shader生成。

float2 IntegrateBRDF(float NdotV, float roughness) { float3 V = float3(sqrt(1.0 - NdotV * NdotV), 0.0, NdotV); float A = 0.0; float 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; }

这张LUT我建议用128x128的分辨率,再大也看不出区别。

6. 调试与验证:怎么判断你的PBR是对的

6.1 用标准材质球做基准测试

PBR实现完之后,最有效的验证方式是渲染一组标准材质球:金属度从0到1,粗糙度从0到1,排成一个网格。如果实现正确,你应该看到:

  • 左下角(metallic=0, roughness=0)应该是非常锐利的白色高光,像镜子一样的塑料。
  • 右下角(metallic=0, roughness=1)应该是完全漫反射的哑光表面。
  • 左上角(metallic=1, roughness=0)应该是带颜色的镜面反射,颜色就是albedo。
  • 右上角(metallic=1, roughness=1)应该是模糊的金属反射,颜色偏暗。

如果金属球看起来像塑料,检查漫反射有没有按metallic正确消除。如果粗糙度变化不明显,检查roughness有没有做平方。

6.2 常见问题排查表

现象可能原因排查方法
金属看起来像塑料漫反射没按metallic消除检查kD计算
高光过亮/过暗F0值不对非金属应为0.04
粗糙度变化不明显roughness没做平方检查D函数输入
背光面死黑没有IBL加环境光或IBL
法线贴图无效切线空间不对检查TBN矩阵
颜色偏亮/偏暗sRGB格式错误检查SRV格式

6.3 性能优化的几个方向

PBR的计算量比Phong大不少,在移动端或者低端GPU上需要优化。我常用的手段有:

  • 预计算BRDF LUT:不要每帧算,离线做好。
  • 降低IBL采样数:辐照度图用32x32就够了,预过滤图用128x128。
  • 合并纹理采样:metallic、roughness、AO打包到一张图。
  • 使用half精度:在支持的平台上,颜色计算可以用half。
  • 避免分支:着色器里的if尽量用lerp代替。

7. 我踩过的那些坑和最后的建议

第一个大坑是常量缓冲区的对齐问题。我一开始用float3定义光照方向,结果在HLSL里读出来全是乱的。后来全部改成float4,问题消失。DX12对内存布局的要求比DX11严格得多,任何不对齐都会导致静默错误。

第二个坑是描述符堆的释放时机。PBR需要更多的SRV,如果每帧都创建新的描述符堆,很快就会耗尽。我的做法是预分配一个足够大的堆,用freelist管理空闲槽位。

第三个坑是法线贴图的绿色通道方向。不同工具生成的法线贴图,绿色通道可能是反的(OpenGL和DirectX的约定不同)。如果法线贴图看起来凹凸相反,把绿色通道取反试试。

最后一个建议:不要一开始就追求完美。我见过很多人卡在IBL的预计算上,结果连直接光的PBR都没跑通。正确的顺序是:先做直接光PBR,验证材质球正确;再加简单的环境光;最后才上完整的IBL。每一步都确保能看到正确的结果,再往下走。

PBR这个东西,看公式觉得复杂,但真正写出来之后你会发现核心代码也就一百多行。难的是理解每个参数背后的物理意义,以及在不同场景下怎么调。我到现在也不敢说完全吃透了,但至少现在做出来的金属像金属、塑料像塑料,这比什么都重要。

返回列表