~5 min read

Building and Optimization

Build Settings, Profiler, the frame budget — from a finished prototype to a build.

The good news: making a build is usually one or two buttons. The bad news: optimization is endless work, in which the big wins come from understanding exactly where time is being spent.

Build Settings

File → Build Profiles (in Unity 6 — the new name; previously Build Settings):

  1. Platform — Windows / macOS / Linux / Android / iOS / WebGL / Console.
  2. Scenes in Build — a list of scenes (by index). SceneManager.LoadScene(0) — the first one.
  3. Build or Build And Run — builds to the specified folder.

Each platform has its own Player Settings (Edit → Project Settings → Player):

  • Product name, icon, splash screen.
  • Scripting Backend: Mono vs IL2CPP (see the chapter on the stack).
  • API Compatibility Level — .NET Standard 2.1 (recommended).
  • Managed Stripping Level — how aggressively the linker discards “unused” code.
Linker and reflection

Managed Stripping = High can discard code that is only used through reflection or Unity Events. If something “disappears” after the build, try Low/Disabled. The long-term solution is a link.xml or the [Preserve] attribute on the necessary types.

The frame budget

For 60 FPS you have 16.6 ms per frame. Of that:

  • 2–4 ms usually go to scene rendering and post-processing (GPU).
  • 2–6 ms — physics + animation + your scripts (CPU).
  • The remainder — buffer, GC, VSync.

At 30 FPS — 33.3 ms. For VR at 90 FPS — 11.1 ms, with no room for drops.

Profiler

Window → Analysis → Profiler. Hit Play, stop on an interesting frame. Look at:

  • CPU Usage — your code’s stack: what ate the time.
  • GPU Usage — which render pass took how long.
  • Rendering — the number of draw calls, triangles, batches.
  • Memory — total memory, allocations.

The main enemy is GC Alloc in Update. Every allocation (new string, new List<>, Vector3.ToString()) will sooner or later trigger a garbage collector pause. The goal is 0 bytes of allocations in steady-state gameplay.

// Bad: every frame it builds a new string and a LINQ chain
private void Update() {
    var alive = enemies.Where(e => e.IsAlive).ToList();
    label.text = "Enemies: " + alive.Count;
}

// Good: the counter is maintained by events, the string via a struct format
private int _aliveCount;
private static readonly System.Text.StringBuilder _sb = new();

private void Update() {
    _sb.Clear();
    _sb.Append("Enemies: ").Append(_aliveCount);
    label.SetText(_sb); // TMP accepts a StringBuilder without allocations
}

Frame Debugger

Window → Analysis → Frame Debugger. It stops rendering at any draw call and shows: which mesh, material, textures, render target. The best way to understand why there are 800 draw calls in a simple scene.

Typical findings:

  • Identical materials that don’t batch (instance copies of materials).
  • UI canvases that rebuild every frame.
  • Dozens of small lights with shadow passes.
  • Post-processing that’s only disabled in one of the scenes.

Profiler workflow — CPU, GPU, Rendering, Memory

The profiler isn’t “turn it on and watch.” An effective workflow:

  1. Prepare a target scene — make it representative of real gameplay (a battle, moving through the world). Warm it up for 10 seconds so the GC stabilizes.
  2. Open the Profiler in Standalone mode (Profiler → Edit → Profile Standalone against a dev build on the target device). Editor profiling hides many real problems.
  3. Enable Deep Profile only if the general view shows a hot path in “unknown” — Deep gives detail at the cost of ×3–10 overhead.
  4. Study CPU Usage first — there you can see frames longer than 16.6 ms. Click → you see the callstack.
  5. If the CPU is clean — Rendering & GPU Usage — frames can “stall” on vsync waiting for the GPU.

Profiler Markers — your own labels

using Unity.Profiling;

public class EnemyManager : MonoBehaviour
{
    static readonly ProfilerMarker s_UpdateAllMarker = new("EnemyManager.UpdateAll");

    private void Update() {
        using (s_UpdateAllMarker.Auto()) {
            // your hot-path code
        }
    }
}

The marker appears in the Profiler as a named section. Indispensable for understanding “what ate the time” inside your own code.

Memory Profiler — a deep memory audit

A separate package (Memory Profiler). It takes memory snapshots and compares them. It helps find:

  • Texture leaks (for example, a material cloned via renderer.material and never freed).
  • Loaded scenes that were never unloaded from memory.
  • Asset bundles that are left hanging around.

Build size

The main sources of weight:

  1. Textures — the biggest. Compress (ASTC for Android, BC for PC). Don’t put 4K textures on an object that occupies 50 pixels on screen.
  2. Audio — long wav tracks. Streaming + Vorbis instead of raw PCM.
  3. Meshes — models with a million vertices. Decimate in Blender, or use LOD groups.
  4. Scripts and dependencies — usually small, but a poorly compiled .NET library can surprise you.

After the build, Unity writes a Build Report to Editor.log (~/Library/Logs/Unity/Editor.log on macOS, %LOCALAPPDATA%\Unity\Editor\Editor.log on Windows) with a breakdown of “what weighs how much.” It’s more convenient to view through the third-party Build Report Inspector tool or through the build files themselves (.unity3d, asset bundles) — but Editor.log gives a minimally workable overview.

LOD (Level Of Detail)

A LOD Group is a component in which a single object has several levels of detail (LOD0 — high, LOD1 — middle, LOD2 — low, Culled — invisible). The camera picks a level based on the object’s on-screen size. Free FPS, especially in large open scenes.

Occlusion and culling

  • Frustum Culling — objects outside the field of view aren’t rendered. Always enabled.
  • Occlusion Culling — objects hidden behind others (walls) aren’t rendered. It needs to be baked (Window → Rendering → Occlusion Culling), and requires the Occluder Static / Occludee Static flags.
  • Layer Culling Distance — on the Camera you can specify that objects on a certain layer are rendered only up to X meters (grass blades disappear at 30 m, trees at 200 m).

WebGL / WebGPU

If the target is the browser:

  • IL2CPP only, AOT only.
  • No threads by default (there’s experimental Threads with COOP/COEP headers on the hosting).
  • A large initial download (tens of megabytes). Use Brotli compression.
  • Audio with limitations (AudioContext autoplay policies).
  • WebGPU backend — publicly available since Unity 6.1 (March 2025). As of May 2026 it’s supported by all major browsers (Chrome, Firefox since July 2025, Safari 26 since September 2025). It provides compute shaders, better performance, and supports Forward+. Important: in the Unity Manual 6.x, WebGPU is still marked as experimental, not for production. For prototypes and dev builds it’s an excellent choice; for shipping, weigh the risks and keep a WebGL2 fallback.

Release checklist

  • The profiler shows a stable 60 FPS on the target device in a representative scene.
  • GC Alloc in Update ≈ 0 in normal gameplay.
  • Draw calls < a reasonable limit for the platform (mobile — < 200, PC — < 1000 for an average scene).
  • Textures are compressed, not raw PNG/TGA on import.
  • LOD on heavy models.
  • Occlusion culling baked for interiors.
  • Player Settings: game version, icon, splash, certificates (for signing).
  • The build is tested on the target device, not just on the dev machine.
  • Logging is disabled in release (via #if DEBUG or Log Strip Level).

In the next (final) section — a short glossary: Unity terms and their web analogs.