返回全部文章

如何制作“测量版本”:Unity / UE5 / Godot 通用,正确测量真机性能

在编辑器里跑得很顺的场景,一放到真机上就开始卡顿。反过来,在开发版本里很重的处理,到了产品版本却像没事一样顺畅。明明是同一个游戏,为什么在不同环境里测出来的数字会对不上?

测量环境的三个阶段

性能测量的环境大致可以分成三个阶段:在编辑器里做性能分析、在 Development 版本上做性能分析,以及在开启了 Release / Shipping 级别优化的版本上做测量。越靠前的阶段越容易拿到数字,但这些数字也离在玩家手中运行的产品版本越远。

编辑器 Development 版本 测量版本

越往右测量噪声越少,数字也越接近玩家实际体验到的数值。

在编辑器里测量,会带上编辑器特有的开销。热重载、资源监视这些编辑器专用系统在后台持续运行;如果是 Unity,跑的还是 Mono 而不是 IL2CPP。内存也要经过调试用的分配器,布局、对齐、碎片化倾向、GC 运行的时机都和产品版本不一致。

换成 Development 版本后,编辑器特有的开销消失了,但调试开销仍然还在。断言检查、冗长的日志输出、性能分析器连接这类调试功能仍处于开启状态,编译器的优化也和线上不一样:内联展开被抑制,LTO(链接时优化)没有生效。用 Development 配置打包出来的版本里还残留着大量日志输出和调试功能,却把它的数字当成产品性能来读,这样的例子并不少见。

也就是说,前两个阶段观测到的”卡顿”,很大一部分其实是测量环境本身制造出来的噪声。

你盯着玩家设备上根本不存在的开销,去和实机上并不存在的瓶颈较劲。

测量版本的制作方法

第三个阶段对应的,就是在与线上相同的优化条件下运行的”测量版本”。基本方针是:先开启 Release / Shipping 级别的优化,然后只保留测量所需的最少日志和钩子。不加作弊功能,也不加过多的诊断。目标始终是一个”以和成品相同的速度运行、但仍然能取到数字”的版本。

Unreal Engine:使用 Test 配置

Unreal Engine 有 DebugGame / Development / Test / Shipping 这几种构建配置。这里要瞄准的是 Test 配置。

DebugGame Development Test ← 测量版本 Shipping

Development 面向日常迭代,虽然有一定程度的优化,但保留了大量调试功能和检查。反过来,Shipping 用于成品发布,连控制台命令和 stat 系列工具都被剔除,很难插入测量钩子。

Test 被设计成介于两者之间。它保持与 Shipping 同等的优化,同时保留 stat unit 等诊断命令和最小限度的工具。因为它能同时兼顾性能测量所需的速度特性和可观测性,所以是测量版本最顺理成章的基础。

不过,Test 并不像 Development、Shipping 那样能在编辑器的打包菜单里一键选择。可靠的做法是通过 Automation Tool(BuildCookRun),或 Project Launcher 的自定义配置文件来指定。

// MyGame.Build.cs (project module)
public class MyGame : ModuleRules
{
    public MyGame(ReadOnlyTargetRules Target) : base(Target)
    {
        PublicDependencyModuleNames.AddRange(
            new string[] { "Core", "CoreUObject", "Engine" });

        // Measurement-only compile switch, scoped to this module.
        // Module-level PrivateDefinitions leave the shared (installed-
        // engine) build environment untouched, unlike target-level
        // GlobalDefinitions, which would require a unique build
        // environment and abort UBT on Launcher engines.
        if (Target.Configuration == UnrealTargetConfiguration.Test)
        {
            PrivateDefinitions.Add("MEASUREMENT_BUILD=1");
        }
    }
}
RunUAT BuildCookRun -project="C:/Path/MyGame.uproject" -platform=Win64 -clientconfig=Test -cook -stage -pak -package -build

