公司动态

UE引擎底层渲染:C++实现ShadowMap与体积光特效

📅 2026/7/29 14:50:07
UE引擎底层渲染:C++实现ShadowMap与体积光特效
1. 项目概述在UE引擎中实现底层渲染特效在游戏开发中光影效果是塑造世界沉浸感的核心。一个场景的氛围是阴森恐怖还是温暖祥和很大程度上取决于光照与阴影的质量。对于使用虚幻引擎UE的开发者而言引擎本身提供了强大且易用的蓝图和材质系统来处理光影。然而当你需要实现一些定制化的、高性能的底层渲染效果或者想要深入理解图形渲染管线的工作原理时直接使用C在渲染线程中“动手”就成为了必经之路。这次我们就来深入探讨两个经典且极具表现力的渲染特效ShadowMap阴影贴图和体积光Volumetric Light/God Ray在UE引擎中的C实现方案。这不是一个简单的材质球连线教程而是一次从理论到实践、从引擎框架到着色器代码的深度拆解。我们将绕过部分高级的封装直接与渲染管线对话理解如何分配渲染目标Render Target如何编写自定义的着色器Shader以及如何将计算好的结果无缝整合到UE的延迟渲染流程中。无论你是希望优化项目中的阴影表现还是想为你的游戏加入一束穿透云层的圣光亦或是单纯对UE的渲染底层感兴趣这篇内容都将提供一条清晰的路径和大量可复现的代码细节。2. 核心需求与方案选型解析在开始动手之前我们必须明确我们要解决的具体问题以及为什么选择C这条路径。2.1 为什么需要C实现UE的材质编辑器非常强大对于许多体积光效果确实可以通过后处理材质Post Process Material或体积材质Volumetric Material来模拟。但C方案在以下场景中具备不可替代的优势性能与可控性C实现允许我们更精细地控制渲染流程。例如我们可以决定ShadowMap的分辨率、更新频率每帧、动态、静态可以管理多级阴影CSM的级联分割策略这些在纯材质方案中要么不可控要么性能开销较大。复杂算法集成一些更先进的阴影技术如Variance Shadow MapVSM、Exponential Shadow MapESM或Moments Shadow Map其算法涉及多次渲染和复杂的数学计算。在C中我们可以方便地管理多个渲染阶段和中间纹理实现更稳定、更柔和的阴影边缘。与引擎深度集成通过C我们可以将自定义的阴影或光体积数据集成到引擎的渲染队列、遮挡查询、灯光代理系统中使其与其他子系统如LOD、流送协同工作行为更像一个“原生”的引擎功能。学习与定制这是理解现代游戏引擎渲染架构的最佳实践。通过亲手实现你能透彻理解从CPU端的数据准备到GPU端的顶点/像素着色再到最终合成的完整链条。2.2 ShadowMap方案选型从基础到进阶ShadowMap的本质是从光源视角渲染一张深度图然后在摄像机视角渲染时将像素点转换到光源空间比较其深度值与ShadowMap中存储的深度值来判断该点是否在阴影中。基础ShadowMap实现简单但容易产生锯齿Aliasing和“阴影粉刺”Shadow Acne问题。我们需要在C中处理偏差Bias来缓解粉刺。PCFPercentage-Closer Filtering一种经典的软阴影技术。它不是在生成深度图时做模糊而是在采样深度图时对周围多个纹素进行采样和比较然后取平均值。这能有效柔化阴影边缘。我们将在着色器中实现PCF。CSMCascaded Shadow Maps用于解决大场景中远景阴影分辨率不足的问题。它将摄像机的视锥体按距离分成多个“级联”每个级联使用一张独立的、分辨率可能不同的ShadowMap。这是方向光如太阳光阴影的工业标准。C实现需要处理级联分割、多个渲染目标以及视锥体矩阵的计算。我们的实现策略为了兼顾教学意义和实用性我们将以实现一个支持PCF软阴影的定向光ShadowMap作为核心目标并会阐述扩展到CSM的思路。这样既能覆盖核心原理又具备良好的视觉效果。2.3 体积光方案选型光线步进Ray Marching体积光模拟的是光线在参与性介质如空气中的尘埃、雾气中发生散射从而变得可见的效果。常见的实现方法有后处理屏幕空间射线步进这是最主流且性能相对可控的方法。在后期处理阶段对屏幕上的每个像素从摄像机位置向光源方向发射一条射线并在射线上进行多次步进采样。每次采样时检查该点是否被阴影遮挡需要用到前面生成的ShadowMap并累积光照强度和散射颜色。这种方法效果出色但计算量集中在像素着色器。体积雾Volumetric FogUE引擎内置的体积雾系统本质上也是一种高效的体积光解决方案。它使用3D纹理体积纹理来存储和传播光照信息性能更好但定制灵活性稍低。我们的实现策略我们将采用后处理屏幕空间射线步进方案。因为它与ShadowMap的结合非常紧密能直观展示两个特效如何协同工作。我们将在C中创建一个自定义的后处理渲染通道并编写对应的计算着色器Compute Shader或像素着色器Pixel Shader来实现光线步进算法。3. 核心模块设计与引擎集成在UE中任何自定义的渲染逻辑都需要通过其渲染模块Rendering Module来集成。我们的工作主要分为三个部分C渲染线程逻辑、HLSL着色器代码以及资源管理。3.1 创建自定义的渲染插件或模块为了保持项目整洁和可移植性最佳实践是创建一个独立的UE插件Plugin。创建插件在引擎或项目目录的Plugins文件夹下创建一个新的插件例如命名为CustomShadowsAndFog。选择“渲染”Rendering类型这会让UE自动包含必要的渲染头文件。核心类设计我们需要创建几个关键的C类FCustomShadowMapRenderer这是一个单例类或由GameInstance管理的对象负责统筹ShadowMap的生成逻辑。它持有渲染目标UTextureRenderTarget2D资源并在每帧或根据需要时触发深度图的渲染。FCustomVolumetricLightRenderer同样是一个管理器负责体积光的后处理计算。它持有体积光计算所需的参数如散射系数、衰减、步进次数等和最终输出纹理。FCustomShadingPipeline这是一个FGlobalShader的派生类是我们与着色器通信的桥梁。它负责声明着色器参数Parameters并将C端的数据如矩阵、纹理、浮点数传递给GPU。3.2 渲染目标与资源管理ShadowMap和体积光的中间结果都需要存储在纹理中。ShadowMap Render Target我们需要创建一张深度格式如PF_DepthStencil或PF_ShadowDepth的渲染目标。大小可以是1024x1024、2048x2048等。关键在于在渲染深度时我们需要一个独立的“渲染通道”。这可以通过重写FDeferredShadingSceneRenderer的相关方法或者更简单地通过DrawDynamicMeshPassAPI来提交一个自定义的深度绘制通道。体积光输出 Render Target体积光计算的结果是一张屏幕空间的RGBA纹理存储每个像素的光强和颜色。我们通常创建一张与屏幕分辨率同比例或降低采样率以提升性能的PF_FloatRGBA格式的渲染目标。注意渲染目标的创建和释放必须在渲染线程中进行。通常我们在模块的StartupModule中创建在ShutdownModule中释放或者使用TRefCountPtr等智能指针结合渲染线程命令进行生命周期管理。错误地跨线程操作渲染资源是导致崩溃或黑屏的常见原因。3.3 集成到UE渲染管线UE的渲染管线是高度模块化的。我们需要找到合适的“插入点”。ShadowMap生成时机对于定向光我们希望在场景的深度预通道PrePass之后基础通道BasePass之前生成ShadowMap。我们可以订阅FSceneViewExtension接口在SubscribeToPostProcessingPass函数中注册一个自定义的EPostProcessingPass并在其回调中执行我们的深度绘制逻辑。体积光合成时机体积光作为后处理效果应该在色调映射Tone Mapping之前但在大部分后处理如Bloom、AA之后合成以避免这些后处理影响体积光的计算。我们可以通过实现一个FScreenPassTexture的处理器并将其添加到后处理链FPostProcessing的合适阶段。一个关键的实操心得调试自定义渲染通道时一个极其有用的技巧是将中间渲染目标如生成的ShadowMap或体积光纹理显示到屏幕上。你可以创建一个简单的调试材质将其SceneTexture节点设置为你的自定义纹理并将其应用到一个全屏的后期处理材质或一个放在摄像机前的平面网格上。这能让你直观地看到每一步的输出是否正确。4. ShadowMap的C实现详解现在让我们深入ShadowMap的具体实现。我们将分步拆解从设置光源视图到最终生成深度图的完整过程。4.1 构建光源视图与投影矩阵一切始于矩阵。我们需要从光源的角度“看”世界。// 假设我们有一个方向光其方向为 LightDirection (FVector 已归一化) FVector LightLocation SceneBounds.GetCenter() - LightDirection * SceneBounds.GetExtent().Length(); FMatrix LightViewMatrix FLookAtMatrix(LightLocation, LightLocation LightDirection, FVector::UpVector); // 计算一个能包裹住整个场景或摄像机视锥体的包围盒在光源视图空间下的AABB FBox LightViewSpaceBounds ... // 将场景世界空间AABB转换到光源视图空间 // 创建正交投影矩阵方向光使用正交投影 float Left LightViewSpaceBounds.Min.X; float Right LightViewSpaceBounds.Max.X; float Bottom LightViewSpaceBounds.Min.Y; float Top LightViewSpaceBounds.Max.Y; float ZNear LightViewSpaceBounds.Min.Z; float ZFar LightViewSpaceBounds.Max.Z; FMatrix LightProjectionMatrix FOrthoMatrix(Left, Right, Bottom, Top, ZNear, ZFar); // 最终的视图投影矩阵 FMatrix LightViewProjectionMatrix LightProjectionMatrix * LightViewMatrix;关键点SceneBounds可以是整个关卡的包围盒也可以是当前摄像机视锥体的包围盒。对于CSM你需要为每一个级联计算不同的LightViewProjectionMatrix。ZNear和ZFar的设置要尽可能紧贴几何体以减少深度精度浪费。4.2 渲染深度到Render Target有了矩阵我们就可以开始渲染了。我们需要设置一个只输出深度的渲染状态。// 1. 设置渲染目标 FRHIRenderPassInfo RPInfo(ShadowMapRenderTarget-GetRenderTargetResource()-GetRenderTargetTexture(), ERenderTargetActions::Clear_Store); // 2. 设置图形管线状态禁用颜色写入启用深度写入和测试 FGraphicsPipelineStateInitializer GraphicsPSOInit; GraphicsPSOInit.RasterizerState TStaticRasterizerStateFM_Solid, CM_None::GetRasterizerState(); // 正面剔除取决于需求 GraphicsPSOInit.DepthStencilState TStaticDepthStencilStatetrue, CF_LessEqual::GetDepthStencilState(); // 启用深度测试LessEqual GraphicsPSOInit.BlendState TStaticBlendStateCW_NONE::GetBlendState(); // 不混合颜色 // 绑定一个只输出深度的着色器 GraphicsPSOInit.BoundShaderState.VertexDeclarationRHI GetVertexDeclarationFVector4(); GraphicsPSOInit.BoundShaderState.VertexShaderRHI VertexShader.GetVertexShader(); GraphicsPSOInit.BoundShaderState.PixelShaderRHI nullptr; // 像素着色器设为nullptr因为我们只写深度 // 3. 提交绘制命令 RHICmdList.BeginRenderPass(RPInfo, TEXT(“RenderShadowDepth”)); { RHICmdList.ApplyCachedRenderTargets(GraphicsPSOInit); SetGraphicsPipelineState(RHICmdList, GraphicsPSOInit); // 设置视图和投影矩阵 RHICmdList.SetViewport(0, 0, 0.0f, ShadowMapResolution, ShadowMapResolution, 1.0f); // ... 遍历所有需要投射阴影的网格体提交绘制 ... for (auto Mesh : ShadowCastingMeshes) { Mesh-RenderDepthOnly(RHICmdList, LightViewProjectionMatrix); } } RHICmdList.EndRenderPass();这里Mesh-RenderDepthOnly是一个简化表示。实际上你需要获取网格体的顶点缓冲区Vertex Buffer和索引缓冲区Index Buffer并提交绘制调用。你需要一个极其简单的顶点着色器它只做一件事将顶点从本地空间变换到世界空间再乘以LightViewProjectionMatrix输出裁剪空间坐标。由于没有像素着色器深度值会自动由光栅化阶段插值得到并写入深度缓冲区。4.3 编写ShadowMap采样与PCF着色器深度图渲染好后在主场景渲染的着色器中我们需要使用它。这通常在延迟渲染的“光照通道”中完成。首先在C端我们需要将LightViewProjectionMatrix和ShadowMap纹理作为参数传递给全局着色器。// 在自定义的着色器参数集例如 FCustomShadingParameters中声明 BEGIN_SHADER_PARAMETER_STRUCT(FCustomShadingParameters, ) SHADER_PARAMETER(FMatrix, LightViewProjectionMatrix) SHADER_PARAMETER_TEXTURE(Texture2D, ShadowMapTexture) SHADER_PARAMETER_SAMPLER(SamplerState, ShadowMapSampler) SHADER_PARAMETER(FVector2D, ShadowMapSize) // 纹理尺寸用于PCF采样偏移计算 SHADER_PARAMETER(float, DepthBias) // 深度偏移解决阴影粉刺 END_SHADER_PARAMETER_STRUCT()然后在HLSL着色器中// 阴影计算函数 float CalculateShadowFactor(float3 WorldPosition, FCustomShadingParameters Parameters) { // 1. 将世界坐标转换到光源裁剪空间 float4 LightClipPos mul(float4(WorldPosition, 1.0), Parameters.LightViewProjectionMatrix); // 2. 进行透视除法得到NDC坐标-1到1 float3 LightNDC LightClipPos.xyz / LightClipPos.w; // 3. 将NDC坐标转换到纹理UV坐标0到1 float2 ShadowUV LightNDC.xy * 0.5 0.5; // 注意DirectX的纹理坐标V轴朝下可能需要翻转yShadowUV.y 1.0 - ShadowUV.y; // 检查是否在光源视锥体内 if (any(ShadowUV 0.0) || any(ShadowUV 1.0) || LightNDC.z 0.0 || LightNDC.z 1.0) { return 1.0; // 不在阴影贴图范围内视为不被遮挡或根据需求处理 } // 4. 从ShadowMap中读取深度值存储在R通道 float ShadowMapDepth Parameters.ShadowMapTexture.Sample(Parameters.ShadowMapSampler, ShadowUV).r; // 5. 当前片元在光源空间的线性深度或使用LightNDC.z取决于投影方式 float CurrentDepth LightNDC.z; // 应用深度偏移解决阴影粉刺 CurrentDepth - Parameters.DepthBias; // 6. 基础阴影比较 // float ShadowFactor (CurrentDepth ShadowMapDepth) ? 0.0 : 1.0; // 7. PCF软阴影3x3采样 float ShadowFactor 0.0; float2 TexelSize 1.0 / Parameters.ShadowMapSize; for (int x -1; x 1; x) { for (int y -1; y 1; y) { float2 OffsetUV ShadowUV float2(x, y) * TexelSize; float SampleDepth Parameters.ShadowMapTexture.Sample(Parameters.ShadowMapSampler, OffsetUV).r; ShadowFactor (CurrentDepth SampleDepth) ? 0.0 : 1.0; } } ShadowFactor / 9.0; // 3x39个采样 return ShadowFactor; }这个ShadowFactor0为完全阴影1为完全光照随后可以乘到你的光照计算中。DepthBias是一个很小的值如0.0001用于避免因深度精度问题导致的“自阴影”阴影粉刺。PCF循环采样周围纹素并取平均实现了边缘柔化的软阴影。常见问题与排查阴影全黑或全白首先检查LightViewProjectionMatrix计算是否正确以及世界坐标传递是否准确。使用前面提到的调试方法将ShadowMapTexture或LightNDC.xy可视化到屏幕上。阴影边缘闪烁Shadow Flickering这通常是由于每帧的LightViewProjectionMatrix因摄像机移动而发生微小变化导致深度比较时在边界处发生跳变。解决方案是让光源的视图投影矩阵“吸附”到ShadowMap的纹素网格上称为“纹素吸附”或“光空间吸附”。PCF性能开销3x3的PCF意味着每个像素需要9次纹理采样和深度比较。对于性能敏感的场景可以尝试2x2 PCF或者使用双线性过滤硬件加速的PCF如SampleCmpLevelZero。5. 体积光的C与着色器实现体积光的效果依赖于ShadowMap提供的遮挡信息。我们的实现将在屏幕空间后处理中完成。5.1 设计光线步进算法核心算法伪代码如下对于屏幕上的每个像素即片元 1. 计算该像素对应的世界空间位置WorldPos和朝向摄像机的射线方向RayDir。 2. 计算从摄像机到该像素对应世界位置的光线Ray。 3. 计算这条光线与光源影响范围一个锥体或球体的交点得到步进的起点和终点RayStart, RayEnd。 4. 将起点到终点的距离分成N等份步进次数。 5. 对于每一步 a. 计算当前步进点的世界坐标CurrentPos。 b. 使用前面实现的CalculateShadowFactor函数计算该点是否被阴影遮挡。 c. 如果未被遮挡则根据光照衰减公式如平方反比定律和散射系数计算该点的光照贡献。 d. 将贡献值累加到总光照强度中。 6. 将最终累积的光照强度输出到渲染目标。5.2 实现后处理渲染通道我们需要在C端创建一个后处理Pass。创建全屏顶点着色器一个简单的传递纹理坐标的着色器。创建体积光像素着色器实现上述算法。设置渲染状态启用加法混合TStaticBlendStateCW_RGBA, BO_Add, BF_One, BF_One因为体积光是叠加在原有画面上。深度测试和写入通常禁用。绘制全屏四边形使用DrawRectangle或DrawPrimitiveAPI绘制一个覆盖整个屏幕的四边形触发像素着色器对每个像素执行一次。关键的C代码在于如何将摄像机的参数位置、视口信息、光源参数位置、方向、颜色、范围以及前面生成的ShadowMap纹理和矩阵传递给着色器。// 在渲染线程中设置着色器参数 FCustomShadingParameters PassParameters *RHICmdList.AllocParametersFCustomShadingParameters(); PassParameters.LightViewProjectionMatrix MyShadowRenderer-GetLightViewProjectionMatrix(); PassParameters.ShadowMapTexture MyShadowRenderer-GetShadowMapTexture()-GetResource()-GetTexture2DRHI(); PassParameters.ShadowMapSampler TStaticSamplerStateSF_Point, AM_Clamp, AM_Clamp, AM_Clamp::GetRHI(); // ... 设置其他体积光参数如摄像机位置、光源位置、散射系数、步进次数等 ... PassParameters.CameraWorldPos View.ViewLocation; PassParameters.LightWorldPos MyLight-GetPosition(); PassParameters.ScatteringCoefficient 0.05f; PassParameters.NumSteps 64; // 设置渲染目标为体积光输出纹理并提交绘制 RHICmdList.SetRenderTarget(VolumetricLightRT-GetRenderTargetResource()-GetRenderTargetTexture(), nullptr); RHICmdList.SetViewport(...); // ... 设置图形管线状态绑定着色器 ... RHICmdList.SetGraphicsPipelineState(GraphicsPSOInit); SetShaderParameters(RHICmdList, PixelShader, PixelShader.GetPixelShader(), PassParameters); RHICmdList.DrawPrimitive(0, 2, 1); // 绘制全屏三角形5.3 体积光着色器核心代码解析HLSL着色器是算法的核心。这里给出一个简化的定向光God Ray体积光实现片段。float4 VolumetricLightPS(FScreenSpaceData Input) : SV_Target { // 1. 重建世界位置需要深度缓冲区和摄像机投影逆矩阵 float Depth TextureDepth.Sample(PointSampler, Input.UV).r; float4 ClipPos float4(Input.UV * 2.0 - 1.0, Depth, 1.0); ClipPos.y * -1.0; // 如需Y轴翻转 float4 WorldPosHomogeneous mul(ClipPos, InvViewProjectionMatrix); float3 WorldPos WorldPosHomogeneous.xyz / WorldPosHomogeneous.w; float3 CameraToWorldRay WorldPos - CameraWorldPos; float RayLength length(CameraToWorldRay); float3 RayDir CameraToWorldRay / RayLength; // 2. 计算光线与光源体积的交点这里简化假设光源是方向光影响整个视锥 // 对于方向光我们通常从摄像机位置开始沿着视线方向步进一段固定距离。 float StepSize MaxRayLength / NumSteps; float3 Step RayDir * StepSize; // 3. 光线步进循环 float3 CurrentPos CameraWorldPos; float3 AccumulatedLight float3(0, 0, 0); for (int i 0; i NumSteps; i) { // 提前退出如果已经步进到世界位置之后 if (distance(CurrentPos, CameraWorldPos) RayLength) break; // 计算当前点是否在阴影中 float Shadow CalculateShadowFactor(CurrentPos, CustomShadingParams); if (Shadow 0.01) // 如果有一点光照 { // 简化光照计算方向光强度 * 阴影因子 * 衰减这里衰减基于距离光源的“深度” // 更真实的模型应考虑相位函数如Mie散射 float LightIntensity MainLightIntensity * Shadow; // 指数衰减模拟介质吸收 float Transmission exp(-ScatteringCoefficient * i * StepSize); AccumulatedLight LightIntensity * Transmission * MainLightColor.rgb; } CurrentPos Step; } // 4. 输出可能还需要进行色调映射适配 return float4(AccumulatedLight, 1.0); }最终VolumetricLightRT中存储的就是体积光贡献图。你需要在最终合成阶段例如在Tonemapping之前以“Screen”混合模式将其叠加到场景颜色上。性能优化技巧降低采样率以半分辨率甚至四分之一分辨率进行体积光计算然后通过双线性上采样到全屏。视觉损失很小性能提升显著。优化步进次数根据光线行进的距离动态调整步进次数。远处的像素可以使用更少的步数。使用3D噪声纹理在步进采样时采样一张3D噪声纹理来扰动采样位置可以以更少的步数获得更丰富的体积细节但可能会引入噪声需要后续模糊。** temporal 滤波**利用上一帧的体积光结果与当前帧进行混合可以平滑噪声并允许使用更激进的降采样和更少的步数是工业界常用的实时体积光优化方案。6. 性能调优与常见问题排查将两个特效结合起来后性能成为必须关注的重点。一个未经优化的实现很容易将帧率拖垮。6.1 性能分析与瓶颈定位GPU Profiler是你的朋友使用UE内置的ProfileGPU控制台命令或RenderDoc等外部工具精确分析每一帧中你的自定义着色器Pass占用了多少GPU时间。重点关注ShadowMap生成Pass绘制调用次数Draw Calls、顶点处理开销、渲染目标切换开销。体积光计算Pass像素着色器或计算着色器的占用率、纹理采样次数、算术逻辑单元ALU指令数。常见瓶颈与优化策略瓶颈点可能原因优化策略ShadowMap渲染耗时绘制调用过多ShadowMap分辨率过高渲染的网格体过于复杂。1. 对静态网格体使用阴影距离场Distance Field Shadows预计算动态物体再用ShadowMap。2. 实施阴影级联CSM近处高分辨率远处低分辨率。3. 使用Instancing合并相同网格体的绘制调用。4. 合理设置阴影投射距离远处的物体不投射阴影。体积光计算耗时步进次数NumSteps太多全分辨率计算着色器指令复杂。1.降分辨率渲染如半分辨率。2.动态步进根据光线长度调整步数。3.使用计算着色器Compute Shader替代像素着色器可能更好地利用GPU并行性。4. 简化散射模型或使用查找表LUT。内存带宽频繁读写高精度渲染目标如FP16/FP32的RT。1. 在满足质量要求下使用更低精度的格式。2. 利用帧间一致性复用或渐进更新数据减少每帧全量计算。6.2 典型问题与解决方案实录在实际集成中你肯定会遇到各种“坑”。以下是一些我踩过并总结出来的问题问题1体积光边缘出现明显的“条带”或“分层”现象。原因这是光线步进算法固有的“带状伪影”Banding Artifact。由于步进是离散的当光照贡献变化剧烈时在步进边界处就会产生可见的条带。解决方案增加步进次数最直接的方法但代价是性能。在步进采样时加入随机偏移Jittering每帧或每个像素在步进起点加入一个微小的随机偏移可以将固定的条带转化为随时间变化的、不易察觉的噪声。这通常与Temporal滤波结合使用效果最佳。使用蓝噪声Blue Noise纹理用一张预计算的蓝噪声纹理来驱动随机偏移其频谱特性使得噪声在视觉上更“悦目”不易形成结构化图案。问题2移动摄像机时阴影或体积光剧烈闪烁。原因如前所述矩阵没有进行“纹素吸附”。每帧光源投影矩阵的微小变化导致世界空间中的同一个点在ShadowMap中映射到的纹素位置在1-2个像素间跳动从而引起比较结果的跳变。解决方案在C端计算完LightViewProjectionMatrix后将其变换回光源的投影空间将平移分量吸附到ShadowMap纹素大小的整数倍上再变换回来。这能保证光源“视图”的稳定性。问题3透明物体如粒子、树叶无法正确投射或接收体积光/阴影。原因我们的简化ShadowMap只渲染了不透明物体的深度。体积光步进时也只采样了不透明深度。透明物体需要特殊处理。解决方案这是一个高级话题。一种方案是渲染透明物体的深度到另一张RT。在体积光步进时需要同时检查不透明深度和透明深度当遇到透明表面时根据其透光度Alpha决定光线的穿透比例。这会使算法复杂度显著增加。问题4在移动端或低端PC上帧率无法接受。原因未经优化的实现在这些平台上开销过大。降级方案关闭体积光保留ShadowMap因为阴影对视觉质量的提升比体积光更基础。大幅降低质量ShadowMap分辨率降至512x512体积光步进次数降至16甚至8关闭PCF使用硬阴影。使用更廉价方案用一张精心制作的全屏噪声图叠加径向模糊来模拟简单的God Ray完全避开光线步进。最后我想分享的一点核心体会是在游戏渲染中“看起来正确”比“物理上精确”更重要。我们的目标是有限的性能预算内创造出最具视觉冲击力和艺术指导性的效果。因此不要拘泥于复杂的物理公式大胆地使用技巧Hack、查找表LUT和艺术家可调节的参数如衰减曲线、颜色梯度来塑造你想要的光影氛围。将ScatteringCoefficient、NumSteps、LightIntensity等参数暴露给UE编辑器让美术同学能够自由地调整直到找到性能与画面之间的最佳平衡点这才是将技术转化为生产力的关键。