Skip to content
Felucca Online

Docs

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

stepchangeall effects onall off¹notes
0baseline (main's measurement, windowed)40.7 ms (25 fps)21.6 msfull physical resolution
1texel-resolution rendering + nearest upscale16.7 ms²10.8 ms²windowed, partly vsync-capped
2SSAO quality High → Low——~1 ms SSAO at texel res
5fixed 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 frame8.7 ms (115 fps)4.45 ms (225 fps)headless from here on; 320x200 floor 6.1 → 2.6 ms
3bloom max_mip_dimension 512 → 2569.2 ms—no measurable gain, reverted
4asun shadow map 4096 → 20488.8 ms—within noise, kept (texel-res view)
4bcoarser 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 msthin 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.

sceneall effectsno sun shadowsall off
demo (~300 sprites)8.0 ms7.0 ms4.4 ms
+ 1 000 sprites10.1 ms7.1 ms—
+ 5 000 sprites (shells stride 3)19.5 ms9.6 ms6.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:

  1. Decimate the shells (merge coplanar texel quads, e.g. meshopt), and use per-sprite-type LOD shells.
  2. Cap shadow casting to sprites taller than some threshold.
  3. 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 or Assets<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/framefps
zoom 4, all defaults (toon + outline + sun shadows + SSAO + bloom + vignette)5.3–5.7175–190
zoom 4, outline off5.2–5.4186–194
zoom 4, toon off5.0199
zoom 4, everything off2.9342
zoom 2 (twice the render pixels), all defaults9.1109
zoom 4, +5 000 sprites, all defaults21.247

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_lighting call 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 reports5.59 ms (#39: 5.3–5.7)
main, everything offno difference from the pre-terrain build 70910a2 within noise
zoom 2, everything off, 4 pairsmain ≤ 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 2same 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):

runframe avg (2-s reports)
main (761b4dc), defaults5.95–7.36 ms
main + --stress 300 (300 extra sprites)7.62–8.14 ms
this scene, defaults7.71–8.55 ms (≈ 128 fps at the quietest)
this scene, --disable shadows7.41–7.95 ms
camera away from the house (--focus 50,50), both buildsequal 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):

runframe avg (2-s reports)
main (761b4dc), zoom 46.1–6.9 ms
village, zoom 4, at the player's home11.0–12.1 ms (≈ 85 fps)
village, zoom 2, over the plaza20.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_oak41–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):

runmain (client of 1c9e8dc), min / medianatlas, min / median
village, defaults10.75 / 11.2 ms12.0 / 12.75 ms
village, --disable shadows9.9 / 11.0 ms5.2 / 5.6 ms
village, everything off9.1 / 9.3 ms3.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 ms4.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 boundvillage shell triangleschanged texels vs the grid shell (default 10 h view)
0 (grid)2.16 M—
0.0050.89 M0.04 %
0.010.53 M0.21 %
0.020.31 M0.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:

runatlas (34895a7), min / p10 / mediansimplified shells, min / p10 / median
village, defaults9.1 / 11.9 / 12.8 ms4.8 / 6.05 / 9.3 ms
village, --disable shadows4.7 / 5.2 / 10.1 ms4.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.