另外,即使在 Test 里,UE_LOG 也会默认被编译剔除(其启用开关 bUseLoggingInShipping 对 Test 和 Shipping 都生效)。不过这个设置在 Launcher 版(installed)引擎上会要求 unique build environment,导致 UBT 中断,所以最好让测量钩子的输出不依赖 UE_LOG

此外,Launcher 版引擎默认不包含用于 Test 的引擎二进制(installed build 的 GameConfigurations 默认为 Shipping、Development、DebugGame)。如果无法用 Test 打包,就使用源码构建的引擎,或包含 Test 一起构建的自定义 installed build。

这套配置在 UE5(以及 UE4)上通用。

Unity:测量用构建配置文件(Unity 6)

Unity 6 引入了名为 Build Profiles(构建配置文件)的机制,它可以把各平台的构建设置保存为命名配置文件并在其间切换。为测量单独切出一个专用配置文件,按以下方针配置:

  • 关掉 Development Build 选项(不背上性能分析器自动连接和脚本调试的开销)
  • 脚本后端选择 IL2CPP,通过与成品相同的原生代码路径来测量
  • 用 Scripting Define Symbols 只启用测量用的最小日志,其余冗长日志一律关闭

具体步骤是这样:从 File > Build Profiles 新建一个测量用配置文件,取消勾选 Development Build。在 Player Settings 里,把 Scripting Backend 设为 IL2CPP、C++ Compiler Configuration 设为 Release。测量用的 MEASUREMENT 定义,添加到该配置文件的 Build Data > Scripting Defines 里(配置文件的定义会叠加在项目和平台的定义之上)。只在这个定义有效的构建里,对测量代码的调用才会保留在编译结果中([Conditional] 去掉的是调用点,方法本体仍留在程序集里):

using System.Diagnostics;

public static class Measure
{
    // Call sites are stripped at compile time unless the MEASUREMENT
    // define is active for the current build profile. The method body
    // itself stays in the assembly.
    [Conditional("MEASUREMENT")]
    public static void Mark(string label)
    {
        UnityEngine.Debug.Log($"[measure] {label} @ {UnityEngine.Time.frameCount}");
    }
}

建议把 Release 作为测量的默认;如果工作室以 Master 出货,就在 Master 上测量。[Conditional] 这种切换本身,在 Unity 6 之前也能用,走下文所说的 BuildPipeline 路线即可。

Unity 6 之前的版本没有 Build Profiles。这种情况下,就用一段能组出等效发布配置的构建脚本来替代(通过 BuildPipeline,不设置 Development 标志,指定 IL2CPP 和最小日志的符号)。

Godot:发布导出加自定义功能标签

在 Godot 里,承担这个角色的是导出预设。导出时关掉 “Export With Debug”(也就是使用发布版导出模板),就能得到剥离了远程调试器、性能分析器连接等调试机制的优化二进制。

测量用途下,复制一份预设,并给它加上自定义功能标签(例如 measurement)。这个标签写在导出预设的配置区块里:

# export_presets.cfg (measurement preset section)
custom_features="measurement"

代码侧据此分支,因此只在这个版本里启用测量所需的最小日志:

// Enable minimal measurement logging only in the measurement build.
if (OS.HasFeature("measurement"))
{
    GD.Print($"[measure] frame={Engine.GetFramesDrawn()} fps={Engine.GetFramesPerSecond()}");
}

自定义功能标签只在导出后的版本里才会解析:从编辑器运行时 OS.HasFeature("measurement") 返回 false,所以在导出之前这个分支看起来像是死代码。

如果想进一步精简二进制,也可以换成用 production=yes 编译的自定义导出模板。自定义模板的构建命令是 scons platform=windows target=template_release production=yes module_mono_enabled=yes,其中 production=yesuse_static_cpp=yes debug_symbols=no lto=auto 的别名(Godot 4.x);C# 项目使用的模板必须带上 module_mono_enabled=yes。支持 C# 的模板仅凭这一行还无法完成,还需要生成 mono glue 并构建 GodotSharp 托管程序集(步骤请参阅官方文档)。

测量版本要测的指标:帧时间、内存、加载时间、存储 IO

准备好测量版本之后,就通过真实的游玩会话来收集数字。应该关注的指标如下:

FPS / 帧时间不只看平均值,还要看 P95。因为即使平均值很平滑,P95 的尖峰也常常在破坏游玩体验
内存占用峰值,以及随时间推移的增长趋势
地图 / 关卡的加载时间由于会随着着色器编译和缓存状态而变化,因此也记录是否为首次加载
存储 IO由流送阻塞引起的 hitch(卡顿)大多会在这里显现

而最重要的,是一定要给这些数字附上情境上下文。具体来说,就是当时的玩家坐标、相机朝向、屏幕上显示的地图 / 场景、构建 ID,以及设备 / 平台。

一条没有上下文、只写着”帧时间跳了一下”的记录,事后根本无从追查。反过来,如果记录的粒度是”build 1042 的 desert_ruins,坐标 (X, Y),相机朝北时 P95 会跳”,那么这个异常就成了可复现的调查对象。

正是上下文,才是把一个普通的数字变成「可以着手调查的异常」的关键。

余量就是预算

测量的目的,不只是修复慢的地方。如果在目标硬件上,CPU、GPU、内存、IO 还留有余量(headroom),那这份余量就是可以直接动用的”预算”。

比如,如果渲染线程还有余力,你就可以有意地把这份预算投向更丰富的 VFX、更多的同屏对象、更远的流送距离。优化既是”做减法”的工作,同时也是一项能告诉你”表现可以做到多有野心”的工作。

只要通过测量版本掌握了准确的余量,这类判断就能靠数字而不是凭感觉来做。是还有富余,还是已经贴着上限了?只有搞清楚这一点,你才能有意识地去设计游戏内容的密度。

从异常,到根因

测量版本的职责,是定位问题”在哪里、在何时”发生。原因本身,在这一步是看不出来的。

比如,测量版本报出了”在特定坐标出现帧尖峰""某次加载之后内存抬高了一档、再也没降回来”这样的异常。下一步,就是在 Development 版本里精确复现那个情形,用详细的性能分析器深入挖掘。

  • Unity 就用 Unity Profiler 和 Memory Profiler
  • Unreal Engine 就用 Unreal Insights
  • Godot 就用内置分析器和监视器(脚本计时仅支持 GDScript,深入分析 C# 代码时请配合 .NET 分析器)

到这里,“为什么”就浮现出来了。是哪个系统在吃掉那一帧,是哪个资源一直攥着内存不放。分工很简单。

测量版本WHERE / WHEN定位问题在哪里、在何时发生
开发版本的性能分析器WHY复现那个情形,并解释根因

这两段式的组合,构成了一个不靠臆测的优化循环。

用 Framedash 自动化测量循环

上面这套”收集 → 汇总 → 找异常”的流程,如果全靠手工来跑,运维成本会越堆越高。Framedash 正是负责把这个循环自动化的工具。

它提供一张性能热力图,把 FPS、帧时间、GPU 时间、内存占用按单元格叠加在游戏地图上。可以按设备或构建配置文件筛选,因此哪种硬件配置、在哪里比较重,一眼就能看清。

你可以给每一个遥测事件附上玩家坐标和相机朝向(偏航 yaw、俯仰 pitch)。相机朝向由 SDK 自动采集(可选择退出),所以无需额外实现,就能把本文所讲的”带情境上下文的测量”直接付诸实践。也可以附上任意的自定义指标或属性。各引擎的 SDK 集成步骤,可参阅 UnityUE5Godot (C#) 的文档。

此外,它还具备按 build_id 比较帧时间、GPU 时间和内存的版本回归分析,超过阈值的回归可以通过 Slack、邮件、Webhook 通知你。这套机制能让你在发布前就发现”这个版本的 P95 变差了”。

SDK 集成只需几行代码,最快 5 分钟 支持的引擎为 Unity、UE5、Godot (C#)。无需信用卡即可开始。
免费开始 UnityUE5Godot (C#)