☰
DX12实战:从根签名到PBR渲染管线的完整实现
2026/9/30 9:25:50 网站建设 项目流程

最近我在一个小型3D渲染Demo里,把工程从DX11整体迁到DX12,同时把光照模型从Phong换成PBR(Physically Based Rendering,基于物理的渲染)。这是“学一下DX12”系列的第二篇:上一期讲了命令队列、命令分配器、围栏这套底层流转,这一期专门讲怎么在DX12里把PBR渲染管线真正跑起来,从根签名和描述符堆开始,到纹理上传,再到一个能用的金属/粗糙度工作流像素着色器。

写这篇东西的动机挺实际:DX12的官方示例大多止步于“画一个三角形”,PBR的教程又往往停留在公式推导,把这两边接起来的资料很少,而我自己在接入过程中踩的坑却不少。这篇适合刚走完DX12基础流程、准备把真实材质放进自己项目的同学,也适合会写Shader但被描述符、资源状态和管线状态对象绕晕的老手。顺带声明一下:搜“PBR”会同时碰到网络领域里的策略路由(Policy-Based Routing),这篇文章里的PBR一律指渲染领域的Physically Based Rendering,后面不重复解释了。

1. 项目背景与整体设计思路

1.1 为什么要在这个时间点学DX12

DX11不是还能用吗?我之前写DX11示例时还算顺手,但做真实渲染器时会碰到几个让人想骂街的时刻:没办法真正多线程提交绘制命令,DrawCall一多CPU就卡;状态切换的代价完全黑盒,你永远不知道驱动为了SetRenderTarget做了什么排序和缓存;新特性——光线追踪、网格着色器、VRS——全都挂在DX12这一代。DX12的设计哲学是“把控制权还给你,同时把责任也还给你”,跟DX11的“驱动替你兜底”完全是两种心态。

具体到工程上,我觉得有三件事必须提前想明白。第一,从“一堆全局状态”变成“管线状态对象”。DX11里Immediate Context像一个大工具箱,随时可以改任意旋钮,DX12则要求你把“一套完整配置”封成一个PSO对象再切换,这个对象包含着色器、混合、光栅化、输入布局、渲染目标格式等所有静态状态。第二,从“驱动全局同步”变成“显式命令队列”。DX12里你先在CPU端录制命令列表,录制过程可以并行,再把命令列表提交给命令队列让GPU异步执行,CPU和GPU之间用围栏同步。第三,从“你不关心资源状态”变成“资源状态由你管”——纹理从拷贝目标切换到着色器采样目标之前,必须由你插入ResourceBarrier。这其实是把主动权还给驱动,但代价是忘写一个Barrier画面就黑。

我当时的体会是,学DX12别急着背API数量,先跑通“录制命令列表、提交队列、等待围栏”这个最小闭环,闭环能稳定跑几帧再往里面加东西,后面遇到的绝大多数问题都能在这个模型里定位。

1.2 为什么把光照模型换成PBR

老项目用的Phong模型很简单:高光等于pow(max(dot(R, V), 0), shininess)乘以高光颜色。这套模型的问题在于没有能量守恒,连续叠加几次镜面反射后,材质反射出去的能量可能超过入射光能量,画面看起来像一块发光的反光板。更麻烦的是,Phong的shininess只能控制高光大小,不能表达反射强度随观察角度变化的事实,所以同一个金属零件换一个观察方向,高光会明显跳变。

PBR的核心思路是让光照模型自洽:光打到微表面上,一部分被反射,一部分被吸收/散射,加起来不能超过1。实际项目里最常用的是金属/粗糙度工作流:Albedo贴图给漫反射颜色,Metallic决定反射颜色和漫反射比例,Roughness控制微表面分布的宽窄,AO负责环境遮蔽。这四个参数对美术来说直观,对引擎侧来说,只要把BRDF实现一次,整条材质链路都能复用,不用为每种材质单独写高光函数。再加上DX12里所有着色逻辑都握在你自己手里,用HLSL实现Cook-Torrance BRDF非常顺理成章。

我更想强调的是,PBR和DX12在“思维模式”上是互补的:DX12要求你显式管理GPU资源,PBR要求你显式理解光照物理,两者都逼着开发者把底层逻辑弄清楚,而不是满足于“调几个参数能看就行”。所以这个系列把两道硬骨头放在一起啃,其实是很自然的路线。

