# Render budget `apps/arcade/BUDGETS.md` is the authority. This is the working summary and the method. ## What the numbers say Measured on an M-class Mac, release. The Quest column in BUDGETS.md is a conservative **estimate** until run on device — do not quote it as measured. | thing | cost | | --- | --- | | 100 movers + 50 rigid bodies + 65×65 terrain | **0.038 ms/tick** | | 60 Hz frame budget | 16.6 ms | | script per tick | ≤ 2 ms, one cumulative 500k-instruction pool | | entity lookup | O(log n) | **Simulation is ~0.2% of the frame. It is never your performance problem.** Draw calls and CPU skinning are. ## Bandwidth | stream | before | after | | --- | --- | --- | | cube instance | 44 floats / 176 B | **32 floats / 128 B** (−27%) | | skinned character vertex | 16 floats / 64 B | **6 floats / 24 B** (−62%) | | shadow mesh vertex | 16 floats / 64 B | **6 floats / 24 B** (−62%) | | terrain vertex | 16 floats / 64 B | unchanged (uploaded once per revision) | The skinned vertex matters most because it re-uploads **every frame**: the Knight at 3716 verts went from 238 KB/frame to 89 KB/frame. On a bandwidth-bound tiler that is the largest single saving available. The instance saving came from moving `sun_color`, `sun_sky`, `sun_ground`, `fog_color` off the instance stream into uniforms — 12 floats of pure per-cube duplication. **Quest is bandwidth-bound before it is ALU-bound.** Optimise bytes per frame first. ## Measuring Never estimate. Read it: ```rust renderer.report_frame_ms(ms); // -> bool: did adaptive quality change? renderer.frame_p90_ms(); // Option - report p90, not mean renderer.quality_level(); renderer.quality_reason(); stats.instance_floats; // RenderStats: from the compiled shader renderer.model_triangles(id); ``` Report **p90, not average**. A 16 ms average with a 40 ms p90 stutters, and the average hides it. Headless evidence: ```bash cargo run -p makepad-arcade --example bigworld_probe --release ``` Always measure in `--release`. A debug-build frame time tells you nothing about shipping. ## Cutting, in order 1. **Instance/vertex bytes** — packed layout everywhere, batch-constant values as uniforms. 2. **Draw calls** — batch by material and mesh. Many small draws beat nothing. 3. **Skinned character count** — the most expensive category per unit. Cap it; degrade distant characters. 4. **Shadow casters** — `set_shadow_budget(n)`; bake AO for the rest. 5. **Terrain resolution** — uploaded once per revision, so this is a memory and vertex-count question more than a per-frame one. 6. **Particle count** — cheap individually, unbounded collectively. Cap the pool. 7. **Fragment work** — last, and only with a measurement showing you are ALU-bound. ## Adaptive quality `GameRenderer` owns it: `report_frame_ms` each frame, `set_refresh_hz` for the device (72 Hz on Quest, not 60), `set_shadow_budget`, `Stage` / `StageMode`, and `quality_reason()` to explain a drop. **Do not build a second frame pacer.** Two controllers oscillate against each other and produce worse frame times than either alone. If quality is dropping unexpectedly, read `quality_reason()` — that is what it is for. ## Budget template Fill this in for the target device before optimising, so you know when to stop: ``` target device Quest 2, 72 Hz -> 13.9 ms sim 0.1 ms (measured) skinned chars 8 x ? ms shadow casters 24 x ? ms opaque draws ? calls ? ms particles ? max ? ms HUD ? ms headroom >= 20% ``` Twenty percent headroom is not padding: it is what absorbs the worst wave, the busiest camera angle, and thermal throttling on a mobile part after ten minutes of play.