上一篇文章我从零把DX12的设备、命令队列、交换链、以及最基础的三角形渲染捋了一遍,这套流程跑通之后,你手里其实已经有一个“能往屏幕画东西”的最小显示管线了。这一篇我不打算继续堆API细节,而是把重点转向画面本身:加入PBR——基于物理的渲染,让模型从“塑料片”进化到“有金属感、有粗糙度差异、灯光照上去会真实衰减”的状态。这部分内容适合已经熟悉DX12基本初始化、但不知道从哪里开始做光照的读者;如果你没看过前一篇,可以直接把文中涉及窗口和设备初始化的部分当成已有基础设施看待,不影响理解PBR相关的主线。
PBR不是某个DX12专属特效,它是一套对光照和材质进行物理模拟的着色方法。DX12本身不提供任何“内置PBR”,所有BRDF公式、贴图采样、光源循环、伽马校正都得自己在HLSL里写出来。这也恰恰是学DX12的人最该经历的一段路程:API只负责把数据送到GPU,画面好不好看,取决于你自己写的着色器逻辑。这篇文章我会从BRDF的数学拆解讲到根签名和描述符堆的布置,再给出一个能跑起来的HLSL实现,最后把我调试过程中踩过的坑一并列出来,方便你少走弯路。
1. 从“能画三角形”到“光照真实”:为什么要在这个阶段引入PBR
1.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和Schlick
PBR的直接光照部分,业界最常用的反射模型是Cook-Torrance微表面模型。它把物体表面的反射拆成两部分:漫反射项和镜面反射项。漫反射项通常用Lambert模型,镜面反射项则由一个BRDF函数描述——这个BRDF由三个核心因子组成:法线分布函数D、几何遮蔽函数G、菲涅耳项F。大多数人在网上看到的、被复制来复制去的PBR代码,本质就是这三项的排列组合。
2.1 法线分布函数:GGX/Trowbridge-Reitz
微表面理论认为,物体表面的法线并不是完全一致的,而是有无数个朝向各异的微小平面。法线分布函数D描述的是:给定一个半程向量H,表面上有多少微表面的法线恰好等于H。如果这个比例高,直观表现就是高光锐利、金属感强;比例低,则高光扩散、表面粗糙。
最常用的GGX(Trowbridge-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。这样方便后续扩展灯光数量。根签名可以这样规划:
| 参数索引 | 类型 | 内容 |
|---|---|---|
| 0 | CBV b0 | 全局数据:MVP、相机位置、时间 |
| 1 | CBV b1 | 材质数据:albedo、metallic、roughness、AO |
| 2 | SRV t0 | Albedo纹理 |
| 3 | SRV t1 | 法线纹理 |
| 4 | SRV 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会全部被裁成白色,画面爆掉。最基础的色调映射就用Reinhard:color = 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、阴影时你会发现,那些技术其实都是在同一个物理框架上做加法而已。