尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

DX12手写PBR:从三角形到金属质感的跳跃

DX12手写PBR:从三角形到金属质感的跳跃 从窗口三角形到金属质感的跳跃比我想象中更难但也更有意思。这个系列上一篇还停留在纯色三角形的阶段这一篇直接把它推进到基于物理的渲染。经过DX12那套绕不开的资源管理、描述符、管线状态折腾之后我大致把PBR跑起来了至少镜面高光不再像是塑料反光而是有了金属该有的那种“咬住环境”的感觉。这篇不是教科书式的PBR原理复读也不打算把Cook-Torrance公式的推导再抄一遍。我更想站在“已经初始化好DX12设备、交换链、管线”的立场上讲讲我从零加入PBR时踩过的坑、做过的取舍以及每一块拼图在DX12里的真实落位方式。如果你刚把DX12的框架搭起来正琢磨着怎么让画面“看起来对”这篇会很对你的路子。1. 项目拆解DX12里加PBR到底在加什么1.1 PBR在这里不是“一个开关”PBR这个词在引擎里已经被用滥了很多商业引擎勾一个选项就能切换渲染模型感觉就像“点击开启”一样轻巧。但在DX12里手写实现情况完全不同。PBR不是某一个函数而是一整套着色与采样流程的组合与光照模型匹配的材质参数体系比如albedo、roughness、metalness、normal、AO。一套能处理直接光照和间接光照的BRDF实现。针对IBLImage-Based Lighting的预滤波环境贴图、辐照度贴图、BRDF LUT。与上述资源对应的SRV、描述符堆、资源屏障、PSO状态切换。所以这篇“加入PBR”对应的实际工作量是“把原来简单的单色材质管线替换为一套支持PBR材质的渲染管线”。没有引擎帮你藏起背后的复杂度每一个资源的诞生和消亡都得自己负责。1.2 为什么这次选择DX12而不是DX11网上关于“dx11 dx12”的讨论很多纯粹从画质上讲同一个PBR着色模型在DX11和DX12里渲染结果可以完全一样因为像素着色器管的是最终颜色API层面的差异不在光照公式里。但DX12会逼你更早地面对性能和非对称问题你在DX11里可能根本不会碰描述符堆和显式资源屏障但在DX12里这几样东西直接决定你的PBR资源能否被正确采样。DX12的多线程录制能力适合现代游戏那种大量物体、大量贴图的场景而PBR恰恰会把贴图数量翻好几倍。从学习价值看用DX12写PBR相当于把“渲染架构”和“渲染理论”两门课一起上虽然痛苦但收益确实翻倍。所以这一篇选择的路线是先手写PBR所需的着色与资源管线再围绕DX12的绑定模型把一切都老老实实落地。2. 基础框架准备PBR的“地基”在DX12里是什么样2.1 描述符堆、Root Signature与PSO三天入门三周放弃我在第一篇搭建基础的时候最简单的三角形也需要Root Signature、PSO、命令列表这三个东西配合。到了PBR阶段复杂度不是线性增长而是指数级增长。原因在于PBR至少要同时访问一个常驻的常量缓冲区携带View/Projection矩阵和光源参数。多张纹理的SRV比如albedo、normal、roughness-metallic、AO、emissive。三张IBL相关的CubeMap比如辐照度图、预滤波环境图、BRDF LUT。如果需要跑一个天空盒或反射探针还得处理额外的深度资源。这么多SRV如果全部硬编码在Root Signature里也不是不行但PSO状态切换时会很僵硬。我建议的默认方案是采用一个包含常量缓冲、静态采样器、一个描述符表的Root Signature。描述符表指向一个ShaderVisible的CBV_SRV_UAV堆在CPU侧上传描述符在GPU侧直接索引。RootParameter params[2]; params[0].AsConstants(1, 0, 0, D3D12_SHADER_VISIBILITY_ALL); // b0全局常量 params[1].AsDescriptorTable(1, srvRange, D3D12_SHADER_VISIBILITY_PIXEL); // t0开始的贴图们很多初学者在这里会犯一个错把一张贴图的SRV创建在非ShaderVisible堆上然后希望像素着色器能直接访问。非ShaderVisible堆是给CPU预取描述符用的比如复制到ShaderVisible堆之前显卡并不会把它当作可访问资源。我这次直接给PBR资源都开了ShaderVisible堆在CPU侧用CreateShaderResourceView填好描述符渲染时把堆设到命令列表上省掉了每帧复制描述符的麻烦。这种做法在贴图数量固定的教学项目里最省事。2.2 静态采样器与各向异性过滤PBR里法线贴图和粗糙度贴图对采样质量非常敏感。粗糙度数值如果从一张mipmap层次很深的贴图里采样纹理过滤选错了画面会出现明显的“脏感”。我这次用D3D12_FILTER_ANISOTROPIC最大各向异性设为8。各向异性过滤在高光边缘的改善非常明显尤其是在金属表面和地面夹角接近视线方向的时候。静态采样器在Root Signature里声明即可不用每次绘制时重新绑定。如果后续想做各向异性强度实时变化那再改成动态描述符堆也不迟但教学项目里静态采样器够用了。2.3 常量缓冲从MVP到PBR参数上一篇的常量缓冲区只塞了一个MVP矩阵。这次我做了一个大小约64字节的结构体struct PassConstants { DirectX::XMFLOAT4X4 viewProj; DirectX::XMFLOAT4 cameraPos; // 用于视线方向计算 DirectX::XMFLOAT4 lightDir; // 主光源方向 DirectX::XMFLOAT4 lightColor; // 主光源颜色强度 };注意这里没有把每帧都在变的物体世界矩阵塞进去。物体矩阵我放到每个物体的常量缓冲区里材质参数也分开。这样“帧级常量”和“物体级常量”的更新频率完全不同以后做多物体渲染时不会被迫每帧上传一大堆相同的数据。PBR阶段的关键在于像素着色器需要知道摄像机的世界位置来计算视线向量这一点最常见的就是相机在局部空间里用错了矩阵导致高光完全不对。3. PBR核心拆解直接光、IBL与法线贴图3.1 先把术语对齐Cook-Torrance到底包含什么PBR的BRDF模型有很多种我采用的是最通用的Cook-Torrance微表面模型加GGX法线分布。用大白话讲它把每个像素的表面想象成无数微观小镜面组成这些小镜面的朝向分布由粗糙度决定。高光不是“整个表面”反射光源而是只有朝向刚好能让视线和光源形成镜像关系的微表面反射光源。于是直接光部分拆解为以下几个函数法线分布函数NDF决定微表面朝向与法线方向一致的比率我用GGX。几何遮蔽函数考虑微表面相互遮挡导致的阴影和遮蔽我用Smith配合Schlick-GGX。菲涅尔项用Schlick近似描述不同角度下反射率的变化金属度在这里起主导作用。写进HLSL之后大概长这样float D_GGX(float NdotH, float roughness) { float a roughness * roughness; float a2 a * a; float d NdotH * NdotH * (a2 - 1.0) 1.0; return a2 / (PI * d * d); } float G_SchlickGGX(float NdotV, float roughness) { float r roughness 1.0; float k r * r / 8.0; return NdotV / (NdotV * (1.0 - k) k); } float3 F_Schlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }这些函数本身不难难的是参数符号对不对。比如把roughness直接当a用忘掉“粗糙度的平方再平方”这一步高光会变得异常宽泛和灰白。我踩过这个坑调试了很久才意识到问题出在GGX的a被传成了0.5而不是0.25。3.2 直接光实现从单个平行光开始我先只实现了一个平行光不急着上多光源。理由是PBR在调试阶段最忌变量太多一个光源能把高光、漫反射、边缘暗角都看清。基于以上的BRDF函数直接光部分的代码大致是float NdotL saturate(dot(N, L)); float3 H normalize(L V); float NdotH saturate(dot(N, H)); float NdotV saturate(dot(N, V)); float VdotH saturate(dot(V, H)); float D D_GGX(NdotH, roughness); float G G_SchlickGGX(NdotV, roughness) * G_SchlickGGX(NdotL, roughness); float3 F F_Schlick(VdotH, F0); float3 specular D * G * F / max(4.0 * NdotV * NdotL, 1e-4); float3 diffuse albedo / PI * (1.0 - F); float3 direct (diffuse specular) * lightColor * NdotL;在视觉上最明显的反馈是金属材质的暗部不是纯黑而是反射环境色非金属材质的F0很小所以高光强度低、颜色偏白。如果你看到一个“塑料感很重”的球多半是F0设错了。金属F0通常在0.9以上非金属大约0.04左右。3.3 IBL部分环境光不再是“环境色”引擎里的PBR跟纯直接光PBR最大的区别在于IBL。一个只有平行光的PBR金属球在没有光照的一面会直接沉入黑暗看起来完全不像金属。所以IBL才是PBR质感的灵魂。IBL的核心思路是假设环境是一个远方的光场将环境CubeMap当作光源。我们分别处理漫反射部分和镜面反射部分漫反射部分先对CubeMap做卷积生成辐照度图Irradiance Map只保留低频光信息。镜面反射部分按不同粗糙度对CubeMap做预滤波生成若干级mipmap。为了正确地混合漫反射和镜面反射还需要一张BRDF LUT它把菲涅尔和几何遮蔽对粗糙度、角度的积分偏好预计算好。在DX12里我直接用DDS格式存储这三张图。最便利的做法是离线生成好CubeMap的DDS带mipchain运行时加载。辐照度图和预滤波图可以用同一个文件的不同mip级别来表示也可以单独生成。我的做法是辐照度图一张64x64、单mip的CubeMap。预滤波图一张256x256、带完整mipchain的CubeMapmip级数约8到10层。BRDF LUT一张512x512的2D纹理R通道存漫反射权重G通道存镜面反射权重。采样IBL的核心代码如下float3 ambientDiffuse irradianceMap.Sample(samplerState, N).rgb * albedo; float3 ambientSpecular prefilteredMap.SampleLevel(samplerState, R, roughness * mipCount).rgb; float2 brdf brdfLUT.Sample(samplerState, float2(NdotV, roughness)).rg; float3 ambient (ambientDiffuse ambientSpecular * (F * brdf.x brdf.y)) * ao;这句话看起来短但背后有两个大坑一个是预滤波图采样时mipLevel要乘上粗糙度很多人忘记这里导致粗糙度设成0.9时采样到的环境图依然全是第一层mip镜面反射糊成一团。另一个是BRDF LUT的坐标轴方向UV的u是NdotVv是roughness一旦交换高光会随着视线角度乱跑。3.4 法线贴图与切线空间高光的“细节命脉”只靠几何体的顶点法线表面再光滑的PBR也做不出细节。金属球的划痕、木地板的纹路全靠法线贴图提供表面微观起伏。DX12这边不需要什么特殊处理关键在于把采样出的法线从切线空间变换到世界空间。我这次准备了一副TBN矩阵通过顶点结构里的切线、法线、副切线计算出来。副切线我直接在顶点着色器里用cross(normal, tangent)得到再乘以手的反转因子。有些模型导出时副切线方向是反的这时候画面会出现“光源方向整体反向”的诡异效果亮的地方应该在左结果在右。我当时的解决方法是把tangent的w分量正确读到用它去控制副切线方向。HLSL里从法线贴图取法线的代码float3 normalTS normalMap.Sample(samplerState, uv).rgb; normalTS normalTS * 2.0 - 1.0; // 解码 float3 N normalize( normalTS.x * T normalTS.y * B normalTS.z * N0 );这一块在DX12里易错的是顶点布局你在D3D12_INPUT_ELEMENT_DESC里必须正确绑定TANGENT语义不然后续的TBN矩阵全错。我一开始把切线和副切线放反了布局顺序静止时看不出来相机一转动高光就像“粘不住”物体一样。4. 渲染流程与资源绑定从“画三角形”到“画场景”4.1 渲染流程简述场景里我放了几个球体和一个平面各物体共享同一个PSO但拥有不同的材质纹理。每次绘制前我需要做这么几件事更新帧级常量缓冲区包括ViewProj、相机位置、光源参数。更新每个物体的常量缓冲区包括世界矩阵和材质参数。把ShaderVisible描述符堆里对应的SRV区域设置好指向当前物体需要的纹理。在命令列表里设置Root Signature、PSO、描述符堆、顶点缓冲、索引缓冲然后DrawIndexedInstanced。DX12里最容易踩的坑是描述符堆和命令列表的绑定顺序。如果命令列表只设置了RootDescriptorTable而没有设置描述符堆着色器里的SRV采样会读到错误数据甚至直接崩溃。这是因为描述符表只是“索引”真正访问的是DescriptorHeap。我这里把所有纹理的SRV放在同一个ShaderVisible堆中物体索引对应固定的堆偏移。这样绘制前不需要重新创建描述符只是把堆整体设好然后用偏移去访问。在教学场景里这非常高效但如果物体贴图数量动态变化就要学会描述符Heap的sub-allocation了。4.2 资源屏障的正确姿势PBR的多次采样依赖多张纹理这就让“资源状态”变得很碎。比如你从渲染目标切换成ShaderResource或从ShaderResource切换成渲染目标每次切换都要插一条ResourceBarrier。我一开始偷懒只在帧首尾各放一条屏障结果第一帧正常第二帧开始画面出现间歇性的黑色闪烁非常打击人。一个稳妥的做法是每帧开头把所有RT和深度缓冲过渡到RENDER_TARGET/DEPTH_WRITE状态画完场景后再过渡到SRV状态供下一帧的IBL采样或后处理使用。如果后续要添加后处理还要考虑UAV状态的切换。DX12的同步模型就是这么直白一步不到位画面就给你颜色看。4.3 绘制顺序与透明混合PBR场景中经常会遇到半透明物体比如玻璃、水面。DX12里处理半透明跟DX11的逻辑一致先画不透明物体开启深度测试和深度写入再画透明物体关闭深度写入开启混合。区别在于你必须在同一个PSO里配置好混合状态或者在绘制透明物体时切换到另一个PSO。我这次的场景里没有做透明全部是不透明物体所以混合状态直接设为D3D12_BLEND_OP_ADD、D3D12_BLEND_SRC_ALPHA、D3D12_BLEND_INV_SRC_ALPHA以备后用。5. 常见问题与画面调试实录那些让我抓狂的Bug5.1 黑屏描述符堆忘了设置现象三角形阶段正常换成PBR材质后整个画面全黑。排查了一圈发现命令列表里设置了RootSignature和PSO但少了IASetDescriptorHeap这一步。因为在三角形阶段只用常量缓冲可以不依赖描述符堆但像素着色器一旦访问纹理堆是必须的。这个坑在DX12里太经典了每次遇到黑屏第一反应就是检查描述符堆是否绑定。5.2 高光闪烁IBL采样mip级数不对现象粗糙度大的物体上高光区域出现明显的“雪花噪点”或“闪烁块状”。一开始以为是roughness贴图没加载对后来发现是预滤波环境贴图的mip级数固定成了0。粗糙度大时应该采样更深层的mip但我的代码里忘了乘上roughness。修正为roughness * mipCount后再看画面稳定多了。5.3 金属球缺乏反射感没做环境镜面项现象场景里有金属球平行光下的确能看到高光但球体暗部是死的仿佛光线被吸走。这多半是IBL里忘了加镜面反射部分只计算了漫反射环境项。金属材质的漫反射接近0如果镜面环境项缺失金属球自然一团黑。修好之后金属球从暗部到亮部的过渡才会有那种“环境信息融入表面”的质感。5.4 法线贴图方向反了切线w分量没处理现象球体上的纹路在光源方向正确的情况下依然凹凸错乱高光和凹痕的位置完全反了。原因是部分模型副切线方向需要用tangent.w修正。我在导入网格数据时忽略了w分量统一设置为1.0结果有的模型就不对。修复方式是读取模型数据中的tangent.w赋给副切线方向再乘上它。5.5 性能突然掉一半贴图采样无节制现象整个场景只有几十个球帧率却比三角形阶段下降了将近一半。排查后发现我在像素着色器里对所有物体都采样了四张贴图包括那些背面、极小、距离很远的物体。DX12里没有自动的纹理LOD裁剪全屏像素的采样开销是硬性的。后续可以按距离和屏幕覆盖率做材质LOD或选择跳过细节贴图但这个优化在简单场景里可以先不做。下面整理一份速查表帮你快速定位这类问题现象常见原因排查方向全黑描述符堆未绑定查IASetDescriptorHeap和RootDescriptorTable偏移高光区域闪烁预滤波图mip选择错误查SampleLevel的mip计算金属物体暗部死黑缺少镜面IBL项查代码是否计算ambientSpecular表面凹凸方向反TBN副切线方向错误查tangent.w高光宽泛发灰GGX的a参数没平方查粗糙度传入D_GGX前是否先做roughness*roughness边缘颜色过亮菲涅尔F0设置错误查非金属是否用了0.04金属是否用了0.9以上画面闪烁资源屏障缺失或不完整查所有RT和SRV之间的过渡6. 从Demo到实用PBR的后续扩展思考6.1 纹理缓存与描述符堆管理现在的项目里每个物体都用自己的纹理和SRV堆偏移固定。但真实场景中一个关卡可能有几百上千张贴图不可能每张都占一个永久SRV。更合理的做法是做一个纹理缓存模块重复纹理共享SRV新纹理加载时动态分配描述符。描述符堆满了再重新分配或使用GPU描述符桶。这个阶段可以先不做但心里要清楚教学Demo的固定偏移方案在规模扩大后会立刻成为瓶颈。6.2 多光源与阴影我当前只有单一平行光换成点光源之后直接光部分需要循环累加每个光源的贡献。点光源的衰减、聚光灯的锥角控制都是标准实现代码量不大。阴影则麻烦一些需要在DX12里实现ShadowMap这涉及深度渲染、比较采样、级联阴影等一堆内容。如果场景里有金属物体没有阴影会非常奇怪因为金属高光会暴露物体与地面之间缺失的光照关系。后续如果要做“加入pbr”系列的第三篇第一个该补的就是点光源和阴影。6.3 从静态环境到动态环境辐照度图和预滤波图目前都是静态加载的天空不能变化。动态IBL是一个相对高级的方向需要在运行时根据环境更新CubeMap并重新生成辐照度和预滤波数据成本较高。商用引擎通常用探针系统和实时反射捕获来做近似。对DX12学习者来说先跑通静态IBL已经足够建立整个PBR流程的认知动态环境可以等render graph和资源复用经验更充足了再碰。这篇博客的最终目标不是把场景做成电影级而是把DX12里PBR这条链路的每个环节都亲手摸一遍。当你发现一个金属球能从环境纹理中“抓住”高光平面上的法线贴图让混凝土看起来有沙粒的颗粒感那种成就感是写三角形时完全体会不到的。如果你也正在DX12的PBR路上挣扎希望这篇能帮你省掉几个通宵。
返回列表