这篇 blog 是这个系列的第三篇,主题依然是 Unity 相关的实践。这一次我们来聊一聊,如何在游戏中实现一个通用的增强版 LOD (Level-Of-Detail) 方案。
先解释一下为什么搞出一个很难念的名字 UMetaLod 吧——这实际上是前缀 u- 和 meta-lod 的组合。所谓 meta-lod 实际上是针对传统 LOD 而言的,用来表示一种更通用的广义的 LOD。
基本思路
我们知道,不管是 Unity 还是 Unreal,都有着内建的基于与摄像机距离的 LOD 机制。如果正确地设置了 LOD 的每个层级对应的模型,当摄像机移动时,引擎会以一定频率计算 LOD,并把目标切换为对应层级精度的模型。
那么为什么我们还要手动实现一个所谓的增强版本呢?
这主要有以下几个方面的考虑:
其一,手动定制的 LOD 系统,除了以该物体与摄像机的距离为基础,还会考虑
- 影响因子 1 - Bounding Box Factor - 该物体的包围盒尺寸
- 影响因子 2 - Geometry Complexity Factor - 该物体的顶点数量
- 影响因子 3 - ParticleSys Complexity Factor - 该物体是否为粒子系统,如果是的话考虑粒子数量等参数
- 影响因子 4 - Visual Impact Factor - 每个子物体的视觉影响,可由美术手动设置
这些影响以不同的可定制权重 (weight) 对整个 LOD 系统发挥作用,这样全面而综合地考虑后,呈现出来的渲染结果对实际画面的影响更小,优化也就会更有效。
除了这些内建的影响因子以外,用户还可以通过 AddUserFactor() 添加若干个定制的影响因子,参与到 LOD 系统的运算和评估中来。
其二,对当前系统的性能进行评估,并把结果以参数形式传入系统,可以有效地形成负反馈,提高系统的伸缩性和健壮性。这里主要可以考虑两个因素:
- 一个是当前系统性能等级的评估,目前用一个枚举 Highend / Medium / Lowend 分别代表高中低档的目标机器
- 一个是当前 5 秒内的平均 FPS 状况,用于表示当前游戏的运行时性能状况
这两个值健康程度越高,整个 LOD 系统就会调整至允许容纳更多的视觉元素;如果情况越恶劣,系统则倾向于使用更严格的约束,从而降低视觉元素的总量。
其三,传统的狭义 LOD 仅会在若干个不同精度的模型之间切换,而 UMetaLod 则是相对广义一些。UMetaLod 通过上面多因素的综合考虑和计算,得到一个针对当前物体的活跃度 (Liveness) 的概念,其值域为 [0, 1]。有了这个值,游戏内不同的系统,可以有针对性地对自己的对象做多种粒度,多个角度的不同处理,下面是一些常见的例子:
- 对于常见的包含多个面片和粒子系统的技能特效,可以通过美术设置的权重 (即上面的 Visual Impact Factor),在活跃度发生变化时有选择地隐藏那些相对次要的部分,或者让其较早地淡出
- 如果一个角色包含高中低的 shader 实现,可以在需要时,根据活跃度在不同复杂度的 shader 实现间切换
- 可以开启/关闭对应的物理模拟,或更细粒度的调整 (调高/调低物理更新的频率)
- 在需要时,根据活跃度使用更低面数的模型,更低骨骼数的骨骼动画,更低分辨率的贴图
- 在需要时,根据活跃度简化或关闭动态的光照运算,调整和精简 shadow caster 的列表
把__对多种影响因子的综合评估__,负反馈的性能调节,和__多层次细粒度的调整__这三者结合起来,就构成了一个广义的 LOD 系统。UMetaLod 能够从整体上根据系统的负载能力和运行情况,自主地去调节和优化系统的性能表现。当然,如果需要的话,也可以通过暴露出来的大量参数去调整它的行为,是激进还是保守,还是每个子系统使用不同的策略,还是针对特定的游戏类型做定制,都是可以考虑的。
上个图吧,看上去跟传统的 LOD 区别不大。
图中为了清晰起见,我隐藏了实际的物体,仅显示表示活跃度的调试线框,黑色表示活跃度为 0 而红色表示活跃度为 1,中间的过渡色则为环状的过渡区域。过渡区域的宽度直接关系到 popping 现象的多寡,也就是视觉跳跃感的强弱。
工程实现
代码简单说一下吧,先说一下伪码的运算流程。为了简明起见,我们把影响因子称为 FOI (factor of impact)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
计算目标物体的活跃度()
{
// ==== 第一阶段 ====
获取目标物体与热点(摄像机或玩家的位置)的距离
分别计算四种内建 FOI 在不同权重下的影响度,并累加
分别计算所有用户添加的 FOI 在不同权重下的影响度,并累加
计算经过所有 FOI 修正过的距离
// ==== 第二阶段 ====
使用当前系统的性能评级和 FPS 来修正活跃度区域的上下限(也即热力环的热力衰减运算)
// ==== 第三阶段 ====
使用上面两个阶段的计算结果得出该物体的活跃度
}
|
这个计算流程的实际代码在类 UMetaLod
的这个函数里:
1
|
private void _updateLiveness(IMetaLodTarget target)
|
下面是系统中内建的四个影响因子,均定义有各自的取值范围和权重。正如上面提到的,用户还可以通过 void AddUserFactor(UImpactFactor userFactor)
来添加定制的影响因子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public class UMetaLodConst
{
// the bounding volume of the target
public const string Factor_Bounds = "Bounds";
// currently corresponds to vertex count of the target mesh, would be 0 for particle system
public const string Factor_GeomComplexity = "GeomComplexity";
// currently correspends to particle count of the target particle system, would be 0 for ordinary mesh
public const string Factor_PSysComplexity = "PSysComplexity";
// a subjective factor which reveals the visual importance of the target in some degrees
// for instance, skill effects casted by player would generally has a
// pretty much higher visual impact than a static stone on the ground
public const string Factor_VisualImpact = "VisualImpact";
}
|
这些影响因子还可以设置不同的 Normalizer 去归一化传进来的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public delegate float fnFactorNormalize(float value, float upper, float lower);
...
public struct UImpactFactor
{
...
// customized Normalizer for different Impact Factor
public fnFactorNormalize Normalizer;
}
...
// use methods like InverseLerp() to transform the parameter value into a valid FOI
Normalizer = (value, upper, lower) => { return UMetaLodUtil.Percent(lower, upper, value); }
|
正如之前的 UQtConfig
,UMetaLod
也提供了一些可配置参数来调整行为
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
public static class UMetaLodConfig
{
// the time interval of an update (could be done discretedly)
public static float UpdateInterval = 0.5f;
// the time interval of an FPS update (could be done discretedly)
public static float FPSUpdateInterval = 5.0f;
// debug option (would output debugging strings to lod target if enabled)
public static bool EnableDebuggingOutput = false;
// performance level (target platform horsepower indication)
public static UPerfLevel PerformanceLevel = UPerfLevel.Medium;
// performance level magnifier
public static Dictionary<UPerfLevel, float> PerfLevelScaleLut = new Dictionary<UPerfLevel, float>
{
{ UPerfLevel.Highend, 0.2f },
{ UPerfLevel.Medium, 0.0f },
{ UPerfLevel.Lowend, -0.2f },
};
// heat attenuation parameters overriding (including the formula)
public static float DistInnerBound = 80.0f;
public static float DistOuterBound = 180.0f;
public static float FpsLowerBound = 15.0f;
public static float FpsStandard = 30.0f;
public static float FpsUpperBound = 60.0f;
public static float FpsMinifyFactor = -0.2f;
public static float FpsMagnifyFactor = 0.2f;
public static fnHeatAttenuate HeatAttenuationFormula = UMetaLodDefaults.HeatAttenuation;
}
|
可以看到末尾的 HeatAttenuationFormula
允许用户使用自定义的公式替换掉默认的热力衰减运算。
其他的代码就不一一说明了,感兴趣可自行查看,文末附有 GitHub 链接。
优化和扩展
这里先简单地提两点吧。
-
一个是可以与之前的 UQuadtree
结合使用,把每个叶节点上的数据集作为一个 UMetaLod
的 Lod Target,这样的好处是可以以区域为单位批量化运算,避免以单个对象为粒度所产生的大量近似的冗余运算。
-
另一个是如果单帧的运算量过大,更新时可以划分为四个象限,逐象限计算和更新,也就是分拆到不同的帧去做增量更新。由于整个系统更新频率较低 (默认为 0.2s 更新一次),相邻的不同帧之前可以看做是等同的。即使万一由于玩家的移动漏更了一两个对象,也会在下一个 0.2s 周期就会处理,问题不大。
正如你可能已经发觉的那样,本文中一些细节并未充分地展开说明,如果你对背后的思路感兴趣,希望了解更多的实现细节,可以阅读此文,这是我此前实现的一个类似系统的一些开发日志的整理,也是此文中一些概念的来源。
代码及对应的测试工程在这里,在 Unity-5.0.1f1 下编译和运行通过。
需要说明一下,这几期的代码都在西山居于 GitHub 上的 SeaSunOpenSource 组织的工程页面内维护。如未明确说明,均以 MIT License 发布。
(全文完)