资讯详情

DX12渲染器接入PBR:从理论到HLSL实战的完整指南

📅 2026/10/2 15:47:59 | 华诺云谱 👁 阅读
DX12渲染器接入PBR:从理论到HLSL实战的完整指南
刚把DX12的基本框架跑通的时候我其实有点迷茫交换链、命令队列、Descriptor Heap这些基础设施都搭好了但屏幕上永远只是一个旋转的立方体那感觉跟搞定了发动机却还没装车身一样。这篇接着上一篇往渲染器里加PBR也就是Physically Based Rendering基于物理的材质系统。PBR不是说换了某个黑科技它是一套从参数到着色都尽量贴近真实物理的规范。加完之后立方体至少能变成一块锈迹斑斑的铁板或者一颗圆润的塑料球而不是反着一层生硬高光的玩具模型。这篇适合已经能用DX12画出三角形、但想往真实渲染方向走的人如果你用的是D3D11、Vulkan甚至OpenGL思路也一样能复用只是API调用的地方不同。1. 为什么在DX12里做PBR以及我说服自己的理由1.1 从光照模型到材质模型换了套思路在接触PBR之前我主要用Blinn-Phong一个漫反射系数加上一个高光系数再用一个pow值控制高光范围。这东西在特定角度下看着还行但只要材质一多问题就来了同一个颜色值配上不同光照强度出来的结果要么发灰、要么死黑而且木纹材质和金属材质用同样一组参数完全没有质感区分。Blinn-Phong本质上是在“凑高光”不是在做“真实材质”。PBR的思路不一样。它假设物体表面是由无数随机朝向的微面元组成的每个微面元本身是完美镜面宏观上我们看到的高光和漫反射其实是这些微面元共同反射和折射的结果。材质只需要给我几个直观参数基础颜色、金属度、粗糙度、环境光遮蔽然后统一走能量守恒的BRDF计算。这样同一个模型放到不同光照环境下材质表现都能保持一致美术不用再为每个灯光调一套高光参数。这正是我想在DX12项目里引入PBR的理由。我的渲染器不打算只画一个物体后面还要接更多模型和灯光如果继续用经验模型每个新材质的调参成本会变成灾难。换成PBR之后参数语义明确比如金属度接近1意味着表面几乎没有漫反射粗糙度接近0意味着高光更尖锐这种规则对使用者非常友好。另外一个很现实的原因是现代模型资源基本都是按PBR流程导出的贴图包里一堆albedo、normal、metallic、roughness不用整套PBR读取它们就是浪费。1.2 DX12里那些为PBR准备的底层设施有朋友可能会问D3D11也能写PBR为什么非要拿DX12折腾我的体会是DX12的资源模型更适合PBR这种自然产生大量纹理绑定的场景。D3D11里绑定SRV数量有上限想弄一堆材质得切来切去而DX12的Descriptor Heap可以一次放几千个描述符GPU执行时通过Descriptor Table索引Material Array或者Bindless方案都容易铺开。另外PBR的场景光照通常需要IBL基于图像的光照。环境贴图要同时支持立方体贴图采样、mip多级细节、各向异性过滤。DX12的Static Sampler可以在PSO里声明一套采样器配置不用频繁在Draw Call之间切采样器状态这对一个带环境照明和粗糙度变化的渲染器来说是实打实的优化。还有一点DX12允许你自己掌控资源状态转换比如把纹理从Copy Dest改成PixelShader Resource这种细节控制对IBL预计算阶段非常方便C端能明确知道什么时候转换不会像旧API那样让驱动猜。不过说句公道话DX12带来的不是画质提升而是编排能力提升。PBR的shader代码和光照公式放在D3D11里跑画面完全一样区别是你能否在复杂场景里把大量材质、灯光、IBL资源管理得清清楚楚。我选择DX12是因为我想把场景规模做得大一点而不是为了“听起来牛”。2. PBR渲染的核心数学与物理不懂这个写不出好shader2.1 Cook-Torrance BRDF拆开看其实很简单PBR的反射率方程写出来是Lo ∫ f(p, ωi, ωo) Li(p, ωi) cosθi dωi刚看到积分符号容易头晕但在实际渲染里这个积分要么用实时灯光近似求和要么用IBL预计算成查找表代码上并不会真的跑积分。重点是里面的f也就是Cook-Torrance BRDF。它分成漫反射和镜面反射两部分f kd * albedo / π ks * (D * F * G) / (4 * NdotV * NdotL)kd加上ks要满足能量守恒而且当金属度上升时漫反射占比下降最后金属几乎只反射高光。公式里的除数4 * NdotV * NdotL是微面元遮蔽和投影的几何校正虽然看起来是额外开销但少了它材质的边缘会显得不自然。我刚开始写shader时总是记不住分母为什么不是π之类的。后来把微面元想象成山坡上的小碎片光打上去一部分反射进眼睛一部分被相邻碎片挡住成了阴影。BRDF里的D管的是碎片的朝向分布F管的是碎片表面在不同角度下的反射率G管的是碎片之间的遮挡三者组合起来才是一个可信的镜面反射。调试的时候千万不要只看到公式就往下抄先把每一项的物理含义写在注释里后面发现问题会省很多时间。2.2 菲涅尔、法线分布和几何遮挡三个角色各有分工菲涅尔我直接用Schlick近似F F0 (1 - F0) * pow(1 - NdotV, 5)F0对于非金属一般是0.04因为大多数电介质在垂直观察时反射率很低金属的F0直接取基础颜色这也是为什么铁会泛白、金子发黄。掠射角下所有材质反射都会增强这也是PBR能让物体边缘比正面更亮的原因。如果用Blinn-Phong这个效果靠经验调整容易过度。法线分布函数我选GGX也叫Trowbridge-Reitz比Blinn-Phong高光更能拟合真实材质拖尾。GGX公式里的粗糙度参数建议使用alpha roughness * roughness。直接用roughness经常让高光范围比预期宽得多画面立刻变成塑料。这个平方关系是GGX计算里最容易踩的地方之一。几何遮挡我用Smith的Schlick-GGX近似配合各向异性版本。模型里粗糙度越高微面元间遮蔽越严重表现是镜面高光变暗但范围变大。如果忽略G项粗糙金属会高光泛白看着像过曝。2.3 IBL把环境光也纳入物理规则场景如果只有直射光PBR材质看起来仍然很假因为现实中物体还会反射天空、周围物体和自发光。IBL的思路是把环境贴图当作无数个方向光光源提前把所有可能方向的光照积分结果存起来运行时直接采样。对于漫反射我用辐照度图相当于对环境贴图做余弦卷积得到一个只包含平均入射辐射的模糊贴图。对于镜面反射则需要预过滤环境贴图根据粗糙度把原始环境图模糊到不同mip level运行时用roughness选择对应mip再和BRDF积分查找表配合。这就是Split-Sum近似的两个预计算部分环境图预滤波 BRDF LUT。分开预计算避免实时做积分代价是换取了一部分近似但在绝大多数PBR场景中已经足够。IBL这部分没有DX12专属API它就是把原始hdr立方体贴图作为输入利用Compute Shader逐像素做卷积再把结果输出为另一张立方体贴图。我在DX12里为它单独准备了SRV和UAV描述符Compute Shader按mip顺序输出保证每个mip之间没有读写依赖冲突。3. DX12管线里塞入PBR资源与绑定设置的实操要点3.1 Root Signature和Descriptor Heap怎么设计才顺手我先在C层把资源分成三类帧数据、材质数据、IBL数据。帧数据是每次draw之前更新一次的矩阵和灯光数组材质数据是albedo贴图、法线贴图、金属粗糙度贴图IBL数据是环境贴图、辐照度图、BRDF LUT。对应在Root Signature里我用了三张Descriptor Table// 根签名对应的Shader可见结构 ConstantBufferb0 FrameConstants : register(b0); Texture2D gAlbedo : register(t0); Texture2D gNormal : register(t1); Texture2D gMask : register(t2); TextureCube gIrradiance : register(t3); TextureCube g_prefiltered : register(t4); Texture2D g_brdfLUT : register(t5); SamplerState g_samplerLine : register(s0);Root Signature的布局我刻意保持简单没有把每个纹理单独放在Root参数里而是通过一个Descriptor Table指向一段连续的Heap区间。这样做的好处是切换材质时只需要改指针区间不需要改整个RootSignature。如果用D3D11的做法逐一绑定SRV到了几百个物体时会非常痛苦。在DX12里我每帧只更新一个CBV其他纹理描述符建好后放在GPU可见的DescriptorHeap里不变Draw Call之间的差别只是几个描述符偏移量。3.2 纹理加载与SRV创建sRGB这个坑必须提前踩一遍加载贴图我用DirectXTex反正DX12没有现成的D3DX。albedo贴图必须加载为sRGB格式比如DXGI_FORMAT_R8G8B8A8_UNORM_SRGB原因是文件里的颜色值其实是伽马空间的shader采样后要转换到线性空间做光照计算。normal和metallic/roughness这类数据贴图则应该加载为普通UNORM不用SRGB否则法线方向和粗糙度值会被错误解码。第一次我把所有贴图统统当成sRGB结果金属度1和0.5全被反编码了一遍高光分布完全乱掉。排查了半天最后用PIX看采样值发现roughness在采样器输出前就已经被开方了。这个教训到现在都记得很清楚颜色贴图才sRGB数据贴图保持原始整数比例。设置SRV时还要给纹理生成完整mip chain尤其是IBL预过滤环境图后面需要使用roughness映射到不同mip level。加载纹理时用GenerateMipMaps或者直接用DirectXTex的CreateTexture加flags默认生成到1x1。过渡到PIXEL_SHADER_RESOURCE之前确保资源状态已经从COPY_DEST转换过来DX12状态不转换就会在验证层报错。3.3 PSO配置别为了PBR开一堆特殊状态默认反而最靠谱Painting管线里我把RenderTarget格式设为DXGI_FORMAT_R8G8B8A8_UNORM_SRGB这意味着渲染目标输出时会自动从线性空间转回sRGB。Depth测试照常开启Blend保持不动。PBR的shader本身输出就是颜色加反射不需要额外混合Alpha Blend这种稍后再考虑。特别需要注意的点是Rasterizer状态。如果开了CullNone法线反面的物体可能在某些角度出现光影异常我吃过一次亏最后发现是模型法线被双面渲染扰乱了。正常PBR材质用CullBack即可双面材质单独再建一个PSO不要让全局状态背锅。Shader Model我用6.2主要为了后续能用Wave Intrinsics优化IBL预计算但5.1跑PBR也完全够。DX12里PSO编译时间比传统API长所以我初始化时把PBR相关PSO提前创建好运行中不动态生成避免卡顿。3.4 HLSL着色器核心代码直接能用的版本我的PBR着色器核心函数是基于CasualShadingDude风格的简化版直接贴出来大家做个参考。需要注意的是这里的roughness已经经过平方如果外部输入是原始roughness记得在C侧或shader侧先转换。float3 fresnelSchlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(1.0 - cosTheta, 5.0); } 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 t NdotH * NdotH * (a2 - 1.0) 1.0; return a2 / (3.14159 * t * t); } 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, float3 V, float3 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 calculateLight(float3 lightDir, float3 lightColor, float3 N, float3 V, float3 L, float3 H, float3 albedo, float metallic, float roughness) { float NdotL max(dot(N, L), 0.0); if (NdotL 0.0) return float3(0, 0, 0); float3 F0 lerp(0.04, albedo, metallic); float3 F fresnelSchlick(max(dot(H, V), 0.0), F0); // 非金属漫反射金属没有漫反射 float3 kd lerp(float3(1,1,1) - F, float3(0,0,0), metallic); float D distributionGGX(N, H, roughness); float G geometrySmith(N, V, L, roughness); float3 specular D * F * G / max(4.0 * NdotL * max(dot(N, V), 0.0) 0.001, 0.0); float3 diffuse kd * albedo / 3.14159; return (diffuse specular) * lightColor * NdotL; }在PixelShader里我循环处理每帧上传的点光源和方向光然后叠加IBL。IBL漫反射直接用辐照度图乘albedo镜面反射用预过滤环境图和BRDF LUT// IBL漫反射 float3 irradiance gIrradiance.Sample(g_samplerLine, N).rgb; float3 F fresnelSchlick(max(dot(N, V), 0.0), F0); float3 kd lerp(1.0 - F, 0.0, metallic); float3 diffuseIBL irradiance * albedo * kd; // IBL镜面反射 float3 R reflect(-V, N); float mipLevel roughness * MAX_REFLECTION_LOD; float3 prefiltered g_prefiltered.SampleLevel(g_samplerLine, R, mipLevel).rgb; float2 brdfLUT g_brdfLUT.Sample(g_samplerLine, float2(max(dot(N, V), 0.0), roughness)).rg; float3 specularIBL prefiltered * (F * brdfLUT.x brdfLUT.y);法线贴图的支持不能漏。我按常规方式计算TBN矩阵把采样到的法线从切线空间转到世界空间。DX12的坐标系是左手系所以TBN的叉积方向要留意否则法线贴图会上下颠倒。最常见的问题是物体旋转后光斑方向不对十有八九是TBN矩阵没有做正交化直接对UV差分求出的TBN在UV缩放非均匀时会变形。我的做法是先对T和B做Gram-Schmidt正交化再乘世界矩阵。3.5 环境贴图预处理用Compute Shader做辐照度和预过滤环境贴图不是随便转成CubeMap就能用的。漫反射辐照度图需要把半球方向上所有入射光加权累加我写了一个Compute Shader每个线程对应一个CubeMap texel在主方向上采样周围的一组方向做cos加权和。采样数48足够平滑再多画面也没差别但性能下降明显。这是典型的时间和精度之间的折衷。镜面预过滤要按粗糙度把原图模糊到不同mip。我这里建议生成5到6级mip因为roughness1时需要的模糊度很高如果mip太少最终结果会出现明显的“跳层”感觉高光会一抖一抖。采样时我用SampleLevel指定mip而不是让硬件自动选择保证同一个像素在所有帧里取同级别的滤波结果闪烁感会小很多。预计算的代码流程是先把原始HDR环境贴图加载成6张2D纹理写入立方体贴图的6个面然后为每个mip运行一次卷积将计算出的结果通过UnorderedAccessView写回。需要在资源状态之间转换初始SRV然后转UAV写完再转回SRV。DX12里UAV和SRV不能同时存在同一资源上所以我是一个mip一个mip处理的避免整张纹理被锁死。4. 调试实录那些颜色、法线和参数翻车现场4.1 画面发灰或者死黑九成是伽马问题我加PBR第一天场景里的金属球永远是灰蒙蒙的高光处又白成一片。翻来覆去不是光照公式问题最后用PIX抓了一下回读颜色发现albedo采样值是0.5但实际期望是0.8原来是albedo贴图没有按sRGB转线性。DX12不会自动帮你处理纹理格式你必须显式创建SRGB格式资源或者在Shader里做pow转换。我后来统一用DXGI_FORMAT_R8G8B8A8_UNORM_SRGB创建颜色贴图RT设成同样的sRGB格式工作流就顺了。如果画面偏暗另一个常见原因是光照强度数值不匹配。PBR假设的是真实物理单位但这个“真实”在实际渲染器里其实没有绝对标准。我的方向光强度初始值设成1.0相当于直接拿一个没有衰减的探头照物体亮度自然很低。建议从3.0开始调或者直接用IBL环境光做基准直射光强度作为微调参数。4.2 材质表现不对先查切线空间和法线贴图法线贴图的表现如果出现明显方向性比如光照从左到右扫过一个平面高光位置偏移了那肯定不是PBR公式问题而是TBN矩阵错了。我曾经偷懒直接拿UV偏导数构造TBN结果切线没有正交化UV缩放不统一时法线被拉扯。后来老老实实做了Gram-Schmidt正交化先算T再算B normalize(cross(N, T))然后T normalize(cross(B, N))这个流程几乎能解决所有法线贴图怪异问题。另外还要注意左手系和右手系差异。DX12是左手坐标系如果是从OpenGL迁移过来的代码cross(N, T)方向可能要反过来否则法线贴图在模型内部看起来是凹进去的。处理方式是做一个可视化的法线预览Shader把最终法线转换到View空间用颜色表示方向一眼就能看出翻转。4.3 金属度、粗糙度不要乱设通道也容易混很多美术导出流程会把AO、roughness、metallic打包到同一张贴图里比如R通道是AOG通道是roughnessB通道是metallic。如果shader里没有按通道解包直接当RGB颜色使用结果会是法线盒子上一片诡异的颜色混合。我在加载贴图时为了避免混乱写了helper函数分别提取mask.r、mask.g、mask.b并且用PIX的单通道预览功能检查每个通道内容。金属度数值建议要么是0要么是1除非你真的在调制一种合金材质。0.5的金属度会让材质看起来像脏塑料。粗糙度范围是0到1没错但GGX中实际参与计算的alpha是roughness的平方如果你在贴图里存的就是平方结果shader里就不要再平方一次。我选择在C端预处理贴图像素把所有roughness统一保存为alpha值这样shader的计算顺序更一致不同模型、不同贴图源混用时不会出现粗糙度差异过大的问题。4.4 性能下降明显先看循环和描述符浪费材质多起来后Draw Call里每个像素循环灯光次数很容易成为瓶颈。我的场景里点光源数量一超过8帧率立刻掉一半。优化方案是只把距离较近的灯光放入FrameConstants或者干脆做成Tiled Shading但作为学习项目我先限制灯光数量并循环展开。完全展开比动态循环快很多因为GPU不用等分支判断。DX12的Shader Model 6支持动态分支但实时渲染里尽量少依赖分支才对。还有一点纹理描述符不要在每个物体里都创建新的一整套否则Descriptor Heap会被塞爆。我的做法是材质系统维护一个描述符表相同贴图的物体共享同一段描述符Draw Call只传一个materialIndex。这样既节省内存也能减少PSO切换时的验证层开销。IBL预计算贴图分辨率控制在128x128大概6级mip肉眼几乎看不出和512的区别性能却好很多。写在最后的一点个人体会我在这个项目里反复改过三版BRDF参数最后发现最影响画面真实感的不是公式细节而是“线性工作流”有没有彻底落实。sRGB创建、sRGB采样、sRGB输出每个环节错一点画面就差一点。第二影响的是IBL质量亮度和方向都对的环境贴图哪怕直射光调得糙材质看起来也舒服。所以以后再做同类功能我会把环境贴图处理当成一等大事不会只把它当作一个可有可无的背景。如果你也在DX12里跑PBR我建议先用一个球体、一个平面、几个方向光把材质参数调到满意再上IBL复杂环境否则出了问题你根本分不清是灯光单位错了还是环境贴图滤波错了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