2. 关键理论基础:DX12的命令模型与PBR公式链

2.1 理解DX12的“两段式”命令模型

PBR要在DX12里落地,先得理解着色器看到的东西是怎么被安排的。DX12里一次绘制至少分两段:第一段是CPU侧的录制段,你拿着ID3D12GraphicsCommandList一遍遍地调用SetGraphicsRootSignature、SetPipelineState、SetGraphicsRootDescriptorTable、DrawIndexedInstanced,这些调用不会立刻让GPU执行,只是把命令写进命令缓冲。录制可以发生在多个线程上,每个线程用自己的CommandAllocator,最后把CommandList提交到CommandQueue。

第二段是GPU侧的执行段,CommandQueue把收进来的CommandList排入执行流,GPU按顺序执行。这里CPU不需要参与每一步,只在需要与GPU协作——比如重置分配器、复用上传堆——时才用Fence做一次栅栏同步。

这个模型带来的直接后果是:没有“全局当前状态”。以前SetBlendState、SetDepthStencilState,不覆盖就是一直有效的状态;现在一切都以PSO为单位。所以每个Draw之前必须问自己:当前PSO对不对?我绑到根签名上的常量、纹理描述符,恰好是PSO里Shader会访问的那一组吗?两者不匹配,轻则这个Draw白提交,重则设备丢失。这也是为什么我强烈建议一开始就开DebugLayer,报错会直接告诉你哪一步不匹配。

2.2 Cook-Torrance BRDF:公式链怎么串起来

PBR的着色核心是BRDF(双向反射分布函数)。说人话就是:一束光从某个方向打到表面后,反射到观察方向的那部分能量占入射的比例是多少。真正在Shader里实现的是Cook-Torrance模型,把反射拆成漫反射和镜面反射两项:

f(i,o) = (1-F) * albedo / π + (D * F * G) / (4 * NdotI * NdotO)

以我工程里的实现来拆解,首先D是法线分布函数,描述微表面中法线恰好指向半角向量h的比例。我用的是GGX(Trowbridge-Reitz):

D = a² / (π * ((NdotH)² * (a² - 1) + 1)²)

其中a等于roughness的平方。roughness接近0时D非常尖锐,高光又亮又小;roughness偏大时D的尾巴拉得很宽,高光柔和。这里有个容易忽略的点:roughness接近0并不代表完美镜面,因为GGX在极低粗糙度下会有能量损失,一些引擎会对粗糙度做重映射,我们先不管精调,直接用这个公式就能得到很好的物理近似。

G是几何遮蔽项,考虑微表面之间的互相遮挡,我用Smith-Schlick-GGX:

k = ((roughness + 1)²) / 8 G_Schlick(nDotX) = nDotX / (nDotX * (1 - k) + k) G = G_Schlick(NdotV) * G_Schlick(NdotL)

初次写代码的人容易把G直接当1,结果会是边缘高光溢出,尤其是斜对着光源看时整片亮得发白。F是菲涅尔反射项,光从掠射角入射时反射率会显著上升,任何材质在边缘都会变成“镜子”,用Schlick近似:

F = F0 + (1 - F0) * (1 - dot(V,H))⁵

F0是垂直入射时的反射率,非金属取0.04左右的灰色,金属则直接取Albedo的颜色,因为金属的反射率随波长变化,反射光会带着它本来的颜色。最后在像素着色器里拼装:

kS = F kD = (1 - kS) * (1 - metallic) 光照贡献 = (kD * albedo / π + DFG / (4 * NdotV * NdotL)) * lightColor * NdotL

公式本身不多,真正难的是让每一项的变量名和坐标系对得上。比如所有角度必须用单位向量点乘,半角向量必须先标准化,很多奇奇怪怪的亮度跳跃都是这些基本功出的错。

2.3 金属/粗糙度工作流的材质属性约定

既然选了金属/粗糙度工作流,就要和美术同事定好一套约定:Albedo是sRGB颜色贴图,非金属表示漫反射色,金属表示F0的反射色;Metallic是单通道,0或1为主,极少用中间值,它控制F0取0.04还是Albedo,也决定漫反射还剩多少;Roughness是单通道,0到1;AO是单通道,乘在间接光和漫反射结果上。

