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)”
Coloris 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.5draws as byte 128; a white texture tinted#3366ccdraws#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::Nearestkeeps 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.
Spaces
Section titled “Spaces”| 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).
Textures: handles, lifetime, memory
Section titled “Textures: handles, lifetime, memory”TextureIdis generational and carries its renderer’s tag. A handle from another renderer, or one kept afterremove_texture, is skipped, counted and logged once (skipped_sprites()), never drawn as another texture.TextureId::WHITEis valid everywhere.- Free with
remove_texture(id);texture_count()andtexture_bytes()report live textures and GPU bytes (all mips). TextureCache(engine) maps asset keys to handles:unload(key)anddrain_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_spritesreturns 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 byclear(). - Atlases (glyphs):
create_atlas_texture(w, h)once, thenupdate_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()), thenctx.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 * scalephysical pixels and each glyph sits on a whole physical pixel;UiOverlay::spritesmultiplies 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 nextbegin_frame, doubled (warningW_UI_ATLAS_GROWN) up toDEFAULT_MAX_ATLAS_SIDE(4096), then emptied and refilled on demand (warningW_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 withctx.take_warnings()(UiOverlay::spriteslogs 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.ttfor 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_cullcompute 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_framesays which frame they describe, and the warning is delayed the same way.render_to_rgba,space3d.captureand 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!naminglights.max_per_clusterand 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_capkeeps 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)afterrender_to_rgba, orspace3d.render.stats->stats.cluster_cap.lights_droppedafterspace3d.capture. - The CPU reference
lights::assign_reference_reportreturns the assignment and the sameClusterOverflow. The GPU result is a superset: the lights andmost_wantedagree 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 earlybreakhad 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) andcrates/sparklight-control/tests/render_stats.rs.
How to prove a rendering change
Section titled “How to prove a rendering change”GAMEWAY_MUTE=1 cargo test -p sparklight-render --test render_2d # pixel assertions on the GPUGAMEWAY_MUTE=1 cargo test -p sparklight-scenario --test render2d_goldens # golden scenesSPARKLIGHT_UPDATE_GOLDENS=1 cargo test -p sparklight-scenario --test render2d_goldens # refresh goldens on purposeThese 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.