Skip to content

2D rendering and UI

Code: crates/sparklight-render (sprites, textures, capture), crates/sparklight-ui (immediate-mode UI), crates/sparklight-engine/src/ui_overlay.rs and textures.rs (the glue). Proof without eyes: docs/agent/testing.md (OffscreenRenderer captures, golden diff). Pixel tests of this document: crates/sparklight-render/tests/render_2d.rs; goldens: crates/sparklight-scenario/tests/render2d_goldens.rs and tests/goldens/render2d/.

The colour rule (one rule, every path follows it)

Section titled “The colour rule (one rule, every path follows it)”

Color is sRGB-authored with straight alpha. Shaders work in linear light on premultiplied colour. Textures are sRGB textures holding premultiplied colour. The target is an sRGB surface.

Where Convention Done by
Color everywhere (sprites, UI theme, clear_color, Color::hex) sRGB, straight alpha: Color::hex("#3366cc") is the colour you see the author
Sprite instance colour converted on the CPU to linear premultiplied [r*a, g*a, b*a, a] to_linear_premultiplied
Texture upload (upload_texture) straight-alpha sRGB bytes in; premultiplied in linear light, stored as sRGB, full mip chain built in linear premultiplied space mips.rs
Atlas textures (glyph atlas) linear data texture; coverage c is stored as premultiplied white (c,c,c,c) update_coverage_region
Fragment shader texture * instance colour, both linear premultiplied; no gamma maths sprite.wgsl
Blend state premultiplied alpha, src + dst * (1 - a), in linear light on an sRGB target PREMULTIPLIED_ALPHA_BLENDING
Clear colour converted to linear and premultiplied on an sRGB target color::clear_value
Surface format an sRGB format when the platform lists one. Otherwise the first format; the shader then encodes sRGB itself (encode_srgb), and translucent sprites blend in encoded space (slightly less accurate, opaque colours exact) surface::choose_format
Alpha mode Opaque if offered, else PreMultiplied (we write premultiplied), else the first mode surface::choose_alpha_mode

Consequences you can rely on (all covered by tests):

  • A sprite whose colour equals the clear colour is invisible against it; grey 0.5 draws as byte 128; a white texture tinted #3366cc draws #3366cc.
  • 50 % white over black is byte 188 (linear 0.5). Transparent-black texels never darken an edge (no dark halos), because filtering sees premultiplied colour.
  • Minified sprites are mip-mapped (trilinear): a 1-pixel checkerboard drawn small averages to 188, it does not shimmer. TextureFilter::Nearest keeps exact texels when magnified (pixel art) and still mip-maps when minified.
  • The 3D renderer (sparklight-3d) has its own clear colour rule (linear, no conversion); the two are not mixed on one target.
Space Origin, axes Used by API
World Y up, world units, seen through Camera2D scene sprites prepare_sprites, capture
Screen origin top-left, Y down, pixels; position is the sprite centre UI overlay, HUD, icons prepare_screen_sprites, render_sprites_with_ui, capture_with_overlay

Both are upright for plain UVs: UvRect has V running top to bottom like image rows, in both spaces. The renderer handles the flipped axis of screen space; callers never swap UVs. Rotation: world space turns counter-clockwise for positive angles, screen space clockwise (it is a Y-down space).

  • TextureId is generational and carries its renderer’s tag. A handle from another renderer, or one kept after remove_texture, is skipped, counted and logged once (skipped_sprites()), never drawn as another texture. TextureId::WHITE is valid everywhere.
  • Free with remove_texture(id); texture_count() and texture_bytes() report live textures and GPU bytes (all mips).
  • TextureCache (engine) maps asset keys to handles: unload(key) and drain_ids() return the handles to free; clear() forgets everything when the renderer was recreated.
  • Failed loads are cached. A key that fails (missing file, bad image) costs one disk read and one log line, not one per frame. to_sprites returns each error once, in the call that first saw the key fail. clear_failures() retries (call it when the file appears: asset import, hot reload). GPU upload failures are forgotten by clear().
  • Atlases (glyphs): create_atlas_texture(w, h) once, then update_coverage_region(id, x, y, w, h, coverage) writes only that rectangle. Never upload a whole atlas to add a glyph.
  • Everything the caller sees is in logical points: rectangles, theme.text_size, the cursor seen by widgets and the draw commands. ctx.set_scale_factor(window.scale_factor()), then ctx.begin_frame(screen_px, input) with the screen size and cursor in physical pixels. ctx.screen() is the logical size.
  • Text is rasterized at size * scale physical pixels and each glyph sits on a whole physical pixel; UiOverlay::sprites multiplies the quads by the scale and snaps both edges to whole pixels, so a button’s neighbours share an edge exactly.
  • Glyph atlas (sparklight-ui): sizes are quantised to quarter pixels, so animated sizes do not rasterize new sets. When a glyph does not fit, the atlas is flagged and, at the next begin_frame, doubled (warning W_UI_ATLAS_GROWN) up to DEFAULT_MAX_ATLAS_SIDE (4096), then emptied and refilled on demand (warning W_UI_ATLAS_EVICTED). A glyph that did not fit is not cached as missing; it appears in the frame after the growth. Read the warnings with ctx.take_warnings() (UiOverlay::sprites logs them with their code).
  • The GPU copy is one texture (AtlasSync): created once, rewritten only where the atlas changed, replaced (old one freed) when the atlas grows. Test: 5000 new glyphs, one texture throughout.
  • The UI font comes from <assets>/fonts/ui.ttf or the system; nothing is committed, so text pixels differ between machines. Goldens in the repository therefore draw shapes and hand-made coverage glyphs, not system-font text.

3D light limits: what the renderer drops and how it tells you

Section titled “3D light limits: what the renderer drops and how it tells you”

The 3D renderer (sparklight-3d) has two limits that leave lights out. Neither is silent: both are counted in Renderer3D::last_frame_stats() (FrameStats) and, for a headless session, in the command space3d.render.stats.

Limit Setting Counter in FrameStats Which lights are kept
Lights in the scene lights.max_lights (Low 128, Medium 256, High 512, Ultra 1024) and 4 directional lights lights_dropped the most important at the camera (intensity / distance^2)
Lights per cluster lights.max_per_cluster (Low 24, Medium 32, High 48, Ultra 64; clamped to 4..256) cluster_cap the first in that same importance order; the others are missing from that cluster, so a surface in it loses their light

cluster_cap (lights::ClusterCapStats) holds the last measured frame in last (ClusterOverflow):

  • clusters_over: clusters that more lights touched than the cap allows;
  • pairs_dropped: light/cluster pairs left out, summed over the clusters;
  • lights_dropped: lights that are missing from at least one cluster they touch;
  • most_wanted: most lights any one cluster wanted, so the cap that would have dropped nothing (0 when nothing overflowed);

and the running maxima since the renderer was created (max_lights_dropped, max_pairs_dropped, max_most_wanted), overflow_frames (measured frames that dropped something), the max_per_cluster in force and measured_frame. Renderer3D::reset_cluster_cap_stats() clears the maxima and overflow_frames (call it when a level changes); it does not re-arm the warning.

  • The cluster_cull compute pass counts what a full cluster drops (atomics; the lights dropped in a cluster are gathered in a bit set per workgroup and merged once); the CPU copies the four counters back without waiting. Only one copy is in flight: while it is, the frames that start measure nothing. A windowed renderer (render_to_view, encode + finish_encoded) therefore reports numbers that trail the frame by as many frames as the GPU is behind, which is several when the CPU runs ahead unpaced; measured_frame says which frame they describe, and the warning is delayed the same way. render_to_rgba, space3d.capture and the golden commands wait and are exact. Renderer3D::wait_cluster_cap_stats() waits for the latest copy on demand.
  • One warning per renderer, and the counters never stop. The cap’s items have no stable name: a dropped light has no id, and a cluster is a cell of a view-space grid that moves with the camera, so a per-item warning would name nothing a person can find. The cap itself is the item: the first measured frame that drops a light logs one log::warn! naming lights.max_per_cluster and its value, with what that frame saw (clusters over the cap, lights dropped, pairs, the worst cluster’s demand) and the hint to raise the setting or spread the lights. No other warning follows for the life of the renderer, whether the scene drops back under the cap or comes back with other lights; resetting the statistics does not re-arm it. Nothing is silent after it: cluster_cap keeps counting in every frame (last frame and running maxima), so a game or a test reads the numbers instead of the log.
  • A test of a game’s light budget reads the counter instead of grepping the log: assert_eq!(r.last_frame_stats().cluster_cap.last.lights_dropped, 0) after render_to_rgba, or space3d.render.stats -> stats.cluster_cap.lights_dropped after space3d.capture.
  • The CPU reference lights::assign_reference_report returns the assignment and the same ClusterOverflow. The GPU result is a superset: the lights and most_wanted agree exactly in the planted test, the cluster and pair counts may be a few percent higher on cluster borders (the compute pass has a little slack).
  • Cost: the pass no longer stops at the cap, so it visits every light in a full cluster. The view transform of the light spheres moved to the CPU (once per light and frame instead of once per light and cluster) and the dropped lights are gathered per workgroup, which keeps the pass at the cost it had before. cargo run -p sparklight-3d --release --example cluster_cap_bench (Apple M5 Max, 2026-10-23, GPU timestamps of the pass, median of 6 alternating runs of 5 x 40 frames, base commit b534df2b against this change, on a Mac that other jobs kept at a load average of about 60, so differences under about 0.05 ms are noise): High tier (cap 48) spread 40 lights 0.026 -> 0.032 ms, 100 stacked lights 0.048 -> 0.062 ms, 512 stacked 0.268 -> 0.307 ms, crowd of 512 0.257 -> 0.273 ms, 1024 stacked 0.261 -> 0.248 ms; Ultra tier (cap 64) 512 stacked 0.245 -> 0.197 ms, crowd of 512 0.212 -> 0.197 ms, 1024 stacked 0.412 -> 0.399 ms. A first version that transformed the spheres on the GPU and flagged every dropped pair with a global atomic took 0.83 ms for 512 stacked lights at High against 0.32 ms for the base in the same session: the early break had hidden that work.
  • Proof: crates/sparklight-3d/tests/cluster_cap_gpu.rs (gpu_ tests: planted overflow, CPU/GPU agreement, 2500 stacked lights, one warning per the log captured: one warning over many overflowing and clean frames and a second light set, counters in every frame; the non-waiting path) and crates/sparklight-control/tests/render_stats.rs.
Terminal window
GAMEWAY_MUTE=1 cargo test -p sparklight-render --test render_2d # pixel assertions on the GPU
GAMEWAY_MUTE=1 cargo test -p sparklight-scenario --test render2d_goldens # golden scenes
SPARKLIGHT_UPDATE_GOLDENS=1 cargo test -p sparklight-scenario --test render2d_goldens # refresh goldens on purpose

These are gpu_ tests: a machine without a GPU adapter fails them, and a CPU run (the default and ci nextest profiles) excludes them by name and lists them as not run; the Mac runs them with --profile needs. Goldens are Metal captures; the tolerance (ΔE) absorbs last-bit differences between adapters.