
DX12的系列笔记写到这里我手上终于有了一个能跑通的渲染框架设备能初始化、命令队列能干活、三角形和网格体能画出来、相机可以转、深度缓冲也没问题。但每次按下F5看着屏幕上那个灰蒙蒙的几何体心里总有一种“这离游戏画面还差着十万八千里”的感觉。于是第二篇的任务就很明确了加入PBR。PBR全称Physically Based Rendering也就是基于物理的渲染今天几乎所有商业引擎和现代美术管线都拿它当默认的光照与材质标准。这篇笔记适合已经搭好DX12基础渲染框架、想让画面真正往“真实”方向走一步的开发朋友也是我自己这个阶段的学习记录。1. 从Phong到PBR为什么要推翻旧的光照体系1.1 Phong那套为什么不够用我最早实现的是Blinn-Phong光照节点很经典也不复杂漫反射部分用NdotL乘一个Diffuse颜色高光部分用半角向量H和法线N的夹角套一个pow指数再用一个Specular颜色控制亮度和颜色。这套模型在OpenGL入门和DX11入门时代被用了几十年因为它简单、直观、数学好写也容易调出“看起来还行”的效果。但问题是它从物理上就是拼出来的。高光的指数完全没有物理含义它只是告诉你“越小越亮、越大越聚拢”。Diffuse和Specular颜色之间也没有能量守恒关系你可以调出一个反射能量比入射能量还高的材质画面上看起来亮得发虚却说不清哪里不对。更麻烦的是不同材质的参数彼此不兼容同一个Mesh换个环境光照模型所有参数就废了。从DX12管线开发者的角度看旧光照还可能引发一类更实际的问题参数一多Shader里的分支和指令就多而且美术同学为了模拟金属感往往需要额外传一个高光颜色再来个mask贴图打包格式五花八门。说白了整个材质体系缺少一个“行业标准”大家都在各自造轮子。PBR的本质不是某个炫技的公式而是把材质描述和光照计算收敛到一套有物理依据的契约上。所以我在第二篇里定的目标是不追求把场景做到电影级而是先把标准的PBR直接光照跑通让材质参数能真实驱动画面给后续的IBL、阴影、后处理留出位置。1.2 PBR的三个底层逻辑要说清楚PBR为什么新可以先记住三个关键词微表面、能量守恒、菲涅尔。第一个是微表面理论。PBR假设一个粗糙表面的法线不是单一方向而是由无数个微小镜面组成每个微表面都有自己的法线朝向。宏观上我们看到的“粗糙”或“光滑”其实是这些微表面法线分布的程度。光滑表面所有微表面几乎指向同一个方向高光又亮又小粗糙表面微表面朝向混乱高光又暗又大。这直接解释了为什么一张roughness贴图就能同时控制高光的亮度和范围。第二个是能量守恒。PBR要求出射光的总能量不能超过入射光的总能量粗糙度越高高光越扩散峰值亮度就必须降下来。这个约束让材质在光照变化时表现稳定不用像Phong那样每换一个光源就要重新调一遍高光指数。第三个是菲涅尔效应。几乎所有非金属材质在掠射角看都会有明显的反射增强最直观的例子就是你站在湖边看正下方的水是透明的往远处看水面却映着天光。PBR用Fresnel公式把这种随视角变化的反射强度算出来不需要美术额外画一张“环境反射贴图”。这三条加起来构成了PBR和传统模型最核心的分水岭一切都由物理量驱动而不是靠“手感”硬凑。2. PBR材质数据与“一张贴图到底该管什么”2.1 一套PBR参数先约定好在写Shader之前第一件事是把材质参数契约定下来。我在实际使用的这套DX12框架里采用的标准PBR材质通道如下表通道数据内容取值范围用途BaseColor/Albedo基础颜色RGB0~1非金属的漫反射颜色金属的F0基准色Normal世界空间切线法线通常为Y-up的切线贴图细节法线扰动影响微表面朝向Roughness粗糙度0光滑~1粗糙控制微表面法线分布展宽Metallic金属度0非金属~1金属控制金属反射特性与漫反射占比AO环境光遮蔽0~1接触阴影弥补实时全局光照缺失你可能注意到我没有写“Specular颜色”。这就是metalness工作流的取舍金属的F0基本就是albedo本身非金属的F0被固定为约0.04的常数只留一个金属度来插值。美术只需要控制BaseColor和Metallic两张图就能覆盖从塑料到黄金的绝大多数材质不需要像旧版那样再把Specular颜色和Specular mask分开维护。这个约定还引出一个非常重要的细节albedo贴图里保存的“颜色”在线性空间中的物理意义才是正确的。如果直接用从图片加载器拿到的原始像素值画面会整体偏暗、高光也会失去层次。后面我会说具体怎么处理。2.2 Metalness工作流里albedo和F0的关系PBR里最容易让人绕晕的点就是F0到底等于什么。直接光照的Cook-Torrance模型中反射项需要一个菲涅尔基础反射率F0。对于绝缘体比如塑料、木材、皮肤F0大致是4%左右的反射率所以shader里写float3(0.04)是合理的。对于导体几乎所有的反射都发生在表面漫反射项可以忽略F0就是albedo颜色本身。也就是说金属度这个参数做的是一次lerpfloat3 F0 lerp(float3(0.04, 0.04, 0.04), albedo.rgb, metallic);金属度越接近1漫反射越弱albedo越接近F0高光越能反映albedo的颜色。金属度越接近0albedo主要作为漫反射颜色F0退化到0.04。这里有个最常见的视觉故障当你把metallic设为1但是场景里没有环境光整个金属球会“黑成一团”因为金属在直接光照下只靠高光活着高光方向一偏所有漫反射都没了。很多新手做PBR调试时第一眼看到金属物体变黑就怀疑公式写错了其实不是公式的问题是场景里缺少环境光底子。这个问题我后面在调试实录里还会重点说。2.3 线性空间、sRGB与DX12里的贴图格式PBR要准确线性空间逃不掉。绝大数贴图资源在磁盘上是sRGB编码也就是8bit的像素值经过了gamma曲线编码。如果Shader直接把编码值当成线性值去算光照结果会偏暗、对比度过强金属的光泽看起来也会脏兮兮的。DX12处理方式和DX11类似创建着色器资源视图时把贴图格式指定为DXGI_FORMAT_R8G8B8A8_UNORM_SRGB采样器在读取时自动把sRGB解码到线性空间。要注意的是这个格式转换只应该针对albedo这种颜色贴图normal、roughness、metallic、AO这些数据贴图本质上存的是“数值”不是“颜色”必须用DXGI_FORMAT_R8G8B8A8_UNORM否则它们会被无意义的解码破坏。在我自己的实现里加载DDS/PNG时统一先读成DXGI_FORMAT_R8G8B8A8_UNORM然后根据贴图语义决定是否生成_SRGB版本的SRV。有些工具链为了方便会在文件后缀里区分_ddata和_ccolor但最终落到DX12里靠的就是创建SRV时这个格式选择。顺带提一句输出端的颜色缓冲也用DXGI_FORMAT_R8G8B8A8_UNORM_SRGB往往会省一道手动gamma校正但如果你后面要做HDR和tonemapping就别在RT格式里用SRGB而是输出float格式在最后的tonemap阶段统一转。我为了给后续HDR留余地第一版就直接上了DXGI_FORMAT_R16G16B16A16_FLOAT。3. DX12管线改造根签名、描述符与PBR Shader3.1 根签名怎么设计才不憋屈这个环节是DX12和旧API体感差异最大的地方。DX11时代绑定贴图很简单PSSetShaderResources一个个塞就行。到了DX12每次Shader访问资源都得通过根签名而根签名又是一个在创建管线时就固定好的“权限契约”。我上一版框架里只有一个cbuffer的绑定根签名自然简单。加入了PBR之后一个材质至少需要5张SRValbedo、normal、roughness、metallic、AO。显然不能再用单个SRV一一对应地塞进根参数。比较自然的做法是用一张描述符表Descriptor Table把这5个SRV打包在一起。我第一版根签名的构建逻辑大致是这样CD3DX12_DESCRIPTOR_RANGE ranges[2] {}; // 纹理SRV区t0~t4 ranges[0].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 5, 0); // 环境纹理SRV区t5暂时预留 ranges[1].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 5); CD3DX12_ROOT_PARAMETER params[2] {}; // 每帧/每物体的常量缓冲 params[0].InitAsConstantBufferView(0, 0, D3D12_SHADER_VISIBILITY_ALL); // 纹理描述符表 params[1].InitAsDescriptorTable(2, ranges, D3D12_SHADER_VISIBILITY_PIXEL); CD3DX12_ROOT_SIGNATURE_DESC desc; desc.Init(2, params, 0, nullptr, D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);我把参数全部放到了寄存器空间0常量缓冲在b0贴图在t0~t5。因为当前版本没有用bindless所以“一个材质对应一段连续描述符堆区间”就够了。后续如果场景里要支持上百种材质再考虑bindless或纹理数组现阶段强行上bindless只会增大调试难度。3.2 描述符堆与多纹理绑定根签名只是契约真正常驻在GPU侧的是描述符堆。DX12里Shader看不见普通的堆它只能看见D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE勾选过的描述符堆。我一开始犯过一个错误把对象的所有发现都放到一个非Shader可见堆绘制时再通过CopyDescriptors复制到可见堆。这样每次draw都要复制5张描述符浪费带宽不说逻辑也绕。后来简化成这样一个模式维护一个Shader可见的CBV_SRV_UAV堆堆大小按“最大物体数 * 6”预留。每帧开始时用GetCPUDescriptorHandleForHeapStart拿到堆起始CPU句柄。每加载一个材质就连续写入5个SRV描述符并记录这个材质在堆里的累积偏移量。绘制时通过SetGraphicsRootDescriptorTable(1, gpuHandle offset)绑定整个材质描述符表。关键点是CPU描述符句柄和GPU描述符句柄在Shader可见堆里都有效但用它更新描述符堆只能通过CPU句柄draw时传给命令列表的必须是GPU句柄。把这两个弄混的报错通常是D3D12 ERROR: GPU descriptor heap ... out of bounds排查了好几次才反应过来。如果你的项目里材质数量不多这已经足够了。真正做大场景再考虑bindless或者智能的descriptor cacheDX12的优势是这些你都能做劣势是你必须自己踩一遍。3.3 HLSL里的PBR核心实现接下来是重头戏HLSL里的PBR直接光照模型。我用的是现在最主流的Cook-Torrance微表面BRDF配合GGX法线分布和Smith几何遮蔽。如果你之前写过Blinn-Phong看到这段代码会觉得很不一样但公式结构并不难// Schlick菲涅尔 float3 F_Schlick(float3 F0, float VdotH) { return F0 (1.0 - F0) * pow(1.0 - VdotH, 5.0); } // GGX法线分布 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); } // Smith几何遮蔽 float G_Smith(float NdotV, float NdotL, float roughness) { float k (roughness 1.0) * (roughness 1.0) / 8.0; float gv NdotV / (NdotV * (1.0 - k) k); float gl NdotL / (NdotL * (1.0 - k) k); return gv * gl; }组装的Pixel Shader核心片段大致长这样float3 albedo SRVTexture0.Sample(samplerLinear, uv).rgb; float3 normalTS SRVTexture1.Sample(samplerLinear, uv).xyz * 2.0 - 1.0; float roughness SRVTexture2.Sample(samplerLinear, uv).r; float metallic SRVTexture3.Sample(samplerLinear, uv).r; float ao SRVTexture4.Sample(samplerLinear, uv).r; // 把切线法线变换到世界空间此处需要TBN矩阵 float3 N normalize(mul(normalTS, TBN)); float3 V normalize(cameraPos - worldPos); float3 L normalize(lightDir); float3 H normalize(V L); float NdotV saturate(dot(N, V)); float NdotL saturate(dot(N, L)); float NdotH saturate(dot(N, H)); float VdotH saturate(dot(V, H)); float3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float3 F F_Schlick(F0, VdotH); float D D_GGX(NdotH, roughness); float G G_Smith(NdotV, NdotL, roughness); float3 specular (D * F * G) / max(4.0 * NdotV * NdotL, 0.001); float3 kD (1.0 - F) * (1.0 - metallic); float3 diffuse albedo * kD * (1.0 - metallic); // 非金属才有漫反射 float3 direct (diffuse specular) * PI * NdotL * lightColor; // 加上一个非常简单的环境项让金属不至于全黑 float3 ambient albedo * ao * 0.03; float3 finalColor direct ambient;这里有几个我实际踩过的细节第一所有dot结果都要做saturateNdotV和NdotL如果是负的几何项里会出现NaN画面会出现一帧一帧闪烁的坏点。这个在数学公式里看着没事跑起来就原形毕露了。第二G_Smith里的k系数我用了UE4风格的重映射(roughness 1)^2 / 8这比原始的GGX公式直接乘更亮一点美术视角更讨喜。严格物理的版本是k roughness^2 / 2IBL用或k (roughness * roughness) / 2直接光两种都行关键是保证直接光和IBL的几何项口径一致否则环境光与主光源过渡会不自然。第三漫反射项里我额外乘了(1.0 - metallic)这是一个小细节。纯金属没有漫反射如果不做这一步金属物体的暗部会泛起奇怪的灰色。3.4 DX11 vs DX12同样的PBR不同的体感为什么要单独说DX11和DX12因为PBR虽然在两个API里都能实现但开发体感完全是两个世界。DX11时代的PBR实现最常规的流程是把所有材质贴图绑定到ShaderResourceView的不同slot然后PSSetShaderResources(0, numTextures, srvArray)。一旦材质切换就重新绑定一堆SRV。引擎层面还要小心地跟踪状态避免没必要的状态切换。这套机制对开发者友好API帮你在背后做了大量状态管理。DX12则把这些都摆到了明面上。在DX12里纹理数据首先要通过上传堆上传到默认堆之后还要手动插入ResourceBarrier把资源状态从COPY_DEST转成PIXEL_SHADER_RESOURCE否则GPU在读取时可能看不到数据或者驱动强制同步带来性能骤降。再加上根签名、描述符堆、管线状态对象PSO每个环节都要自己搭桥PBR的代码量反而比DX11显得更重。但好处也很明显DX12允许你把“材质描述符表”一次性绑定换材质只是改一个GPU句柄可以用bindless让GPU用索引访问任意纹理可以在多线程环境里并行记录命令列表把材质排序做得比DX11更细。对于PBR这种纹理种类多、参数复杂的渲染DX12的灵活性上限远高于DX11只不过你得先把这些控制权接过来。一句话总结我的感受DX11是保姆式托管你只管调APIDX12是把钥匙全给你也从你把责任全担起来。同样跑一个PBR的金属球DX12初学阶段会比DX11多踩一倍坑但越过这个坎之后后面的路会顺畅得多。4. 真机调试三个常见视觉Bug与排查思路4.1 视觉问题快速定位三板斧PBR的Shader代码写完后第一次跑大概率不会一次就完美。我的调试思路基本可以归纳成三板斧。第一板斧用固定参数替换贴图。当画面不对劲先不要怀疑PBR公式先把albedo设成一个纯色roughness设成0.5metallic设成0.0用这个“基准材质球”渲染。如果纯色材质球看起来是对的问题多半出在贴图加载或UV映射上如果纯色材质球就是错的那才去排查公式和参数传递。第二板斧把中间量可视化。在调试用的Shader里直接把NdotL、粗糙度、F0这些值当成颜色输出比如return float4(NdotL.xxx, 1)。这样能快速看出法线方向对不对、粗糙度是否被正确采样、F0是否偏离预期。这招比盯着最终画面猜问题快得多。第三板斧二分法开关功能。把PBR拆成diffuse、specular、environment三部分一个开关一个开关地加。先开环境光再开主光源漫反射最后加高光。每一步都验证一遍很多难查的“黑脸”和“过曝”是组合后的问题拆开之后反而一眼就能看出是哪个功能拖了后腿。4.2 常见问题速查表这里是我在调试过程中遇到最多的几个问题整理成一张速查表现象概率原因解决建议金属物体整片发黑metallic1时漫反射为0但场景缺少环境光依赖高光却有方向无高光加一个低强度的半球环境光或Image-based lighting物体整体过曝、颜色像漂白albedo贴图被当作线性数据直接采样或最终颜色未做tonemapping使用_SRGB格式的SRV采样albedo或做x / (x 1)的简单曝光映射高光闪烁坏点NdotV/NdotL出现负值或0导致几何项除零所有dot结果加saturate分母加max(..., 0.001)粗糙材质完全没有高光roughness太大D项峰值被摊平或菲涅尔F过低导致反射弱检查roughness通道是否为线性数据是否误用了sRGB格式贴图参数钳制到0.05~0.95接缝处出现细黑线贴图uv在三角形边界采样不连续或mipmap开启异常检查纹理mipmap生成方式确保uv连续必要时开启各向异性过滤金属表面看到“脏灰”漫反射项没有乘(1 - metallic)金属被混入了非金属漫反射在diffuse项中乘(1.0 - metallic)4.3 性能调试记录最后说一点性能和实际运行的东西。DX12 Debug Layer开着时PBR这种多纹理场景每帧会产生大量验证信息我一开始就是这样跑结果在RTX级别的显卡上帧率居然只有个位数。排查了半天关掉Debug Layer之后立刻恢复流畅。日常调试可以开着但测性能时一定要关或者用ID3D12Device::RemoveDevice级别的GPU验证子集。描述符堆也是一个容易忽视的性能点。我第一版每个材质都新建一个描述符堆结果DrawCall一多每帧要切换堆GPU延迟很扎眼。改成刚才说的一体化Shader可见堆、每组描述符按偏移量复用之后问题消失了。顺带说一句DX12里描述符堆的NumDescriptors是创建时写死的不能事后扩容所以一开始就要给最大数量留足余量。纹理数据落地后几个PBR贴图的尺寸对显存带宽的影响也很直接。我用的是一套2K分辨率的材质5张贴图差不多几百MB带宽在简单场景里没什么问题但如果后面想支持大场景多材质尽早打算做纹理图集或mipmap裁剪会更稳。5. 一点个人心得从第一帧能画三角形到这颗能正确反映金属度的球中间的距离比我预想的大得多。PBR表面上只是换一个BRDF公式实际上整个管线都得围绕“物理量”重新梳理贴图要用线性空间材质参数要有统一语义资源绑定要有清晰的描述符规划连环境光的缺失都会让金属材质在黑画面里暴露出来。我这里只是先跑通了直接光照加很小的环境常量IBL、阴影、tone mapping这些还没接但框架和Shader里已经给它们留了位置。我个人的体会是学DX12千万不要一上来就追bindless和光追那些最炫的技术。先把“三角形变金属球”这条路完整走通你收获的不仅是PBR的公式更是DX12资源管理的肌肉记忆。如果这篇笔记里某个问题恰好是你也卡过的希望那个速查表能帮你省下几晚上排查时间。下一阶段我大概率会把IBL环境贴图加进来让这颗金属球在没有主光源的时候也不再黑成一片到那一步画面的真实感才算真正立住。