ARTICLE DETAIL

资讯详情

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

DX12 PBR渲染实战:从直接光照到物理材质管线搭建

DX12 PBR渲染实战:从直接光照到物理材质管线搭建 1. 从固定管线到PBR为什么DX12的第二课必须啃下这块硬骨头如果你已经用DX12把三角形画出来了把命令队列、命令列表、围栏同步这套流程跑通了那恭喜你最难啃的初始化部分已经过去了。但接下来这一步才是真正决定你渲染画面“像不像那么回事”的分水岭——把那个灰扑扑的、靠手调颜色和简单光照凑出来的模型换成基于物理的渲染也就是PBR。我见过太多人卡在这个节点上。DX12的样板代码本来就多PBR又牵扯到一整套材质参数、光照模型、色彩空间的概念两边一叠加很容易就懵了。网上讲PBR的文章不少但大多停留在公式推导或者拿Unity、Unreal的材质节点说事真正落到DX12这种底层API上、从零把PBR管线搭起来的完整记录其实并不多。这篇东西就是把我自己从“能画出东西”到“画出的东西有物理依据”这个过程里的关键节点、踩过的坑、以及那些文档里不会写的细节完整地捋一遍。先明确一下这篇内容适合谁。如果你已经完成了DX12的初始化能渲染出带纹理的模型对HLSL着色器有基本了解知道什么是顶点着色器和像素着色器那这篇就是为你准备的。如果你还在跟交换链和描述符堆较劲建议先把基础部分搞定再回来。PBR本身不复杂复杂的是它要求你对整个渲染管线的每个环节都有清晰的认知——从顶点数据里带什么到常量缓冲区怎么排布再到最终输出到屏幕前要不要做色调映射环环相扣少一环画面就不对。核心关键词就两个DX12和PBR。DX12是工具PBR是目标。我会把重点放在“怎么在DX12里把PBR落地”上而不是泛泛地讲PBR理论。理论部分我会讲但只讲那些你写代码时必须知道的比如为什么粗糙度要放在金属度通道里、菲涅尔项到底该怎么算、IBL和直接光照的区别在哪。每个参数、每个步骤我都会说清楚为什么这么做而不是甩一段代码让你抄。2. PBR的核心思路与DX12下的方案选型2.1 为什么是PBR而不是继续用Phong或Blinn-Phong在动手改代码之前得先想明白一件事我为什么要换PBRPhong模型调一调参数画面也能看何必折腾这个问题的答案决定了你后面写代码时的心态。Phong和Blinn-Phong是经验模型它们的高光、漫反射、环境光都是各自独立的项靠美术或者程序手动调参数来“凑”出好看的效果。一个材质在一种光照下调好了换一个光照环境可能就完全不对了。而PBR是基于物理原理的它遵循能量守恒材质参数有明确的物理含义——金属度就是金属度粗糙度就是粗糙度换任何光照环境材质的表现都是一致的。我举个实际的例子。你用Blinn-Phong做一个金属球高光颜色你得手动调环境反射你得手动加换个场景光照这些参数全得重调。而PBR的金属球你只需要设置金属度为1粗糙度给个0.2剩下的交给光照计算不管你把光源换成什么颜色、什么强度球体的表现都是自洽的。这就是PBR的价值——一次调好到处能用。在DX12里做PBR还有一个额外的好处DX12的底层特性让你能更精细地控制资源绑定和管线状态。PBR需要传递的材质参数比Phong多得多常量缓冲区的组织方式直接影响性能。DX12的描述符堆和根签名机制让你可以按需绑定不像DX11那样所有常量都得塞在一起。这个优势在材质种类多的时候特别明显。2.2 直接光照PBR与IBL的取舍PBR的光照分两大块直接光照和基于图像的光照IBL。直接光照就是处理场景里的平行光、点光源、聚光灯这些IBL则是用环境贴图来模拟间接光照。对于刚上手DX12 PBR的人来说我的建议是先把直接光照做扎实IBL可以后面再加。原因很简单直接光照的PBR公式是自包含的你不需要额外的预计算资源不需要生成辐照度贴图和预过滤环境贴图不需要BRDF积分贴图。你只需要一个光源方向、一个光源颜色加上材质的反照率、金属度、粗糙度就能算出像素颜色。这个闭环很短调试起来也直观——画面不对你改光源或者改材质参数立刻能看到变化。IBL就复杂多了。你需要把环境贴图做卷积生成辐照度贴图需要做预过滤生成不同粗糙度级别的环境贴图还需要一张BRDF积分贴图通常是LUT。这三样东西在DX12里怎么生成、怎么绑定、怎么采样每一步都有坑。我见过不少人直接上IBL结果环境贴图生成出来是黑的或者预过滤的mip级别对不上画面要么全黑要么全白排查起来非常痛苦。所以我的方案是第一阶段只做直接光照PBR用一个平行光作为主光源把Cook-Torrance BRDF跑通。等这个稳了再往上加IBL。这样每一步都有明确的验证标准不会一锅粥。2.3 材质参数的存储与传递方案PBR的材质参数比传统模型多怎么存、怎么传直接影响代码结构和性能。最直观的方案是每个材质一个常量缓冲区里面放反照率、金属度、粗糙度、法线强度这些。但DX12里常量缓冲区有256字节对齐的要求一个材质参数如果只有几十字节会浪费大量空间。而且频繁切换常量缓冲区意味着频繁更新根签名或描述符表性能不划算。我的做法是把材质参数打包进纹理。具体来说用一张基础颜色贴图存反照率RGB通道用一张金属粗糙度贴图存金属度B通道和粗糙度G通道法线贴图存切线空间法线。这样每个像素的材质参数直接从纹理采样不需要每帧更新常量缓冲区。常量缓冲区里只放全局的东西比如光源方向、光源颜色、相机位置、曝光值这些。这个方案的好处是材质切换成本极低——换材质就是换纹理绑定DX12的描述符表切换很快。而且美术做资产的时候直接导出这几张贴图就行流程很顺。坏处是纹理采样多了对带宽有压力但这个在现代GPU上不是大问题。注意金属粗糙度贴图的通道分配不是随便定的。粗糙度放G通道、金属度放B通道这是行业惯例很多资产库导出的贴图都遵循这个规则。你如果自己定通道后面接入外部资产时会很麻烦。3. 核心细节解析与DX12实操要点3.1 Cook-Torrance BRDF的代码落地PBR的核心公式是渲染方程但实际写代码时我们用的是它的实时近似版本。对于直接光照每个光源对像素的贡献可以写成Lo (kd * albedo / PI specular) * lightColor * NdotL其中漫反射项kd (1 - F) * (1 - metallic)镜面反射项specular用Cook-Torrance模型specular D * G * F / (4 * NdotV * NdotL)D是法线分布函数G是几何函数F是菲涅尔项。这三个函数各有多种实现选哪个版本有讲究。法线分布函数我推荐用GGX/Trowbridge-Reitz它的公式是D alpha^2 / (PI * (NdotH^2 * (alpha^2 - 1) 1)^2)其中alpha roughness^2。注意这里有个细节粗糙度要先平方再参与计算。很多教程直接拿roughness当alpha用结果就是粗糙度参数不线性调起来手感很怪。粗糙度平方之后参数变化对视觉的影响更均匀美术调起来更直观。几何函数我用Smith-GGX它和GGX法线分布是配套的G G1(NdotV) * G1(NdotL) G1(x) x / (x * (1 - k) k) k alpha / 2 // 直接光照用alpha/2IBL用alpha^2/2菲涅尔项用Schlick近似F F0 (1 - F0) * (1 - NdotV)^5F0是基础反射率对于非金属F0约等于0.04对于金属F0就是反照率本身。所以F0的计算是float3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic);这个lerp是PBR金属工作流的关键。金属度从0到1F0从0.04过渡到albedo中间值对应的是半金属或者脏金属物理上不完全准确但美术上很有用。在HLSL里这些函数我建议写成独立的工具函数放在一个公共的.hlsl文件里顶点着色器和像素着色器都能引用。DX12的着色器编译模型支持#include用起来很方便。3.2 常量缓冲区的对齐与打包DX12对常量缓冲区的内存布局有严格的对齐要求。每个变量必须按照其类型对齐float是4字节float2是8字节float3和float4都是16字节。这意味着你不能随便把float3和float拼在一起编译器会在中间插入填充。我踩过的坑一开始我把光源方向float3和光源强度float放在一起以为正好16字节。结果HLSL编译器把float3对齐到16字节边界float被挤到了下一个16字节块整个常量缓冲区的布局全乱了传进去的数据全是错的。正确的做法是显式地用float4打包。比如cbuffer PerFrameConstants : register(b0) { float4x4 viewProjection; float4 cameraPosition; // xyz是位置w不用 float4 lightDirection; // xyz是方向w是强度 float4 lightColor; // rgb是颜色w不用 float4 ambientColor; // rgb是环境光颜色w是曝光 };每个float4正好16字节对齐没问题。如果某个参数用不到w分量就空着不要试图塞别的类型进去。浪费几个字节比布局错乱强得多。在C侧对应的结构体也要用XMFLOAT4或者alignas(16)来保证对齐。我习惯用DirectXMath的XMFLOAT4系列它们本身就是16字节对齐的省心。3.3 纹理资源的绑定与采样器配置PBR需要至少三张纹理基础颜色、金属粗糙度、法线。在DX12里这三张纹理要放在描述符堆里通过根签名绑定到着色器。我的做法是创建一个着色器可见的CBV/SRV/UAV描述符堆把这三张纹理的SRV放在连续的位置。根签名里定义一个描述符表指向这个堆的起始位置。绘制的时候用SetGraphicsRootDescriptorTable把描述符表的GPU句柄传进去。采样器的配置也有讲究。基础颜色和金属粗糙度用线性过滤、重复寻址就行。法线贴图最好也用线性过滤但寻址模式要注意——如果法线贴图有接缝问题可能需要用钳制寻址。我一般先用重复寻址出问题了再改。还有一个细节法线贴图采样后法线分量是在[0,1]范围的需要映射到[-1,1]float3 normal tex.Sample(sampler, uv).rgb * 2.0 - 1.0;然后还要做切线空间到世界空间的变换。这需要顶点数据里带切线和副切线。如果你的模型没有切线数据可以用屏幕空间导数来重建切线但效果会差一些。我建议在导入模型时就生成好切线省得后面麻烦。提示金属粗糙度贴图的采样粗糙度在G通道金属度在B通道。采样后直接取.g和.b就行不用做额外映射。但要注意如果你的贴图是sRGB格式金属粗糙度贴图不能是sRGB因为金属度和粗糙度是线性数据不是颜色。基础颜色贴图才需要sRGB。4. 完整实操流程从零搭建DX12 PBR管线4.1 项目结构与着色器文件组织在动手改代码之前先把文件结构理清楚。我的习惯是Shaders/目录放所有.hlsl文件PBRCommon.hlsl公共函数BRDF计算、法线变换这些PBRVS.hlsl顶点着色器PBRPS.hlsl像素着色器Source/目录放C代码PBRRenderer.h/cppPBR渲染逻辑D3D12Utils.h/cppDX12工具函数资源创建、描述符管理等着色器编译我用DXCDirectX Shader Compiler它支持Shader Model 6.0以上编译出来的DXIL比FXC的DXBC效率更高。在C里用DxcCreateInstance创建编译器编译成DXIL字节码然后创建管线状态对象。编译参数要注意PBR的像素着色器计算量大建议开优化。DXC的编译参数里加-O3并且指定目标为ps_6_0或更高。如果要用到波次内在函数wave intrinsics得用ps_6_0以上。4.2 顶点数据结构与输入布局PBR的顶点数据比之前多了切线和副切线。我的顶点结构是这样的struct Vertex { XMFLOAT3 position; XMFLOAT3 normal; XMFLOAT3 tangent; XMFLOAT2 uv; };副切线不用显式存在着色器里用叉积算bitangent cross(normal, tangent) * tangent.w。tangent.w存的是手性符号保证副切线方向正确。输入布局要对应D3D12_INPUT_ELEMENT_DESC inputLayout[] { { POSITION, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }, { NORMAL, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }, { TANGENT, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 24, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }, { TEXCOORD, 0, DXGI_FORMAT_R32G32_FLOAT, 0, 36, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }, };偏移量要算准。position从0开始12字节normal从12开始12字节tangent从24开始12字节uv从36开始8字节。总共44字节一个顶点。4.3 像素着色器中的PBR计算流程像素着色器是PBR的核心。我按顺序说一遍完整的计算流程。第一步采样纹理。基础颜色、金属粗糙度、法线三张纹理各采一次。基础颜色贴图是sRGB格式采样后硬件自动转到线性空间。金属粗糙度贴图是UNORM格式采样后直接是线性值。第二步处理法线。采样法线贴图映射到[-1,1]然后用TBN矩阵变换到世界空间。TBN矩阵的构建float3 N normalize(input.normal); float3 T normalize(input.tangent); float3 B cross(N, T) * input.tangentSign; float3x3 TBN float3x3(T, B, N); float3 worldNormal normalize(mul(tangentNormal, TBN));注意mul的顺序是向量右乘矩阵等价于矩阵转置左乘向量。HLSL里mul(tangentNormal, TBN)和mul(TBN, tangentNormal)结果不同前者是行向量乘矩阵后者是矩阵乘列向量。我习惯用前者。第三步计算BRDF的各项。先算中间向量float3 V normalize(cameraPosition - worldPosition); float3 L normalize(-lightDirection); 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);然后算F0、D、G、Ffloat3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float alpha roughness * roughness; float alpha2 alpha * alpha; float D alpha2 / (PI * pow(NdotH * NdotH * (alpha2 - 1.0) 1.0, 2.0)); float k alpha / 2.0; float G1V NdotV / (NdotV * (1.0 - k) k); float G1L NdotL / (NdotL * (1.0 - k) k); float G G1V * G1L; float3 F F0 (1.0 - F0) * pow(1.0 - VdotH, 5.0);第四步合成镜面反射和漫反射float3 specular D * G * F / (4.0 * NdotV * NdotL 0.001); float3 kd (1.0 - F) * (1.0 - metallic); float3 diffuse kd * albedo / PI; float3 color (diffuse specular) * lightColor * NdotL;分母加0.001是防止除零。NdotV或NdotL为0时镜面反射项应该是0加个小量避免NaN。第五步加环境光。简单起见环境光可以就用一个常数颜色乘以albedocolor ambientColor * albedo;第六步色调映射和伽马校正。PBR计算出来的颜色是HDR的需要映射到LDR。我用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)); }然后伽马校正color pow(color, 1.0 / 2.2);如果交换链是sRGB格式伽马校正可以省掉硬件会自动做。但我建议在着色器里做这样输出格式可以用UNORM更灵活。4.4 管线状态对象的配置细节PSO的配置有几个关键点。光栅化状态里填充模式用SOLID剔除模式用BACKFrontCounterClockwise根据你的顶点绕序来定。我用的模型是逆时针绕序所以FrontCounterClockwise设为FALSE。深度模板状态要开深度测试和深度写入深度函数用LESS_EQUAL。混合状态默认关闭PBR不需要混合除非你做透明物体。渲染目标格式要和交换链一致。如果交换链是DXGI_FORMAT_R8G8B8A8_UNORM渲染目标也是这个。深度缓冲用DXGI_FORMAT_D32_FLOAT。采样器在根签名里定义静态采样器可以放在根签名的静态采样器数组里这样不用每次绘制都设置。我定义一个线性过滤、重复寻址的静态采样器够用了。5. 常见问题与排查技巧实录5.1 画面全黑或全白这是最常见的问题。画面全黑通常是几个原因光源方向传错了NdotL算出来是负数或者常量缓冲区的数据没传对光源强度是0或者纹理采样返回了0。排查方法先把光照计算简化直接输出NdotL看看是不是有值。如果NdotL是0检查光源方向和法线的点积。如果NdotL有值但画面还是黑检查光源颜色和强度。我习惯在像素着色器里临时输出中间变量比如return float4(NdotL, NdotL, NdotL, 1.0);这样能快速定位问题在哪一步。画面全白通常是色调映射或伽马校正的问题。如果ACES函数的输入值太大输出会饱和到1。检查光源强度是不是设得太高或者曝光值是不是太大。5.2 金属材质看起来像塑料这是PBR新手最常遇到的问题。金属材质的关键在于金属的漫反射为0所有反射都来自镜面反射。如果你的金属看起来像塑料大概率是漫反射项没清零。检查kd的计算kd (1 - F) * (1 - metallic)。当metallic为1时kd应该是0。如果F算出来接近1kd也会接近0。但如果F0设错了比如金属的F0还是0.04那F就会偏小kd就不为0金属就会带漫反射看起来像塑料。确保F0的计算是lerp(0.04, albedo, metallic)。金属的F0就是albedo不是0.04。5.3 粗糙度参数不线性如果你发现粗糙度从0.1调到0.2画面变化很小但从0.8调到0.9变化巨大那就是粗糙度没平方。GGX的alpha是roughness的平方这个平方操作让粗糙度的感知更线性。很多教程漏了这一步导致粗糙度调起来手感很怪。在代码里确保alpha roughness * roughness然后用alpha去算法线分布函数。5.4 法线贴图导致光照方向错误法线贴图采样后法线是在切线空间的。如果TBN矩阵构建错了法线方向就会不对光照看起来就是从错误的方向打过来的。检查TBN矩阵的构建。切线T和法线N应该正交如果不正交需要做Gram-Schmidt正交化T normalize(T - N * dot(N, T));副切线B用叉积算注意手性。如果法线贴图看起来是反的试试把B取反或者把切线的手性符号取反。5.5 性能问题与优化方向PBR的像素着色器计算量大如果分辨率高、光源多帧率会掉。优化方向有几个一是减少纹理采样。金属粗糙度贴图把金属度和粗糙度打包在一张图里就是减少采样次数的做法。基础颜色和法线贴图也可以考虑打包但会损失精度。二是用更简单的BRDF近似。如果目标平台性能有限可以用Lambert漫反射加Blinn-Phong镜面反射的混合模型虽然物理上不准确但视觉上接近计算量小很多。三是把常量缓冲区的更新频率降下来。相机位置、光源方向这些每帧更新一次就行不用每次绘制都更新。DX12里可以用多个常量缓冲区按更新频率分组。问题现象可能原因排查方法画面全黑光源方向错误、常量缓冲区未更新、纹理采样为0输出NdotL中间值检查常量缓冲区数据画面全白光源强度过高、曝光值过大、色调映射饱和降低光源强度检查ACES输入范围金属像塑料漫反射项未清零、F0计算错误检查kd公式确认金属的F0等于albedo粗糙度不线性未对粗糙度平方确认alpha roughness * roughness法线光照错误TBN矩阵构建错误、切线不正交正交化切线检查副切线手性性能下降纹理采样过多、BRDF计算复杂打包纹理通道简化BRDF近似提示调试PBR时把光照计算拆开输出是最有效的方法。先输出albedo再输出NdotL再输出镜面反射项最后输出最终颜色。每一步都确认正确了再往下走比一次性调完再猜问题在哪快得多。6. 从直接光照到IBL的扩展路径直接光照PBR跑通之后画面已经比之前好很多了但还缺环境反射。金属材质在没有环境反射的情况下看起来会有点“干”不够亮。这时候就需要加IBL。IBL的核心是用环境贴图来模拟间接光照。对于漫反射需要对环境贴图做辐照度卷积生成一张低频的辐照度贴图。对于镜面反射需要做预过滤生成不同粗糙度级别的环境贴图粗糙度越高mip级别越高采样越模糊。在DX12里做这些预计算可以用计算着色器。创建一个计算管线把环境贴图作为输入输出辐照度贴图和预过滤贴图。这个过程只需要在加载时做一次运行时直接采样就行。BRDF积分贴图LUT也是预计算的。它是一个2D纹理横轴是NdotV纵轴是粗糙度每个像素存的是BRDF积分的两个分量。运行时用NdotV和roughness去采样得到镜面反射的缩放因子。IBL的完整实现篇幅不小这里先不展开。我的建议是直接光照PBR稳定运行一周确认没有明显bug之后再动手加IBL。加IBL的时候先把辐照度贴图做出来只加漫反射环境光确认没问题了再加预过滤的镜面反射。一步一步来比一次性全加上去好排查。我在实际项目里的体会是PBR的难点不在公式而在管线的每个环节都要正确。常量缓冲区的对齐、纹理的格式、采样器的配置、TBN矩阵的构建任何一个环节出错画面都会不对。而且这些错误往往不是非黑即白的而是“看起来差不多但就是不对劲”。这时候就需要耐心地一步步排查用中间输出定位问题。DX12的调试工具比如PIX可以抓帧看每个像素的着色器执行过程非常有用。我建议在开发阶段就养成用PIX抓帧的习惯比在代码里加printf高效得多。
返回列表