尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

DX12 PBR渲染实战:金属度、粗糙度与线性空间全解析

DX12 PBR渲染实战:金属度、粗糙度与线性空间全解析 如果你是跟着这个系列一路写过来的应该还记得第一篇里我花了很大篇幅处理 DX12 的初始化三件套创建设备、配命令队列、建交换链最后屏幕上能画出一个受了 Blinn-Phong 光照的旋转立方体。这周继续第二篇目标很明确给这个 DX12 小渲染器加入 PBR 材质把它从一眼假的手工模型变成能用参数表达真实质感的材质系统。这篇文章适合已经跑通 D3D12 最基础绘图流程、想进一步了解 PBR 在 DX12 里到底怎么组织数据和踩坑的人。1. 为什么换掉 Blinn-Phong上一版效果差在哪PBR 补了什么先说上一篇那个立方体。我用的是最传统的 Blinn-Phongspec pow(max(dot(N,H),0), shininess)然后漫反射用 Lambert两者简单叠加。写的时候感觉很正常但调了几次参数之后就发现问题了每个物体的高光都靠一个光滑度数字硬撑材质本身不是数据而是 shader 里写死的常数。最让我接受不了的是两个表现。第一金属和塑料在我那个旧 shader 里长得差不多无非是贴图颜色不同、高光强度调一调。但真实的金属会把反射光染上自身的颜色而塑料的反射光基本是白的这个差异靠 Blinn-Phong 的specularColor参数很难自然表达。第二旧光照完全没有能量守恒的概念一个粗糙度很低的球在半球光源下会亮到发白粗糙度高的球反而像被吸了光同一套参数换场景就得重新调。PBR 的思路是反过来不直接调高光强度而是用一组物理上说得通的材质参数来描述表面。最常见的是金属度工作流Metalness Workflow一个材质主要由四个值控制Albedo基础颜色。对金属来说它决定反射的色调对非金属来说它决定漫反射颜色。Metallic金属度。0 是非金属1 是金属中间值一般只在做过渡时用。Roughness粗糙度。0 是完全光滑1 是极度粗糙。AO / NormalMap环境光遮蔽和法线贴图负责补充细节。这套参数最妙的地方在于它不会因为场景光照不同就失效。同一个材质参数在一张 HDR 环境贴图下能产生金属质感在单一方向光下也能呈现正确的能量分布。PBR 的根源是微表面理论任何表面在微观尺度下都布满了小镜面宏观上看到的高光其实是无数个小镜面按照不同朝向反射叠加的结果。粗糙度控制这些小镜面的朝向分布金属度控制反射率这两者在数学上就对应了后面要写的 BRDF。关于参考我强烈建议去看 Epic 的《Real Shading in Unreal Engine 4》和《Moving Frostbite to PBR》这两篇它们把工程落地讲得非常清楚。我这篇文章不会搞完整推导只讲怎么在 DX12 里用最少的代码把效果立起来。2. Cook-Torrance 公式落进 HLSLGGX Smith Schlick 的工程写法PBR 光照模型里核心是 Cook-Torrance BRDF。公式长这样f kD * (albedo / PI) kS * (D * F * G) / (4 * dot(N,V) * dot(N,L))其中D是法线分布函数描述微表面朝向分布G是几何遮蔽项描述微表面互相遮挡F是菲涅尔项描述观察角度变化时反射率的提升。直接光渲染时最常用的组合是GGX Smith-Schlick Schlick Fresnel下面这段 HLSL 就是整个 PBR 光照的数学核心。2.1 公式与 HLSL 代码float DistributionGGX(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); } float GeometrySchlickGGX(float NdotX, float roughness) { float k (roughness 1.0) * (roughness 1.0) / 8.0; // 直接光照用的公式 return NdotX / (NdotX * (1.0 - k) k); } float GeometrySmith(float NdotV, float NdotL, float roughness) { return GeometrySchlickGGX(NdotV, roughness) * GeometrySchlickGGX(NdotL, roughness); } float3 FresnelSchlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); }三个地方必须说清楚。第一DistributionGGX里的a是roughness * roughness不是直接拿界面的粗糙度。原因是最初的 GGX 公式里的 α 本身就是方均根斜率工程上大家都习惯把它和roughness²对应起来。如果你直接把粗糙度当作 α 用会看到一个诡异现象roughness 从 0.1 调到 0.3 时高光几乎没变化到 0.8 以后又瞬间糊成一团。这个细节我一开始没注意测试球看起来永远像同一颗球。第二GeometrySchlickGGX里直接光的k是(roughness 1)^2 / 8而 IBL 环境光里用的是roughness^2 / 2。这两个公式经常在教程里混着贴我第一次直接把 IBL 版本用在直接光上结果光滑球的边缘亮得刺眼。第三Fresnel 用的是clamp(1.0 - cosTheta, 0, 1)cosTheta要取dot(H, V)不要偷懒用dot(N, V)。用NdotV的话掠射角的菲涅尔会提前出现甚至反光更弱整个球的暗部边缘会脏脏的。2.2 直接光循环与点光源衰减有了 BRDF 函数主函数里就是遍历灯光、合成光照。我目前只写了方向光和点光源点光源衰减用一个带半径的公式float3 PBR_DirectLight(float3 N, float3 V, float3 L, float3 radiance, float3 albedo, float metallic, float roughness) { float NdotL saturate(dot(N, L)); float NdotV saturate(dot(N, V)); float3 H normalize(V L); float D DistributionGGX(saturate(dot(N, H)), roughness); float G GeometrySmith(NdotV, NdotL, roughness); float3 F FresnelSchlick(saturate(dot(H, V)), F0); float3 specular (D * G * F) / max(4.0 * NdotV * NdotL, 1e-4); float3 kS F; // 菲涅尔本身决定镜面占比 float3 kD (1.0 - kS) * (1.0 - metallic); // 金属没有漫反射 return (kD * albedo / PI specular) * radiance * NdotL; }F0的取法也值得记一下float3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic);0.04 是常见电介质材质的垂直反射率近似值所有非金属默认用这个灰值。金属的 F0 直接等于 albedo这就是金属会染色反射的原因。这个 lerp 写法在工程里非常普及但它其实是把金属度和反射色绑在一起的近似做法真正的 PBR 工作流里镀层、清漆之类的特效还是要单独处理 F0。2.3 第一次跑起来为什么金属球发黑代码写完后我用一组测试球渲染结果肯定没有意外地翻车了所有金属度等于 1 的球除了一个极亮的点其余大块区域几乎全黑。仔细想了很久才反应过来PBR 的金属模型假定环境里有足够的光线来源包括来自周围的反射光。我只放了一个方向光金属球的漫反射被(1 - metallic)直接乘没了镜面反射又是又窄又亮的一小块其余区域自然就是黑的。这不是公式错了是场景里缺少环境光。所以补环境光之前单纯靠直接光看金属材质效果一定不完整。这一点也正好说明为什么后面必须做 IBL基于图像的光照——下一节最后我再展开。3. 把材质参数送到 GPU根签名、描述符堆与纹理上传PBR 落到实处很大一部分工程量不在 shader而是在 DX12 的资源管理上。D3D12 和 D3D11 最大的区别是资源绑定状态完全由你自己决定没有PSSetShaderResources这种自动帮你处理的 API。所以要先想清楚一个常量怎么送到 GPU、一个纹理怎么送到 GPU。3.1 常量缓冲区的切分我的做法是把常量分成两本一本是 per-frame放相机位置、视锥、灯光位置和颜色每帧更新一次另一本是 per-draw放世界矩阵和 PBR 参数。PBR 参数我直接在 CPU 端包了一个 Material 结构struct MaterialCB { float4 Albedo; float Metallic; float Roughness; float Ao; float _pad; float4x4 World; };学习阶段用这个结构完全够用但注意常量缓冲区要求 256 字节对齐结构体里要补 padding别把 Metalic/Roughness 直接堆在矩阵后面就算完。如果你像我一样后面打算做材质系统最好一早就把 per-draw 里的 PBR 参数固定下来之后换材质只需要改这个 CB不用动 shader。3.2 根签名与描述符堆的排布DX12 里资源绑定的 Entry Point 是根签名。我用的是一份比较基础的根签名总共三个根参数CD3DX12_DESCRIPTOR_RANGE cbvRange0; cbvRange0.Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 1, 0); // b0: per-frame CD3DX12_DESCRIPTOR_RANGE cbvRange1; cbvRange1.Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 1, 1); // b1: per-draw CD3DX12_DESCRIPTOR_RANGE srvRange; srvRange.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 3, 0); // t0: albedo, t1: normal, t2: orm CD3DX12_ROOT_PARAMETER params[3]; params[0].InitAsDescriptorTable(1, cbvRange0, D3D12_SHADER_VISIBILITY_ALL); params[1].InitAsDescriptorTable(1, cbvRange1, D3D12_SHADER_VISIBILITY_ALL); params[2].InitAsDescriptorTable(1, srvRange, D3D12_SHADER_VISIBILITY_PIXEL);为什么用 descriptor table 而不是 root descriptor学习阶段两者都能跑但 root descriptor 的数量和大小都有限制而且把纹理全部塞进 root 也不现实。用 descriptor table 的话每帧只需要往描述符堆里顺序分配几个槽位然后SetDescriptorTable指过去就完事。堆的申请方式很简单D3D12_DESCRIPTOR_HEAP_DESC heapDesc {}; heapDesc.NumDescriptors 2048; heapDesc.Type D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; heapDesc.Flags D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;我的做法是每帧开头从偏移 0 开始线性分配画完不用手动释放下一帧直接覆盖学习阶段这个策略最省事。注意你要在渲染命令列表执行前把 heap set 到 command list 上否则直接绑定根签名你会发现 shader 里全是黑的。3.3 纹理上传与通道打包纹理加载我直接用 stb_image 读图然后手动建默认堆资源、上传堆拷贝。DX12 里没有 D3D11 那种ID3D11DeviceContext::UpdateSubresource拷贝纹理要用D3D12_SUBRESOURCE_DATA配合UpdateSubresources。这里有个最常见的坑纹理每一行必须对齐到 256 字节否则拷贝出来的图是歪的。UINT64 rowWidth width * 4; UINT64 alignedRow Align(rowWidth, D3D12_TEXTURE_DATA_PITCH_ALIGNMENT); // 256 D3D12_SUBRESOURCE_DATA texData {}; texData.pData pixels; texData.RowPitch alignedRow; texData.SlicePitch alignedRow * height;如果你用 DirectXTex 的CreateWICTextureFromFile这个对齐它会内部处理但用 stb_image 就得自己动手。另外记得给纹理设置格式albedo 用DXGI_FORMAT_R8G8B8A8_UNORM_SRGBnormal 和 ORM 贴图用DXGI_FORMAT_R8G8B8A8_UNORM这里面的区别下一节讲。材质贴图打包我是自己定的R 通道放金属度G 通道放粗糙度B 通道放环境光遮蔽。如果你从 glTF 导入模型标准排布是 RAO、GRoughness、BMetallic。两种排布很容易写串shader 里采样之后通道名一定要对清楚float3 orm g_materialMap.Sample(g_sampler, uv).rgb; float ao orm.r; // glTF 排布Rao float rough orm.g; float metallic orm.b;我自己的测试球是自己用工具生成的贴图所以按照我方便的顺序来但代码里我用注释标明了。工程上建议跟 glTF 统一否则以后导入美术资源时每张图都要手动转通道。采样器用各向异性过滤就够我的静态采样器设成D3D12_STATIC_SAMPLER_DESC samplerDesc {}; samplerDesc.Filter D3D12_FILTER_ANISOTROPIC; samplerDesc.MaxAnisotropy 8; samplerDesc.AddressU D3D12_TEXTURE_ADDRESS_MODE_WRAP; samplerDesc.AddressV D3D12_TEXTURE_ADDRESS_MODE_WRAP; samplerDesc.AddressW D3D12_TEXTURE_ADDRESS_MODE_WRAP;4. 线性空间与 sRGB影响效果的大敌PBR 最大的隐形杀手不是公式而是色彩空间的混乱。我一开始直接把 albedo 贴图当普通贴图采样然后直接渲染到交换链结果整个画面灰蒙蒙的高光区域泛白暗部死黑。折腾大半天才发现问题是所有贴图在美术端都是 sRGB 编码光照计算必须在线性空间进行否则整个 BRDF 的能量换算全错。4.1 为什么必须在线性空间计算显示器的显示曲线是非线性的所以贴图存颜色时用了 sRGB 编码。GPU 在采样UNORM_SRGB格式的贴图时硬件会自动把 sRGB 编码解码回线性值然后你在线性空间里做光照。光照结果再在最终输出时编码回 sRGB。整个过程等价于linearColor pow(srgbColor, 2.2); // 解码近似 finalColor pow(linearLight, 1.0 / 2.2); // 编码如果中间少了一步相当于在 gamma 空间里做了一堆乘法加法那么漫反射会偏暗、高光会过曝不同亮度下的材质反射率和真实情况完全对不上。这一条在 PBR 里是硬性的不做线性空间后面所有测试球都没法看。albedo 贴图必须用UNORM_SRGB让硬件帮你解码。normal map 和 ORM 贴图则必须用UNORM普通格式因为它们是数据不是颜色不该被那条曲线扭曲。4.2 渲染目标格式、HDR 与伽马输出的选择另一个容易踩的点是渲染目标格式。PBR 的高光可能远远超过 1.0如果直接输出到R8G8B8A8_UNORM交换链超过 1 的部分全被硬件 clamp 成 1金属球上本来应该明亮的反射会糊成一大片白斑。想要正确表现至少要有一条R16G16B16A16_FLOAT的 HDR 中间渲染目标做完光照后用简单曝光映射再输出到交换链。我在测试阶段用的方案是中间渲染目标用R16G16B16A16_FLOATfinal 阶段做一次简单曝光float3 hdrColor float3(output.rgb); float exposure 1.0 / (1.0 luminance(hdrColor)); float3 mapped hdrColor * exposure; mapped pow(mapped, 1.0 / 2.2); // 近似 sRGB 编码这一步看起来很简陋但对于验证 PBR 效果已经足够了。等以后做 IBL环境贴图的动态范围和 tone mapping 会更复杂但现在先保证高光有超 1 的空间这一步做对。很多网上的 DX12 示例不愿意开浮点 RT直接往 UNORM 上炸颜色等到加 IBL 时一定会后悔。5. 用测试球验证 PBR肉眼/截图下的判断标准写完代码之后最重要的一步怎么判断 PBR 真的对了。别急着放一个 CG 模型进来先摆一组测试球。5.1 灰度球矩阵我搭了一个 2 行 N 列的球阵列第一行 metallic0第二行 metallic1每行 roughness 从 0 到 0.8 递增。所有球都用一个中性灰 albedo0.5, 0.5, 0.5这样可以排除颜色的干扰专心看反射分布。预期效果大概是下面这样MetallicRoughness预期观察00.0近似理想球面镜只有一个极其锐利的点高光其余全黑00.4高光扩散成一个小柔光圈暗部背景保留漫反射00.8高光铺开成整半球漫反射主导边界处有掠射 fresnel 亮边10.0金属镜面效果没有漫反射高光颜色依然由 albedo 调制10.4金属的粗糙镜面高光边缘开始模糊反射颜色仍偏 albedo10.8粗糙金属亮斑大而柔和看起来接近磨砂金属我最初把 metallic0 的球的 F0 设成了 albedo结果非金属球的高光全是彩色的像涂了一层油漆。改成 F00.04 之后才正常。所以测试时一定要盯住非金属高光是白的金属高光是 albedo 颜色这个区分点。5.2 调参心得与常见故障我在调试过程中记录了几个印象深刻的故障高光炸成一片白光照强度太大或者没有 HDR 渲染目标。先调低radiance试试如果颜色能降下来就是范围问题。粗糙度调了没反应基本都在DistributionGGX里把roughness原封不动用上没有做平方。改成a roughness * roughness之后立刻见效。金属球发黑但高光又亮不是 bug是场景没有环境光。加一个暗的 constant ambient 可以暂时缓解但最好尽早做 IBL。掠射角处看不清 fresnel检查 Fresnel 函数里cosTheta用的是不是dot(H, V)以及最终乘完之后有没有.x.xyz之类的通道截断。贴图看起来歪歪扭扭RowPitch 没对齐或者上传堆的 slice pitch 算错检查D3D12_TEXTURE_DATA_PITCH_ALIGNMENT。还有一条很容易忽略法线贴图里我用的切线空间法线但球体的 UV 展开方向不能太乱否则切线方向算错高光会旋转偏移。我第二个测试球的高光位置一直不对排查半天发现是模型加载器里 tangent 没生成那当然不可能对。下一节我专门聊这块。6. 容易被忽略的边角料法线、坐标空间、资源状态与下一步6.1 法线贴图与切线空间正常情况下albedo/ORM 贴图已经能提供金属度和粗糙度信息但要让表面出现凹凸细节还是得用法线贴图。我在 shader 里用切线空间的法线贴图采样然后和 TBN 矩阵变换到世界空间float3 tangentNormal g_normalMap.Sample(g_sampler, uv).xyz * 2.0 - 1.0; float3 N mul(tangentNormal, TBN);这里的前提是模型必须有 tangent 属性。如果你的模型加载器只导入了 position/normal/uv没有导入 tangent/bitangent需要自己在 CPU 端根据 UV 和三角形拓扑生成。那套算法网上很多常见的手写实现基于 D3DX 的 D3DXComputeTangent。注意如果你的 UV 有镜像共用的情况要按三角形法线朝向判断 tangent 的方向否则法线贴图会在镜像区域出现翻转断裂。线代上还有个小坑如果模型有非均匀缩放世界矩阵不能直接拿来变换法线要用逆转置矩阵不要直接把 WVP 矩阵连乘到 TBN 上。我这次球体没有缩放所以偷懒了但如果你后面导入非均匀缩放的模型这个点必须单独处理。6.2 资源状态与坐标一致性DX12 比 D3D11 更要求你精确管理资源状态。纹理上传之后必须从D3D12_RESOURCE_STATE_COPY_DEST过渡到D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE否则采样到的一律是垃圾数据。我的常见写法CD3DX12_RESOURCE_BARRIER barrier CD3DX12_RESOURCE_BARRIER::Transition( texture.Get(), D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); commandList-ResourceBarrier(1, barrier);不要把所有资源一次性全部初始化再一次性提交过渡最好在每帧开始或者资源创建完立刻做。连续在两个命令列表里访问同一块纹理时也要考虑之前的资源状态是否已经正确过渡。这块如果出问题现象往往是第一帧纹理正常第二帧开始花屏或者黑屏。坐标一致性也要检查。我刚开始把灯光位置和相机位置放在 world space但球体用的是模型 local 坐标光照函数里直接用 local 法线和 world 灯光点乘结果高光位置永远不对而且旋转球体灯光也跟着转了。PBR 计算必须在同一个空间里最简单的是把 position 先变换到 world space 再算V normalize(cameraPos - worldPos)别在 local 空间里偷懒。6.3 下一步IBL 环境光回到 2.3 里说的方向光点光不能让金属表面完整表现的问题。真正的 PBR 想让金属看着像金属必须要有环境光源。下一期的计划是加 IBL把一张 HDR 全景图过滤成漫反射 irradiance map 和预滤过的 specular prefiltered map再配合一张 BRDF LUT 贴图。其中 specular 部分会对 GGX 进行不同 roughness 的预积分到时候你就能看到那颗金属球在环境里转出非常自然的反射了。现在这个阶段我建议你至少先做到这一步把方向光强度调低加一个很弱的环境常数项让测试球能看清材质特征的分布不然金属球永远是黑的。最后说一点个人体会PBR 的入门门槛不在于公式有多难而在于它把所有看似合理但物理错误的近似都拒绝掉了。线性空间、能量守恒、HDR 范围每一项单独拎出来都很简单但组合在一起时任何一个环节出错都会让最终的材质效果变得塑料。我这次调了两晚最后的收获不是多了一个会算 GGX 的渲染器而是终于理解了一个基础问题为什么你的金属球看起来不像金属。答案往往不在 BRDF而在 BRDF 外面的整个工程链路里。
返回列表