Client performance (uo-client)
Machine: M1 (8 GPU cores), macOS, 2560x1600 window, zoom 2, offline demo scene (~300 sprites), release build, quiet machine (load ≤ 10 from other apps).
How to measure
Windowed runs on macOS turned out to be throttled to the 60 Hz display
cadence even with --no-vsync (several configurations measured exactly
16.67 ms), so frame costs are measured headless: no window, offscreen
render target of the same size, unthrottled loop.
UO_FRAME_STATS=1 target/release/uo-client --headless --width 2560 --height 1600 \
--zoom 2 --screenshot /dev/null --frames 100000000 [--disable ...] [--stress N]
It prints the mean frame time every 2 s after 3 s of warm-up (the numbers
below are means of 4–8 such reports). --perf's GPU counter queries can't be
used headless: Metal runs out of counter sample buffers in an unthrottled
loop and the device is lost. Headless numbers exclude the final present (one
upscaling quad and the swapchain), which is cheap.
Sustained vs burst. An unthrottled run of a light configuration
(≈ 5–6 ms per frame) slows to ≈ 10–11 ms after 5–10 s, with or without
shadows, while configurations already slower than ≈ 10 ms don't change: the
M1's power/thermal governor clocks down under flat-out rendering.
UO_FRAME_CAP_MS=<ms> paces the headless loop (minimum frame period; the
reported frame time is about the cap + 1.2 ms). To check a frame-rate target,
pace just under it and see whether the frame time holds: e.g.
UO_FRAME_CAP_MS=7 holds 8.2 ms for 30 s on the village (see Simplified
shadow shells).
Image comparisons. Light flicker, walking NPCs and animations follow
elapsed time, which differs between runs, so two captures of the same build
differ in 2–25 % of the pixels. UO_FREEZE_TIME=1 stops game time at zero
(TimeUpdateStrategy::ManualDuration(0)), which makes --screenshot
captures (windowed; headless captures fail) pixel-identical from run to run.
Compare a change against the same build with the change switched off; a
build without the switch can only be compared on pixels that are stable
across several of its own runs.
Results
| step | change | all effects on | all off¹ | notes |
|---|---|---|---|---|
| 0 | baseline (main's measurement, windowed) | 40.7 ms (25 fps) | 21.6 ms | full physical resolution |
| 1 | texel-resolution rendering + nearest upscale | 16.7 ms² | 10.8 ms² | windowed, partly vsync-capped |
| 2 | SSAO quality High → Low | — | — | ~1 ms SSAO at texel res |
| 5 | fixed per-frame floor: the Toggles resource was mutated every frame, so camera effects were re-inserted, Shading changed and every material (~25) was re-prepared and its bind groups rebuilt each frame | 8.7 ms (115 fps) | 4.45 ms (225 fps) | headless from here on; 320x200 floor 6.1 → 2.6 ms |
| 3 | bloom max_mip_dimension 512 → 256 | 9.2 ms | — | no measurable gain, reverted |
| 4a | sun shadow map 4096 → 2048 | 8.8 ms | — | within noise, kept (texel-res view) |
| 4b | coarser shadow shells (small 1 → 2, big 2 → 4 texels); torch point shadows off by default (key 6) | 7.9–8.0 ms (126 fps) | 4.4 ms | thin brazier-leg shadows still separate |
¹ --disable shadows,ssao,bloom,vignette.
² vsync-throttled measurement; the real cost was lower.
Effect costs at the final state (headless, 2560x1600, zoom 2): sun shadows ≈ 1.7 ms, SSAO ≈ 0.9 ms, bloom ≈ 1.0 ms, vignette ≈ 0.
Targets: ≤ 8.3 ms with all default effects (met: ~8.0 ms) and ≥ 200 fps with effects off (met: 225 fps).
Scaling with object count (--stress N)
--stress N scatters N extra sprites (tree, crate, wall_x, wall_y,
character, ore vein, brazier, mixed) over the 64x64 map, in addition to the
~300 demo sprites.
| scene | all effects | no sun shadows | all off |
|---|---|---|---|
| demo (~300 sprites) | 8.0 ms | 7.0 ms | 4.4 ms |
| + 1 000 sprites | 10.1 ms | 7.1 ms | — |
| + 5 000 sprites (shells stride 3) | 19.5 ms | 9.6 ms | 6.6 ms |
| + 5 000 sprites (shells stride 4) | 17.4 ms | — | — |
Sun shadows are what scales badly: every visible sprite's closed shadow shell (thousands of triangles for a tree) is drawn into the cascade. The camera passes scale fine: one quad per sprite.
When the visible sprite count gets into the low thousands:
- Decimate the shells (merge coplanar texel quads, e.g. meshopt), and use per-sprite-type LOD shells.
- Cap shadow casting to sprites taller than some threshold.
- Atlas all sprite maps into array textures with one material, so draws batch across sprite types. Today batching is only within a (mesh, material) pair, i.e. per sprite type and frame.
Other findings
- CPU per frame is now small. Render-pass CPU times are all < 0.25 ms, and
the main thread mostly waits on the render thread. The remaining fixed
~2.5 ms floor is wgpu-core command encoding/submission (
command_encoder_finish,queue_submit/maintain) plus Bevy's render schedule. - Things to keep out of per-frame paths: mutating
ResMuts orAssets<Material>unconditionally (it re-prepares materials and bind groups), and re-inserting camera components.
After the switch to 32-px tiles, outlines and toon lighting
Same setup, but now with texel density halved (TILE_W = 32), default zoom 4
(the same on-screen size as zoom 2 before), 32-texel guard band, shadow shells
at full sprite resolution, plus the outline pass and toon lighting. The
machine was busier (load 7–10 from other apps), so these are means of two
runs.
| scene (2560x1600) | ms/frame | fps |
|---|---|---|
| zoom 4, all defaults (toon + outline + sun shadows + SSAO + bloom + vignette) | 5.3–5.7 | 175–190 |
| zoom 4, outline off | 5.2–5.4 | 186–194 |
| zoom 4, toon off | 5.0 | 199 |
| zoom 4, everything off | 2.9 | 342 |
| zoom 2 (twice the render pixels), all defaults | 9.1 | 109 |
| zoom 4, +5 000 sprites, all defaults | 21.2 | 47 |
At zoom 4 the render target is a quarter of the size it was at zoom 2.
- The outline pass costs about 0.2 ms: one fullscreen pass at texel resolution with 5 depth, 5 normal and up to 5 colour loads per pixel.
- Toon lighting costs about 0.3 ms: a second, diffuse-only
apply_pbr_lightingcall per pixel, plus a third full call for metallic texels only. - Shadow shells are now built at full resolution because the sprites have a quarter of the texels. The +5 000 stress case is therefore still bound by shadow-shell triangles (see above).
Textured terrain (decisions #45–#47)
The terrain shader now reads the baked ground tiles: per texel it checks the owning screen-lattice tile and its three front neighbours (alpha + height loads), picks a variant (for Wang terrains, four edge hashes per candidate), then loads albedo, normal, material and emission. Measured on 2026-10-15, M1, 2560x1600, headless, release.
The machine was not quiet: load 5–30, with Chrome and WindowServer competing for the GPU. Single runs of the same binary varied by 2–3x, and one series of pairs showed a steady +2.9 ms at zoom 2 that the rotated runs below did not reproduce (main's best frames were faster than the baseline's). So the numbers come from rotated short runs (binaries alternated, 11 s each, every 2-s report kept) compared by p10/minimum, plus an amplified build that runs the lookup 9 times per texel.
| measurement (zoom 4 unless noted) | result |
|---|---|
| main (68e2c16), all defaults, median of 20 reports | 5.59 ms (#39: 5.3–5.7) |
| main, everything off | no difference from the pre-terrain build 70910a2 within noise |
| zoom 2, everything off, 4 pairs | main ≤ 70910a2 (the old shader's non-toon path evaluated six fBm noise calls per texel for its bump) |
| bench build: 8 extra Wang (swamp) lookups per terrain texel | +1.3–1.5 ms at p10 and minimum |
| ⇒ one lookup, worst case (Wang terrain) | ≈ 0.17–0.19 ms per frame |
| flat-colour terrain (lookups removed), zoom 2 | same as main within noise |
The lookup costs less than the ~0.3 ms that would justify optimising it, so
the shader is unchanged. If it ever matters, the cheap win is skipping the
three front-neighbour candidates for texels farther from the tile's front
edges than the terrain's tallest relief reaches (most texels). TileSet::pick
must then mirror the skip so the no-holes test still covers it.
Measuring on a busy machine. Rotating binaries in short runs, and amplifying the code under test N times in a throwaway bench build, gave stable answers where single A/B runs did not. Compare the p10 or minimum, not the mean: contention only ever adds time.
Static objects demo (decision #51)
The demo scene now builds a furnished stone house and yard from static objects: about 300 more sprites in view than before (48 floor tiles, 48 roof pieces, ~35 wall pieces, ~25 furniture and lights, yard props, larger trees) and 9 object point lights. Measured on 2026-10-15, M1, 2560x1600, zoom 4, headless, release, rotated short runs (load 7–50):
| run | frame avg (2-s reports) |
|---|---|
| main (761b4dc), defaults | 5.95–7.36 ms |
main + --stress 300 (300 extra sprites) | 7.62–8.14 ms |
| this scene, defaults | 7.71–8.55 ms (≈ 128 fps at the quietest) |
this scene, --disable shadows | 7.41–7.95 ms |
camera away from the house (--focus 50,50), both builds | equal within noise (≈ 4.5 ms) |
The extra ≈ 1.8 ms is the per-sprite cost of #33 (main with 300 extra sprites costs the same), not the new code: it is local to the house view, the client is CPU-bound there (one core at ~97 %), and dropping the object lights, roof or floor each moved the frame by less than the run-to-run noise. The scene still meets the ≥ 120 fps target of #29. Cheapest next wins: draw floor tiles through the terrain path (one textured mesh instead of 48 impostors), and the deferred atlas + instanced impostor draw (#29, #33).
Demo village (decision #53)
The demo now places the whole catalog: 2222 statics of 484 sprites (plus 260 scattered trees and rocks) on a 128 x 128 map. Measured on 2026-10-15, M1, 2560x1600, headless, release, rotated runs (load 7–10; noisier runs dropped):
| run | frame avg (2-s reports) |
|---|---|
| main (761b4dc), zoom 4 | 6.1–6.9 ms |
| village, zoom 4, at the player's home | 11.0–12.1 ms (≈ 85 fps) |
| village, zoom 2, over the plaza | 20.6–21.6 ms |
village, camera off the village (--focus 110,110) | 4.4–4.7 ms |
| all statics skipped (map, characters and lights only) | 5.8–6.2 ms |
every static drawn as crate_small (same positions) | 6.7–6.9 ms |
every static drawn as tree_oak | 41–47 ms (sun-shadow shells; 7.1–9.0 without shells or shadows) |
What the extra ≈ 5 ms is not: sun shadows, shadow shells, the ~70 object
point lights, roofs or upper storeys (--roofs ground-floor), or any single
object category (floors, walls, roofs, vegetation) — each moved the frame by
less than ~0.6 ms. Nor fill: per-frame cut-out meshes that rasterise only the
2-texel cells holding opaque texels (49 % of the quad area instead of 100 %)
changed nothing, and were not kept. The process is GPU-bound (no thread above
~40 % CPU). The cost tracks the number of different sprites in view: the
same 2500 statics as one sprite cost +0.9 ms, as the real mix of 484 sprites
(≈ 1000 per-frame materials, five textures each) +5 ms — consistent with a
per-material draw cost (bind group and texture switches in every pass).
The fix is the impostor atlas deferred in #29/#33: all sprite textures in shared atlases or texture arrays, one material for all impostors and the frame/rect per instance, so the whole layer batches into a few draws. Until then the village misses the 120 fps target at the default view. (The atlas landed in #64; it removed this cost but exposed the shadow-shell cost behind it, see Impostor atlas below.)
Impostor atlas (decision #64)
All impostors now share one material and one unit-quad mesh: the sprite maps
live in atlas texture arrays and each entity picks its frame record with a
MeshTag (docs/rendering.md › Sprite atlas). Images are unchanged: the
albedo, normal, position and depth debug views of the village are
pixel-identical to main except on the animated 3D characters, and the lit
day and night (--roofs no-roofs) shots differ from main no more than two
main runs differ from each other (characters, flicker and torch-carrying
NPCs follow wall-clock time, which differs between runs).
Measured on 2026-10-15, M1, 2560x1600, zoom 4, headless, release, rotated 15-s runs (load 7–10; other sessions were building and baking, so the minimum is the figure to trust):
| run | main (client of 1c9e8dc), min / median | atlas, min / median |
|---|---|---|
| village, defaults | 10.75 / 11.2 ms | 12.0 / 12.75 ms |
village, --disable shadows | 9.9 / 11.0 ms | 5.2 / 5.6 ms |
| village, everything off | 9.1 / 9.3 ms | 3.9 / 3.9 ms |
| village, shadow shells removed (bench build) | 10.7 ms (min of 4) | 6.7 ms (min of 4) |
| village, shells on a 2-texel grid (bench build) | — | 7.1 / 10.4 ms |
camera off the village (--focus 110,110) | 4.3 ms | 4.2 ms |
Both builds are GPU-bound (no thread above ~35 %). The atlas removes the per-material cost found under Demo village: ≈ 4.5–5 ms whenever the sun shadow pass is off. But it exposes the shadow shells: main costs the same ≈ 10–11 ms with or without shells, so its per-material work was hiding ≈ 6 ms of shell rendering that ran alongside it on the GPU. Without that work the sun-shadow pass runs on its own and the default view ends up ≈ 1.2 ms slower than main. The shells are triangle-bound: 622 different shell frames in the village, 2.16 M triangles (3.5 k per shell on average, full texel grid since #34), drawn once per instance in the light's view; a 2-texel grid (4x fewer triangles) saves ≈ 4 ms.
Tried and not kept:
- Lossless shell simplification (merging square blocks of cells whose front and back sheets are planar into fans, border vertices kept so there are no T-junctions): 0.2 % fewer triangles at a 0.001-unit tolerance, 0.6 % at 0.005. The baked relief makes almost no surface planar.
- Back- or front-face culling of the shells: no clear gain in noisy runs, and the shells are not guaranteed closed.
Under heavy GPU contention (load 30–160) the atlas build stayed ≈ 35 % faster than main in every configuration (≈ 20 vs ≈ 32–40 ms), shadows or not.
The next step for the ≥ 120 fps target is the shell cost: a shell LOD (coarser grid or error-bounded decimation for large sprites; changes shadow detail slightly) or a cached static-shadow map. With shells as cheap as none, the village would sit at ≈ 6–7 ms. (Done by simplifying the shells, see Simplified shadow shells.)
Simplified shadow shells (decision #65)
The shells were triangle-bound (see Impostor atlas), so every texel-grid
shell is now decimated with meshoptimizer edge collapses within an absolute
error of SHELL_ERROR = 0.01 units (≈ 0.2 sprite texels, about half a
sun-shadow-map texel at the default view, below the 0.04 depth bias). The
simplified vertices are a subset of the grid shell's. Flat and gently curved
areas and straight outline runs lose their per-texel vertices; outlines,
depth jumps and thin parts stay within the error.
| error bound | village shell triangles | changed texels vs the grid shell (default 10 h view) |
|---|---|---|
| 0 (grid) | 2.16 M | — |
| 0.005 | 0.89 M | 0.04 % |
| 0.01 | 0.53 M | 0.21 % |
| 0.02 | 0.31 M | 0.44 % |
Images were compared with UO_FREEZE_TIME=1 (identical captures from run to
run) against the same build with simplification off, at 7, 13 and 17 h:
0.28 %, 0.27 % and 0.16 % of the texels change, all single texels along
shadow edges and in self-shadowed roof slopes and tree crowns; the shadow
shapes are the same. Simplifying all shells of the village takes ≈ 0.3 s in
total, spread over sprite loads.
Measured on 2026-10-15, M1, 2560x1600, zoom 4, headless, release, rotated 9–13-s runs, 3 series. The machine was shared (load 7–15, other sessions compiling and sometimes running their own client). Within many runs the frame time stepped up after 5–10 s, also with shadows off and in both binaries (atlas build, shadows off: first reports ≈ 5–6 ms, median of all 10.1 ms), so the median below mixes in that contention and p10 is the figure to trust:
| run | atlas (34895a7), min / p10 / median | simplified shells, min / p10 / median |
|---|---|---|
| village, defaults | 9.1 / 11.9 / 12.8 ms | 4.8 / 6.05 / 9.3 ms |
village, --disable shadows | 4.7 / 5.2 / 10.1 ms | 4.2 / 5.1 / 6.5 ms |
Sustained. The step-up in those runs is the power/thermal governor
(How to measure › Sustained vs burst). Paced with UO_FRAME_CAP_MS
after rebasing on main (3daf8ed, calm/time-of-day looks), the village holds
a steady 8.22 ms (122 fps) for 30 s at a 7-ms pace; main (no atlas)
stays at ≈ 10.7 ms however it is paced, and a 5-ms pace or none throttles
back to ≈ 11 ms.
The default village view now meets the ≤ 8.3 ms target (p10 6.05 ms, median of the first report of each run 6.2 ms). The sun-shadow pass costs ≈ 1 ms over shadows off, down from ≈ 6.7 ms. A cached static shadow map was not needed.