[UFSH2025]《命运扳机》的光与影
[UFSH2025]《命运扳机》的光与影 | 杨彬 黄兆铭 萨罗斯网络科技 引擎研发工程师
1. 核心挑战:项目背景与技术选型
讲座首先阐述了项目(《命运扳机》)面临的独特挑战,这些挑战直接决定了其光照和阴影的技术选型。
-
游戏类型与风格:
-
6x6 大世界地图,风格为日式卡通(二次元)。
-
场景:包含室内与室外战斗。
-
-
核心冲突:
-
高品质需求: 卡通渲染“很卷”,需要高品质美术资产。GI对室内氛围、阴影对室外层次感至关重要。
-
多端与性能:
-
多人射击游戏: 帧率和稳定性是重中之重。
-
多端互通: 需要跨越不同算力设备,实现渲染表现基本一致,以保证竞技公平性。
-
包体大小: 大世界游戏资源量大,包体是一个硬性限制指标。
-
-
-
最终技术战略:
-
可伸缩框架 (Scalable Framework): 针对光照和阴影都设计了可伸缩的方案,以适配不同设备。
-
离线方案 (Offline Solutions): 成为必要选项。将重度计算(如烘焙)放到预处理阶段,使低性能设备也能流畅运行。
-
2. 全局光照 (GI) 方案
本场分享的第一个重点是GI。项目组设计了一套“实时”与“离线”相结合的可伸缩GI框架。
2.1 基础GI方案:Lumen vs. Probe GI
项目根据画质等级,选用了两套基础GI技术:
-
高画质方案:Lumen (UE5 实时GI)
-
用于超高和极致画质。
-
提供实时的、高质量的全局光照和反射。
-
-
中/低画质方案:Probe GI (离线烘焙)
-
项目自研的基于Probe的离线方案。
-
核心原理:
-
空间离散化: 在场景中布置Probe点。
-
离线烘焙: 预先计算每个Probe点的光照信息。
-
运行时采样: 通过采样并插值(Interpolation)周围Probe的数据,来重建任意点的GI。
-
-
本项目实现细节:
-
存储内容: Radiance (辐射度) 信息。
-
压缩方式: 使用 二阶球谐 (Second-order Spherical Harmonics, SH2) 系数来存储。
-
优势: 运行时还原GI只需要简单的线性运算,性能开销极低。
-
-
2.2 画质分级与可伸缩设计
基于上述两套基础方案,项目组定制了详细的GI画质分级策略:
核心思路:
Lumen 内部也分级: 通过调整Lumen自身参数(如Radiance Cache分辨率)来伸缩。
Probe GI 内部也分级: 通过调整数据精度(MIP Level)和加载距离来伸缩。
详细画质分级配置
A. 实时方案 (Lumen) Tiers
-
极致 (Extreme) 画质:
- 使用标准 Lumen。
-
超高 (Ultra) 画质:
-
同样使用 Lumen。
-
性能优化: 使用了分辨率更低的 Screen Probe 去做 Radiance Cache,以此在效果和性能之间做平衡。
-
B. 离线方案 (Probe GI) Tiers
-
高 (High) 画质:
-
近距离: 采样 MIP0 级别的GI数据(高精度)。
-
远距离: 采样 MIP1 级别的GI数据(低精度)。
-
特点: GI 加载距离最远,能看到更远处的高精度GI效果。
-
-
中 (Medium) 画质:
-
近距离: 采样 MIP0。
-
远距离: 采样 MIP1。
-
与“高画质”的主要区别: GI 加载距离更近。
-
-
低 (Low) 画质:
-
全部使用 MIP1 级别的GI数据(近处和远处都用低精度)。
-
性能优化: 使用 “简单遮蔽信息” (Simple Occlusion) 来做 防漏光 (Anti-Light-Leak) 处理,这是一种低成本的防漏光方案。
-
(讲座预告:后续会详细讲解 MIP0/MIP1 的数据结构以及“简单遮蔽信息”的具体实现。)
3. 关键术语小结
-
Probe GI: 一种离线GI烘焙技术,通过在空间中离散化采样点(Probe)来存储光照。
-
Radiance (辐射度): 存储的光照信息,比仅存储Irradiance(辐照度)包含更多方向性信息。
-
二阶球谐 (SH2): 一种高效编码和解码低频光照(如Diffuse GI)的数学工具,性能开销小。
-
MIP0 / MIP1: 类似于贴图的MIPMAP,这里指代不同精度的GI烘焙数据。MIP0为高精度(可能数据量更大或Probe更密集),MIP1为低精度。
-
Radiance Cache (Lumen): Lumen中用于缓存和复用场景中Radiance信息的一种技术,降低Screen Probe的采样分辨率可以提升性能。
-
简单遮蔽信息 (Simple Occlusion): 一种低成本的遮蔽数据,用于在低画质下防止光线从墙体等薄表面泄露。
3. GI 关键技术方案
本节深入探讨 Probe GI 方案中的四大关键技术:防漏光、Final Gather、反射,以及在半透明和体积雾上的应用。
3.1 防漏光 (Anti-Light-Leak)
核心问题: 渲染的像素(P点)采样到了错误的光照信息(例如,采样到墙体另一侧的Probe),导致漏光或漏影。
1. Lumen 方案的局限:
-
Lumen 的漏光可以通过调参缓解,但大世界场景无法找到一套通用参数。
-
项目方案: 将可调参数放入 Post Process Volume 中,由引擎实时插值解算,实现分区域的精细控制。
2. Probe GI 方案的防漏光(重点):
项目组的离线 Probe GI 方案参考了 DDGI (Dynamic Diffuse Global Illumination) 的思想,并进行了大量优化。
算法A:Relocation (探针重定位)
核心思想: 在离线烘焙时,就把 Probe 移动到“正确”的采样位置。
-
情况1:Probe 陷在物体内部
-
问题: 导致漏影(采样到被遮挡的GI)。
-
解决方案: 必须将 Probe 推出墙外。
-
检测方法(离线): 从 Probe (O点) 向四周发射大量射线。
-
如果射线方向与击中点法线的夹角小于90°,说明O点在Mesh内部(计数-1)。
-
如果夹角大于90°,说明O点在Mesh外部(计数+1)。
-
根据最终总计数的正负,判断 O 点是否在物体内部。
-
-
-
情况2:Probe 在外部但远离表面
-
问题: P点靠近墙面,但采样了远离墙面的 Probe,导致光照信息不准(衰减错误)。
-
解决方案: 将 Probe 推向最近的墙边。
-
检测方法(离线): 从 Probe 发射射线,找到距离最近的相交点,将该点作为重定位后的新位置。
-
-
Relocation 的数据压缩:
-
问题: 存储一个完整的偏移向量(方向+距离)数据量太大。
-
解决方案: 离散化 (Discretization)。
-
在 Probe 周围预定义N个固定的偏移点。
-
计算出理想的 Relocation 位置后,查找离它最近的那个预设点。
-
最终只存储这个预设点的索引 (Index)(例如用 1 byte)。
-
-
算法B:Visibility Check (可见性检测)
核心思想: 运行时动态判断 P 点与 Probe 之间是否有遮挡,被挡住的 Probe 不应被采样。
-
DDGI/VSM 方案:
-
原理: 基于切比雪夫不等式 (Chebyshev's Inequality),类似方差阴影(VSM)。
-
离线存储: 烘焙时,Probe 需存储到最近表面的距离(Depth)和距离的平方(Depth²)。
-
运行时: 根据 P 点和 Probe 的相对位置,以及 Probe 存储的 Depth/Depth² 算出方差,套用不等式估算可见性概率。
-
数据压缩: 可使用八面体映射 (Octahedral Mapping) 来控制存储的遮挡方向数量。
-
-
《命运扳机》的改进:混合遮蔽 (Hybrid Occlusion)
-
问题: 完整的 DDGI 可见性数据量依然巨大。
-
关键洞察: 场景中大部分 Probe 周围的几何体很简单(例如只靠近一面墙)。
-
解决方案:
-
Simple Occlusion (简单遮蔽):
-
用于绝大多数 Probe。
-
仅用 1 个方向 + 1 个距离 就能描述其遮挡信息。
-
数据量极大压缩 这是实现包体大小可控的关键。
-
-
Complex Occlusion (复杂遮蔽):
-
仅用于少数几何复杂区域(如角落)。
-
使用完整的 DDGI 可见性数据,保证效果。
-
-
-
结果: 实现了视觉无损的数据压缩,使这套算法得以在项目中实际落地。
-
算法C:Bias (偏移)
核心思想: 在采样 GI 前,将 P 点的世界坐标沿法线 (Normal Bias) 或视线 (View Bias) 方向偏移一点,避免自遮挡。
-
问题: 偏移后的点 (A点) 可能移到墙体内部,采样到错误信息 ( "干坏事" )。
-
解决方案: 验证偏移的有效性
-
将偏移后的 A 点投影回屏幕空间。
-
比较 A 点的深度
Depth(A)和 Depth Buffer 在该像素的深度Depth(Buffer)。 -
如果
Depth(A) > Depth(Buffer),说明 A 点在可见表面之后(即在墙内),是“坏点”。 -
算法: 使用二分查找 (Binary Search) 快速搜寻一个更小但有效的偏移量。
-
3.2 Final Gather (FG)
核心思想: 运行时,根据 P 点周围所有 Probe 的信息(SH2系数)和可见性,混合计算出P点的最终GI。
-
基本原理:
-
目标: 求解渲染方程中的球面光照 (来自GI)。
-
方法: 是一个球面函数,可以用球谐 (SH) 重建:
-
是P点的球谐系数。
-
是球谐基函数(取决于方向 )。
-
-
P点的 是由其周围所有可见 Probe 的 加权平均得到的。
-
权重 (Weight):这个权重函数非常综合,前面所有的防漏光算法(Relocation, Visibility)最终都作用在这里,用于决定每个 Probe 的贡献大小。
-
-
方向性采样:
-
Diffuse GI: 使用P点的法线 (Normal) 方向去计算基函数 。
-
Rough Reflection / Subsurface: 使用反射方向或其他特定方向去计算,以获得不同的光照效果。
-
-
性能优化 (重点):
-
问题: Final Gather 是一个全屏 Shading Pass,开销较大。
-
方案1:降分辨率 (Downsampling)
- GI Pass 在 1/2 分辨率下渲染。
-
方案2:上采样 (Upsampling)
-
关键洞察: GI 是低频信息,不需要昂贵的降噪器。
-
算法: 棋盘格渲染 (Checkerboard Rendering) + 时间累积 (Temporal Reprojection)。
-
逐帧逻辑(以N+1帧为例):
-
在一个 2x2 的像素块中,只计算1个像素(如左下角)的 FG 结果。
-
对于另外 3个 像素:
a. 邻居复用: 检查当前帧已计算的邻居(如左下角),如果深度和法线接近,直接复用其结果。
b. 历史帧复用: 如果邻居复用失败,则将该像素重投影 (Reprojection) 到上一帧 (N帧) 的结果中。
c. 最差情况: 如果重投影也失败,直接复用当前帧已计算的那个邻居的结果(例如左下角)。
-
-
效果: 因为GI是低频的,这种简单的复用没有明显视觉问题。
-
性能提升: 在 4070 显卡上,提速约 75%。
-
-
3.3 反射 (Reflections)
-
问题: 室内金属物体如果只采样天光,表现会很差。
-
解决方案: 混合反射 (Hybrid Reflections)
-
粗糙反射 (Rough):
-
使用 Probe GI 数据。
-
采样方向:使用反射向量 (Reflection Vector) 去查询 SH 数据。
-
效果: 修正了粗糙表面的GI颜色和亮度。
-
-
光滑反射 (Smooth):
-
Probe GI 信息频率太低,不适用于光滑表面。
-
混合使用 SSR (Screen Space Reflections) 和经典的反射探针 (Reflection Probes / Cubemaps)。
-
-
最终混合:
- 根据材质粗糙度 (Roughness) 和像素到反射探针的距离,来混合 Probe GI、SSR 和反射探针三者的结果。
-
3.4 体积雾 (Volumetrics) 与半透明 (Translucency)
-
Lumen 方案: 需要一个额外的 Transluency Volume 来存储和追踪半透明物体的 GI,消耗额外显存。
-
Probe GI 方案的优势:
-
GI数据已存在于空间中(即离散的 Probe 数据),无需额外显存。
-
半透明物体: 可以在
Translucency Base Pass中直接采样 Probe GI。 -
体积雾: 在体积光渲染(Volume Marching)的每个 Cell 中,根据视线和位置去采样 Probe GI 作为散射光照。
-
效果: 避免了室内体积雾被天光错误照亮的漏光问题,得到相对正确的效果。
-
4. GI 数据生产与压缩
本节讨论 Probe GI 所需的数据、如何进行高效压缩,以及离线烘焙的完整管线。
4.1 数据定义与压缩
-
所需数据: 每个Probe需要存储 Radiance(光照)、Occlusion(遮蔽) 和 Offset(防漏光偏移)。
-
核心问题: 原始数据量巨大(示例中15GB),无法承受。
-
压缩方案 1:Simple Occlusion
- (如前所述)使用简化的遮蔽信息(如1个方向+距离)来代替完整的遮蔽数据,极大压缩了Occlusion的数据量。
-
压缩方案 2:GI MIPMAPs (空间层级)
-
核心假设:
-
GI 本身是低频信息。
-
动态物体在大部分情况下不需要高精度的防漏光信息。
-
-
技术实现:
-
Brick (数据块): 将 4x4x4 个 Probe 合并为一个管理对象,称为一个
Brick。 -
MIPMAP: 通过
Brick的设计,实现了不同尺度的GI数据,构成了GI的 MIPMAP。-
MIP0: 高精度,Probe 密度高(例如1个Brick覆盖小范围)。
-
MIP1: 低精度,Probe 密度低(1个MIP1数据块覆盖了MIP0的 64 倍空间体积)。
-
-
-
MIP Level 的选择策略:
-
传统 MIPMAP: 仅基于与摄像机的距离。(远处用MIP1,近处用MIP0)。
-
项目改进: 同时基于场景复杂度。
- 关键优化: 即使在近距离,如果是空旷区域(复杂度低),也强制使用 MIP1 的GI数据。
-
-
压缩成果:
-
显存节省: 最低画质(全MIP1)相比高画质(含MIP0)可节省 90% 以上的显存。
-
包体压缩: 原始 15GB 的资源量被压缩到了 231MB。
-
视觉效果: MIP1 的GI效果会更“平”(模糊),但对于二次元画风来说,这种变化并不突兀,可以接受。
-
-
4.2 离线烘焙管线 (Baking Pipeline)
Probe GI 像 Lumen 一样,需要对场景进行预处理。
1. 范围与对象过滤
-
Detail Volume: 由美术师在房屋等重要资产周围手动放置的包围盒。
-
作用1: 框定玩家主要活动范围,此范围内的 Probe 使用 MIP0 数据。
-
作用2: 极大加速后续烘焙流程(只处理重点区域)。(注:也可用
Navigation Mesh替代)
-
-
物体过滤 (Object Filtering):
-
在烘焙前剔除特定物体,例如:
-
对GI贡献不大的超大物体。
-
不适合烘焙光照的物体。
-
为保证竞技公平性而隐藏的物体(如
Detail Mode: High的草丛)。
-
-
2. 材质导出 (Material Export)
-
挑战: 虚幻的离线烘焙器 Lightmass 对UE的现代材质系统(如RVT、分层材质)支持不充分。
-
解决方案 A:RVT (运行时虚拟纹理) 地形
-
问题: Lightmass 无法直接烘焙带 RVT 特性的地形。
-
方案: “预渲染”RVT结果。
-
从地形正上方(-Z方向)进行一次 Scene Capture,抓取一张应用了RVT的地形顶视图纹理。
-
调整地形 Mesh 的 UV,使其在 Lightmass 中能正确采样到这张“预渲染”好的RVT纹理。
-
-
-
解决方案 B:分层材质 (Layered Material)
-
问题: 游戏中使用顶点的2U作为材质混合参数,但 Lightmass 导出时默认只使用基础UV,导致导出的纹理错误。
-
方案: 重写 Lightmass 的材质导出模块。
-
为 Static Mesh 生成一个 Dynamic Mesh 专门用于烘焙器。
-
关键技巧: 将该 Dynamic Mesh 的顶点坐标 (Position) 设置为它的 Lightmap UV。
-
保持其他顶点属性不变。
-
效果: 在导出材质时,运行时逻辑能生成正确的纹理数据。
-
-
3. 烘焙器 (Lightmass) 定制
-
工具: 使用虚幻官方的 Unreal Lightmass,因为它足够稳定。
-
定制 1: 增加了自定义烘焙通道,用于计算 Irradiance、Occlusion 和 Relocation 数据。
-
定制 2:全阶段分布式烘焙 (Full-Stage Distributed Baking)
-
问题: 原版 Lightmass 的分布式计算只支持最后最耗时的阶段,且更适用于小场景。对于大世界,烘焙的每个阶段计算压力都很重。
-
方案: 改进了 Lightmass 的分布式系统,使其支持所有阶段的分布式计算。
-
5. GI 运行时管理
5.1 数据流式加载 (Streaming)
-
挑战: 大世界项目,不可能一次性加载所有GI数据。
-
数据划分 (Offline):
-
类比 World Partition: 像
World Partition系统一样,GI数据也根据网格单元 (Grid Cell) 进行划分。 -
GI Data Actor: 使用一个 Actor 来记录每个 Cell 空间中所有的GI数据。
-
(注:这与 UE 5.5 中的实验性功能
World Partition Static Lighting思路相似。)
-
-
数据更新 (Runtime):
-
GI Volume: 在GPU显存中只保留摄像机周围有限范围的GI数据(称为
GI Volume)。 -
环形更新 (Ring Update):
-
当相机移动超过一个
Brick的距离时,触发更新。 -
计算
GI Volume中哪些数据已过时(需要释放),哪些是新进入的(需要上传)。 -
同时判断新进入区域的 MIP Level。
-
-
分帧上传 (Per-Frame Upload Limit):
-
目的: 避免某一帧上传数据量过大导致卡顿。
-
策略: 每一帧只上传固定大小的数据。
-
效果: 视觉无损,因为需要更新的数据总是在
GI Volume的边缘。
-
-
5.2 GPU 数据结构与采样
-
挑战: 如何在GPU上高效存储和访问这些流式加载的、离散的
Brick数据? -
解决方案: 两级数据结构。
1. 资源池 (Resource Pool)
-
定义: 一块(或多块)有限的、固定大小的显存空间(类似于内存池或大 Texture Array)。
-
作用: 存储实际的GI数据(Irradiance, Occlusion等)。
Brick数据被动态换入换出到这个池中。
2. 层次化索引 (Hierarchical Index)
-
定义: 一个 3D 纹理(或 Buffer),其空间结构与摄像机周围的
GI Volume一一对应。 -
作用: 它不存储实际数据,而是存储一个地址或索引,该地址指向这个
Brick的实际数据在Resource Pool中的位置。 -
“层次化”: 索引的编码方式可以指向不同MIP层级(MIP0或MIP1)的数据。
-
更新:
Ring Update过程 Compute Shader 负责计算并更新这个Hierarchical Index。
3. 运行时采样 (Shader - Final Gather)
-
核心: 间接访存 (Indirect Access)。
-
流程:
-
着色器根据 Probe 的世界坐标,计算出它在
GI Volume中的位置。 -
使用这个位置去采样
Hierarchical Index,获得一个地址(指针)。 -
使用这个地址去
Resource Pool中间接访问 (fetch) 实际的GI数据。
-
6. 性能与总结
-
低画质 (4070):
-
GI 仅需 0.2ms GPU 时间。
-
Resource Pool和Hierarchical Index总显存占用仅 1.44MB (因为全部使用 MIP1)。
-
-
画质对比:
-
中画质 (Probe GI): 相比低画质有更高层次感(MIP0 细节)。
-
高画质 (Probe GI): 相比中画质有更高质量的反射。
-
超高 (Lumen) vs 极致 (Lumen): 极致画质下的 Lumen 更准确,因为它能 Trace Mesh SDF。
-
-
设备适配: 展示了四种不同配置的机型(对应低、中、高、超高四档默认画质)的GI渲染时间,证明了该方案的高可伸缩性。
7. 阴影方案:背景与目标
讲座的第二部分转为介绍项目中的静态阴影烘焙方案。
-
方案名称: MMH Shadow
-
核心定义: 一套使用 MMH 压缩算法 的大世界静态阴影烘焙方案。
-
技术定位: 作为 CSM (Cascaded Shadow Maps) 和 VSM (Virtual Shadow Maps) 的替代方案,用于静态场景。
-
方案特点:
-
适用范围: 仅针对方向光 (Directional Light)。
-
前提: 光源和场景必须是静态的。
-
兼容性: 方案本身依然能支持动态物体(接收静态阴影)。
-
核心目标: 优化性能,具体针对的就是
ShadowDepthsPass。
-
8. 核心问题:为什么需要压缩?
-
UE 动态阴影的痛点:
-
ShadowDepthsPass(即渲染 ShadowMap)每帧都会执行。 -
即使有 VSM 的缓存机制,在摄像机移动或场景变化时,开销依然很高。
-
-
简单烘焙的局限:
-
既然光源和场景是静态的,理论上可以把 ShadowMap 离线烘焙下来。
-
致命问题:包体大小。 一张 4096x4096 的标准 ShadowMap(单通道)就需要 64MB。对于几公里的大世界,这种存储开销是绝对无法接受的。
-
-
解决方案: 必须使用高效的压缩算法。
9. 核心算法:阴影压缩技术演进
方案基于三篇论文,核心思想是将高分辨率的 ShadowMap 压缩成一个四叉树 (Quadtree)。该方案的“黑盒”输入是一个双层阴影贴图 (Dual Shadow Map),输出是一个可序列化的、无损压缩的四叉树。
9.1 算法 1:MH (Multi-resolution Hierarchy)
-
核心概念:Dual Shadow Map / 双层阴影贴图
-
传统的 ShadowMap 只存储最近点的深度(遮挡物正面,A点)。
-
洞察: 遮挡物背面的深度(B点)也是一个有效的遮挡深度。任何在 [A, B] 之间的深度都处于阴影中。
-
存储: 一个深度的区间 (Depth Interval)
[Front_Depth, Back_Depth],而不是单一深度值。
-
-
压缩原理:四叉树压缩 (Quadtree Compression)
-
自底向上 (Bottom-up) 构建四叉树。
-
核心思路: 尝试找到一个父节点,其存储的深度区间能够尽可能多地代表其四个子节点。
-
示例: 假设 4 个子节点的深度分别是
[1],[2],[2],[3]。压缩算法可能会选择[2]作为父节点,此时父节点代表了两个子节点,另外两个子节点[1]和[3]则保留。这样,4个节点就被压缩成了3个节点(父、子、子)。
-
-
采样(还原):
-
自顶向下 (Top-down) 遍历四叉树。
-
从根节点出发,根据要采样的像素坐标,向下遍历,直到叶子节点,得到最终的深度区间。
-
-
压缩率: 极高。论文数据显示 4K 贴图能压缩到原始大小的 0.64%。
9.2 算法 2:MMH (Merged Multi-resolution Hierarchy)
-
核心改进: 在 MH 的基础上,增加了子树合并 (Merge) 功能。
-
合并条件:
-
两颗子树具有完全相同的拓扑结构。
-
两颗子树的深度区间相匹配。
-
-
关键优化:存储相对深度 (Relative Depth)
-
问题: 即便结构相同,两颗子树的绝对深度也几乎不可能匹配(例如,一个在近处,一个在远处)。
-
解决方案: 节点不存储绝对深度,而是存储相对于其父节点的相对深度。
-
效果:
[父=10, 子=11, 子=12]和[父=50, 子=51, 子=52],它们的相对深度都是[+1, +2],因此可以合并。
-
-
采样变化:
- 还原时需要累加深度:
Final_Depth = Self.Relative_Depth + Parent.Depth。
- 还原时需要累加深度:
-
压缩率: 相比 MH,数据量又压缩了约 1/3。
9.3 算法 3:DAG-MH (Directed Acyclic Graph MH)
-
核心改进: 数据结构分离。这是《命运扳机》项目当前正在使用的算法。
-
原理: 将 MH 的数据拆分为两种结构,并分别进行压缩。
-
数据结构 A:数组 (Array)
- 内容: 存储所有不重复的深度区间 (Depth Interval)。
-
数据结构 B:四叉树 (Quadtree)
- 内容: 节点不再存储深度,而是存储一个指向 数组A 的下标 (Index)。
-
-
进一步优化:
-
四叉树 (B): 同样可以使用 MMH 算法进行压缩(合并结构相同的子树)。
- 优势: 此时压缩的是整数(下标),而不是浮点数(深度),理论上更容易找到匹配项,压缩率更高。
-
数组 (A): 使用调色板压缩 (Palette Compression) 算法进行压缩。
- 原理: 寻找一条最优直线,使得直线上的点能近似代表数组中的深度区间。
-
-
采样流程(最复杂):
-
采样四叉树 (B),得到一个(相对)数组下标。
-
累加得到绝对下标后,用该下标去采样调色板(压缩后的数组A)。
-
从调色板中还原出最终的深度区间。
-
-
压缩率: 相比 MMH,数据量又压缩了约 1/3。
10. 离线烘焙 (Offline) 实现细节
10.1 烘焙流程与数据划分
-
烘焙工具: 依然基于虚幻的 Unreal Lightmass。
-
烘焙范围:
-
美术师在场景中放置 Importance Volume 来框定烘焙范围。
-
将这个 Volume 投影到光源空间,形成一个2D矩形。
-
-
数据切分 (Tiling):
-
将该2D矩形划分为 4096x4096 的 Tile (瓦片)。
-
每个 Tile 对应一张 Dual Shadow Map (DS-Map)。
-
-
数据生成:
- 以 Tile 为单位,逐像素向场景发射射线(光线追踪),获取每个像素的深度区间 (Depth Interval)
[Front, Back]。
- 以 Tile 为单位,逐像素向场景发射射线(光线追踪),获取每个像素的深度区间 (Depth Interval)
-
数据采样(反向):
- 运行时,将世界坐标点转换到光源空间,再转换到 Tile 空间,即可采样对应的深度区间。
10.2 数据存储 (World Partition)
-
核心策略: 基于 World Partition 系统进行数据管理。
-
存储单位:
-
每个 WP Cell 放置一个自定义 Actor。
-
当该 Cell 被流式加载时,Actor 也会被加载。
-
压缩后的 Tile 数据存储在该 Actor 的一个 Component 中。
-
-
核心技术问题:2D Tile → 3D Actor 的映射
-
问题: Tile 数据是2D的,无法仅凭 (x, y) 坐标确定它属于哪个3D空间中的 Cell Actor。
-
解决方案: 在离线处理时,对 Tile 数据进行一次采样,得到一个深度值,从而反算出该 Tile 对应的世界坐标(贴在物体表面的位置)。根据这个世界坐标,即可确定它属于哪个 Cell。
-
10.3 关键优化与细节
-
精度选择:1cm/pixel
- 权衡: 2cm/pixel 能大幅减少数据量,但 1cm/pixel 产生的阴影瑕疵 (Artifacts) 更少。项目最终选择 1cm/pixel 以保证质量。
-
烘焙数据大小的进一步压缩(重点):
-
观察 1:树冠(高频信息)压缩率极低。
-
策略: 单独对树(LOD)进行降分辨率烘焙。
-
原因: 树冠内部无法进入,且烘焙精度的降低在视觉上不明显。
-
-
观察 2:屋顶、围墙(大块连续平面)
- 策略: 使用 DAG-MH 算法 对这类结构有效压缩。
-
最终成果: 压缩率从 0.55% 进一步降低到 0.24%。
-
11. 运行时 (Runtime) 实现
本节讲述 MH Shadow 数据如何在运行时被高效加载、解压和渲染。
11.1 数据流送 (Streaming)
-
加载: 当 WP 加载
Component时,会创建Scene Proxy,其中包含了压缩的 Tile 数据。 -
GPU 数据结构:
-
Memory Pool(资源池): 一个固定大小的 GPU Buffer。用于存储摄像机Upload Range范围内的压缩 Tile 数据。 -
Index Buffer(索引): 另一个 Buffer,其长度等于 Tile 数量,用于存储每个 Tile 在Memory Pool中的起始地址(指针)。
-
-
问题: 简单加载的显存开销巨大(例如800米范围就需要 400MB)。
11.2 核心方案:Virtual Clipmap
-
解决方案: 借鉴 VSM (Virtual Shadow Maps) 思路,实现了一个自定义的、轻量级的 Virtual Clipmap。
-
Clipmap: 阴影被分为多级,每级分辨率相同,但覆盖范围逐级增大。
-
Virtual (虚拟化):
-
将 Clipmap 进一步划分为 128x128 像素的分页 (Page)。
-
引擎只在显存中保留(缓存)解压后的、当前帧可见的 Page。
-
Physical Texture(物理页池): 用于存储这些解压后的 Page。
-
-
为何不复用 VSM?
-
解耦合: 方便后续升级和维护。
-
轻量级: 项目只需要一个轻量级的 Virtual Clipmap 功能。
-
11.3 Virtual Clipmap 渲染流程
-
Feedback Pass(反馈): 读取 SceneDepthZ ,分析当前帧哪些 Page 是可见的。 -
Async Readback(异步回读): CPU 异步获取这个可见性列表。 -
CPU Analysis(CPU分析):-
分析需要
Request(请求加载)哪些新 Page。 -
向物理页池申请空闲页。
-
释放(Evict)不再使用的旧 Page。
-
-
Request Pages Pass(页面请求 - 核心):- 执行解压缩,将 Tile 数据还原为深度图,并写入
Physical Texture。
- 执行解压缩,将 Tile 数据还原为深度图,并写入
-
Shadow Projection(阴影投影):- 正常渲染 Pass,采样
Physical Texture来生成最终的阴影遮罩 (ShadowMask)。
- 正常渲染 Pass,采样
11.4 解压缩策略 (重点)
-
问题: 在快速转动视角时,阴影出现明显的加载延迟和“刷”出来的现象。
-
原因: CPU 解压太慢(每帧只能解压1页),而中远距离的 Clipmap 之前依赖 CPU 解压。
-
分级解压策略:
-
近距离 (Level 0): GPU 解压。
- 数据已在 GPU 的
Memory Pool中,解压极快,每帧可处理大量 Page。
- 数据已在 GPU 的
-
远距离 (Far Clipmaps): CPU 解压。
- 数据不在 GPU,CPU 解压后上传。依然很慢(每帧1页),但远处不明显。
-
中距离 (Mid Clipmaps) - 优化后:
-
将压缩的 Tile 数据动态上传到一个临时 GPU Buffer。
-
在 GPU 端执行解压缩。
- 结果: 性能较好,只需少量临时显存,完美解决了视角转动时的阴影延迟问题。
-
-
11.5 软阴影 (Soft Shadows)
-
实现: 有了 Clipmap(本质是一张 ShadowMap),可以套用任意软阴影算法。
-
选型: 为贴近 VSM 的效果,项目选用了 SMRT(Shadow Map Ray Tracing) 算法。
11.6 动态物体 (Dynamic Objects)
-
角色的阴影: 使用
PerObject Shadow,该技术独立于CSM/VSM,可直接复用。 -
其他动态物体(含 Nanite):
-
目标: 避免同时开启 CSM/VSM(双倍显存开销)和 Nanite 的高固有开销。
-
解决方案: 将动态物体绘制(Rasterize)到我们的 Virtual Clipmap 中。就像VSM对Non-Nanite物体做的那样。
-
流程:
-
构建一个FProjectedShadowInfo。
-
收集Clipmap范围内FMeshBatch。
- 将Nanite当作Fallback Mesh处理。
-
Submit Culling Pass:- 一个线程处理一个Primitive。
- 包围盒跟每级Clipmap判断是否需要绘制。
- 如果需要(覆盖了脏页)则InstanceCount加一。
-
Submit Raster Pass:- 只在
InstanceCount > 0的 Clipmap Page 上绘制动态物体。
- 只在
-
-
优势: 大部分情况下,
InstanceCount为 0(因为阴影已被缓存)。
-
12. 总结与未来工作
12.1 性能与效果
-
性能对比 (vs VSM):
-
ShadowDepthsPass(核心开销):-
VSM: 0.58 ms
-
MHM Shadow: 0.06 ms (这 0.06ms 几乎全是动态物体的开销)
-
-
总开销: 显著优于 VSM,并且优化了渲染线程(RT)和RHI线程(Draw Call 大幅减少)。
-
-
视觉效果:
-
MMH Shadow 提供了比低、中、高画质(CSM)精度更高的阴影。
-
在某些区域,MMH Shadow 的精度甚至高于 VSM (Ultra) 的表现。
-
12.2 未来工作
-
阴影 (Shadows):
-
尝试使用神经网络进行压缩。
-
实现 Time-of-Day (TOD) 动态烘焙。
-
支持 Local Lights(点光源、聚光灯)。
-
更高效率。
-
-
全局光照 (GI):
-
尝试使用神经网络压缩 GI 数据或实现 TOD GI。
-
完善远景 GI 细节。
-
为可破坏的动态物体实现动态 GI 效果。
-