尧图精选

DX12实战:从三角形到PBR——BRDF、GGX与HLSL实现全解析

🕒 发布时间:2026/10/2 15:47:43 📁 来源:尧图网络
上一篇文章我从零把DX12的设备、命令队列、交换链、以及最基础的三角形渲染捋了一遍这套流程跑通之后你手里其实已经有一个“能往屏幕画东西”的最小显示管线了。这一篇我不打算继续堆API细节而是把重点转向画面本身加入PBR——基于物理的渲染让模型从“塑料片”进化到“有金属感、有粗糙度差异、灯光照上去会真实衰减”的状态。这部分内容适合已经熟悉DX12基本初始化、但不知道从哪里开始做光照的读者如果你没看过前一篇可以直接把文中涉及窗口和设备初始化的部分当成已有基础设施看待不影响理解PBR相关的主线。PBR不是某个DX12专属特效它是一套对光照和材质进行物理模拟的着色方法。DX12本身不提供任何“内置PBR”所有BRDF公式、贴图采样、光源循环、伽马校正都得自己在HLSL里写出来。这也恰恰是学DX12的人最该经历的一段路程API只负责把数据送到GPU画面好不好看取决于你自己写的着色器逻辑。这篇文章我会从BRDF的数学拆解讲到根签名和描述符堆的布置再给出一个能跑起来的HLSL实现最后把我调试过程中踩过的坑一并列出来方便你少走弯路。1. 从“能画三角形”到“光照真实”为什么要在这个阶段引入PBR1.1 老式光照模型的问题在哪里在前一篇的Demo里如果我们给三角形加颜色那只是一个纯色填充没有任何明暗变化。很多初学图形学的人会先接触Blinn-Phong环境光加漫反射加高光再算一个半程向量看起来好像也能转。但Blinn-Phong有一个根本性问题——它的参数是人为调出来的不具备物理一致性。比如你把一个材质从环境A搬到环境B光照角度的变化会让反射显得怪异金属和塑料在同样的参数下区别也不够直觉。PBR换了一套思路它不问你“这个物体高光多大”而是问你“这个物体是什么”。怎么理解粗糙度Roughness决定微表面把光线散射开的程度金属度Metallic决定反射光是来自漫反射还是镜面反射基础色Albedo/BaseColor决定表面吸收了哪些波长的光。这三个属性一旦设定光照计算就自动给出在不同光源下的稳定结果。对于做引擎封装的人来说这等于把“调参猜谜”变成了“填物理属性”。1.2 DX12环境里做PBR要准备什么在DX12里引入PBR并不是装一个库就有特效你需要准备四样基础能力根签名Root Signature定义着色器从哪个槽位读取常量缓冲区、纹理、采样器。描述符堆Descriptor Heap存放CBV、SRV、Sampler的描述符类似一个“资源索引表”。常量缓冲区或动态常量至少要把MVP矩阵、相机位置、光源参数送进GPU。完整的纹理资源加载链路从磁盘读图、上传到默认堆、生成Mip、创建SRV。如果你的项目还没有这些那这一篇其实和“材质系统入门”是同一件事。我不建议一上来就铺一张巨大的PBR贴图集先用纯数学参数把BRDF跑通再用贴图去替换常量这样排错范围会小很多。1.3 从一个球体开始的效果验证方案PBR算法是否正确肉眼观察一个球体是最直观的检验方式。球体法线方向连续变化任何光照模型的问题都会在球面上暴露无遗。所以我的建议是先别急着加载复杂模型生成一个UV球或者直接用几何体工具导出球体网格给它指定固定的粗糙度和金属度用单个方向光照射确认反射高光、边缘菲涅耳、暗部滚降都符合预期了再逐步替换成实际游戏模型。这一步能帮你隔离问题如果球体上PBR效果对了那问题一定出在模型数据或贴图如果球面上就不对那就是BRDF实现的问题。2. 被塞进着色器前的数学Cook-Torrance、GGX、Smith和SchlickPBR的直接光照部分业界最常用的反射模型是Cook-Torrance微表面模型。它把物体表面的反射拆成两部分漫反射项和镜面反射项。漫反射项通常用Lambert模型镜面反射项则由一个BRDF函数描述——这个BRDF由三个核心因子组成法线分布函数D、几何遮蔽函数G、菲涅耳项F。大多数人在网上看到的、被复制来复制去的PBR代码本质就是这三项的排列组合。2.1 法线分布函数GGX/Trowbridge-Reitz微表面理论认为物体表面的法线并不是完全一致的而是有无数个朝向各异的微小平面。法线分布函数D描述的是给定一个半程向量H表面上有多少微表面的法线恰好等于H。如果这个比例高直观表现就是高光锐利、金属感强比例低则高光扩散、表面粗糙。最常用的GGXTrowbridge-Reitz公式如下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 denom (NdotH2 * (a2 - 1.0) 1.0); denom 3.14159265359 * denom * denom; return a2 / max(denom, 1e-6); }注意这里有个容易踩的坑顶部直接把roughness平方了一下。这是Disney提出的“roughness重映射”很多引擎拿到的粗糙度贴图已经是重映射过的如果你再平方一次等于把粗糙度往更粗糙的方向推了一档。你需要确认自己美术管线里贴图的规范到底是“线性粗糙度”还是“GGX roughness”。2.2 几何遮蔽函数Smith几何函数G评估的是微表面之间互相遮挡的程度。表面越粗糙凹陷越深光线进入时被附近的微表面挡住、反射出来时又挡住一部分的概率越大。直接光照里最常用的是Smith Schlick GGX版本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); }这个G函数在掠射角光线几乎平行于表面时数值急剧下降正好模拟了现实里“角度越平越容易被遮挡”的效果。很多初学实现里高光边缘出现一整圈异常亮带就是因为G项写错或者漏掉了。2.3 菲涅耳项Schlick近似菲涅耳效应讲的是从一个角度观察表面时反射率会随角度变化。垂直看玻璃它接近透明侧着看玻璃它就像镜子。Schlick近似用一个极低成本的公式模拟这个现象float3 FresnelSchlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); }这里的F0是材质在0度角法线方向时的基础反射率。非金属一般取值在0.02到0.08之间也就是几乎不反射金属的F0则直接取Albedo值因为金属的反射光本身就带有颜色。这就是为什么PBR实现里通常用这样的逻辑float3 F0 lerp(0.04, albedo, metallic);。0.04是一个“标准非金属”的默认F0省去为每种塑料、木头、石头单独查反射率表的麻烦。这三个公式合在一起就构成了Cook-Torrance镜面反射部分。加上Lambert漫反射完整的直接光照BRDF大概是这个骨架float3 Lo float3(0.0, 0.0, 0.0); float NdotL max(dot(N, L), 0.0); 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 * NdotL * NdotV, 1e-4); Lo (kd * albedo / 3.14159265359 specular) * radiance * NdotL;这段代码里我特意把分母的4.0 * NdotL * NdotV保留完整网上有些简化版会直接省略但在高光区域会出现能量异常。加上一个极小值防止除零是保值的好习惯。kd项乘以(1.0 - metallic)是为了让金属不产生漫反射——金属的漫反射能量几乎为零。3. 资源链路设计根签名、描述符堆、常量缓冲区和纹理采样器数学公式只是理论真正让PBR在DX12里跑起来的是资源的组织和调度。DX12的难点一向不在着色器本身而在CPU侧如何把数据高效地送到GPU那边。这里我以一个典型的PBR场景为例一个物体、一个方向光、一个点光源三张贴图Albedo、Normal、PBR粗糙度/金属度合并贴图。3.1 根签名怎么设计最省事我的建议是使用表格型根参数Descriptor Table而不是每个资源都开一个Root Parameter。这样方便后续扩展灯光数量。根签名可以这样规划参数索引类型内容0CBV b0全局数据MVP、相机位置、时间1CBV b1材质数据albedo、metallic、roughness、AO2SRV t0Albedo纹理3SRV t1法线纹理4SRV t2粗糙度/金属度/遮挡合并纹理也可分开三张5静态采样器 s0线性过滤、Wrap根签名一旦在创建PSO时固定之后修改布局就要重建PSO所以最开始宁可多预留也别做得太紧凑。我把材质数据单独放到一个CBV而不是塞进全局CBV是为了后面支持多物体、多材质时能按实例更新材质缓冲区否则每换一个材质都要重建整个全局数据。3.2 常量缓冲区的布局对齐问题常量缓冲区在DX12里是按256字节对齐的这意味着你的HLSL结构体在CPU端声明时要遵循相同的对齐规则。我用的一个最小化布局是这样的struct PBRMaterialCB { float4 Albedo; // 16字节 float Metallic; float Roughness; float AO; float Pad0; // 补齐到16字节 // 总计 32字节 };对齐错误在DX12里很隐蔽因为GPU读到的数据就像被“错位”了一样贴图颜色变花、金属度变成了一个随机值。解决方法是在HLSL和C两侧都定义完全一致的结构体并在C结构体中使用alignas(16)或者DirectX::XMFLOAT4这类本身就是16字节对齐的类型。材质常量缓冲区的更新频率取决于你的数据变化频率。如果只是Demo每一帧把材质数据拷贝进上传堆、再Copy到默认堆即可如果做了完整的材质系统建议按“材质创建时上传一次存一个长期CBV”的方式避免每帧重复上传。3.3 从纹理文件到SRV描述符的完整流程PBR最少需要一套能采样的贴图。DX12里创建纹理的链路比OpenGL长很多但逻辑并不复杂从文件读像素数据我用WIC或DirectXTex库加载jpg/png。创建一个纹理资源状态为D3D12_RESOURCE_STATE_COPY_DEST。创建一个上传堆把像素数据按行对齐规则复制到上传堆。用CopyTextureRegion把数据从上传堆拷到纹理资源。用ResourceBarrier把纹理状态从COPY_DEST转到PIXEL_SHADER_RESOURCE。创建SRV描述符放入描述符堆。这里最容易翻车的是“行对齐”。纹理的每一行在默认堆里要求按256字节对齐而上传堆里也有自己的对齐限制。DirectXTex库封好的接口会处理好这些如果你手写像素拷贝一定要先查GetCopyFootprint拿到每行字节数再用memcpy逐行拷贝不能把整张图像当作连续内存一次性拷过去否则宽幅纹理右下区域会出现斜切错位。法线贴图加载时要注意格式。压缩法线贴图BC5需要专门的SRV格式直接当BC7或者R8G8B8A8加载会把法线解成完全错误的向量这属于我后期排错里碰到过的真实案例。3.4 静态采样器与各向异性过滤采样器在DX12里有两种创建方式静态采样器和描述符堆里的采样器。对于PBR这种常规用途用根签名里的静态采样器即可不需要额外的描述符。我通常设置一个线性过滤、各向异性8x、Wrap模式的静态采样器给贴图用再设一个比较采样器给阴影用如果后面做阴影。“各向异性过滤”在PBR里特别重要因为粗糙度变化导致的纹理拉伸非常明显不开启的话球体边缘的反射细节会糊成一团。4. 把BRDF写进HLSL从顶点着色器到像素着色器的完整实现理论讲完了资源也准备好了下面是我实际放在Shader文件里的一套最小PBR实现。这是基于我之前那篇Demo的延续所以输入布局和常量缓冲区命名可能和你自己的工程不同但核心逻辑可以直接照搬。4.1 顶点着色器的数据传递PBR需要的关键数据是世界坐标位置、法线方向、切线方向如果用法线贴图、UV坐标。这些数据的计算最好在顶点着色器里完成插值后再进像素着色器。否则全部在像素阶段逐像素做矩阵变换性能会差很多。struct VSInput { float3 Position : POSITION; float3 Normal : NORMAL; float2 UV : TEXCOORD0; }; struct VSOutput { float4 Position : SV_Position; float3 WorldPos : TEXCOORD0; float3 Normal : TEXCOORD1; float2 UV : TEXCOORD2; }; VSOutput main(VSInput input) { VSOutput output (VSOutput)0; output.WorldPos mul(float4(input.Position, 1.0), WorldMatrix).xyz; output.Normal normalize(mul(input.Normal, (float3x3)WorldMatrix)); output.UV input.UV; output.Position mul(float4(output.WorldPos, 1.0), ViewProjMatrix); return output; }这里有个细节法线变换不能直接使用包含平移的世界矩阵否则法线方向会被平移分量污染。正确做法是用世界矩阵的3x3部分并且如果模型有非均匀缩放需要用法线的逆转置矩阵。我的Demo里模型没有缩放所以直接用3x3矩阵简化处理。你在实际工程中如果有Scale操作记得改用transpose(inverse(mat3(WorldMatrix)))。4.2 像素着色器里的BRDF函数集像素着色器的核心就是把第二节里的公式组装起来。我通常会把这些函数单独放到一个PBR.hlsl头文件里方便多个Shader复用。完整的峰值部分如下float3 DirectLight(float3 N, float3 V, float3 L, float3 lightColor, float lightIntensity, float3 albedo, float metallic, float roughness) { float3 F0 lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float NdotL max(dot(N, L), 0.0); if (NdotL 0.0) return float3(0.0, 0.0, 0.0); 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 numerator NDF * G * F; float denominator max(4.0 * NdotL * max(dot(N, V), 0.0), 0.001); float3 specular numerator / denominator; float3 kd (1.0 - F) * (1.0 - metallic); float3 diffuse (kd * albedo) / 3.14159265359; return (diffuse specular) * lightColor * lightIntensity * NdotL; }我跟你讲一下为什么要把NdotL 0.0提前返回。很多人写PBR循环多个光源时忘了背对光源的面要做零贡献处理。max(dot(N, L), 0.0)在数学上已经把负值钳到0但后面再乘光照颜色和强度在浮点运算上浪费了不少周期。提前剪枝对多光源场景的效能提升很明显尤其当你一个像素要循环几十个点光源时。4.3 多光源循环的组织方式单光源版本很快就能跑通但真正让画面有层次感的是多光源。我的做法是在全局常量缓冲区里放一个“灯光上限”例如最多支持16个点光源外加1个方向光。灯光数据用一个结构体数组传入struct LightData { float3 Position; // 世界坐标 float Intensity; float3 Color; // 灯光颜色 float Range; // 影响范围 // 到下一个 float4 对齐 };在实际展开循环时我建议你直接手动展开前几个光源或者用动态循环而不要过度相信编译器。GPU上的循环虽不至于像CPU那样开销巨大但循环内依然要做分支和索引计算。对于PBR这种本身就包含pow、除法、sqrt的着色器充裕的指令预算比省几个分支更有价值。一个性能差异明显的小技巧点光源可以先用距离判断做跳过。Range和距离的平方比较如果灯已经超出范围直接continue这样能避开后续所有昂贵的BRDF运算。在视觉上点光按平方衰减是最物理的但直接用在场景里经常导致“点个蜡烛半边屋子都没光”的效果所以很多引擎实际用的是Intensity / (1.0 d * d)这种改进式衰减既保留了范围限制又避免了除以零和距离过近时的无限大亮度。5. 颜色不只是颜色线性空间、伽马校正和色调映射的隐藏坑PBR跑起来之后你大概率会碰到一个现象画面整体偏暗或高光处爆得一塌糊涂。这通常是颜色空间没有处理好。PBR的物理计算是建立在“线性光照”假设上的而纹理图片和显示器天然工作在sRGB伽马空间。如果你直接拿sRGB颜色去做光照计算结果就相当于在一个扭曲的空间里做物理模拟出来的画面自然不准。5.1 从sRGB纹理到线性空间的转换贴图是PBR材质的最大污染源之一。Albedo贴图通常由美术在sRGB空间绘制如果你的Shader直接采样后当作albedo参与BRDF计算漫反射颜色会过亮高光响应也会偏移。解决办法有两条路在采样代码里手动做sRGB转线性albedo pow(texColor.rgb, 2.2);或者利用硬件格式支持把SRV格式指定为DXGI_FORMAT_R8G8B8A8_UNORM_SRGB硬件在采样时自动转线性。我推荐后者。DX12里SRV格式可以比资源格式更“宽松”创建SRV时指定_SRGB版本即可不需要重新构造纹理资源。但要注意法线贴图、粗糙度/金属度贴图不能设置成SRGB格式它们是数据贴图不是颜色贴图。你把法线贴图也转成sRGB法线方向就会整个扭曲掉。这是我早期踩过的最典型的低级错误说一说希望你不要重蹈。5.2 输出端的伽马修正与色调映射渲染目标是线性颜色但呈现出来的画面还需要转回sRGB。两种做法渲染目标不带SRGB在最后的像素着色器里手动pow(color, 1.0 / 2.2)。把RenderTargetView格式设为DXGI_FORMAT_R8G8B8A8_UNORM_SRGB硬件在写入时自动做线性到sRGB的转换。我更推荐第二种因为自动硬件转换不会影响中间混合步骤比如透明物体混合而且省一段Shader指令。但如果你后续要做HDR、泛光等后处理则应该在最终输出前单独做一个色调映射和伽马修正的Pass。色调映射Tone Mapping是把HDR亮度范围压到显示器能表现的[0,1]区间。PBR计算出的像素亮度可以超过1直接输出到LDR交换链Super white会全部被裁成白色画面爆掉。最基础的色调映射就用Reinhardcolor color / (color float3(1.0, 1.0, 1.0));虽然简单但能把明亮区域压下来。之后再做伽马修正顺序是先Tone Mapping再Gamma顺序反了画面会发灰、发闷。6. 让渲染结果有“材质感”的实测排错从球体测试到贴图验证这套PBR管线做完之后你大概率会遇到一些“渲染得很奇怪”的情况。这些坑我在调试中反复踩过在这里跟你逐一梳理。6.1 排错第一关把PBR参数可视化PBR的难点在于三个参数互相耦合。高光异常并不一定是菲涅耳光项的问题可能只是金属度设置错了。我的调试方法是先给每个参数单独生成可视化输出把albedo直接输出、把metallic直接输出、把roughness直接输出。如果发现粗糙度贴图里本该光滑的地面显示成纯白那就说明贴图通道读取错位了或者常数缓冲区对齐错误导致roughness读到其他数据。这一步可以把“数据问题”和“算法问题”区分开。6.2 常见错误的九宫格排查表现象可能原因检查方法高光整体过亮像塑料反光F0计算里金属度恒为0或F0没有随角度变化输出F项观察球体边缘是否明显增亮球体边缘出现一圈亮边G函数使用半程向量而不是V和L分别计算换成标准Smith双项G函数暗部完全死黑没有环境光过渡没有环境光项或ambient直接乘以0加一个简化的ambient albedo * AO * 环境色法线贴图物体出现奇怪凹凸法线贴图被当作sRGB采样确认SRV格式不是_SRGB版本金属物体没有反射光颜色金属度传了0可视化metallic参数粗糙度变化没体现在高光扩散上roughness在采样前被多次平方检查是否在Shader和贴图生成端重复转换多光源时亮度爆炸每个光源都叠加了完整BRDF且没有衰减给点光源加Range衰减或使用能量归一化画面偏暗伽马输出缺失渲染目标没有做线性到伽马的转换检查RTV格式或加pow(1/2.2)换贴图后UV错位纹理行对齐错误或输入布局UV顺序改变先输出UV坐标可视化6.3 环境光的简化处理PBR里最理想的环境光是用IBL——从环境贴图采样漫反射辐照度和镜面预滤波反射。但在一篇基础PBR文章里我没有强行上IBL而是用一个简化的常值环境光照。这个方法在视觉上会稍显“平”但至少保证暗部不是纯黑。简化代码是float3 ambient albedo * 0.03 * AO;如果你希望画面更有空气感可以把这个0.03替换成一张非常模糊的Cubemap采样结果甚至只是天空色常值。对Demo来说足以验证直接光照的BRDF是否正确。真正常把PBR画面做“活”的是后续IBL、阴影、AO这三件套的叠加但那是进阶内容。6.4 性能剖析从Shader常量折叠开始PBR着色器比普通Phong着色器贵是正常的但有些开销是可以避免的。我通常在第一次跑通后做一轮性能检查用PIX或Nsight Graphics截帧看像素着色器的平均指令数。如果高光计算占了大量指令考虑把多个光源循环折叠成最多8个并按距离排序取前8个效果不错节省巨大。检查是否在像素着色器里调用了pow作为普通参数传入。pow函数在GPU上并不是特别慢但在循环热路径里连续调用还是会产生可观的ALU压力。启用RenderDoc的“Shader Debug”看一眼中间变量确认编译器没有把saturate优化掉。有些人以为max(dot(N, L), 0.0)和saturate等价但saturate会让编译器知道结果一定在[0,1]从而可能做更多的优化。性能优化的优先级永远是先确定瓶颈在ALU还是带宽再动手。PBR场景中如果大量使用高分辨率纹理带宽压力往往比ALU更先到瓶颈。7. 从技术验证到工程化这套PBR还能往哪个方向扩展最后分享几条后续扩展的思路。我自己在Demo稳定之后一般会按这个顺序往下迭代第一是IBL。把环境贴图按粗糙度预滤波成多个Mip级别的Cubemap漫反射部分用预卷积的辐照度图代替常值环境光镜面部分用Split Sum近似。这一步做完金属物体的质感会直接提升一个量级。第二是阴影。PBR在视觉上非常依赖阴影来建立空间感。可以在原有的管线里加ShadowMap Pass在PBR像素着色器里采样阴影比较器把Lo乘以可见性因子。注意阴影贴图本身需要用线性深度或带偏移的深度比较配合PCF不然边缘会出现毛毛糙糙的走样。第三是法线贴图与细节贴图。PBR的魅力在于粗看是物理正确细看是细节丰富。给球体增加一组裂缝、划痕的细节贴图在BRDF的roughness或metallic上做一个细颗粒度的扰动画面会变得很有层次。这个扰动最好在切线空间完成避免世界空间法线插值产生接缝。在实际操作中我最重要的一个体会是PBR参数不是越多越好而是越准越好。很多初学者看到一套参数多、公式长的PBR实现就觉得高级其实画面反而脏乱差。先从最简单的单方向光加常量环境光开始确认反射模型正确再逐步添加贴图、多光源、IBL每一步的视觉变化都能预判这样才不容易迷失。如果你正卡在“DX12能画三角形但画面很丑”的阶段这一篇的路子可以直接照着走一遍。把球体渲染出来调粗糙度和金属度看它是不是真的像一块金属、一块塑料、一块石头这比任何理论都能说明问题。等这套基础PBR沉淀稳定了后续加IBL、阴影时你会发现那些技术其实都是在同一个物理框架上做加法而已。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →