1. 从零搭建DX12渲染框架后,为什么下一步必须啃下PBR
很多人在学完DX12的第一部分之后,手里已经能跑出一个三角形或者一个带贴图的立方体了。那种感觉确实不错——设备初始化、命令队列、交换链、根签名、PSO,一整套流程跑通,屏幕上终于出现了东西。但紧接着就会遇到一个很现实的问题:画面看起来太假了。一个金属球在场景里,不管你怎么调光照参数,它要么像塑料,要么像涂了一层灰漆,完全没有金属该有的反射感和质感。
这就是为什么在DX12的第二个阶段,一定要把PBR加进来。PBR全称是Physically Based Rendering,中文叫基于物理的渲染。它不是一个具体的算法,而是一套光照计算的框架和约定。核心思想很简单:用物理上合理的参数来描述材质,让光照计算遵循能量守恒,这样在不同光照环境下,同一个材质看起来都是对的。
你可能会问,DX11时代也有PBR,为什么在DX12里要单独拿出来说?原因在于DX12把资源管理、描述符堆、根签名这些底层细节全部暴露给了开发者。在DX11里,你创建一个常量缓冲区,更新一下,绑定到管线,驱动帮你处理了很多同步和内存管理的事情。到了DX12,你得自己管理常量缓冲区的上传堆、自己处理帧资源的多缓冲、自己确保描述符在GPU使用期间不被覆盖。PBR需要传递的参数比传统光照模型多得多——基础颜色、金属度、粗糙度、法线、环境光遮蔽、各种预计算的LUT贴图——这些在DX12里怎么高效组织,是一个必须解决的问题。
这篇文章适合两类人:一类是已经跟着教程把DX12的基础框架搭起来了,正在寻找下一步该做什么的开发者;另一类是从DX11迁移过来,想搞清楚在DX12里做PBR和以前有什么本质区别的人。我会从整体设计思路讲起,然后拆解PBR的核心参数和数学原理,接着给出在DX12框架下具体的实现步骤和代码结构,最后分享一些我在实际项目中踩过的坑和排查技巧。
2. PBR核心原理拆解与DX12下的方案选型
2.1 为什么是金属度-粗糙度工作流
PBR发展到现在,主流的工作流有两种:金属度-粗糙度(Metallic-Roughness)和镜面反射-光泽度(Specular-Glossiness)。前者是迪士尼在2012年那篇经典论文里提出的,后来被虚幻引擎和大多数游戏引擎采纳为标准。后者在一些离线渲染和影视制作中还有使用,但在实时渲染领域已经基本被前者取代了。
为什么选金属度-粗糙度?最直接的原因是参数更少,纹理通道利用率更高。金属度是一个0到1的标量,粗糙度也是一个0到1的标量,这两个可以打包到同一张贴图的不同通道里。基础颜色占三个通道,金属度和粗糙度各占一个通道,再加一张法线贴图,基本上四张贴图就能描述一个完整的PBR材质。而镜面反射-光泽度工作流需要额外的镜面反射颜色贴图,纹理数量更多,内存占用更大。
从物理角度理解,金属度这个参数其实是在描述材质的导电性。金属的电子可以自由移动,所以金属对光的反应是:所有折射进入表面的光都被吸收,反射光带有金属自身的颜色。非金属(电介质)则不同,光会穿透表面,在内部散射后从不同位置射出,形成漫反射,而反射光通常是无色的。金属度这个参数就是在告诉着色器:这个像素更偏向金属行为还是非金属行为。
粗糙度描述的是微观表面的平整程度。粗糙度越低,微表面越接近完美镜面,高光越集中越锐利;粗糙度越高,微表面越杂乱,高光越分散越模糊。这个参数直接决定了法线分布函数(NDF)的形状。
2.2 渲染方程与实时近似的取舍
PBR的理论基础是渲染方程,但完整的渲染方程在实时渲染里是解不出来的,因为要计算半球积分,还要考虑多次弹射。所以实时PBR做了一系列近似:
第一层近似:把直接光照和间接光照分开处理。直接光照就是光源直接照射到表面的部分,用解析公式计算;间接光照是环境光和其他物体反射过来的光,用预计算的环境贴图来近似。
第二层近似:把BRDF拆成漫反射项和镜面反射项。漫反射用Lambert模型或者迪士尼的漫反射模型,镜面反射用微表面模型,通常是Cook-Torrance BRDF。
第三层近似:镜面反射项拆成三项相乘。法线分布函数D、几何遮蔽函数G、菲涅尔函数F。每一项都有多种近似公式可选,比如D项有GGX、Beckmann、Blinn-Phong,G项有Smith、Schlick-GGX,F项有Schlick近似。
在DX12里做PBR,这些数学公式本身和DX11没有区别,区别在于数据怎么传给GPU。DX12的根签名决定了着色器能访问哪些资源,描述符堆决定了这些资源怎么绑定。PBR需要传递的常量数据包括:光源方向、光源颜色、相机位置、材质参数、曝光参数等。这些数据每帧都在变,必须放在上传堆里,每帧更新。
2.3 DX12资源绑定模型对PBR的影响
DX11里有一个隐式的常量缓冲区绑定机制,你调用VSSetConstantBuffers或者PSSetConstantBuffers,驱动会在后台处理很多事情。DX12把这些全部去掉了,你需要:
- 创建常量缓冲区资源,放在上传堆里
- 把常量缓冲区的GPU虚拟地址写入根签名的根常量或者根描述符
- 或者把常量缓冲区的描述符写入描述符堆,然后在命令列表里设置描述符表
对于PBR来说,每帧需要更新的常量数据量不小。一个典型的PBR着色器需要传递:世界矩阵、视图投影矩阵、相机位置、光源数组(方向、颜色、强度)、材质参数(基础颜色因子、金属度因子、粗糙度因子、法线强度)、环境贴图参数(mipmap级别、强度)。这些数据加起来大概几百字节,放在一个常量缓冲区里完全够用。
我的建议是:把每帧不变的全局数据和每个物体不同的数据分开放在不同的常量缓冲区里。全局数据比如相机矩阵、光源信息,每帧更新一次;物体数据比如世界矩阵、材质参数,每个物体更新一次。这样在DX12里可以用不同的根参数来绑定,减少不必要的上传操作。
3. PBR着色器核心细节与DX12实现要点
3.1 常量缓冲区的数据结构设计
在DX12里,常量缓冲区的大小必须是256字节的整数倍,这是硬件对齐的要求。设计数据结构的时候要特别注意这一点,否则会出现数据错位的问题。
我通常会把PBR相关的常量数据分成两个结构体:
// 全局常量,每帧更新一次 struct GlobalConstants { DirectX::XMFLOAT4X4 viewProjection; DirectX::XMFLOAT4X4 inverseViewProjection; DirectX::XMFLOAT4 cameraPosition; DirectX::XMFLOAT4 lightDirection; // 方向光方向 DirectX::XMFLOAT4 lightColor; // 方向光颜色和强度 DirectX::XMFLOAT4 ambientColor; // 环境光颜色 float exposure; float gamma; float padding[2]; }; // 物体常量,每个物体更新一次 struct ObjectConstants { DirectX::XMFLOAT4X4 world; DirectX::XMFLOAT4X4 worldInverseTranspose; DirectX::XMFLOAT4 baseColorFactor; float metallicFactor; float roughnessFactor; float normalScale; float occlusionStrength; };这里有几个细节需要注意。worldInverseTranspose是用来变换法线的,因为法线不能用世界矩阵直接变换,需要用世界矩阵的逆转置矩阵。baseColorFactor是一个四分量向量,前三个是RGB,第四个是alpha。金属度和粗糙度是标量,但为了内存对齐,通常也会放在四分量向量里。
在DX12里,这些常量缓冲区资源要创建在上传堆(D3D12_HEAP_TYPE_UPLOAD)里,这样CPU才能写入。每个帧资源需要一份独立的常量缓冲区,避免GPU还在读上一帧的数据时CPU就覆盖了。通常用三重缓冲或者双重缓冲,取决于你的交换链缓冲数量。
3.2 根签名的设计策略
根签名是DX12里最核心的概念之一,它定义了着色器能访问哪些资源,以及这些资源怎么绑定。对于PBR渲染,我建议的根签名设计是:
- 根参数0:常量缓冲区视图(CBV),绑定全局常量
- 根参数1:常量缓冲区视图(CBV),绑定物体常量
- 根参数2:着色器资源视图(SRV)描述符表,包含基础颜色贴图、金属度粗糙度贴图、法线贴图、环境贴图
- 根参数3:静态采样器,用于各向异性过滤
为什么把全局常量和物体常量分开作为两个根参数?因为根签名里的根参数数量是有限的(D3D12最多64个DWORD),每个根参数都会占用一定的根签名空间。把常量缓冲区直接作为根参数(而不是放在描述符堆里)的好处是访问速度快,不需要额外的描述符堆跳转。但根参数的数量有限,所以要把最常用的资源放在根参数里。
描述符表用来放纹理资源。PBR需要至少四张贴图:基础颜色、金属度粗糙度、法线、环境贴图。这些贴图的描述符在描述符堆里连续排列,然后在命令列表里通过SetGraphicsRootDescriptorTable来绑定。
采样器我建议用静态采样器放在根签名里,因为采样器的状态在渲染过程中通常不会改变。各向异性过滤对于PBR材质的地面或者倾斜表面非常重要,能显著提升视觉质量。
3.3 BRDF的HLSL实现细节
PBR的核心计算在像素着色器里。下面是一个典型的Cook-Torrance BRDF实现:
// 法线分布函数:GGX/Trowbridge-Reitz 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); } // 几何遮蔽函数:Schlick-GGX 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 / max(denom, 0.0001); } 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; } // 菲涅尔方程:Schlick近似 float3 fresnelSchlick(float cosTheta, float3 F0) { return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); } // 带粗糙度修正的菲涅尔,用于环境光 float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness) { float3 F90 = max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), F0); return F0 + (F90 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); }这些函数看起来数学味很重,但实际用起来就是套公式。关键要理解每个参数的含义:N是法线,V是视线方向,L是光线方向,H是半角向量(normalize(V + L)),roughness是粗糙度。
F0是材质的基础反射率,对于非金属,F0通常取0.04(大约4%的反射率);对于金属,F0就是基础颜色。这个转换在着色器里是这样做的:
float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic);这行代码的意思是:如果金属度是0,F0就是0.04;如果金属度是1,F0就是基础颜色。中间值就是线性插值。
3.4 法线贴图的处理与切线空间
PBR材质几乎一定会用到法线贴图,用来表现表面的微观细节。法线贴图存储的是切线空间下的法线方向,需要转换到世界空间才能参与光照计算。
切线空间的转换需要三个基向量:切线(T)、副切线(B)、法线(N)。这三个向量构成一个3x3的矩阵,把切线空间下的法线变换到世界空间。
在DX12里,顶点数据需要包含切线信息。如果模型文件里没有切线,可以用法线和UV计算出来,但这样计算出来的切线在UV接缝处可能会有问题。我的建议是在导入模型的时候就生成好切线数据,Assimp这个库可以自动生成切线。
// 从法线贴图采样 float3 normalSample = normalTexture.Sample(anisoSampler, uv).rgb; normalSample = normalSample * 2.0 - 1.0; // 从[0,1]映射到[-1,1] normalSample.xy *= normalScale; // 控制法线强度 // 构建TBN矩阵 float3 N = normalize(input.normal); float3 T = normalize(input.tangent); float3 B = cross(N, T) * input.tangent.w; // tangent.w存储副切线的方向 float3x3 TBN = float3x3(T, B, N); // 变换到世界空间 float3 worldNormal = normalize(mul(normalSample, TBN));这里有个容易忽略的细节:tangent.w存储的是副切线的方向(+1或-1),用来处理UV镜像的情况。如果模型在UV展开时做了镜像,副切线的方向会反过来,这个符号必须正确传递,否则法线贴图的效果会完全错误。
4. DX12框架下PBR的完整实操流程
4.1 资源加载与纹理打包
PBR材质通常包含四张贴图:基础颜色(Albedo)、金属度粗糙度(Metallic-Roughness)、法线(Normal)、环境光遮蔽(AO)。为了减少纹理采样次数和内存占用,通常会把金属度、粗糙度和AO打包到同一张贴图的不同通道里。常见的打包方式是:
- R通道:环境光遮蔽
- G通道:粗糙度
- B通道:金属度
- A通道:保留或者用于其他用途
这样一张贴图就能提供三个参数,着色器里只需要一次采样:
float4 mrTexture = metallicRoughnessTexture.Sample(anisoSampler, uv); float metallic = mrTexture.b * metallicFactor; float roughness = mrTexture.g * roughnessFactor; float ao = mrTexture.r;在DX12里加载纹理,需要用CreateCommittedResource创建资源,然后用CopyTextureRegion把数据从上传堆拷贝到默认堆。这个过程比DX11繁琐,但好处是你可以精确控制资源的状态转换和内存布局。
我通常会把纹理加载封装成一个函数,输入是文件路径,输出是ComPtr<ID3D12Resource>和描述符堆里的索引。加载完成后,资源需要从COPY_DEST状态转换到PIXEL_SHADER_RESOURCE状态,这个转换通过ResourceBarrier来实现。
4.2 描述符堆的管理
DX12的描述符堆分为几种类型:CBV/SRV/UAV堆、采样器堆、RTV堆、DSV堆。PBR渲染需要用到CBV/SRV/UAV堆来存放常量缓冲区视图和着色器资源视图,以及采样器堆来存放采样器。
描述符堆的大小需要在初始化时就确定。对于PBR渲染,我建议分配一个足够大的堆,比如1024个描述符,这样可以容纳多个物体的纹理和常量缓冲区。描述符堆的创建:
D3D12_DESCRIPTOR_HEAP_DESC srvHeapDesc = {}; srvHeapDesc.NumDescriptors = 1024; srvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; srvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; device->CreateDescriptorHeap(&srvHeapDesc, IID_PPV_ARGS(&srvHeap));SHADER_VISIBLE标志很重要,它表示这个堆可以被着色器访问。只有带这个标志的堆才能在命令列表里通过SetDescriptorHeaps绑定。
每个描述符在堆里占用的空间大小是固定的,可以通过GetDescriptorHandleIncrementSize获取。在堆里定位第n个描述符的句柄:
CD3DX12_CPU_DESCRIPTOR_HANDLE handle( srvHeap->GetCPUDescriptorHandleForHeapStart(), n, descriptorSize );4.3 每帧常量缓冲区的更新
DX12里常量缓冲区的更新需要特别注意同步问题。因为CPU写入上传堆的内存后,GPU可能还在读上一帧的数据,如果直接覆盖会导致画面闪烁或者数据错乱。
解决方案是为每个帧资源分配独立的常量缓冲区。假设交换链有三个缓冲,那就创建三份常量缓冲区,每帧使用不同的那份。帧索引通过swapChain->GetCurrentBackBufferIndex()获取。
// 每帧更新全局常量 GlobalConstants globalConstants = {}; // ... 填充数据 memcpy(globalConstantBufferMappedData[frameIndex], &globalConstants, sizeof(globalConstants));这里globalConstantBufferMappedData是映射后的CPU指针,在初始化时通过Map获取,整个生命周期内保持映射状态,不需要每帧Map和Unmap。
对于物体常量,如果场景里有多个物体,每个物体都需要一份独立的常量数据。可以创建一个大的常量缓冲区,里面包含所有物体的数据,然后每个物体通过偏移量来访问自己的部分。在命令列表里设置根常量时,传入对应的GPU虚拟地址。
4.4 环境贴图的预计算与使用
PBR的间接光照部分需要用到环境贴图。直接使用原始的HDR环境贴图效果不好,因为镜面反射需要根据粗糙度采样不同级别的模糊。所以需要预计算:
- 辐照度贴图(Irradiance Map):用于漫反射间接光照,对原始环境贴图做卷积,得到低频的漫反射信息
- 预过滤环境贴图(Prefiltered Environment Map):用于镜面反射间接光照,根据不同的粗糙度级别做不同强度的模糊
- BRDF积分贴图(BRDF LUT):一张2D查找表,存储菲涅尔项和几何项的积分结果
这三张贴图可以在初始化时用计算着色器预计算,也可以离线生成好直接加载。在DX12里用计算着色器预计算的好处是不依赖外部工具,但实现起来比较复杂。我的建议是先用离线工具生成,比如用cmft或者IBL Baker,等整个渲染管线跑通了再考虑用计算着色器替代。
在着色器里使用环境贴图:
// 漫反射间接光照 float3 irradiance = irradianceMap.Sample(linearSampler, N).rgb; float3 diffuseIBL = irradiance * albedo * (1.0 - 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); // 合并 float3 ambient = (diffuseIBL + specularIBL) * ao;这里maxMipLevel是预过滤环境贴图的最大mip级别,通常根据预过滤时使用的采样数来确定。brdfLUT的采样坐标是(NdotV, roughness),返回的是菲涅尔项和几何项的积分结果。
5. 常见问题与排查技巧实录
5.1 画面全黑或者全白
这是最常见的问题,通常有几个原因:
常量缓冲区数据没有正确上传。检查memcpy的目标地址是否正确,检查映射的指针是否有效。在DX12里,如果常量缓冲区创建在默认堆而不是上传堆,CPU是无法写入的,会导致数据全零。
根签名不匹配。着色器里期望的根参数和实际绑定的不一致。比如着色器里常量缓冲区在寄存器b0,但根签名里绑定到了b1。这种问题不会报错,但数据会错位。
描述符堆没有设置。在命令列表里必须调用SetDescriptorHeaps,否则着色器访问描述符表时会得到未定义行为。
资源状态没有转换。纹理资源创建后处于COPY_DEST状态,必须转换到PIXEL_SHADER_RESOURCE才能被着色器采样。常量缓冲区不需要状态转换,但纹理需要。
排查方法:用PIX或者RenderDoc抓一帧,查看常量缓冲区的实际内容,查看纹理资源的状态,查看描述符堆的绑定情况。这些工具在DX12开发中是必不可少的。
5.2 金属材质看起来像塑料
这个问题通常出在F0的计算上。如果金属度参数没有正确传递到着色器,或者F0的计算公式写错了,金属材质就会失去金属感。
检查步骤:
- 确认金属度贴图的通道是否正确。有些贴图把金属度放在R通道,有些放在B通道,必须和着色器里的采样代码一致。
- 确认
F0 = lerp(0.04, albedo, metallic)这行代码是否正确。如果写成了F0 = albedo * metallic,非金属部分的反射率会变成0,看起来就不对。 - 确认环境贴图是否正常工作。金属材质的反射主要来自环境贴图,如果环境贴图是纯黑或者没有正确采样,金属看起来就是暗的。
5.3 法线贴图效果异常
法线贴图的问题通常表现为:表面看起来凹凸方向反了,或者接缝处有明显的断裂。
凹凸方向反了:检查切线空间的副切线方向。如果tangent.w的符号搞反了,法线的Y分量会反向,凹凸感就反了。可以在着色器里试试把normalSample.y取反,看看效果是否正常。
接缝处断裂:这通常是切线计算的问题。如果切线是用cross(N, T)计算的,在UV接缝处N和T可能不垂直,导致TBN矩阵不正交。解决方案是在导入模型时生成正确的切线数据,或者在着色器里对TBN矩阵做正交化处理。
法线贴图看起来太强或太弱:调整normalScale参数。这个参数控制法线贴图的强度,通常在0到2之间。如果法线贴图是DirectX格式(Y轴向下),而你的着色器假设是OpenGL格式(Y轴向上),需要把Y分量取反。
5.4 性能问题排查
PBR的计算量比传统光照模型大不少,特别是在像素着色器里。如果发现帧率下降明显,可以从以下几个方面排查:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 帧率骤降 | 像素着色器计算量过大 | 用PIX查看GPU耗时,确认瓶颈在像素着色器 |
| 画面卡顿 | 常量缓冲区每帧重新创建 | 检查是否复用了常量缓冲区资源 |
| 显存占用高 | 纹理没有压缩 | 使用BC7压缩基础颜色和法线贴图 |
| 加载时间长 | 环境贴图预计算在CPU做 | 改用计算着色器或者离线生成 |
| 画面撕裂 | 没有开启垂直同步 | 检查交换链的Present参数 |
纹理压缩是提升性能的重要手段。基础颜色贴图可以用BC7压缩,法线贴图可以用BC5压缩(只存储XY两个通道,Z通道在着色器里计算)。金属度粗糙度贴图可以用BC4压缩(如果只用一个通道)或者BC7压缩。
减少纹理采样次数也很关键。把金属度、粗糙度、AO打包到一张贴图里,从三次采样减少到一次采样,性能提升很明显。
5.5 多帧同步问题
DX12里最常见的同步问题是资源 Hazard。比如CPU正在写入常量缓冲区,GPU还在读上一帧的数据。这种问题不会导致崩溃,但会导致画面闪烁或者物体位置错乱。
解决方案是使用帧资源(Frame Resource)模式。每个帧资源包含自己的常量缓冲区、命令分配器、命令列表。帧开始时,等待上一帧的GPU工作完成,然后使用下一个帧资源。这样CPU和GPU可以并行工作,同时避免资源冲突。
// 等待上一帧完成 frameResources[currentFrameIndex]->fenceValue = fenceValue; commandQueue->Signal(fence.Get(), fenceValue); fenceValue++; // 如果帧资源还在使用中,等待 if (fence->GetCompletedValue() < frameResources[currentFrameIndex]->fenceValue) { fence->SetEventOnCompletion(frameResources[currentFrameIndex]->fenceValue, fenceEvent); WaitForSingleObject(fenceEvent, INFINITE); }这段代码是DX12帧同步的核心。fence是一个围栏对象,用来标记GPU的工作进度。Signal在命令队列里插入一个标记,SetEventOnCompletion在GPU完成到那个标记时触发事件。通过这种方式,CPU可以知道GPU什么时候完成了对某个帧资源的使用。
6. 从PBR延伸出去:还可以做哪些扩展
PBR跑通之后,整个渲染管线就有了一个很扎实的基础。接下来可以往几个方向扩展:
第一,加入阴影。方向光的阴影可以用级联阴影贴图(Cascaded Shadow Maps),点光源的阴影可以用立方体阴影贴图。阴影和PBR结合之后,画面的立体感和真实感会大幅提升。
第二,加入后处理。色调映射(Tone Mapping)是PBR的标配,因为PBR计算出来的颜色是HDR的,需要映射到LDR显示。常用的色调映射算子有ACES、Reinhard、Filmic。还可以加入泛光(Bloom)、屏幕空间环境光遮蔽(SSAO)、抗锯齿(TAA或FXAA)。
第三,支持多光源。目前只处理了一个方向光,实际场景中可能需要多个点光源、聚光灯。可以把光源数据打包成结构化缓冲区,在着色器里循环遍历。DX12的根签名支持结构化缓冲区作为SRV,绑定起来很方便。
第四,材质系统。把PBR材质参数从代码里抽出来,做成数据驱动的材质系统。每个材质包含贴图路径、参数因子、着色器变体。这样美术人员可以直接在编辑器里调整材质,不需要程序员改代码。
第五,GPU Driven Rendering。DX12支持间接绘制(ExecuteIndirect),可以把绘制调用放到GPU端生成。结合计算着色器做视锥剔除和遮挡剔除,可以大幅减少CPU开销,支持更大规模的场景。
我在实际项目里的体会是,PBR本身不难,难的是DX12的资源管理和同步。把帧资源模式、描述符堆管理、资源状态转换这三件事做扎实了,后面加什么功能都是水到渠成。反过来,如果这三件事没处理好,每加一个新功能都会遇到各种奇怪的bug,排查起来非常痛苦。
最后分享一个小技巧:在开发阶段,可以写一个调试着色器,把中间结果可视化出来。比如把法线显示成颜色,把粗糙度显示成灰度图,把F0显示成颜色。这样一眼就能看出数据是否正确传递到了着色器,比用调试器一步步查快得多。