这些贴图的存储格式要特别注意:只有Albedo需要从sRGB转换到线性,其余三张按线性读。我最早把所有贴图都建成了非sRGB格式,结果Albedo偏灰、高光反射发暗,原因是拿一个sRGB编码后的颜色数据直接参与线性光照计算,等于把亮度整体抬高。把Albedo的格式改成R8G8B8A8_UNORM_SRGB后,硬件采样时自动解码到线性,效果立刻正常。这个坑几乎每个做PBR的人都会踩一次,我在这里浪费过一个晚上。

3. 实操第一步:DX12中的资源准备与绑定设计

3.1 创建PBR纹理并上传到显存

DX12里纹理一般放在DEFAULT堆(GPU访问最快),但CPU不能直接写DEFAULT堆。常见流程是先创建一个DEFAULT堆的纹理对象,再用上传堆做中转,最后把像素数据拷贝过去。

第一步用CreateCommittedResource创建纹理,格式按用途选R8G8B8A8_UNORM_SRGB(Albedo)或R8G8B8A8_UNORM(Metallic/Roughness/AO),初始状态设为D3D12_RESOURCE_STATE_COPY_DEST。第二步创建上传缓冲,这里不要自己算行距,使用GetCopyableFootprints拿每一行的RowPitch和总大小:

D3D12_SUBRESOURCE_FOOTPRINT footprint; UINT rowCount; UINT64 rowSize, totalSize; device->GetCopyableFootprints(&texDesc, 0, 1, 0, &footprint, &rowCount, &rowSize, &totalSize); auto uploadHeap = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD); auto uploadDesc = CD3DX12_RESOURCE_DESC::Buffer(totalSize); ID3D12Resource* uploadBuffer = nullptr; device->CreateCommittedResource(&uploadHeap, D3D12_HEAP_FLAG_NONE, &uploadDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&uploadBuffer));

第三步把图片像素数据写入上传缓冲,位置要按footprint.RowPitch对齐,不能按图片固有宽度直接写。之后调用CopyTextureRegion,把数据从上传缓冲复制到DEFAULT堆纹理。第四步最关键:添加ResourceBarrier,把纹理从COPY_DEST过渡到PIXEL_SHADER_RESOURCE | NON_PIXEL_SHADER_RESOURCE。这个Barrier不写,Shader采样时会拿不到数据,结果不是黑就是设备报错。

我当时的错误是把四张贴图放在一个循环里上传,上传完成后只提交了一次Barrier,结果只有最后一张图能采样,其余三张全是黑色。原因是一次ResourceBarrier只能作用于它指定的那一批资源,你要把四张图的过渡都写进同一组Barrier数组里再一次提交,千万不要认为“上传完就自然可见”。

3.2 根签名和描述符堆的规划设计

我的根签名按材质做了三块:0号槽是每帧参数的CBV,用Root Descriptor直接绑定GPU地址,避免每次更新描述符堆;1号槽是SRV描述符表,绑定Albedo、Metallic、Roughness、AO四张纹理;2号槽是一个静态采样器,线性过滤加Wrap寻址。根签名代码大致长这样:

CD3DX12_DESCRIPTOR_RANGE srvRange; srvRange.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 4, 0, 0, 0); CD3DX12_ROOT_PARAMETER rootParams[2]; rootParams[0].InitAsConstantBufferView(0, 0, D3D12_SHADER_VISIBILITY_ALL); rootParams[1].InitAsDescriptorTable(1, &srvRange, D3D12_SHADER_VISIBILITY_PIXEL); CD3DX12_STATIC_SAMPLER_DESC staticSampler(0, D3D12_FILTER_ANISOTROPIC); CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(2, rootParams, 1, &staticSampler, D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);

采样器我选择静态采样器而不是单独的描述符堆,是因为采样器设置(线性/各向异性)通常全局不变,动态采样器堆空间很紧,没必要。如果你的项目需要不同过滤模式,再考虑把它挪进描述符堆。

