多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

DX12实战:从Blinn-Phong到PBR的完整实现与工程避坑指南

DX12实战:从Blinn-Phong到PBR的完整实现与工程避坑指南 1. 从零到一为什么在DX12里加入PBR是绕不开的一步如果你已经跟着上一篇文章把DX12的初始化框架搭起来了命令队列、交换链、描述符堆、根签名这些基础设施都跑通了屏幕上能稳定输出一个三角形或者一个带贴图的立方体那么恭喜你最难啃的骨头已经啃掉了一半。但接下来你大概率会面临一个很现实的问题画面看起来太“平”了。贴图贴上去之后模型表面要么像塑料要么像纸片光照一打上去高光位置不对暗部死黑一片完全没有真实材质该有的那种质感。这不是你的代码写错了而是你用的光照模型还停留在Lambert或者Blinn-Phong那个年代。PBR全称Physically Based Rendering中文一般叫基于物理的渲染。它不是一个具体的算法而是一整套关于光和材质如何交互的建模思路。你可以在DX11里做PBR也可以在DX12里做PBR甚至用OpenGL、Vulkan都能做。但DX12给了你更底层的控制权你可以自己管理描述符、自己控制资源屏障、自己决定什么时候更新常量缓冲区这些在DX11里是驱动帮你做的。换句话说DX12让你有能力把PBR的每一个环节都捏在手里但代价是你得知道每一个环节到底在干什么。这篇文章要解决的问题很具体在已经跑通的DX12框架里把原来的简单光照替换成PBR光照模型。我会从PBR的核心参数讲起然后一步步拆解怎么把这些参数塞进DX12的常量缓冲区、怎么组织根签名、怎么在着色器里做正确的计算最后会分享几个我在实际项目里踩过的坑比如法线贴图的切线空间怎么处理、金属度工作流的能量守恒怎么保证、以及DX12特有的描述符管理在PBR材质系统里该怎么设计。适合已经写过DX12初始化代码、但还没把PBR完整跑起来的读者。如果你连命令列表都还没提交过建议先回去把上一部分补完不然直接上PBR会非常痛苦。2. PBR的核心参数与DX12常量缓冲区的映射关系2.1 从Blinn-Phong到PBR到底变了什么先回忆一下Blinn-Phong是怎么算的。你有一个漫反射项一个高光项高光项用半角向量和法线的点积来算再乘一个高光强度。这个模型的问题在于它没有能量守恒的概念。你把高光强度调大漫反射不会相应减弱物体表面反射的总能量就凭空增加了。而且高光的大小和强度是独立的你没法通过一个物理上有意义的参数来控制它。PBR的核心改进就是引入了能量守恒和微表面理论。能量守恒的意思是入射光的总能量等于反射光加折射光对于不透明材质折射光就是被吸收的部分。你看到的颜色要么是反射出来的要么是被吸收后重新辐射出来的不会凭空多出来。微表面理论则是说任何表面在微观尺度上都是凹凸不平的由无数微小的镜面组成。这些微表面的朝向分布决定了高光的形状和强度。基于这个理论PBR用三个核心参数来描述材质基础颜色Albedo、金属度Metallic、粗糙度Roughness。Albedo是材质的固有色对于非金属它是漫反射颜色对于金属它是反射颜色。金属度是一个0到1的值0表示非金属电介质1表示纯金属。粗糙度也是0到10表示绝对光滑的镜面1表示完全漫反射的粗糙表面。这三个参数加上法线贴图基本上就能描述绝大多数常见材质了。2.2 DX12常量缓冲区怎么组织PBR参数在DX11里你可能会把材质参数直接塞进一个cbuffer然后每个物体更新一次。DX12里也能这么做但你需要更小心地管理常量缓冲区的生命周期。因为DX12是显式同步的你不能在GPU还在读某个常量缓冲区的时候去写它。常见的做法是使用环形缓冲区或者多份常量缓冲区每帧或者每个物体切换一份。对于PBR材质我建议把常量缓冲区分成两个部分一个是每帧更新的全局常量比如相机位置、光照方向、光照颜色另一个是每个物体更新的材质常量包括Albedo、Metallic、Roughness、法线贴图强度等。全局常量可以放在一个大的常量缓冲区里所有物体共享材质常量则需要每个物体一份或者用结构化缓冲区加索引的方式。下面是一个典型的PBR材质常量缓冲区布局cbuffer MaterialConstants : register(b2) { float4 albedoColor; // rgb: albedo, a: alpha float metallic; // 0 or 1, but can be in between float roughness; // 0 to 1 float normalScale; // normal map intensity float ambientOcclusion; // AO strength float2 padding; // align to 16 bytes };注意这里的对齐问题。DX12的常量缓冲区要求每个变量按照16字节对齐所以float后面经常需要补padding。如果你不补HLSL编译器可能会报错或者更糟糕的是数据会错位你看到的光照结果完全不对。我见过有人把metallic和roughness放在一起结果因为对齐问题roughness读到的其实是normalScale的值调了半天才发现是内存布局的问题。2.3 根签名设计把材质参数送到该去的地方根签名是DX12里比较绕的一个概念但理解之后你会发现它其实很灵活。简单说根签名就是告诉GPU着色器需要哪些资源这些资源怎么绑定。对于PBR你至少需要绑定一个常量缓冲区材质参数、一个纹理数组Albedo、Normal、Metallic-Roughness、AO等、以及一个采样器。我推荐的设计是根参数0放一个常量缓冲区视图CBV指向每帧的全局常量根参数1放一个描述符表里面包含所有材质纹理的SRV根参数2放一个描述符表里面是采样器。材质常量可以放在全局常量缓冲区的一个数组里用索引来访问也可以单独用一个CBV。如果材质数量不多直接每个物体一个CBV更简单。这里有个经验根签名的版本要尽量保持稳定。一旦你发布了根签名再改的话所有着色器都要重新编译。所以在项目初期就要想好PBR需要哪些资源把根签名设计得稍微宽裕一点比如预留几个根常量用于调试。我一般会留一个根常量作为“调试模式”开关可以在运行时切换不同的光照模型或者显示不同的中间结果非常方便。3. 着色器实现从理论公式到HLSL代码3.1 法线分布函数GGX还是Blinn-PhongPBR的核心公式是渲染方程但在实时渲染里我们用的是它的近似形式。最常用的微表面BRDF模型是Cook-Torrance它由三部分组成法线分布函数D、几何遮蔽函数G、菲涅尔项F。法线分布函数描述微表面法线的统计分布GGX是目前最流行的选择因为它有长尾特性高光边缘更自然。GGX的公式是float D_GGX(float NdotH, float roughness) { float a roughness * roughness; float a2 a * a; float NdotH2 NdotH * NdotH; float denom NdotH2 * (a2 - 1.0) 1.0; return a2 / (PI * denom * denom); }这里的roughness是感知粗糙度实际计算时通常用roughness的平方作为alpha。注意分母里的PI很多人会忘记除结果高光亮度差了一个数量级。我一开始也犯过这个错调了半天光照强度最后发现是公式里少了个PI。3.2 几何遮蔽与菲涅尔项几何遮蔽函数我用的是Smith-GGX它和GGX法线分布是配套的float G_Smith(float NdotV, float NdotL, float roughness) { float r roughness 1.0; float k (r * r) / 8.0; float Gv NdotV / (NdotV * (1.0 - k) k); float Gl NdotL / (NdotL * (1.0 - k) k); return Gv * Gl; }菲涅尔项用Schlick近似float3 F_Schlick(float VdotH, float3 F0) { return F0 (1.0 - F0) * pow(1.0 - VdotH, 5.0); }F0是垂直入射时的反射率。对于非金属F0大约是0.04对于金属F0就是Albedo。所以你可以用metallic来插值float3 F0 lerp(0.04, albedo, metallic);3.3 直接光照的完整计算把上面三部分组合起来直接光照的BRDF就是float3 SpecularBRDF(float3 N, float3 V, float3 L, float3 F0, float roughness) { float3 H normalize(V L); float NdotV max(dot(N, V), 0.0); float NdotL max(dot(N, L), 0.0); float NdotH max(dot(N, H), 0.0); float VdotH max(dot(V, H), 0.0); float D D_GGX(NdotH, roughness); float G G_Smith(NdotV, NdotL, roughness); float3 F F_Schlick(VdotH, F0); return (D * G * F) / (4.0 * NdotV * NdotL 0.001); }分母里的0.001是为了防止除零。漫反射部分用Lambertfloat3 DiffuseBRDF(float3 albedo, float metallic) { return albedo * (1.0 - metallic) / PI; }最终颜色是float3 color (DiffuseBRDF(albedo, metallic) SpecularBRDF(...)) * lightColor * NdotL;注意这里的NdotL要乘在最后因为BRDF本身已经包含了NdotL的归一化。如果你在BRDF里已经除了NdotL这里再乘一次就对了。我见过有人在BRDF里除了NdotL外面又乘了一次结果光照强度完全不对。4. DX12资源管理与PBR材质系统的工程实践4.1 纹理数组与描述符堆的规划PBR材质通常需要至少四张贴图Albedo、Normal、Metallic-Roughness、AO。如果每个材质都单独创建描述符描述符堆会迅速膨胀。更好的做法是把所有材质的贴图打包成一个纹理数组然后用一个描述符表来访问。DX12的纹理数组要求所有纹理的格式和尺寸一致所以你可能需要预处理一下把不同尺寸的贴图统一缩放到相同大小。描述符堆的规划也很重要。我一般会创建三个堆一个用于SRV着色器资源视图一个用于采样器一个用于RTV/DSV渲染目标和深度模板。SRV堆的大小要提前估算好比如你计划支持100个材质每个材质4张贴图那就是400个SRV。再加上一些中间渲染目标的SRV总共可能需要500个左右。DX12允许你创建很大的描述符堆但显存有限所以不要盲目开太大。4.2 资源屏障与纹理上传DX12里上传纹理比DX11麻烦得多。你需要创建一个上传堆Upload Heap和一个默认堆Default Heap然后把数据从上传堆拷贝到默认堆最后用资源屏障把默认堆的状态从COPY_DEST转换到PIXEL_SHADER_RESOURCE。这个过程对于每个纹理都要做一次如果纹理很多启动时间会很长。我的做法是批量上传。把所有纹理的上传命令记录到一个命令列表里然后一次性提交。这样可以减少CPU和GPU之间的同步开销。另外对于PBR材质Metallic-Roughness贴图通常是把金属度放在B通道粗糙度放在G通道这样可以节省一张贴图。AO可以放在R通道或者单独一张。如果你用ORM工作流Occlusion-Roughness-Metallic一张贴图就够了。4.3 常量缓冲区的更新策略前面提到过DX12里常量缓冲区的更新需要小心。我推荐使用多缓冲策略创建3到4个常量缓冲区每帧轮换使用。这样GPU在读第N帧的常量时CPU可以写第N1帧的常量不会冲突。对于材质常量如果材质数量不多可以直接放在一个大的常量缓冲区里每个物体一个偏移量。如果材质数量很多用结构化缓冲区加索引更合适。这里有个细节常量缓冲区的大小必须是256字节的倍数。如果你有100个材质每个材质64字节那就是6400字节需要向上取整到256的倍数也就是6656字节。这个大小完全没问题DX12支持很大的常量缓冲区。但要注意常量缓冲区的访问速度比结构化缓冲区快所以如果材质参数经常变用常量缓冲区更好。5. 常见问题与排查技巧实录5.1 画面全黑或者全白这是最常见的问题。如果画面全黑先检查光照方向是不是反了。在PBR里光照方向通常是从表面指向光源如果你传的是从光源指向表面NdotL就是负的整个场景就黑了。另外检查一下F0是不是设成了0如果F0是0菲涅尔项就没了高光完全消失。如果画面全白很可能是能量不守恒。检查一下漫反射和高光是不是都乘了NdotL或者BRDF的分母是不是漏了。还有一个可能是粗糙度设成了0GGX在粗糙度为0时会产生一个极亮的高光点如果分辨率不够整个屏幕都会过曝。5.2 高光位置不对或者形状奇怪高光位置不对通常是法线贴图的问题。检查切线空间的基向量是不是正确。在DX12里法线贴图的绿色通道方向可能和OpenGL相反如果你用的是从OpenGL移植的贴图需要翻转绿色通道。另外切线空间的TBN矩阵要用顶点法线、切线、副切线来构建副切线的方向由切线和法线的叉积决定但要注意手性。高光形状奇怪可能是粗糙度贴图的通道搞错了。有些引擎把粗糙度放在G通道有些放在A通道。如果你读错了通道粗糙度值可能一直是1或者0高光就会要么完全消失要么变成一个点。5.3 性能突然下降PBR的计算量比Blinn-Phong大不少特别是GGX的法线分布函数里面有除法和平法。如果你在移动端或者集成显卡上跑可能会感觉到明显的帧率下降。优化方法包括用半精度浮点数如果硬件支持、预计算部分BRDF项到查找纹理LUT、或者用近似公式代替精确计算。DX12特有的性能问题可能来自描述符堆的切换。如果你每画一个物体就切换一次描述符堆GPU会频繁等待。更好的做法是把相同材质的物体排在一起减少状态切换。另外根签名的切换也有开销尽量把相同根签名的绘制调用放在一起。5.4 常见问题速查表问题现象可能原因排查方法画面全黑光照方向反了、F0为0、NdotL为负检查光照向量方向打印F0值画面全白能量不守恒、粗糙度为0、分母漏了检查BRDF公式调大粗糙度高光位置不对法线贴图绿色通道反了、TBN矩阵错误翻转绿色通道检查切线计算高光形状奇怪粗糙度通道读错、贴图采样器设置错误检查贴图通道确认采样器过滤模式性能下降BRDF计算量太大、描述符堆频繁切换用LUT优化合并绘制调用材质看起来像塑料金属度设成了0、粗糙度太低调整金属度和粗糙度参数边缘有黑边法线贴图强度太大、AO太强降低法线强度检查AO贴图6. 个人实操心得与后续扩展方向6.1 几个让我少走弯路的习惯第一个习惯是写一个调试用的着色器模式。我通常会在材质常量里加一个debugMode字段0是正常PBR1是只显示Albedo2是只显示法线3是只显示粗糙度4是只显示金属度。这样当画面不对时我可以快速定位是哪个参数出了问题。这个习惯帮我省了无数个小时的调试时间。第二个习惯是把所有PBR参数都做成可以在运行时调整的。你可以用键盘按键来增减粗糙度或者金属度实时看到画面变化。这对于理解每个参数的作用非常有帮助。我刚开始学PBR的时候就是靠实时调整参数才真正搞明白金属度和粗糙度到底在干什么。第三个习惯是保存多个版本的根签名和着色器。PBR的实现方式有很多种你可能今天用GGX明天想试试Blinn-Phong的PBR变体。把不同版本保存下来方便对比和回退。DX12的着色器编译比较慢如果每次都要重新编译所有着色器开发效率会很低。6.2 后续可以扩展的方向如果你已经把基础PBR跑通了接下来可以尝试几个方向。一个是图像基于光照IBL用环境贴图来做漫反射和镜面反射的间接光照。这个需要预计算辐照度贴图和预过滤环境贴图稍微复杂一点但效果提升非常明显。另一个是多光源支持把直接光照循环起来支持点光源、聚光灯、方向光。DX12里可以用结构化缓冲区来传递光源数据非常灵活。还有一个方向是材质系统。你可以做一个材质编辑器把PBR参数可视化编辑然后导出成二进制格式运行时直接加载。这个对于实际项目非常有用因为美术不可能每次都改代码来调材质。DX12的描述符管理可以让材质切换非常高效只要提前把描述符堆组织好就行。最后如果你对性能有极致追求可以研究一下GPU驱动的渲染管线。DX12支持ExecuteIndirect可以把绘制调用打包成GPU命令减少CPU开销。对于PBR这种计算密集型的渲染GPU驱动的管线可以显著提升帧率。不过这个比较复杂建议先把基础PBR跑稳再说。我在实际项目里发现PBR的难点不在于公式本身而在于工程实现。公式是固定的但怎么组织资源、怎么管理状态、怎么优化性能这些才是真正花时间的地方。DX12给了你控制权但也给了你责任。每一个资源屏障、每一个描述符堆、每一个常量缓冲区更新你都要想清楚为什么这么做。想清楚了画面就对了。
返回列表