ARTICLE DETAIL

资讯详情

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

DX12实战:从根签名到PBR渲染管线的完整实现

DX12实战:从根签名到PBR渲染管线的完整实现 最近我在一个小型3D渲染Demo里把工程从DX11整体迁到DX12同时把光照模型从Phong换成PBRPhysically Based Rendering基于物理的渲染。这是“学一下DX12”系列的第二篇上一期讲了命令队列、命令分配器、围栏这套底层流转这一期专门讲怎么在DX12里把PBR渲染管线真正跑起来从根签名和描述符堆开始到纹理上传再到一个能用的金属/粗糙度工作流像素着色器。写这篇东西的动机挺实际DX12的官方示例大多止步于“画一个三角形”PBR的教程又往往停留在公式推导把这两边接起来的资料很少而我自己在接入过程中踩的坑却不少。这篇适合刚走完DX12基础流程、准备把真实材质放进自己项目的同学也适合会写Shader但被描述符、资源状态和管线状态对象绕晕的老手。顺带声明一下搜“PBR”会同时碰到网络领域里的策略路由Policy-Based Routing这篇文章里的PBR一律指渲染领域的Physically Based Rendering后面不重复解释了。1. 项目背景与整体设计思路1.1 为什么要在这个时间点学DX12DX11不是还能用吗我之前写DX11示例时还算顺手但做真实渲染器时会碰到几个让人想骂街的时刻没办法真正多线程提交绘制命令DrawCall一多CPU就卡状态切换的代价完全黑盒你永远不知道驱动为了SetRenderTarget做了什么排序和缓存新特性——光线追踪、网格着色器、VRS——全都挂在DX12这一代。DX12的设计哲学是“把控制权还给你同时把责任也还给你”跟DX11的“驱动替你兜底”完全是两种心态。具体到工程上我觉得有三件事必须提前想明白。第一从“一堆全局状态”变成“管线状态对象”。DX11里Immediate Context像一个大工具箱随时可以改任意旋钮DX12则要求你把“一套完整配置”封成一个PSO对象再切换这个对象包含着色器、混合、光栅化、输入布局、渲染目标格式等所有静态状态。第二从“驱动全局同步”变成“显式命令队列”。DX12里你先在CPU端录制命令列表录制过程可以并行再把命令列表提交给命令队列让GPU异步执行CPU和GPU之间用围栏同步。第三从“你不关心资源状态”变成“资源状态由你管”——纹理从拷贝目标切换到着色器采样目标之前必须由你插入ResourceBarrier。这其实是把主动权还给驱动但代价是忘写一个Barrier画面就黑。我当时的体会是学DX12别急着背API数量先跑通“录制命令列表、提交队列、等待围栏”这个最小闭环闭环能稳定跑几帧再往里面加东西后面遇到的绝大多数问题都能在这个模型里定位。1.2 为什么把光照模型换成PBR老项目用的Phong模型很简单高光等于pow(max(dot(R, V), 0), shininess)乘以高光颜色。这套模型的问题在于没有能量守恒连续叠加几次镜面反射后材质反射出去的能量可能超过入射光能量画面看起来像一块发光的反光板。更麻烦的是Phong的shininess只能控制高光大小不能表达反射强度随观察角度变化的事实所以同一个金属零件换一个观察方向高光会明显跳变。PBR的核心思路是让光照模型自洽光打到微表面上一部分被反射一部分被吸收/散射加起来不能超过1。实际项目里最常用的是金属/粗糙度工作流Albedo贴图给漫反射颜色Metallic决定反射颜色和漫反射比例Roughness控制微表面分布的宽窄AO负责环境遮蔽。这四个参数对美术来说直观对引擎侧来说只要把BRDF实现一次整条材质链路都能复用不用为每种材质单独写高光函数。再加上DX12里所有着色逻辑都握在你自己手里用HLSL实现Cook-Torrance BRDF非常顺理成章。我更想强调的是PBR和DX12在“思维模式”上是互补的DX12要求你显式管理GPU资源PBR要求你显式理解光照物理两者都逼着开发者把底层逻辑弄清楚而不是满足于“调几个参数能看就行”。所以这个系列把两道硬骨头放在一起啃其实是很自然的路线。2. 关键理论基础DX12的命令模型与PBR公式链2.1 理解DX12的“两段式”命令模型PBR要在DX12里落地先得理解着色器看到的东西是怎么被安排的。DX12里一次绘制至少分两段第一段是CPU侧的录制段你拿着ID3D12GraphicsCommandList一遍遍地调用SetGraphicsRootSignature、SetPipelineState、SetGraphicsRootDescriptorTable、DrawIndexedInstanced这些调用不会立刻让GPU执行只是把命令写进命令缓冲。录制可以发生在多个线程上每个线程用自己的CommandAllocator最后把CommandList提交到CommandQueue。第二段是GPU侧的执行段CommandQueue把收进来的CommandList排入执行流GPU按顺序执行。这里CPU不需要参与每一步只在需要与GPU协作——比如重置分配器、复用上传堆——时才用Fence做一次栅栏同步。这个模型带来的直接后果是没有“全局当前状态”。以前SetBlendState、SetDepthStencilState不覆盖就是一直有效的状态现在一切都以PSO为单位。所以每个Draw之前必须问自己当前PSO对不对我绑到根签名上的常量、纹理描述符恰好是PSO里Shader会访问的那一组吗两者不匹配轻则这个Draw白提交重则设备丢失。这也是为什么我强烈建议一开始就开DebugLayer报错会直接告诉你哪一步不匹配。2.2 Cook-Torrance BRDF公式链怎么串起来PBR的着色核心是BRDF双向反射分布函数。说人话就是一束光从某个方向打到表面后反射到观察方向的那部分能量占入射的比例是多少。真正在Shader里实现的是Cook-Torrance模型把反射拆成漫反射和镜面反射两项f(i,o) (1-F) * albedo / π (D * F * G) / (4 * NdotI * NdotO)以我工程里的实现来拆解首先D是法线分布函数描述微表面中法线恰好指向半角向量h的比例。我用的是GGXTrowbridge-ReitzD a² / (π * ((NdotH)² * (a² - 1) 1)²)其中a等于roughness的平方。roughness接近0时D非常尖锐高光又亮又小roughness偏大时D的尾巴拉得很宽高光柔和。这里有个容易忽略的点roughness接近0并不代表完美镜面因为GGX在极低粗糙度下会有能量损失一些引擎会对粗糙度做重映射我们先不管精调直接用这个公式就能得到很好的物理近似。G是几何遮蔽项考虑微表面之间的互相遮挡我用Smith-Schlick-GGXk ((roughness 1)²) / 8 G_Schlick(nDotX) nDotX / (nDotX * (1 - k) k) G G_Schlick(NdotV) * G_Schlick(NdotL)初次写代码的人容易把G直接当1结果会是边缘高光溢出尤其是斜对着光源看时整片亮得发白。F是菲涅尔反射项光从掠射角入射时反射率会显著上升任何材质在边缘都会变成“镜子”用Schlick近似F F0 (1 - F0) * (1 - dot(V,H))⁵F0是垂直入射时的反射率非金属取0.04左右的灰色金属则直接取Albedo的颜色因为金属的反射率随波长变化反射光会带着它本来的颜色。最后在像素着色器里拼装kS F kD (1 - kS) * (1 - metallic) 光照贡献 (kD * albedo / π DFG / (4 * NdotV * NdotL)) * lightColor * NdotL公式本身不多真正难的是让每一项的变量名和坐标系对得上。比如所有角度必须用单位向量点乘半角向量必须先标准化很多奇奇怪怪的亮度跳跃都是这些基本功出的错。2.3 金属/粗糙度工作流的材质属性约定既然选了金属/粗糙度工作流就要和美术同事定好一套约定Albedo是sRGB颜色贴图非金属表示漫反射色金属表示F0的反射色Metallic是单通道0或1为主极少用中间值它控制F0取0.04还是Albedo也决定漫反射还剩多少Roughness是单通道0到1AO是单通道乘在间接光和漫反射结果上。这些贴图的存储格式要特别注意只有Albedo需要从sRGB转换到线性其余三张按线性读。我最早把所有贴图都建成了非sRGB格式结果Albedo偏灰、高光反射发暗原因是拿一个sRGB编码后的颜色数据直接参与线性光照计算等于把亮度整体抬高。把Albedo的格式改成R8G8B8A8_UNORM_SRGB后硬件采样时自动解码到线性效果立刻正常。这个坑几乎每个做PBR的人都会踩一次我在这里浪费过一个晚上。3. 实操第一步DX12中的资源准备与绑定设计3.1 创建PBR纹理并上传到显存DX12里纹理一般放在DEFAULT堆GPU访问最快但CPU不能直接写DEFAULT堆。常见流程是先创建一个DEFAULT堆的纹理对象再用上传堆做中转最后把像素数据拷贝过去。第一步用CreateCommittedResource创建纹理格式按用途选R8G8B8A8_UNORM_SRGBAlbedo或R8G8B8A8_UNORMMetallic/Roughness/AO初始状态设为D3D12_RESOURCE_STATE_COPY_DEST。第二步创建上传缓冲这里不要自己算行距使用GetCopyableFootprints拿每一行的RowPitch和总大小D3D12_SUBRESOURCE_FOOTPRINT footprint; UINT rowCount; UINT64 rowSize, totalSize; device-GetCopyableFootprints(texDesc, 0, 1, 0, footprint, rowCount, rowSize, totalSize); auto uploadHeap CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD); auto uploadDesc CD3DX12_RESOURCE_DESC::Buffer(totalSize); ID3D12Resource* uploadBuffer nullptr; device-CreateCommittedResource(uploadHeap, D3D12_HEAP_FLAG_NONE, uploadDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(uploadBuffer));第三步把图片像素数据写入上传缓冲位置要按footprint.RowPitch对齐不能按图片固有宽度直接写。之后调用CopyTextureRegion把数据从上传缓冲复制到DEFAULT堆纹理。第四步最关键添加ResourceBarrier把纹理从COPY_DEST过渡到PIXEL_SHADER_RESOURCE | NON_PIXEL_SHADER_RESOURCE。这个Barrier不写Shader采样时会拿不到数据结果不是黑就是设备报错。我当时的错误是把四张贴图放在一个循环里上传上传完成后只提交了一次Barrier结果只有最后一张图能采样其余三张全是黑色。原因是一次ResourceBarrier只能作用于它指定的那一批资源你要把四张图的过渡都写进同一组Barrier数组里再一次提交千万不要认为“上传完就自然可见”。3.2 根签名和描述符堆的规划设计我的根签名按材质做了三块0号槽是每帧参数的CBV用Root Descriptor直接绑定GPU地址避免每次更新描述符堆1号槽是SRV描述符表绑定Albedo、Metallic、Roughness、AO四张纹理2号槽是一个静态采样器线性过滤加Wrap寻址。根签名代码大致长这样CD3DX12_DESCRIPTOR_RANGE srvRange; srvRange.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 4, 0, 0, 0); CD3DX12_ROOT_PARAMETER rootParams[2]; rootParams[0].InitAsConstantBufferView(0, 0, D3D12_SHADER_VISIBILITY_ALL); rootParams[1].InitAsDescriptorTable(1, srvRange, D3D12_SHADER_VISIBILITY_PIXEL); CD3DX12_STATIC_SAMPLER_DESC staticSampler(0, D3D12_FILTER_ANISOTROPIC); CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(2, rootParams, 1, staticSampler, D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);采样器我选择静态采样器而不是单独的描述符堆是因为采样器设置线性/各向异性通常全局不变动态采样器堆空间很紧没必要。如果你的项目需要不同过滤模式再考虑把它挪进描述符堆。描述符堆的规划更关键。我维护一个全局的非Shader可见堆存放所有材质的描述符这个堆可以慢慢增长每帧录制前从GPU可见的Shader可见堆里预留一块连续区域用CopyDescriptors把当前帧用到的描述符复制进去。这样做的好处是GPU可见堆的大小不会因为材质数量膨胀录制线程之间也不互相冲突。特别提醒最终绑定给命令列表的堆必须带D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE否则着色器什么都看不到绘制结果全是占位色。3.3 管线状态对象PSO的配置要点PSO在DX12里是一个不可变对象所有静态状态都写在D3D12_GRAPHICS_PIPELINE_STATE_DESC里创建后不能改。最容易踩的坑集中在几个字段pRootSignature必须跟录制时用的根签名一致否则D3D12_ERROR_INVALIDARGVS和PS的字节码必须确认编译成功非空我发生过Shader编译失败返回空指针创建PSO时直接崩InputLayout要跟HLSL的VS输入结构按顺序对齐顺序错了顶点数据就错乱。BlendState这里有个细节如果你的PBR输出目标是16F的HDR缓冲后处理阶段再做ToneMap那么PBR阶段一般用不透明直写混合状态保持默认即可。我最初在最终输出阶段随手开了Alpha混合所有像素都被叠加了一层画面像蒙着半透明灰纱排查了很久才想起来是混合状态的问题。RasterizerState的CullMode也容易写反CullCounterClockwise表示逆时针剔除有一次写成CullClockwise模型正面全被剔掉只剩下半张脸。最后是格式绑定PSO描述的RTVFormat和DSVFormat必须跟渲染目标、深度缓冲的实际创建格式一致这个不一致会直接报E_INVALIDARG错误本身好查但特别容易在你把渲染目标从8位改成16位后忘记同步更新PSO描述。我建议把“创建渲染目标格式”和“创建PSO格式”放在同一个文件里定义改一处就能联动能省不少调试时间。4. 核心实现PBR着色器的HLSL编写4.1 顶点输入与常量缓冲区设计HLSL层的输入输出结构我定为顶点位置、法线和UV常量缓冲区分成每帧和每物体两组。每帧数据包含视角投影矩阵、相机位置、光源方向和光源颜色每物体数据包含世界矩阵。VS里做坐标变换法线要用世界矩阵的逆转置来变换如果模型没有缩放直接用世界矩阵的3x3部分也行但为了通用性我保留了逆转置的写法。一个DX12不背锅但很坑的点HLSL默认列主序矩阵。如果你用DirectXMath的行主序习惯直接传矩阵所有变换都会转置表现就是模型斜着飞、法线错乱。我统一用XMMatrixMultiply左乘并且传入时会确认矩阵已经按列主序布局。这个问题写代码的时候发现不了一跑就会看到极其诡异的物体变形。4.2 像素着色器中的PBR光照实现完整的直接光版本PS如下Texture2D gAlbedoMap : register(t0); Texture2D gMetallicMap : register(t1); Texture2D gRoughnessMap : register(t2); Texture2D gAOMap : register(t3); SamplerState gSampler : register(s0); 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 denom (NdotH * NdotH * (a2 - 1.0) 1.0); return a2 / (3.14159265359 * denom * denom); } float GeometrySchlickGGX(float NdotX, float k) { return NdotX / (NdotX * (1.0 - k) k); } float GeometrySmith(float3 N, float3 V, float3 L, float roughness) { float r roughness 1.0; float k (r * r) / 8.0; return GeometrySchlickGGX(max(dot(N, V), 0.0), k) * GeometrySchlickGGX(max(dot(N, L), 0.0), k); } float3 FresnelSchlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); } float4 PS(VertexOut pin) : SV_Target { float3 albedo gAlbedoMap.Sample(gSampler, pin.UV).rgb; float metallic gMetallicMap.Sample(gSampler, pin.UV).r; float roughness gRoughnessMap.Sample(gSampler, pin.UV).r; float ao gAOMap.Sample(gSampler, pin.UV).r; float3 N normalize(pin.NormalW); float3 V normalize(gCameraPos.xyz - pin.WorldPos); float3 L normalize(-gLightDir.xyz); float3 H normalize(V L); float NdotL max(dot(N, L), 0.0); float3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float D DistributionGGX(N, H, roughness); float G GeometrySmith(N, V, L, roughness); float3 F FresnelSchlick(max(dot(H, V), 0.0), F0); float3 specular D * F * G / max(4.0 * max(dot(N, V), 0.0) * NdotL, 0.001); float3 kS F; float3 kD (1.0 - kS) * (1.0 - metallic); float3 diffuse kD * albedo / 3.14159265359; float3 direct (diffuse specular) * gLightColor.rgb * NdotL; float3 color direct * ao; return float4(color, 1.0); }这段代码里最容易写错的地方是NdotL的归属。我在最初版本里把diffuse和specular分别乘过NdotL最后又在外面乘了一次结果高光被乘了两次整个画面亮得刺眼。正确做法是先算specular和diffuse再统一乘以NdotL分母里的4 * NdotV * NdotL只负责BRDF的归一化跟NdotL的物理衰减不是一回事。初次跑这个PS时我建议先把F0固定成0.04、metallic固定成0用一个纯非金属材质验证高光的形状和能量然后再加金属贴图。直接从头跑金属加非金属混合一黑一亮你根本分不清是哪一项写错。我当时的调试顺序很笨但很有效先拿一个大球只用方向光调Roughness从0.05到0.9来回切观察高光大小变化再切Metallic看反射颜色整套链路验证完才敢往场景里摆多物体。4.3 容易被忽略的线性空间与Gamma校正PBR的所有光照方程都是在线性空间成立的。所谓线性空间就是颜色数值和实际辐射亮度成线性比例。显示用的sRGB有约2.2的Gamma曲线如果你把sRGB数值直接当线性亮度计算画面会过曝、发灰阴影提不上来。正确的流程是Albedo以sRGB纹理存储用R8G8B8A8_UNORM_SRGB创建采样时硬件自动解码到线性空间另外三张贴图按UNORM读因为它们不是颜色而是0到1的系数光照计算完成后输出到屏幕前再做ToneMapping和Gamma校正。最简单的SDR流程就是color saturate(color); color pow(color, 1.0 / 2.2);想做HDR就得渲染到16F目标先ToneMap再Gamma。否则金属球的高光会糊成一坨白非金属阴影部分死黑所有材质都像塑料。这个环节我反复栽过跟头有一次在PS输出前做了pow(color, 1/2.2)又把RTV格式设成带SRGB编码的格式结果两次Gamma叠加整个画面又黑又灰。我后来把“颜色空间链路”画成一张阶段图sRGB纹理输入、采样线性化、光照计算、HDR合并、ToneMap、Gamma、显示。每次画面不对就顺着这张图逐级排查基本能定位到是输入问题还是输出问题。5. 踩坑实录开发中遇到的高频问题与排查方法5.1 校验层与崩溃类问题速查开了DebugLayer后绝大多数错误会被定位到源码行。第一次跑DX12项目我强烈建议用ID3D12Debug::EnableDebugLayer并配合SetBreakOnSeverity这样一报错就会弹调试器停在出问题的地方。错误现象典型原因排查方向D3D12_ERROR_INVALIDARGPSO根签名和命令列表绑定的根签名不一致或InputLayout与VS输入布局不一致逐个字段对齐PSO与根签名、InputLayout与Shader签名DXGI_ERROR_DEVICE_REMOVED资源状态切换出错或描述符生命周期管理不当导致驱动重置开启DebugLayer看最近一条错误信息重点查BarrierE_OUTOFMEMORY描述符堆空间耗尽或命令分配器没等GPU完成就被回收检查每帧描述符增长情况分配器必须等FenceE_INVALIDARG创建PSORTVFormat或DSVFormat与渲染目标/深度缓冲创建格式不一致打印PSO描述里的格式与实际的堆格式逐一核对设备丢失DEVICE_REMOVED是最折磨人的错误它往往不是发生在一开始错误的代码行而是显卡驱动撑不住后整体重置。这种时候唯一的办法就是开DebugLayer看历史错误日志它会把最近一次验证层错误打印出来。我遇到过一次忘了给一片纹理做从RENDER_TARGET到PIXEL_SHADER_RESOURCE的BarrierDUMP日志里明明白白写着省了我一整天。5.2 画面异常类问题排查画面整体发灰像蒙了一层雾百分之八十是sRGB和线性空间没处理好。检查Albedo用什么格式采样输出前有没有做Gamma校正。我做过一次双重Gamma之后整个渲染画面黑到只能勉强看见物体的轮廓那段时间我一度怀疑是PSO问题最后才发现是格式叠加。金属球没有颜色高光是白色一般是F0取错了。要么把F0写死了0.04要么F0用了float而不是float3导致金属反射色丢失。记住F0必须是三维向量因为不同波长反射率不同金属的颜色恰恰体现在F0的RGB分量上。高光边缘狂闪、锯齿感强烈这一步多半不是Blend问题而是GGX加直接光本身会有光亮变化。CN先不管你可以用MSAA或者做specular AA简单方案是在PS里用mipmap对粗糙度做模糊化或者把N做Karis平均。这个方法我是在项目接近尾声时才加的效果改善非常明显。固定一个方向光同一材质不同角度亮度跳跃大概率是分母没有做CLAMP或者NdotL被乘了两次。每次看到材质角度变亮就先回去审查BRDF三项的变量名把公式和代码一行行对齐。5.3 多线程录制与描述符堆的配合问题DX12的多线程录制很爽但“资源生命周期”必须自己掌控这个比DX11严格得多。多线程录制时要保证每个线程有自己的CommandAllocatorCommandAllocator不能跨线程同时Reset。更麻烦的是录制时塞进描述符堆的材质描述符在命令列表执行完之前绝不能释放。我在项目里做了一个简单的描述符分配器从GPU可见堆里按帧预留一段区域每帧开始时重置区域头材质需要描述符时用原子变量递增取位置这样录制线程之间分配描述符是安全的也不会出现一个线程覆盖另一个线程数据的情况。命令分配器的高频错误是CPU已经把它Reset了但GPU还在执行旧的命令列表于是新的录制会破坏还没执行完的数据。解法很简单每帧用一个Fence值Reset前先WaitForFence上一帧该分配器完成的序号。我封装了一个“帧资源”容器把CommandAllocator、常量缓冲、上传堆都按帧索引存放这样能避免使用中的资源被提前复用。还有一个容易忽略的点一个线程录制命令列表时线程只保存了描述符堆的CPU句柄真正对GPU可见的堆必须在SetDescriptorHeap阶段就完整存在否则GPU执行时看到的堆可能已经不可访问。在调试多线程渲染时一旦看到“同一帧内材质随机错乱”先检查描述符堆的生命周期再检查线程间的共享数据。6. 最后聊几句实操之外的感受写到这里这篇“学一下DX12二加入pbr”的核心链路已经走完了从根签名和描述符规划到纹理上传、PSO创建再到直接光PBR着色器最后把最常见的坑都列了出来。我在这个项目的实际体会是学DX12最大的门槛不是API数量而是要把CPU和GPU的协作模型真正想明白学PBR最大的门槛也不是那几个公式而是要把从贴图格式、采样器、线性空间到光照强度的整条链路打通。这两个东西分开看都有大量资料合在一起做网上能找到的系统性内容并不多。我写作时有一个习惯每解决一个错误就把当时的报错信息和复位方法记进一个Markdown文件时间久了这比任何官方文档都更适合我自己。如果你也在做类似的迁移记住两句话遇到画面发黑或设备丢失别急着怀疑引擎先开DebugLayer遇到PBR效果不对别急着加IBL和环境贴图先用一只纯色球验证BRDF的形状。后面我的计划是给这个Demo加入IBL环境光、法线贴图和阴影把材质库进一步扩展。DX12让你控制一切PBR让你明白一切这两件事都急不得也偷懒不得。
返回列表