描述符堆的规划更关键。我维护一个全局的非Shader可见堆,存放所有材质的描述符,这个堆可以慢慢增长;每帧录制前,从GPU可见的Shader可见堆里预留一块连续区域,用CopyDescriptors把当前帧用到的描述符复制进去。这样做的好处是GPU可见堆的大小不会因为材质数量膨胀,录制线程之间也不互相冲突。特别提醒:最终绑定给命令列表的堆必须带D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE,否则着色器什么都看不到,绘制结果全是占位色。

3.3 管线状态对象(PSO)的配置要点

PSO在DX12里是一个不可变对象,所有静态状态都写在D3D12_GRAPHICS_PIPELINE_STATE_DESC里,创建后不能改。最容易踩的坑集中在几个字段:pRootSignature必须跟录制时用的根签名一致,否则D3D12_ERROR_INVALIDARG;VS和PS的字节码必须确认编译成功非空,我发生过Shader编译失败返回空指针,创建PSO时直接崩;InputLayout要跟HLSL的VS输入结构按顺序对齐,顺序错了顶点数据就错乱。

BlendState这里有个细节:如果你的PBR输出目标是16F的HDR缓冲,后处理阶段再做ToneMap,那么PBR阶段一般用不透明直写,混合状态保持默认即可。我最初在最终输出阶段随手开了Alpha混合,所有像素都被叠加了一层,画面像蒙着半透明灰纱,排查了很久才想起来是混合状态的问题。RasterizerState的CullMode也容易写反,CullCounterClockwise表示逆时针剔除,有一次写成CullClockwise,模型正面全被剔掉,只剩下半张脸。

最后是格式绑定:PSO描述的RTVFormat和DSVFormat必须跟渲染目标、深度缓冲的实际创建格式一致,这个不一致会直接报E_INVALIDARG,错误本身好查,但特别容易在你把渲染目标从8位改成16位后忘记同步更新PSO描述。我建议把“创建渲染目标格式”和“创建PSO格式”放在同一个文件里定义,改一处就能联动,能省不少调试时间。

4. 核心实现:PBR着色器的HLSL编写

4.1 顶点输入与常量缓冲区设计

HLSL层的输入输出结构我定为:顶点位置、法线和UV,常量缓冲区分成每帧和每物体两组。每帧数据包含视角投影矩阵、相机位置、光源方向和光源颜色,每物体数据包含世界矩阵。VS里做坐标变换,法线要用世界矩阵的逆转置来变换,如果模型没有缩放,直接用世界矩阵的3x3部分也行,但为了通用性我保留了逆转置的写法。

一个DX12不背锅但很坑的点:HLSL默认列主序矩阵。如果你用DirectXMath的行主序习惯直接传矩阵,所有变换都会转置,表现就是模型斜着飞、法线错乱。我统一用XMMatrixMultiply左乘,并且传入时会确认矩阵已经按列主序布局。这个问题写代码的时候发现不了,一跑就会看到极其诡异的物体变形。

4.2 像素着色器中的PBR光照实现

完整的直接光版本PS如下:

Texture2D gAlbedoMap : register(t0); Texture2D gMetallicMap : register(t1); Texture2D gRoughnessMap : register(t2); Texture2D gAOMap : register(t3); SamplerState gSampler : register(s0); 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 denom = (NdotH * NdotH * (a2 - 1.0) + 1.0); return a2 / (3.14159265359 * denom * denom); } float GeometrySchlickGGX(float NdotX, float k) { return NdotX / (NdotX * (1.0 - k) + k); } float GeometrySmith(float3 N, float3 V, float3 L, float roughness) { float r = roughness + 1.0; float k = (r * r) / 8.0; return GeometrySchlickGGX(max(dot(N, V), 0.0), k) * GeometrySchlickGGX(max(dot(N, L), 0.0), k); } float3 FresnelSchlick(float cosTheta, float3 F0) { return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); } float4 PS(VertexOut pin) : SV_Target { float3 albedo = gAlbedoMap.Sample(gSampler, pin.UV).rgb; float metallic = gMetallicMap.Sample(gSampler, pin.UV).r; float roughness = gRoughnessMap.Sample(gSampler, pin.UV).r; float ao = gAOMap.Sample(gSampler, pin.UV).r; float3 N = normalize(pin.NormalW); float3 V = normalize(gCameraPos.xyz - pin.WorldPos); float3 L = normalize(-gLightDir.xyz); float3 H = normalize(V + L); float NdotL = max(dot(N, L), 0.0); float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic); float D = DistributionGGX(N, H, roughness); float G = GeometrySmith(N, V, L, roughness); float3 F = FresnelSchlick(max(dot(H, V), 0.0), F0); float3 specular = D * F * G / max(4.0 * max(dot(N, V), 0.0) * NdotL, 0.001); float3 kS = F; float3 kD = (1.0 - kS) * (1.0 - metallic); float3 diffuse = kD * albedo / 3.14159265359; float3 direct = (diffuse + specular) * gLightColor.rgb * NdotL; float3 color = direct * ao; return float4(color, 1.0); }

