Performance¶
This page explains how to keep a Skywalker game inside its frame budget: how to measure where time goes, which settings trade quality for speed, what the CPU side costs, and how startup and memory behave. Every number quoted here was measured on an Apple M1 Pro under the stated conditions; measure your own scenes with the same tools.

perf_stats benchmarks of this scene.Concepts¶
The budget mindset¶
A frame has a fixed budget: 16.6 ms at 60 fps, 8.3 ms at 120 fps. The budget is shared by the CPU (simulation, building the frame, encoding GPU work) and the GPU (rendering), which run in parallel; the slower of the two sets the frame rate.
| Part | What runs | Where it shows |
|---|---|---|
| Simulation | Fixed 1/60 s ticks: Wander behaviors, physics, animation, particles, native systems | CPU scope sim.step |
| Frame build | Gathering visible entities, terrain and foliage chunks, particles, 2D | CPU scopes frame.build, scene.buildFrame, world.gather, particles.gather, 2d.gather |
| Encode and present | Recording GPU commands; waiting for readback; presenting | render.encode, render.readback, render.present |
| GPU | Shadows, the main pass, screen-space effects, volumetrics, post, upscaling | profile.passes and profile.groups |
The simulation always ticks at 60 Hz. On a faster display, frames between ticks show interpolated transforms, so rendering more frames per second costs GPU time but no extra simulation.
Budget by area, not by feature: decide what a scene may spend on shadows, foliage, hair, particles and post, measure each, and keep the sum under the frame time with headroom for the slowest view.
Measuring¶
perf_stats¶
perf_stats is the main measuring tool. Without arguments it reports the current scene's complexity and last frame
cost: GPU and CPU frame time, triangles drawn, draw calls, lights, terrain nodes, foliage instances, entities, behaviors
and assets. With frames it first benchmarks real-time rendering of a view (temporal anti-aliasing, no supersampling)
and returns average, minimum and maximum GPU milliseconds.
| Argument | Meaning |
|---|---|
frames |
Benchmark this many real-time frames first (0 = just report). With frames > 0 the profile covers exactly the benchmark frames. |
width, height |
Benchmark resolution (default 1920 × 1080). |
view |
"editor" (default), "scene" (the game camera), or a custom camera {eye, target, fov}. |
quality |
The viewport tier to benchmark: full (default, as in play mode and captures), balanced, fast. |
passes |
Add the per-pass GPU timeline and the CPU scopes. |
With passes: true the result has a profile:
| Field | Contents |
|---|---|
passes |
GPU time per render pass in encode order (Main, Shadow cascades, SSGI, SSR, Lighting resolve, TAA, Bloom, Composite, Foliage cull, GPU particles, Hair, ...), with rolling 60-frame average, minimum and maximum |
groups |
The same summed by area: shadows, main, ao, ssgi, ssr, resolve, effects, volumetrics, clouds, temporal, upscale, post, foliage, particles, hair, skinning, environment, 2d, ui, overlays, debug |
cpu |
CPU scopes: frame.build, scene.buildFrame, world.gather, particles.gather, 2d.gather, render.encode, render.readback, render.present, sim.step |
spanMs, sumMs, frameGpuMs |
First-to-last sample span, the sum of the groups, and the command-buffer time; sumMs is close to frameGpuMs |
The gpu section also carries foliage statistics (meshInstances, impostorInstances, foliage triangles with and
without shadows, per-model triangle counts with their impostor and cull distances, impostor bake and memory figures),
light counts in gpu.lights, and perf_stats.frameFlow reports render-interpolation alpha and frame-pacing jitter. The
Profiler page explains how the GPU timeline is sampled and how to read overlapping passes.
The viewport stats overlay shows fps and CPU and GPU milliseconds. Click its frame line to expand the same per-pass
list perf_stats returns.
Specialized measurements¶
| Tool | Measures |
|---|---|
fx_benchmark |
Renders frames back to back with the effects clock advancing (GPU particles and hair simulate) and reports GPU time per frame and per effects pass, plus wall-clock time. serial: true (default) waits per frame for exact per-pass times; false overlaps frames like a game loop. |
fx_stats |
The measured GPU cost of the last rendered frames: the whole frame, the GPU simulation pass (particles and hair), per-emitter live particle counts and per-groom sizes. |
groom_info |
Strand, guide and point counts, CPU and GPU memory, generation time and the measured GPU cost of each groom. |
engine_info |
Startup: how the shader library was loaded, shaderCompileMs, renderer startup and the pipeline cache's hits and misses. |
wander_bench |
A standalone benchmark of Wander workloads in the VM and, with --native, as AOT code (tools/wander_bench in the repository). |
viewport_capture with debug_view |
Visual diagnostics: overdraw, lod, light_complexity, texel_density, uv_checker, wireframe, impostors. |
fx_benchmark {"width": 1920, "height": 1080, "frames": 120}
fx_stats {}
groom_info {}
engine_info {}



Three diagnostic views of the look-development courtyard, captured with viewport_capture and debug_view. See
Debug views for every legend.
The levers¶
| Lever | Setting | Effect |
|---|---|---|
| Internal resolution | environment.renderScale 0.33–1, or renderScale in game.json |
0.5–0.77 renders fewer pixels and MetalFX temporal upscaling reconstructs full resolution. About 25% faster at 0.67 on an M1 Pro. Textures get a mip bias so they stay sharp. |
| Shipping quality preset | quality in game.json |
low = 0.67 scale, no GI or screen-space reflections, half AO, flat clouds; medium = 0.85 scale, half-strength GI (Shipping). |
| Editor tier | viewport_quality |
fast (the default while editing): lower internal resolution, no screen-space GI, reflections or light shafts, near-only foliage shadows, coarser LODs. balanced in between. full is what the game and captures show; play mode always renders full. |
| Screen-space GI and reflections | environment.gi, environment.giDistance, environment.ssr |
Lower values reduce the ssgi and ssr groups. |
| Clouds | environment.cloudMode flat instead of volumetric |
Removes the ray-marched cloud pass. |
| Volumetric light | environment.godRays (0 = off) |
Removes or reduces the volumetrics group. |
| Sun shadows | environment.shadowDistance |
A shorter range concentrates the four cascades. |
| Lights | light.range, light.distanceFade with fadeBegin and fadeLength |
Shorter ranges mean fewer lights per pixel in clustered shading; faded-out lights are not sent to the GPU at all. |
| Mesh LODs | Automatic for meshes of 3,000+ triangles | The level is chosen by on-screen error under one pixel (foliage allows about 3 px). |
| Foliage impostors | impostors, impostorDistance, impostorResolution, impostorFrames per foliage layer |
Heavy models (300+ triangles) become octahedral impostors beyond a transition distance set from on-screen size. The balanced and fast tiers move it to 0.75× and 0.5×. |
| Foliage triangle budget | Automatic | All foliage LODs coarsen if a frame would exceed 120M camera triangles (30M in safe mode after a GPU fault). |
| Hair | groom.strands, groom.segments, groom.lod (auto, strands, cards), groom.cardsBelow (pixels) |
Fewer strands and segments, or cards when the groom is small on screen. groom_info suggests keeping a hero head under about 2 ms at 1080p. |
| GPU particles | particles.maxParticles, look |
Lit, sorted smoke costs far more than additive glow at the same count (fill rate). |
environment_update {"renderScale": 0.67}
environment_update {"gi": 0.5, "ssr": 0.5, "godRays": 0.5, "cloudMode": "flat"}
entity_update {"entity": "StreetLamp", "components": {"light": {"range": 8, "distanceFade": true, "fadeBegin": 40, "fadeLength": 10}}}
groom_update {"entity": "Hero", "fields": {"strands": 60000, "lod": "auto"}}
viewport_quality {"quality": "fast"}
The CPU side¶
| Area | What to know |
|---|---|
| Wander VM | An empty on tick on 5,000 entities costs about 0.24 ms per tick; vars, movement, branches and timers on 5,000 entities about 1.2 ms (M1 Pro, Release). |
| AOT behaviors | wander_compile_native ran the arithmetic and math-builtin benchmarks about 2–2.5× faster than the VM, with identical results. Behaviors dominated by property access and engine calls stay in the VM. |
| Native modules | C++ for algorithms that do not belong in a script: flocking, procedural generation, custom simulation (Graphs and native code). |
| Step budget | 1,000,000 steps per handler run. A script that hits it has a loop that does too much work per tick: spread the work over ticks with wait frames 1. |
| Shared programs | Identical behavior sources compile once, so thousands of identical entities cost one compile. |
| Frame data | Per-frame data uses a triple-buffered ring with a frames-in-flight limit. Static geometry, terrain and instance buffers are uploaded once and cached; on Apple silicon, terrain and instance textures use unified memory without staging copies. |
Startup: shader library and pipeline cache¶
With the offline Metal toolchain installed at build time, the shader library is precompiled to a .metallib and
embedded; without it, the engine compiles about 6,000 lines of shader source at startup. Every pipeline state also goes
through a binary archive in ~/Library/Caches/Skywalker/shaders/, keyed by shader source, engine version, GPU and OS
build, so a changed shader never loads stale binaries.
Startup on an M1 Pro (skywalker call engine_info, release build, without the offline Metal toolchain):
| Case | Renderer startup | Library compile | Pipelines (86) |
|---|---|---|---|
| Cold: first launch after a shader change | 2464 ms | 1201 ms | 1203 ms |
| Warm: system Metal cache and pipeline archive | 48–51 ms | 3 ms | 4 ms |
The cold library compile (1.2 s) is what the precompiled .metallib removes. The archive pays off when the system's own
Metal cache misses (after an OS or Xcode update, a cache purge, or in a shipped game whose archive was pre-warmed);
recording it costs one write of about 0.3 s on the cold launch.
Measured numbers¶
All on an Apple M1 Pro.
Foliage with impostors, ashen_peaks, 1920 × 1080, full quality, 30-frame perf_stats:
| View | Before impostors | With impostors (full) | Fast editing tier |
|---|---|---|---|
| Aerial | 852 ms, 1.35 G triangles | 19 ms | 7 ms |
| Mid-valley | 2022 ms, 3.9 G triangles | 39 ms, 7.7 M triangles | 10 ms |
| Ground | 2002 ms, 3.2 G triangles | 63 ms, 18 M triangles | 19 ms |
The same scene without any foliage costs about 13 ms (aerial) and 17 ms (ground). What remains at ground level is mostly one 3.5M-triangle tree mesh near the camera and dense alpha-tested grass. Assets with game-ready triangle counts (20–100k) fall well within budget.
Effects, 1920 × 1080, fx_benchmark serial mode, on a stage scene (floor, props, sky, TAA, GI and reflections,
bloom) that costs 7.4 ms per frame on its own:
| Workload | Frame (GPU) | Simulation |
|---|---|---|
| 1,000,000 GPU particles, additive glow, curl noise | 16.2 ms (+8.8 ms) | 3.0 ms |
| 1,000,000 GPU particles, lit smoke, GPU bitonic sort | 38.1 ms (+30.7 ms, fill rate) | 8.4 ms including the sort |
| 100k-strand groom × 25 points, close-up filling the screen (49k strands drawn) | 28.8 ms (+21.4 ms) | 1.4 ms + 6.7 ms deep opacity |
| The same groom, medium shot (20k strands drawn) | 17.5 ms (+10.1 ms) | 0.7 ms + 4.1 ms deep opacity |
Velocity buffer (motion vectors), 1920 × 1080, perf_stats with frames: about +0.2 to +0.4 ms per frame
(hello_sky 5.95 → 6.35 ms, ashen_peaks 25.9 → 26.1 ms).
Wander, fixed 1/60 s ticks, Release build, best of four runs:
| Scenario | VM | AOT |
|---|---|---|
| Arithmetic loop, 500 iterations × 100 entities | 0.93 ms | 0.37 ms |
| Vars, movement, branches, timers × 5,000 entities | 1.20 ms | 1.22 ms (left in the VM) |
| Vector math loop, 100 iterations × 100 entities | 0.50 ms | 0.41 ms |
| Math builtins, 300 iterations × 100 entities | 1.99 ms | 0.93 ms |
Empty on tick × 5,000 entities |
0.24 ms |
Movie rendering, smugglers_cove at 1920 × 1080, 24 fps, samples: 8, shutter: 0.5, simulate: true: about
230 ms per frame (Movie renderer).
Recipe: find and fix a slow frame¶
-
Benchmark the view players see and read where the time goes:
Read
profile.groupsfor the expensive area andprofile.passesfor the pass. -
mainorshadowshigh. Look for stacked transparent quads and dense grass, and for missing LODs:viewport_capture {"debug_view": "overdraw", "samples": 1} viewport_capture {"debug_view": "lod", "samples": 1}Red or magenta near the camera is fine in the
lodview; green far away means a mesh has no coarser level (meshes under 3,000 triangles get none) or an imported model needs decimating (DCC bridge). -
Many lights. Orange and red areas evaluate seven or more lights per pixel: shorten
rangeor merge lights. -
ssgi,ssr,cloudsorvolumetricshigh. Lowergi,ssr, the cloud mode orgodRayswithenvironment_update, or benchmarkquality: "balanced"to see what the lower tier would buy. -
foliageorhairhigh. Check impostor statistics inperf_statsand per-groom cost ingroom_info; reduce strands or switch distant grooms to cards. -
Everything high. Try
renderScale0.67 and compare. -
Repeat step 1 and compare
profile.groupsbefore and after.
For textures, the texel_density view should be mostly green (512 texels per meter) near the camera, and uv_checker
shows stretched or flipped UVs.
Pitfalls¶
- The editor is not the game. The editor viewport renders the
fasttier while you edit; benchmark withquality: "full"(the default ofperf_stats) orview: "scene"for numbers that match play mode and shipped builds. - One frame is noise. Use
frames: 30or more;profilereports rolling averages, minimums and maximums. - Vertex and fragment overlap. On Apple GPUs, vertex work of later passes overlaps earlier fragment work, so the
profiler reports vertex time separately (
vertexMs) and pass times are fragment spans. MetalFX upscaling is not sampled. - Debug builds are slow on the CPU. Measure with the
releasepreset; debug builds are several times slower. - Several heavy GPU jobs at once (two movie renders, a benchmark during a render) interleave through the machine-wide GPU lock and distort each other's timings.
- GPU particles and hair are visual only. They do not affect gameplay, so cutting them never changes the simulation.
For agents
Measure after every big change (a scatter, a generator, a look change) and before claiming a scene is fast:
perf_stats {"frames": 30, "passes": true, "view": "scene"} # where the frame time goes
viewport_capture {"debug_view": "overdraw", "samples": 1} # why main or shadows are expensive
impostor_bake {"entity": "Forest", "preview": true} # check foliage atlases and transition distances
fx_benchmark {"frames": 120} # cost of particles and hair
groom_info {} # per-groom strands, memory and GPU cost
environment_update {"renderScale": 0.67} # the cheapest large win
Report numbers with their conditions: resolution, view, quality tier and frame count.
Reference¶
- Tools:
perf_stats,fx_benchmark,fx_stats,groom_info,impostor_bake,environment_update,viewport_quality,viewport_capture - Components:
environment,light,groom,foliage - Profiler, Debug views
- Design documents: docs/RENDERING.md, docs/WANDER.md