references/profiling-and-budgets.md
# Profiling & budgets — depth for `performance-optimization`
Detail the body defers here: per-engine profiler walkthroughs, the CPU-vs-GPU triage flow, a
pooling manager, batching/instancing rules, allocation/GC guidance, LOD/culling, and asset
budgets. Targets **Godot 4.7**, **Unity 6.3 LTS**, **Unreal 5.8**.
## 1. CPU-vs-GPU triage (decide before you fix)
```text
1. Read total frame time vs your budget (16.67 ms @60).
2. Compare CPU-frame time and GPU-frame time:
GPU >> CPU → GPU-bound → draw calls, overdraw, shader cost, resolution, lights/shadows.
CPU >> GPU → CPU-bound → scripts, physics, pathfinding, allocations/GC, too many nodes.
Both high / alternating → find the per-frame spike in the timeline (one function/system).
3. Within the bound side, sort costs descending and attack the top one only.
4. Re-measure. If it didn't move the frame time, you fixed the wrong thing — revert and re-triage.
```
A GPU-bound game won't speed up from faster C#; a CPU-bound game won't speed up from fewer draw
calls. This split is the single most important decision in performance work.
## 2. Per-engine profiler quick start
**Godot 4.7**
- Editor: **Debugger ▸ Profiler** (per-function script + physics time, frame time), **Debugger ▸
Visual Profiler** (CPU and GPU time spent on rendering — the Profiler tab does not show GPU cost),
and the **Monitors** tab (FPS, draw calls, video/static memory, object/node counts).
- Code: `Performance.get_monitor(Performance.TIME_PROCESS)` (frame process time in **seconds**;
multiply by 1000 for ms), `Performance.TIME_PHYSICS_PROCESS`,
`Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME`, `Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME`,
`Performance.MEMORY_STATIC`.
- Visual debugging: 3D viewport **View Information / View Frame Time** overlays.
- Native-code traces: an engine built from source with `scons profiler=tracy` (or `perfetto`,
`instruments`) streams engine zones to that profiler.
**Unity 6.3 LTS**
- **Profiler** window: CPU Usage, GPU Usage, Rendering, Memory modules. Use **Deep Profile**
sparingly (high overhead, skews numbers).
- **Frame Debugger** to step draw calls and see what breaks batching (SetPass calls, batches).
- Code: `ProfilerRecorder` on the built-in counters `"CPU Main Thread Frame Time"`,
`"GPU Frame Time"` (release players need **Frame Timing Stats** enabled) and
`"GC Allocated In Frame"` (development players only) for an in-build HUD/CSV;
`FrameTimingManager` for CPU/GPU frame times.
- **Profile a Development build on device** (`Autoconnect Profiler`), not just the editor.
- **Project Auditor** (`com.unity.project-auditor` package) statically flags costly code and
settings before you profile.
**Unreal 5.8**
- Console: `stat unit` (Frame / Game / Draw / RHIT / GPU ms — Game is the game thread, Draw the
render thread, both CPU), `stat fps`, `stat scenerendering` (draw calls, primitives),
`stat game`, `stat gpu`, `stat hitches` (logs frames over `t.HitchFrameTimeThreshold`).
- **Unreal Insights** for full timeline traces; `ProfileGPU` (Ctrl+Shift+,) for a GPU breakdown.
**External tools (engine-agnostic)**
- **RenderDoc** (1.46 at last check): GPU frame capture for Vulkan, D3D11/12 and OpenGL/ES on
Windows, Linux and Android; no Metal. On Apple platforms use the Xcode Metal debugger and
Instruments; on Windows D3D12, PIX.
- **Tracy** (0.14.1 at last check): instrumented CPU/GPU timeline for native code; keep the
client library and the viewer on the same release, because the network protocol can change.
## 3. Pooling manager (generic)
```text
class Pool<T>:
free: list
create_fn, reset_fn
prewarm(n): for n → free.push(create_fn()) # allocate up front, off the hot path
acquire(): t = free.pop() or create_fn(); activate(t); return t
release(t): reset_fn(t); deactivate(t); free.push(t) # never destroy; recycle
```
Pool anything spawned frequently and briefly: bullets, shells, particles, damage numbers,
enemies in waves, audio one-shots. Pre-warm at load to avoid first-use hitches. Cap the pool and
decide an overflow policy (grow, or recycle the oldest).
## 4. Batching & instancing rules
- **What breaks a batch:** a different material, texture, or render state between objects. Share
materials and **atlas** textures so runs of objects submit as one draw call.
- **Identical meshes, many instances** → GPU instancing: Unity (enable *GPU Instancing* on the
material — but under URP/HDRP an SRP Batcher-compatible shader is drawn by the SRP Batcher
instead; for large counts enable the **GPU Resident Drawer**, which needs the Forward+ path and
a compute-capable graphics API other than OpenGL ES) / Godot `MultiMesh` +
`MultiMeshInstance2D/3D` / Unreal Instanced Static Mesh or Hierarchical ISM.
- **Static geometry** → static batching (Unity), mark static; bake where possible. Unity's
dynamic batching is no longer recommended outside low-end devices: its CPU cost can exceed the
draw calls it saves.
- **2D** → texture atlases + a shared material batch sprites; avoid per-sprite materials.
- **UI** → minimize canvas rebuilds (Unity: split static/dynamic canvases); a changing element
shouldn't dirty the whole canvas.
- **Lights/shadows** → bake static lighting; cap real-time shadow casters; cull small shadows.
## 5. Allocation / GC guidance
- **C# (Unity):** no per-frame `new`, no LINQ in `Update`, avoid boxing (e.g. `enum` as dictionary
key), use `NonAlloc` physics queries, reuse `List`/arrays (`Clear()` not realloc), prefer
structs for small hot data, cache `GetComponent`/`Find` results. The goal is **0 B GC.Alloc per
frame** in steady state.
- **GDScript (Godot):** don't build new `Array`/`Dictionary` each `_process`; reuse; prefer typed
arrays; avoid heavy work in `_process` that belongs on a timer/signal.
- **General:** strings are a classic hidden allocator (concatenation, formatting) — build them
rarely, cache results.
## 6. Do-less techniques (algorithmic wins)
- **Run less often:** update AI/HUD/expensive checks on a timer or every N frames, not every
frame; stagger across frames (time-slicing).
- **Spatial partition:** grid/quadtree/octree so queries touch nearby objects only, not all N.
- **LOD & culling:** lower detail at distance; frustum/occlusion culling; despawn off-screen
far entities.
- **Cache results:** memoize pathfinding, line-of-sight, and derived data; invalidate on change.
- **Defer/Amortize:** spread procedural generation and loading across frames to avoid spikes.
## 7. Asset budgets (prevent regressions at the source)
| Asset | Typical desktop budget | Mobile budget | Notes |
|-------|------------------------|---------------|-------|
| Texture max size | 2048–4096 | 1024–2048 | use mipmaps; compress (BCn / ASTC) |
| Character triangles | 30k–80k | 5k–20k | LODs for distance |
| Draw calls / frame | low thousands | a few hundred | the count that matters most on mobile |
| Real-time lights | a few | 1–2 + baked | bake the rest |
| Audio | streamed music, short SFX in memory | same | don't decompress everything at load |
Mobile adds **thermal throttling**: a game that hits 60 FPS for 2 minutes then drops is
overheating — target headroom, cap frame rate, and reduce sustained GPU load.
SKILL.md
---
name: performance-optimization
description: >
Find and fix game performance problems methodically — measure with the engine profiler first,
reason about the frame-time budget, locate the CPU-vs-GPU bottleneck, then apply the right fix:
object pooling, draw-call batching, fewer allocations/GC spikes, and asset budgets. Engine-
neutral method that pairs with each engine's profiler. Use when the user mentions performance,
optimize, low/dropping FPS, frame drops, stutter, lag, profiler, frame budget, draw calls,
batching, garbage collection/GC spikes, object pooling, or "the game runs slow".
---
# Performance optimization
Performance work is a measurement discipline, not a bag of tricks. The method is always the
same: **profile → find the one bottleneck → fix that → measure again**. This skill teaches that
loop and the highest-leverage fixes (pooling, batching, allocation control, asset budgets), and
points you at each engine's profiler. It pairs with `physics-tuning` for simulation cost.
## When to use
- Use when the frame rate is low or uneven, the game stutters/hitches, or it must hit a target
(60 FPS desktop, 30/60 mobile) and currently doesn't.
- Use to decide *what* to optimize: profile, read the frame budget, and identify whether the CPU
or GPU is the bottleneck before changing any code.
- Use to apply specific fixes: object pooling, draw-call/batch reduction, removing per-frame
allocations and GC spikes, and setting asset budgets.
**When *not* to use:** for physics jitter/tunneling/timestep specifically, use `physics-tuning`.
For the engine's concrete profiler UI and rendering settings, use that
engine skill (`godot-export` covers some build settings; engine cores cover the rest). This skill
is the cross-engine method and the shared fixes.
## The golden rule: measure first, never guess
Most performance "fixes" applied without profiling target the wrong thing and add complexity for
no gain. **Do not optimize code you have not measured.** Open the profiler, find the single
biggest cost in a representative scene on representative hardware, and fix that. Re-measure to
confirm the fix helped before moving on. Profile a **release/optimized build** where it matters —
editor and debug builds lie (editor overhead, no compiler optimization).
## Core workflow
1. **Define the target and reproduce.** State the goal (e.g. 60 FPS = 16.67 ms/frame) and find a
repeatable worst-case scene. "Sometimes slow" is unfixable; a reproducible spike is fixable.
2. **Profile before touching code.** Run the engine profiler and read the frame: total frame
time, and the split between CPU (game logic, physics, scripts) and GPU (rendering).
3. **Find the bottleneck — CPU or GPU.** If GPU time ≫ CPU, attack draw calls/overdraw/shaders/
resolution. If CPU time dominates, attack scripts/physics/allocations. Fixing the wrong side
does nothing.
4. **Fix the single biggest cost.** Prefer an **algorithmic** win (do less work, cache, spatial
partition, run less often) over micro-optimizing a hot line. Apply the matching shared fix
(pooling, batching, allocation removal).
5. **Re-measure on the same scene/hardware.** Confirm the number moved. Keep or revert based on
data, not intuition.
6. **Set budgets so it stays fixed.** Per-frame ms budgets per subsystem, plus asset budgets
(texture sizes, triangle counts, draw-call ceilings); add a perf check to verification.
7. **Report measured numbers.** State before/after frame time, the bottleneck found, and the fix
— never "should be faster". If you could only measure in-editor, say so.
## Patterns
### 1. Frame budget math (turn "feels slow" into a number)
```text
target FPS → frame budget: 60 FPS = 16.67 ms | 30 FPS = 33.3 ms | 120 FPS = 8.33 ms
The WHOLE frame (CPU sim + render submit + GPU) must fit the budget; the GPU runs in parallel,
so the slower of CPU-frame and GPU-frame sets your FPS. Allocate sub-budgets, e.g. @60 FPS:
gameplay/scripts ~5 ms · physics ~3 ms · rendering(CPU submit) ~4 ms · UI/other ~2 ms · slack.
If one subsystem blows its slice, that's your target — not whatever you assumed.
```
### 2. Measure with the engine profiler (do this before any fix)
```text
Godot 4.7 : Debugger ▸ Profiler (script/physics time), Visual Profiler (CPU vs GPU render time
per pass) and Monitors tab (FPS, draw calls, memory).
In code: Performance.get_monitor(Performance.TIME_PROCESS) returns SECONDS
(× 1000 for ms); Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME for draw calls.
Unity 6.3 LTS : Profiler window (CPU/GPU/Memory/Rendering modules) + Frame Debugger for draw calls.
In code: ProfilerRecorder on "CPU Main Thread Frame Time" / "GPU Frame Time"
(release players need Frame Timing Stats enabled in Player Settings).
Unreal 5.8 : `stat unit` (Frame / Game / Draw / RHIT / GPU ms), `stat fps`,
`stat scenerendering` (draw calls); Unreal Insights for deep traces.
# Read the split: Game = gameplay thread, Draw = render thread (both CPU), GPU = the graphics card.
# Whichever is closest to Frame is the bottleneck; that decides the fix.
```
### 3. Object pooling (stop allocating/freeing in hot loops)
```gdscript
# Bullets, particles, enemies, damage numbers: reuse a fixed set instead of instantiate()/free()
# every frame — that thrashes memory and (in C#) feeds the GC.
var _pool: Array[Node2D] = [] # Node3D for 3D; both have `visible`
func acquire() -> Node2D:
var n: Node2D = _pool.pop_back() if not _pool.is_empty() else bullet_scene.instantiate()
n.set_process(true); n.visible = true
return n
func release(n: Node2D) -> void:
n.set_process(false); n.visible = false # disable + hide; DON'T free
_pool.append(n) # back to the pool for reuse
# RIGHT: pre-warm the pool at load; reuse. WRONG: instantiate()/queue_free() per shot.
```
### 4. Cut draw calls (the most common GPU-side win)
```text
Draw calls and render-state changes (material/shader/texture/blend switches) cost CPU submit time;
thousands of them make the render thread the bottleneck. 2D batchers merge runs of sprites that
share state (most bind several textures per batch), so ordering and atlasing decide the count.
- Atlas textures and share materials so sprites/meshes batch into one call.
- Identical meshes → GPU instancing (Unity; under URP/HDRP the SRP Batcher or GPU Resident Drawer
takes precedence), MultiMesh / MultiMeshInstance (Godot), Instanced Static Mesh (Unreal).
- Static geometry → static batching / baking; mark non-moving objects static.
- Reduce overdraw: limit large overlapping transparent/particle layers (they re-shade pixels).
- If fill rate dominates, lower render resolution: 2× pixel ratio means 4× the pixels of 1×.
For three.js game defaults and warm-up-aware resolution tuning, read `threejs-scene-setup`.
- Fewer real-time lights/shadows; bake lighting where it doesn't move.
Measure draw calls before and after — the count should drop, and so should GPU frame time.
```
### 5. Kill per-frame allocations (GC spikes = stutter)
```csharp
// Unity 6.3 LTS (C#). Allocating every frame fills the managed heap; the GC then stalls a frame.
// WRONG (new array + scene walk every call):
// foreach (var e in FindObjectsByType<Enemy>(FindObjectsSortMode.None)) ... // + LINQ, new[]
// (FindObjectsOfType is obsolete in Unity 6.3; its replacement still allocates.)
// RIGHT: cache references once, reuse buffers, avoid LINQ/boxing in Update.
void Update() {
_hits = Physics.RaycastNonAlloc(ray, _hitBuffer); // reuse a preallocated array
for (int i = 0; i < _hits; i++) { /* ... */ } // no per-frame allocation
} // hit order is undefined and a full buffer may miss the nearest hit: size it, sort if needed
// Godot/GDScript: avoid building new arrays/dictionaries every frame in _process; reuse them.
```
## Pitfalls
- **Optimizing without profiling.** The intuitive culprit is usually wrong. Measure first, every
time.
- **Profiling the editor / a debug build.** Editor overhead and unoptimized code mislead. Profile
a release build on target hardware for real numbers.
- **Fixing the wrong side.** Micro-optimizing CPU code when the GPU is the bottleneck (or vice
versa) changes nothing. Check the CPU-vs-GPU split first.
- **Micro-optimizing over algorithm.** Shaving a function when an O(n²) loop or a per-frame
full-scene query is the real cost. Reduce the work, don't polish it.
- **Instantiate/free in hot loops.** Spawning and destroying bullets/particles every frame causes
fragmentation and GC spikes. Pool them.
- **Per-frame allocations / LINQ / boxing in `Update`** (C#) feed the GC → periodic hitches.
Cache and reuse.
- **Draw-call explosion** from unique materials and unbatched sprites/meshes. Atlas, share
materials, instance, batch.
- **Unity "Enable GPU Instancing" with no effect** under URP/HDRP: the SRP Batcher takes
precedence for compatible shaders. Check the Frame Debugger; for many identical meshes use the
GPU Resident Drawer (Forward+, compute-capable APIs, not OpenGL ES).
- **Overdraw** from stacked transparents/particles/full-screen effects re-shading pixels.
- **No budgets.** Without per-subsystem ms and asset ceilings, performance silently regresses;
enforce them in your build/CI checks.
- **Optimizing too early.** Don't contort a prototype for performance before it's fun or measured.
## References
- For per-engine profiler walkthroughs, the CPU-vs-GPU triage flowchart, a complete pooling
manager, batching/instancing rules per engine, allocation/GC guidance, LOD/culling, and asset
budgets (texture sizes, triangle counts, audio, mobile thermals), read
`references/profiling-and-budgets.md`.
## Related skills
- `physics-tuning` — simulation cost, fixed-step budget, sleeping bodies, broadphase layers.
- `godot-export` — release/build settings that affect measured performance.
- `procedural-gen`, `game-ai` — common CPU hotspots (generation, pathfinding) to budget and defer.
- `roguelike`, `tower-defense`, `survival-crafting` — entity-heavy genres that need pooling/budgets.