DX12下PBR渲染实战:从根签名、描述符堆到BRDF与IBL落地

发布时间:2026/9/30 9:44:39
DX12下PBR渲染实战:从根签名、描述符堆到BRDF与IBL落地 做图形这块的兄弟肯定绕不开两座山DX12那套又底层又折磨人的资源调度以及PBR这坨看着简单、一上手全是细节的物理渲染模型。我自己学DX12到第二篇就想把PBR接进去结果发现“PBR算法不难难的是怎么用DX12的想法去组织它们”。如果你跟我一样DX11那套“绑个SRV就当完事”的思维还没纠正过来或者说你本来就会PBR但换到DX12之后发现shader里拿不到纹理、PSO老报错、资源状态一个没转对就黑屏那这篇文章就是给你准备的。这篇不讲虚的直接从项目实战出发覆盖从资源绑定、根签名到BRDF落地、IBL接入再到调试排坑的完整链路代码和踩坑记录都有可以直接抄作业。1. 先把路数理清楚DX12里加PBR到底在加什么1.1 PBR不是一串特效代码而是一整套物理约定很多人一说到PBR就以为是一段特别牛皮的shader其实不是。PBR的核心是“基于物理的渲染模型”它不规定你必须用哪一行代码而是规定了一套材质参数和光的交互规则。业界现在用得最广的就是Cook-Torrance微表面BRDF模型配金属度/粗糙度工作流。说白了你要给shader喂进四个关键参数基础色BaseColor、金属度Metallic、粗糙度Roughness、还有法线Normal。再辅助一个环境光遮蔽和自发光。这样一套参数美术同学在Substance里刷的材质到引擎里渲染出来跟真实世界贴近靠的就是统一的PBR约定。这套约定里最重要的物理基础是能量守恒物体反射出去的光能不可能超过它接收到的那部分。尤其反射项和折射项加起来不能超过1金属材质又把漫反射吸收成零。这就是为什么网上那些PBR shader里总会有一行float3 specContrib ...的加权逻辑不懂能量守恒你的材质一调高金属度整张图就会“烧”起来。1.2 DX12在整个PBR方案里扮演什么角色PBR本质是一套数学和采样策略换到OpenGL、Metal、甚至软件光追都能写它跟API没关系。但DX12在这里扮演的角色特别重要它管的是GPU到底怎么拿到shader、顶点数据、纹理怎么切换资源状态怎么把渲染命令排到队列里。PBR是“画什么”DX12是“怎么把画的东西搬到GPU上”。在DX12之前你用DX11写PBR绑定SRV就是一个PSSetShaderResources的事shader里Texture2D gTex : register(t0)完事。DX12不这么干。你手里没有“全局可用的SRV绑定槽位”只有一个根签名Root Signature它明确规定“shader参数从哪里来”是根常量Root Constant、还是描述符表Descriptor Table、还是一个inline描述符。每一个CBV/SRV/UAV都要你手动放进描述符堆告诉GPU在哪个CPU地址能找到那张纹理。你如果不理解这层连贴图都采不到更别说PBR的效果了。所以我的建议是先不要一上来就铺开一堆PBR数学先把你手里的DX12管线搭到“能正常渲染一个带纹理的球”的程度再往里面加BRDF。因为PBR的坑大多不是公式算错而是数据链路没打通。2. 数据链路模型、贴图怎么进到GPU里去2.1 顶点布局这关切线别漏DX12定义InputLayout的方式跟DX11有区别。以前你写一个POSITION、一个NORMAL、一个TEXCOORD就完事现在需要你自己构造一个D3D12_INPUT_ELEMENT_DESC数组指明每个语义对应的格式、输入槽和偏移量。PBR这套工作流里法线贴图是一定要用的所以顶点里必须有切线TANGENT后面算TBN矩阵用。没有切线法线贴图就是无源之水。我自己在项目里有一个典型的顶点结构struct Vertex { DirectX::XMFLOAT3 Position; DirectX::XMFLOAT3 Normal; DirectX::XMFLOAT2 UV; DirectX::XMFLOAT3 Tangent; };对应的InputLayout就是四个元素const D3D12_INPUT_ELEMENT_DESC inputLayout[] { { POSITION, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_PER_VERTEX_DATA, 0 }, { NORMAL, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D12_INPUT_PER_VERTEX_DATA, 0 }, { TEXCOORD, 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D12_INPUT_PER_VERTEX_DATA, 0 }, { TANGENT, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 32, D3D12_INPUT_PER_VERTEX_DATA, 0 }, };注意一个细节NORMAL和TANGENT都用了R32G32B32_FLOAT如果你为了省带宽用DXGI_FORMAT_R8G8B8A8_SNORM会损失精度导致法线细节闪烁得不偿失。还有Tangent和Normal不要做插值在shader里你往往需要把它们按不同的分量重组形式化处理。稳妥起见就保持float3。2.2 纹理格式与SRGB这个隐形炸弹PBR用到的贴图不少Albedo、Normal、Roughness、Metallic、AO偶尔还有Emissive。在DX12里每张贴图都要创建资源指定DXGI_FORMAT。这里有一个特别反直觉的坑Albedo一般是sRGB格式DXGI_FORMAT_R8G8B8A8_UNORM_SRGB而Normal、Roughness、Metallic必须用线性的UNORM格式不能标SRGB。为什么因为美术在Substance里存颜色贴图时图片文件本身就是sRGB编码的方便存储和显示。但PBR的光照计算必须在线性空间里做。如果你把Albedo也当成线性纹理采样光照会偏暗反过来如果你把Roughness/Tangent那张图也标成sRGBGPU会做一次pow(color, 2.2)的纹理解码等于直接把粗糙度变成走样值材质会亮成一坨。在这里补一个常见的认知偏差GPU的硬件sRGB格式采样后会自动把sRGB数据decode到线性直方图看起来更“亮”这个行为发生在纹理采样阶段。所以你要做的就是在创建D3D12_RESOURCE_DESC时把Format填对不用自己在shader里写pow(texColor.rgb, 2.2)。但要注意RT的输出格式也同样重要渲染目标的R8G8B8A8_UNORM_SRGB会在输出时做sRGB encode这样屏幕上看到的颜色才是正常的。我见过不少同学shader里的PBR数学全都对结果最后屏幕整体发灰就是因为后缓冲格式没设置成SRGB。2.3 描述符堆与SRV是怎么绑进shader的DX12里你要用一个堆Heap来管理描述符。PBR这个场景里要绑定的资源有一组材质纹理Albedo、Normal、Roughness、Metallic、AO、环境Cubemap、光照参数缓冲区、相机参数缓冲区。所以至少要有两类堆CBV_SRV_UAV堆和采样器堆Sampler Heap并且都要标记为D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE。我建一个常量描述符堆和纹理描述符堆纹理堆长度设个大一点的值比如1024。创建SRV的代码大概这样// 假设已经拿到ID3D12Device* device 和 ID3D12Resource* albedoTexture D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc {}; srvDesc.Shader4ComponentMapping D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; srvDesc.Format DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; // 看纹理类型 srvDesc.ViewDimension D3D12_SRV_DIMENSION_TEXTURE2D; srvDesc.Texture2D.MipLevels albedoTexture-GetDesc().MipLevels; uint32_t offset heap-GetGPUDescriptorHandleForHeapStart().ptr descriptorIndex * descriptorSize; device-CreateShaderResourceView(albedoTexture, srvDesc, cpuHandle);然后绘制前把描述符堆set到命令列表上。shader侧只需要通过根签名告诉它这个表在哪个寄存器范围即可。这里特别提醒Shader可见的描述符堆在整个帧绘制期间不能被两个不同内容的堆交替set来set去DX12不允许两个ShaderVisible堆同时绑定这就是为什么我会把所有PBR材质纹理堆在一起集中管理而不是给每个物体都单独搞一个堆。3. PBR着色从BRDF公式到能跑的HLSL3.1 Cook-Torrance BRDF的数学核心和实现完整BRDF长这样fr kd * (albedo / PI) ks * (D * F * G) / (4 * NdotL * NdotV)其中D是法线分布项GGXF是菲涅尔项Schlick近似G是阴影-遮挡项Smith的GGX近似。这三大件每一项都不难难点是日常实现时别忘了能量守恒和入射光方向处理逻辑。我在HLSL里的核心实现是这样的PBR是学习项目写得直白一点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, 1e-6); } float GeometrySchlickGGX(float NdotV, float roughness) { float r roughness 1.0; float k (r * r) / 8.0; return NdotV / (NdotV * (1.0 - k) k); } float GeometrySmith(float3 N, float V, float L, float roughness) { float NdotV max(dot(N, V), 0.0); float NdotL max(dot(N, L), 0.0); 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); }然后主循环里点光源方向光的写法这样float3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float3 V normalize(cameraPos - worldPos); float3 L normalize(lightDir); float3 H normalize(V L); float NDF DistributionGGX(N, H, roughness); float G GeometrySmith(N, V, L, roughness); float3 F FresnelSchlick(max(dot(H, V), 0.0), F0); float3 kD (1.0 - F) * (1.0 - metallic); float3 specular (NDF * G * F) / max(4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0), 0.001); float3 diffuse kD * albedo / PI; float NdotL max(dot(N, L), 0.0); color (diffuse specular) * lightColor * NdotL;从这段代码能看出F0默认取0.04是电介质的基础反射率当你把金属度调成1F0就完全变成基础色同时kD变成0漫反射被完全吃掉。这正好就是金属和非金属在视觉上的本质区别。3.2 切线空间和法线贴图到底那套变换怎么做法线贴图存的法线是在切线空间里的方向都是偏向于物体表面的“凹凸”结果。你在shader里拿到顶点法线N和切线T通过叉积算出副切线B组成TBN矩阵把采样得到的切线空间法线转到世界空间或视图空间看你在哪个空间计算光照。这一步不同教程写出来的矩阵顺序往往都不一样容易抄错。我在项目里用的是“按列排列”或者混用mul的方式。DX12摸透之后我建议你在HLSL里统一用**行主序列主序**这套纠结放到一边直接用下面这段代码不存TBN矩阵直接插值后的T、N、B向量操作直观不容易错float3x3 TBN float3x3(tangent, bitangent, normal); // 注意很多引擎用的是 float3x3(tangent, bitangent, normal) 但方向相反会偏这里要检查UV方向 float3 sampledNormal normalTexture.Sample(baseSampler, uv).xyz; sampledNormal sampledNormal * 2.0 - 1.0; // 如果你启用了DX12的正方形UV方向一半模型要颠倒Y通道 sampledNormal.y -sampledNormal.y; float3 N normalize(mul(sampledNormal, TBN));这里有两个容易踩的坑。第一个坑法线贴图的Y通道方向依赖于你的UV轴方向和模型导入时的设置。如果模型是从Blender/SM引擎导出后用DX12渲染经常会出现“法线好像反了背面发光”的现象。解决办法不是去矩阵里瞎改而是先输出float3(normal.xy, 0)做诊断渲染看一眼凹凸方向到底是往哪边偏。第二个坑DX12和OpenGL的NDC坐标系不一样左手系统深度范围0到1。如果你是从OpenGL转过来的TBN矩阵的最后一行或者副切线B的方向Vulkan、OpenGL习惯是右手很可能需要翻转。具体怎么判就是那句老话先用一个平面球体法线可视化调试调对了再上复杂模型。3.3 实时IBL环境贴图与粗糙度的映射关系纯点光源或方向光下PBR球体底部会黑成一片。要让材质有“被环境照亮”的感觉就得加IBL基于图像的光照。IBL的核心做法是把一张HDR环境贴图提前卷积好把环境光按漫反射和镜面反射分开存。漫反射存一张低分辨率的辐照度图Irradiance Map镜面反射存一张多级Mip的预滤波图Pre-filtered Environment Map同时还要保存一张BRDF积分查找表LUT。在DX12里这三步通常都在加载阶段用Compute Shader或者单独的Pass一次性做完。因为顺序上它们是纯GPU上的“预处理”不依赖场景所以可以做成一个Load-time任务。有一个关键经验预滤波环境贴图的每一级Mip对应不同粗糙度。你采样的时候要根据材质的roughness选择Mip层级公式给一个工程上常用的float mipLevel roughness * (maxMip - 1); // 粗略版本但要精确点很多人会直接在运行时调用texture.SampleLevel(sampler, R, roughness * N)。这里我不展开采样细节就说一个问题HDR环境贴图的格式。如果你图省事用R8G8B8A8_UNORM来存Cubemap高光高亮区块会被clamp到1导致PBR金属反射看起来一点金属感都没有像塑料。强烈建议环境贴图用R16G16B16A16_FLOAT或更高精度的浮点格式存储至少高光部分不要被截断。4. 资源状态、同步与调试DX12最容易翻车的角落4.1 Resource Barrier到底要怎么转我一度被ResourceBarrier整得怀疑人生。后来总结出一个好记的口诀“谁改资源状态谁写Barrier”。你要采样一张贴图就先把它从COPY_DEST转成PIXEL_SHADER_RESOURCE你拿RT做渲染目标就从RENDER_TARGET转成SRV再采样。顺序不能错多一步少一步都会让验证层直接报错。一个典型的流程从CPU上传纹理到默认堆时你要在拷贝CommandList里上传堆UPLOAD_HEAP作为SRC默认堆DEFAULT_HEAP作为DST做一次Copy然后在拷贝结束之后、渲染Pass提交之前把默认堆转成PIXEL_SHADER_RESOURCE。简单写就是D3D12_RESOURCE_BARRIER barrier {}; barrier.Type D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Transition.pResource textureResource; barrier.Transition.StateBefore D3D12_RESOURCE_STATE_COPY_DEST; barrier.Transition.StateAfter D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; barrier.Transition.Subresource D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; commandList-ResourceBarrier(1, barrier);如果是一个带Mip链的纹理尤其是Cubemap别用ALL_SUBRESOURCES一把梭。有时候不同Mip层的状态并不相同比如你只是重新生成了其中某一层ALL_SUBRESOURCES会把不存在的子资源也带过去你会在验证层看到一些“Subresource index out of range”的奇怪报错。按子资源逐个Barrier更稳。4.2 上传堆和默认堆纹理更新的正确姿势DX12里没有D3D11那种UpdateSubresource你得自己维护上传堆UploadBuffer把CPU端的数据拷进去再调用CopyTextureRegion把它复制到显存中的默认堆里。这就引出一个很常见的坑因为PBR需要载入大量纹理如果在每一帧都反复创建临时上传堆不释放内存会炸。我的做法是复用上传堆创建一个足够大的ID3D12Resource作为环形缓冲RingBuffer每帧要更新的常量/纹理数据按offset填进去然后手动保证同一个上传堆的区间在GPU执行完之前不被覆盖。具体到PBR场景大多数纹理只在加载时候传一次之后只有每帧的相机常量、光照常量会变。所以我会把“动态数据”的UploadBuffer独立出来并且每帧换一个位置写也就是前文说的“双缓冲”或者“三缓冲”。这样做的好处是CPU不必阻塞等GPU读完上传区帧率能稳定很多。4.3 验证层与PIX的调试思路DX12开验证层是个老生常谈了。但我想分享一个教训不要一看到验证层的红字就发怵很多红字其实是同一个根原因引起的一连串报错。比如我曾经遇到一个报错“Current command list is in a invalid state”前面跟了一堆关于ResourceBarrier和DrawCall的警告。后来发现根因是我在ExecuteCommandLists之后命令列表的Close和Reset时机错了导致后面的录制状态都乱了。定位方法很简单只保留一个最小的渲染序列一步步加功能每次加完跑一遍验证层。PBR接入尤其要这样干因为PBR加了纹理、法线、环境随便一个环节没对准都会黑屏。真遇到黑屏我的排查次序是确认RT和深度目标有被正确Set包括格式是否匹配。确认绘制指令的顶点数是对的在PIX里看Draw Call没有报错。确认shader里的采样器索引和根签名对得上。这块最容易出现“根签名descriptor范围登记了3个纹理你却采样第4个”。确认纹理资源没有全黑或全白。可以在开头阶段强制在shader里输出albedo.xyz看是不是材质没传对。5. 把一切都串起来一个完整的PBR渲染循环实现5.1 场景数据组织和Draw CallDX12里Draw Call本身不贵但每次设置Render State的切换挺烦。为了让PBR项目跑起来清晰我会把所有物体分成两个层级组织物体实例Mesh Instance存储顶点/索引缓冲、AABB等几何数据。材质实例Material Instance存储贴图描述符在纹理堆里的索引、PSO使用的参数金属度、粗糙度是否覆盖。绘制时用同一个PSO同一套顶点格式按材质sort一下。因为DX12不支持DX11的“SetShaderResource到某个slot”你要通过描述符表一次性把全部纹理set上去。所以每个材质实例里就放一段D3D12_GPU_DESCRIPTOR_HANDLE指向纹理堆里的5个SRVAlbedo/Normal/Roughness/Metallic/AO。绘制循环就是for (auto instance : opaqueInstances) { commandList-IASetVertexBuffers(0, 1, instance.vbView); commandList-IASetIndexBuffer(instance.ibView); commandList-SetGraphicsRootDescriptorTable(2, instance.material-gpuSRVHandle); commandList-DrawIndexedInstanced(instance.indexCount, 1, 0, 0, 0); }根签名里我安排了三块RootConstant放每帧的帧内索引或切换层表0是全局相机常量光照表1是每个物体的变换常量表2是材质纹理描述符表。这个结构对PBR来说是够用的。5.2 单Pass还是GBuffer先跑通Forward再考虑DeferredPBR接下来一个绕不开的抉择是Forward前向还是Deferred延迟。作为学习项目我强烈建议先用Forward单Pass跑通。原因是Deferred里GBuffer管理、MRT输出、以及最后Lighting Pass都要自己手搓和PBR本身的调试叠加在一起翻车概率成倍上升。Forward下绘制顺序相对简单深度Pass不用单独做DX12的DepthWrite在主Pass里就开了。先画所有不透明物体Alpha blend off深度比较LESS深度写入ON。最后画半透明物体Blend State开深度写入OFF。每帧的最后一步做SRGB编码如果你的RT是UNORM_SRGB这一步会自动发生。我当时测试用的是一个大球球体是PBR最容易看出问题的地方周围放几个小金属球用不同的粗糙度和金属度再看反射高光是不是随着相机变化而变化。这个环境非常适合验证BRDF是否正确。5.3 常量缓冲区怎么每帧更新而不卡CPUPBR运行时要常变的数据有两类每帧的相机参数View/Projection矩阵、相机位置和灯光参数颜色、方向。每物体级别的变换常量你也可以在CPU端提前写入。在DX12里我会给动态常量专门创建一个UploadBuffer映射到MappedMemory以后每帧更新时直接memcpy。注意不要每帧CreateCommittedResource那个代价很大。推荐做法是// 每帧获取一个写入区间的偏移量 UINT8* mappedData uploadBuffer-Map(); memcpy(mappedData alignedOffset, perFrameData, sizeof(perFrameData)); uploadBuffer-Unmap(); // 传给命令列表 commandList-SetGraphicsRootConstantBufferView(0, uploadBuffer-GetGPUVirtualAddress() alignedOffset);alignedOffset用帧缓冲圈数取模保证帧之间不会互相踩内存。这个模式是所有DX12实时渲染项目的通用节奏不为PBR特别设计但它会让你的PBR渲染稳定地以较高帧率跑起来不至于CPU一直等GPU。6. 常见问题速查表与避坑经验总结PBR接进去之后大概率会遇到下面这些经典问题我直接整理成一张表方便你排查症状可能原因解决思路整个物体黑屏纹理资源没有正确的SRV或者描述符堆没Set先用纯数值探针输出float3(1,0,0)看是否绘制流程本身没问题高光过爆、像塑料一样环境贴图格式不够UNORM被clamp或者粗糙度贴图被当SRGB采样环境图改R16G16B16A16_FLOAT检查Roughness是否走线性格式法线效果反了、凹凸倒置TBN矩阵的副切线方向反了输出法线可视化调试并尝试翻转Tangent或法线Y通道金属度调到1时画面发黑漫反射项没有乘以(1-F)或镜面项分母用0检查kD (1-F)*(1-metallic)并确保denom不被0除验证层报资源状态错误没有在Draw前转SRV状态按第4.1节的Barrier流程排查每个资源的状态一个纹理更新后其他物体都脏了描述符堆的offset计算错了重叠了用PIX去看SRV的GPU Handle是否都落在正确范围PSO创建失败日志超长漏配了某个RenderState深度格式或BlendDesc先给一个最小可行的PSO配置逐步打开状态用调试层验证个人实际测试下来我最想单独拎出来提醒你的一件事是PBR不是写完BRDF公式就有“物理感”。很多同学第一步公式对了但场景里灯光强度和单位完全是拍脑袋结果物体要么过曝要么死黑。一个有经验的调法是用一个“灰球白布光”测试场景灰球放在一个中灰背景下用纯白色的方向光粗糙度0.5金属度0然后看灰球颜色是不是基本等于BaseColor * lightColor * NdotL的预期结果差不多就说明你整个光照链路是稳定的。把链路调对再去看高级的IBL和点光源细节这时候才不会越调越迷。后续还能怎么扩展这篇项目我暂时停在Forward PBR这个层面跑通了球体若干点光源环境贴图这一步。如果你跟我一样想把这套学习项目继续往下做我建议后面往这几个方向拓展切到Deferred管线再把GBuffer换成压缩过的编码格式加入Clustered Forward把上百个点光源的剔除做好把IBL预卷积从加载阶段挪到运行时让它支持动态环境——这一步需要搞一个离屏渲染刚好能用上你在这篇里已经会用的Compute Shader和Cubemap那套资源管理。拿DX12写PBR这事看似是在学API实际是把你之前那套对渲染管线和GPU资源流动的认知重新拆开再拼一次。拼完之后你会发现自己对“为什么需要描述符堆”“为什么要注意资源生命周期”这些原本抽象的问题突然通透了。