这段代码里最容易写错的地方是NdotL的归属。我在最初版本里把diffuse和specular分别乘过NdotL,最后又在外面乘了一次,结果高光被乘了两次,整个画面亮得刺眼。正确做法是先算specular和diffuse,再统一乘以NdotL,分母里的4 * NdotV * NdotL只负责BRDF的归一化,跟NdotL的物理衰减不是一回事。

初次跑这个PS时,我建议先把F0固定成0.04、metallic固定成0,用一个纯非金属材质验证高光的形状和能量;然后再加金属贴图。直接从头跑金属加非金属混合,一黑一亮你根本分不清是哪一项写错。我当时的调试顺序很笨,但很有效:先拿一个大球,只用方向光,调Roughness从0.05到0.9来回切,观察高光大小变化,再切Metallic看反射颜色,整套链路验证完才敢往场景里摆多物体。

4.3 容易被忽略的线性空间与Gamma校正

PBR的所有光照方程都是在线性空间成立的。所谓线性空间,就是颜色数值和实际辐射亮度成线性比例。显示用的sRGB有约2.2的Gamma曲线,如果你把sRGB数值直接当线性亮度计算,画面会过曝、发灰,阴影提不上来。

正确的流程是:Albedo以sRGB纹理存储,用R8G8B8A8_UNORM_SRGB创建,采样时硬件自动解码到线性空间;另外三张贴图按UNORM读,因为它们不是颜色而是0到1的系数;光照计算完成后,输出到屏幕前再做ToneMapping和Gamma校正。最简单的SDR流程就是:

color = saturate(color); color = pow(color, 1.0 / 2.2);

想做HDR就得渲染到16F目标,先ToneMap再Gamma。否则金属球的高光会糊成一坨白,非金属阴影部分死黑,所有材质都像塑料。

这个环节我反复栽过跟头:有一次在PS输出前做了pow(color, 1/2.2),又把RTV格式设成带SRGB编码的格式,结果两次Gamma叠加,整个画面又黑又灰。我后来把“颜色空间链路”画成一张阶段图:sRGB纹理输入、采样线性化、光照计算、HDR合并、ToneMap、Gamma、显示。每次画面不对,就顺着这张图逐级排查,基本能定位到是输入问题还是输出问题。

5. 踩坑实录:开发中遇到的高频问题与排查方法

5.1 校验层与崩溃类问题速查

开了DebugLayer后,绝大多数错误会被定位到源码行。第一次跑DX12项目,我强烈建议用ID3D12Debug::EnableDebugLayer并配合SetBreakOnSeverity,这样一报错就会弹调试器停在出问题的地方。

错误现象典型原因排查方向
D3D12_ERROR_INVALIDARGPSO根签名和命令列表绑定的根签名不一致,或InputLayout与VS输入布局不一致逐个字段对齐PSO与根签名、InputLayout与Shader签名
DXGI_ERROR_DEVICE_REMOVED资源状态切换出错,或描述符生命周期管理不当导致驱动重置开启DebugLayer,看最近一条错误信息,重点查Barrier
E_OUTOFMEMORY描述符堆空间耗尽,或命令分配器没等GPU完成就被回收检查每帧描述符增长情况;分配器必须等Fence
E_INVALIDARG(创建PSO)RTVFormat或DSVFormat与渲染目标/深度缓冲创建格式不一致打印PSO描述里的格式,与实际的堆格式逐一核对

