A scene that ran smoothly in the editor starts hitching the moment it’s on a real device. Or a process that dragged in your development build flows effortlessly in the production build. Why do the numbers diverge depending on where you measure the same game?
The three tiers of a measurement environment
Performance measurement environments fall into three broad tiers: profiling inside the editor, profiling a Development build, and measuring on a build with Release / Shipping-grade optimization enabled. The earlier tiers hand you numbers with less effort, but those numbers sit further from the production build running on your players’ machines.
Measurement noise drops as you move right, and the numbers get closer to what players actually experience.
Measuring in the editor adds editor-specific costs. Editor-only systems such as hot reload and asset watchers keep running in the background, and on Unity you are running on Mono rather than IL2CPP. Memory goes through a debug allocator too, so layout, alignment, fragmentation patterns, and GC timing don’t match the production build.
Moving to a Development build removes the editor-specific costs, but the debug overhead stays. Assertion checks, verbose logging, and debug facilities like profiler connections remain enabled, and the compiler optimization still isn’t what production gets: inlining is held back, and LTO (link-time optimization) isn’t in effect. It’s not unusual to see a package built in the Development configuration, heavy logging and debug features still in place, with its numbers read as the product’s performance.
In other words, much of the “cost” you observe in the first two tiers is noise generated by the measurement environment itself.
You end up looking at costs that don’t exist on a player’s device and fighting bottlenecks that aren’t real.
How to create a measurement build
The third tier is the “measurement build”: one that runs under the same optimization conditions as production. The basic principle: enable Release / Shipping-equivalent optimization, then keep only the minimal logging and hooks that measurement requires. No cheats, no excessive diagnostics. The goal is a build that “runs at the same speed as the product, yet still lets you pull the numbers.”
Unreal Engine: use the Test configuration
Unreal Engine has DebugGame / Development / Test / Shipping build configurations. The one to aim for here is Test.
Development is meant for day-to-day iteration; optimization is applied to a degree, but plenty of debug features and checks remain. Shipping, at the other end, is the retail configuration — console commands and stat tools are stripped out, leaving few places to hook measurement in.
Test is designed to sit in between. It keeps Shipping-equivalent optimization while leaving diagnostic commands like stat unit and a minimal set of tools available. Because it balances the speed characteristics and the observability that performance measurement needs, it’s the most natural foundation for a measurement build.
That said, Test isn’t a one-click choice in the editor’s packaging menu the way Development and Shipping are. The reliable path is the Automation Tool (BuildCookRun) or a custom profile in the 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
Note that UE_LOG is compiled out by default even in Test (the enabling switch, bUseLoggingInShipping, affects both Test and Shipping). This setting, however, requires a unique build environment on Launcher (installed) engines and makes UBT abort, so it’s safer to keep your measurement hooks’ output independent of UE_LOG.
Also, Launcher engines don’t include a Test engine binary by default (the installed build’s GameConfigurations default to Shipping, Development, and DebugGame). If you can’t package in Test, use a source-built engine or a custom installed build compiled with Test included.
This setup works across UE5 (and UE4).
Unity: a measurement Build Profile (Unity 6)
Unity 6 introduced a mechanism called Build Profiles: a feature that lets you save per-platform build settings as named profiles and switch between them. Carve out one dedicated profile for measurement and configure it along these lines:
- Turn off the Development Build checkbox (so you don’t carry the overhead of automatic profiler connection and script debugging)
- Choose IL2CPP as the scripting backend, so you measure through the same native code path as the product
- Enable only the minimal measurement logging via Scripting Define Symbols, and turn off any other verbose logging
Concretely: from File > Build Profiles, create a measurement profile and uncheck Development Build. In Player Settings, set Scripting Backend to IL2CPP and C++ Compiler Configuration to Release. Add the MEASUREMENT define under the profile’s Build Data > Scripting Defines (profile defines are additive to the project and platform defines). Calls to the measurement code then survive compilation only when that define is active ([Conditional] strips the call sites; the method body itself stays in the assembly):
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}");
}
}
Make Release the measurement default; if the studio ships Master, measure on Master. The [Conditional] switch itself also works before Unity 6, through the BuildPipeline route described next.
Versions before Unity 6 don’t have Build Profiles. In that case, substitute a build script that assembles an equivalent release configuration (via BuildPipeline, without setting the Development flag, specifying IL2CPP and the minimal-logging symbols).
Godot: a release export plus custom feature tags
On Godot, the export preset plays this role. Export with “Export With Debug” turned off — that is, with the release export templates — and you get an optimized binary with the debug machinery, such as remote debugger and profiler connections, stripped out.
For measurement, duplicate a preset and give it a custom feature tag (for example measurement). The tag lives in the export preset’s config section:
# export_presets.cfg (measurement preset section)
custom_features="measurement"
Your code branches on it, so only the minimal measurement logging stays enabled in this build:
// Enable minimal measurement logging only in the measurement build.
if (OS.HasFeature("measurement"))
{
GD.Print($"[measure] frame={Engine.GetFramesDrawn()} fps={Engine.GetFramesPerSecond()}");
}
Custom feature tags only resolve in exported builds: OS.HasFeature("measurement") returns false when you run from the editor, so the branch looks dead until you export.
If you want to trim the binary further, you can swap in custom export templates compiled with production=yes. The custom template build is scons platform=windows target=template_release production=yes module_mono_enabled=yes, where production=yes is an alias for use_static_cpp=yes debug_symbols=no lto=auto (Godot 4.x); module_mono_enabled=yes is required for templates used by C# projects. A C# template isn’t complete from this one line alone: you also need to generate the mono glue and build the GodotSharp managed assemblies (see the official documentation for the steps).
What to measure: frame time, memory, load times, storage I/O
Once the measurement build is ready, collect numbers across real play sessions. The metrics to watch are:
And the most important thing is to always attach situational context to these numbers. Specifically: the player’s coordinates at that moment, the camera orientation, the map / scene on screen, the build ID, and the device / platform.
A record that just says “frame time spiked,” with no context, is impossible to chase down afterward. Conversely, if it’s recorded at the granularity of “P95 spikes in desert_ruins on build 1042, at coordinates (X, Y), with the camera facing north,” that anomaly becomes a reproducible investigation target.
Context is exactly what turns a bare number into an anomaly you can start investigating.
Headroom is a budget
The goal of measurement isn’t only to fix slow spots. If there’s still headroom left on the CPU, GPU, memory, or I/O on your target hardware, that headroom is budget you can choose to spend.
For example, if the render thread has room to spare, you can deliberately spend that budget on richer VFX, more on-screen objects, or longer streaming distances. Optimization is work that cuts things down, but it’s equally work that tells you how ambitious your presentation can get.
If you have an accurate read on your headroom from the measurement build, you can make these calls with numbers instead of gut feeling. Is there room, or are you already at the limit? Only once you know that can you deliberately design the density of your gameplay.
From anomaly to cause
The measurement build’s job is to pin down where and when a problem occurs. It won’t tell you the cause itself.
Say the build surfaces an anomaly like “frame spikes at a specific coordinate” or “memory steps up after a certain load and never comes back down.” The next step is to reproduce exactly that situation in a Development build and dig in with a detailed profiler.
- On Unity, the Unity Profiler and the Memory Profiler
- On Unreal Engine, Unreal Insights
- On Godot, the built-in profiler and monitors (script timing covers GDScript only, so pair a .NET profiler when digging into C# code)
This is where the “why” comes into view. Which system is eating that frame, which asset is holding onto memory and never letting go. The division of labor is simple.
This two-stage approach is what turns optimization into a loop that doesn’t rely on guesswork.
Automate the measurement loop with Framedash
Running the “collect -> aggregate -> find anomalies” flow above by hand racks up operational cost. Framedash automates this loop.
It provides a performance heatmap that overlays FPS, frame time, GPU time, and memory usage cell by cell on your game map. You can filter by device or build profile, so you can see at a glance which hardware configuration is heavy and where.
You can attach player coordinates and camera orientation (yaw and pitch) to each telemetry event. The SDK collects camera orientation automatically (opt-out available), so with no extra implementation you can put the “measurement with situational context” described in this article straight into practice. You can also attach any custom metrics or attributes. The SDK integration steps for each engine are documented for Unity, UE5, and Godot (C#).
On top of that, it offers build regression analysis that compares frame time, GPU time, and memory per build_id, and it can notify you of regressions that cross a threshold via Slack, email, or webhook. It’s a way to catch “P95 got worse in this build” before release.