这篇 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 区别不大。

metalod_0

图中为了清晰起见,我隐藏了实际的物体,仅显示表示活跃度的调试线框,黑色表示活跃度为 0 而红色表示活跃度为 1,中间的过渡色则为环状的过渡区域。过渡区域的宽度直接关系到 popping 现象的多寡,也就是视觉跳跃感的强弱。


工程实现

代码简单说一下吧,先说一下伪码的运算流程。为了简明起见,我们把影响因子称为 FOI (factor of impact)

计算目标物体的活跃度()
{
    // ==== 第一阶段 ==== 

    获取目标物体与热点(摄像机或玩家的位置)的距离

    分别计算四种内建 FOI 在不同权重下的影响度,并累加

    分别计算所有用户添加的 FOI 在不同权重下的影响度,并累加

    计算经过所有 FOI 修正过的距离

    // ==== 第二阶段 ==== 

    使用当前系统的性能评级和 FPS 来修正活跃度区域的上下限(也即热力环的热力衰减运算)

    // ==== 第三阶段 ==== 

    使用上面两个阶段的计算结果得出该物体的活跃度 
}

这个计算流程的实际代码在类 UMetaLod 的这个函数里:

    private void _updateLiveness(IMetaLodTarget target)

下面是系统中内建的四个影响因子,均定义有各自的取值范围和权重。正如上面提到的,用户还可以通过 void AddUserFactor(UImpactFactor userFactor) 来添加定制的影响因子。

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 去归一化传进来的值

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); }

正如之前的 UQtConfigUMetaLod 也提供了一些可配置参数来调整行为

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 链接。


优化和扩展

这里先简单地提两点吧。

  1. 一个是可以与之前的 UQuadtree 结合使用,把每个叶节点上的数据集作为一个 UMetaLod 的 Lod Target,这样的好处是可以以区域为单位批量化运算,避免以单个对象为粒度所产生的大量近似的冗余运算。

  2. 另一个是如果单帧的运算量过大,更新时可以划分为四个象限,逐象限计算和更新,也就是分拆到不同的帧去做增量更新。由于整个系统更新频率较低 (默认为 0.2s 更新一次),相邻的不同帧之前可以看做是等同的。即使万一由于玩家的移动漏更了一两个对象,也会在下一个 0.2s 周期就会处理,问题不大。


正如你可能已经发觉的那样,本文中一些细节并未充分地展开说明,如果你对背后的思路感兴趣,希望了解更多的实现细节,可以阅读此文,这是我此前实现的一个类似系统的一些开发日志的整理,也是此文中一些概念的来源。


代码及对应的测试工程在这里,在 Unity-5.0.1f1 下编译和运行通过。

需要说明一下,这几期的代码都在西山居于 GitHub 上的 SeaSunOpenSource 组织的工程页面内维护。如未明确说明,均以 MIT License 发布。

Gu Lu
2015-07-19

Comments
Write a Comment

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。