设备丢失(DEVICE_REMOVED)是最折磨人的错误,它往往不是发生在一开始错误的代码行,而是显卡驱动撑不住后整体重置。这种时候唯一的办法就是开DebugLayer看历史错误日志,它会把最近一次验证层错误打印出来。我遇到过一次忘了给一片纹理做从RENDER_TARGET到PIXEL_SHADER_RESOURCE的Barrier,DUMP日志里明明白白写着,省了我一整天。

5.2 画面异常类问题排查

画面整体发灰,像蒙了一层雾,百分之八十是sRGB和线性空间没处理好。检查Albedo用什么格式采样,输出前有没有做Gamma校正。我做过一次双重Gamma之后,整个渲染画面黑到只能勉强看见物体的轮廓,那段时间我一度怀疑是PSO问题,最后才发现是格式叠加。

金属球没有颜色,高光是白色,一般是F0取错了。要么把F0写死了0.04,要么F0用了float而不是float3,导致金属反射色丢失。记住F0必须是三维向量,因为不同波长反射率不同,金属的颜色恰恰体现在F0的RGB分量上。

高光边缘狂闪、锯齿感强烈,这一步多半不是Blend问题,而是GGX加直接光本身会有光亮变化。CN先不管,你可以用MSAA或者做specular AA,简单方案是在PS里用mipmap对粗糙度做模糊化,或者把N做Karis平均。这个方法我是在项目接近尾声时才加的,效果改善非常明显。

固定一个方向光,同一材质不同角度亮度跳跃,大概率是分母没有做CLAMP,或者NdotL被乘了两次。每次看到材质角度变亮,就先回去审查BRDF三项的变量名,把公式和代码一行行对齐。

5.3 多线程录制与描述符堆的配合问题

DX12的多线程录制很爽,但“资源生命周期”必须自己掌控,这个比DX11严格得多。多线程录制时要保证每个线程有自己的CommandAllocator,CommandAllocator不能跨线程同时Reset。更麻烦的是,录制时塞进描述符堆的材质描述符,在命令列表执行完之前绝不能释放。我在项目里做了一个简单的描述符分配器:从GPU可见堆里按帧预留一段区域,每帧开始时重置区域头,材质需要描述符时用原子变量递增取位置,这样录制线程之间分配描述符是安全的,也不会出现一个线程覆盖另一个线程数据的情况。

命令分配器的高频错误是:CPU已经把它Reset了,但GPU还在执行旧的命令列表,于是新的录制会破坏还没执行完的数据。解法很简单,每帧用一个Fence值,Reset前先WaitForFence上一帧该分配器完成的序号。我封装了一个“帧资源”容器,把CommandAllocator、常量缓冲、上传堆都按帧索引存放,这样能避免使用中的资源被提前复用。

还有一个容易忽略的点:一个线程录制命令列表时,线程只保存了描述符堆的CPU句柄,真正对GPU可见的堆必须在SetDescriptorHeap阶段就完整存在,否则GPU执行时看到的堆可能已经不可访问。在调试多线程渲染时,一旦看到“同一帧内材质随机错乱”,先检查描述符堆的生命周期,再检查线程间的共享数据。

6. 最后聊几句实操之外的感受

写到这里,这篇“学一下DX12(二)加入pbr”的核心链路已经走完了:从根签名和描述符规划,到纹理上传、PSO创建,再到直接光PBR着色器,最后把最常见的坑都列了出来。我在这个项目的实际体会是,学DX12最大的门槛不是API数量,而是要把CPU和GPU的协作模型真正想明白;学PBR最大的门槛也不是那几个公式,而是要把从贴图格式、采样器、线性空间到光照强度的整条链路打通。这两个东西分开看都有大量资料,合在一起做,网上能找到的系统性内容并不多。

我写作时有一个习惯,每解决一个错误,就把当时的报错信息和复位方法记进一个Markdown文件,时间久了这比任何官方文档都更适合我自己。如果你也在做类似的迁移,记住两句话:遇到画面发黑或设备丢失,别急着怀疑引擎,先开DebugLayer;遇到PBR效果不对,别急着加IBL和环境贴图,先用一只纯色球验证BRDF的形状。后面我的计划是给这个Demo加入IBL环境光、法线贴图和阴影,把材质库进一步扩展。DX12让你控制一切,PBR让你明白一切,这两件事都急不得,也偷懒不得。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询