Live renderer design
Status: design for the live renderer, 2026-10-01. The implementation target is wgpu 27 on an RX 9060 XT with 16 GiB. All timings, scene limits and package sizes below are engineering estimates, not measurements on the new renderer. The existing apps provide behavior and look references, not a 4K performance baseline.
Contract and frame budget
Section titled “Contract and frame budget”Render the scene at 3840×2160, present at a steady 60 Hz, and retain fine silhouettes, print, and specular detail during motion. No live path tracing, including a hidden low-resolution full-screen trace. The offline path tracer remains the reference for stills, clips and bakes. Its cameras (perspective, orthographic and equirectangular) and the staging of one scene for both renderers are in trace.md. A bounded raster pass for transparent materials is part of the live renderer; a small SDF pass is an optional migration bridge, not the final default.
One frame is 16.67 ms. The initial target is at most 14.5 ms of serialized GPU work, leaving 2.17 ms for variance, queue and present. CPU scene update, command preparation and submission should complete within 3 ms and overlap GPU work. The pass allocation is a budget estimate to be replaced with wgpu timestamp measurements on the target GPU:
| Pass | Target ms | Work at native 4K | The bench at 4K, measured |
|---|---|---|---|
| Animation, deformation, LOD and culling | 0.7 | Visible meshes and prior transforms | not among the 16 passes timed |
| Sun shadow maps | 1.7 | Three cascades, cached static draws where possible | 0.76 for the three cascades, 0.21 for the canopy cookie |
| Depth and motion vectors | 0.8 | Opaque depth, IDs, velocity | 0.49 depth and velocity, 0.13 linear depth, 0.12 background motion |
| Opaque and cutout PBR | 4.1 | 8.29 million shaded pixels, light/probe lookup | 6.10 at rest, 6.49 moving |
| Reflections and transparent surfaces | 1.8 | Screen-space reflection plus probes; bounded glass/liquid | 0.40 reflections, 0.19 their resolve, 0.24 scene colour mips, 0.20 glass front |
| Particles, volume and wind/followers | 0.9 | Half/quarter resolution volume, bounded instances | 0.13 the plume, 0.49 the room haze (march and upsample) |
| TAA resolve | 1.1 | Native-resolution history and rejection | 1.09 |
| Bloom, tone map and grade | 1.2 | Bloom extract and blur at reduced resolution; one fused full-size grade pass | 0.20 for the standard finish |
| Text and image overlays | 0.7 | Sharp UI after TAA where appropriate | not among the 16 passes timed at 4K; lit surface text 0.04 at 1280×800; on a page, surface text after TAA 0.007, the crisp copy 0.164 with surface text, the optional sharpen 0.34 in its place |
| Uploads, barriers, submit and present allowance | 1.0 | No synchronous readbacks | video frame upload, CPU submit p50 0.39 ms |
| Allocated | 14.0 | 2.67 ms unallocated |
The measured column is the engine’s own bench (crates/live/examples/bench/, “The engine bench” below) at 3840×2160 and 16:00 with its probe and cube bakes on, on the RX 9060 XT under RADV: the mean of each pass over 600 frames at rest with the camera director moving, capped at 60 fps. The renderer reports the 16 costliest passes of a frame, so a cheap pass may be missing. The bench’s whole frame at 4K is 16.6 ms at the median at rest (60.3 fps) and 19.0 ms moving (52.6 fps), with 10.7 to 11.3 ms in the 16 timed passes. Capped at 60 fps a pass reads up to 1.7× longer than at the GPU’s fast clock, because RADV drops its clock between frames; the paired modes below keep only the pairs at the fast clock.
The estimate starts with 8,294,400 pixels/frame: one RGBA16F 4K surface is 63.3 MiB and a full read plus write is about 126.6 MiB before cache effects. Full-screen passes, overdraw, shadow fill and transparency make the real cost scene-dependent. It assumes one opaque shading pass, reduced-resolution bloom/volume, indexed geometry, resident textures and no per-frame bake. It does not extrapolate a 1440p or 1586×992 result to 4K. Each pass gets GPU timestamp markers; measure median, p95 and p99 GPU and display frame times after warm-up, while moving the camera, turning the page, changing daylight and animating liquid. A shippable preset requires p99 display intervals at or below 16.67 ms over a five-minute representative run with no 33 ms frames caused by the renderer, on the stated GPU/driver and shipping content. Record memory, visible triangles, texture misses, upload bytes and image quality alongside timing. If the scene exceeds a budget, reduce invisible geometry, effect density or expensive reflection coverage before touching native pixel density or text sharpness.
The engine bench
Section titled “The engine bench”crates/live/examples/bench/ is the engine’s own measuring scene, and the numbers in this section come from it. It reads nothing outside the engine: the room is built by seeded code, its materials come from the bench’s own materials.toml, its fonts are the engine’s EB Garamond with the Noto Sans SC subset as the CJK fallback, and its pictures, video, cards and caustics are painted procedurally at start.
- The room. A 6 m × 5.4 m × 2.8 m box of plaster walls, a plank floor and a ceiling, with a window in the left wall closed by ten tilted slats, a 3.6 m table, three shelves of objects on the back wall and a dark open cavity on the floor. At 16:00 the sun comes in through the slats at 37° and lands on the left end of the table, where a sloped drafting board, three small contact objects, a tinted glass slab and a glass lens stand in the beam. 141 parts: 112 on three shared meshes (62 on a cube and 26 on a can, both with a UV1 atlas, and 24 on a sphere) and 28 sheets and cards on a page mesh with a second UV set, with a perforated grille (cutout), an awning outside the window (shadow only), a lamp and two small boxes that cast no shadow, a rolled sheet on a
Rollerdeformer, and a seeded tree outside the window with the procedural canopy and petals. Every material family is on screen but one, the sheet family whose name the agnostic check denies in new files until it is renamed, and so is every detail kind: plank wood, grime, scratch, fibre, crinkle, coat wobble (as a kind and as a bump), wall mottle, leaf, bark, value and fBm noise, with ageing, patina and thin film. - Glass and the rest. A clear sheet, the tinted slab casting a tinted shadow, a glass tank with rippling water and a plume above it, the lens with a caustic map traced on the CPU through a refracting sphere (
maps::Caustic), a milky slab, a soap-film sphere and a filmed slab; room haze with the room’s bounds, 1,200 motes in the sunbeam, three birds outside the window, two impostor props, surface, lit, deformed and glancing MSDF text, an overlay title, rich spans, a CJK line through the fallback, and three numbers that change every frame on the shared atlas’s fast path; a procedural photo, a 90-frame procedural video uploaded per frame and a content atlas of 216 cards, an ink page and a decal. - Light. The light is the bench’s own
pfx bakerecipe,bench/bake.toml: appbench, the solar sun at day 172, 45° N, with the daylight sky fill, anchors at 12:00, 16:00 and 19:00 and noon as the reference hour. The bench reads it through the bake library’sSceneSource, exactly aspfx bakedoes, and lights, bakes and traces the room from it, sopfx bakeof the recipe and the bench’s own bake are the same scene (a test checks the triangles and every material). The recipe sits besideroom.glb(the static parts, which the bench writes itself) andmaterials.toml(a bake-safe materialLibrary; content slots are bound by the bench at run time). It holds a 21×11×20 probe grid every 0.3 m, two local probe volumes (the table at 0.15 m and the cavity at 0.07 m) and two reflection cubes (table and shelves); the bench caches them undertmp/bench/by scene hash, and the whole bake took 8.2 minutes with the GPU to itself.BENCH_RECIPE=writerewrites the three files and a test fails when they drift from the code; a GPU test bakes the cavity volume and the table cube twice and finds every file byte-identical. - References.
bench/reference.csvis the engine’s own trace of the staged room (bare materials, glass, no tree, text or volume) at 1280×800 and 256 samples per pixel, sliced by the Pacer, kept as the mean linear colour of every part with at least 200 interior pixels and of a 16×10 grid of screen cells.BENCH_REFERENCE=tracetraces it again;BENCH_REFERENCE=comparerenders the same staging live and prints how far each region’s luma sits from the trace.
Run it after a release build, under the queue: cargo build --release -p pfx-live --example bench, then pgpu run --class clip --as pfx -- target/release/examples/bench. Every mode caps itself at 60 fps with turns between frames; PFX_UNCAPPED=1 lifts the cap. BENCH_SIZE=deck draws at 1280×800 instead of 3840×2160.
| Switch | What it measures |
|---|---|
| none | stills of the bare, moving and resting room; 600 frames each of Bare, Rest and Moving with the camera director’s neutral preset, the moving sun, the rolling sheet and the video; per-pass means |
BENCH_BREAKDOWN |
the opaque pass with each opaque feature compiled out, paired frame by frame |
BENCH_CUTS |
the opaque pass’s cuts one by one against today’s shader, with the image change |
BENCH_COLD (=warm) |
Renderer::new, warming and the first frames’ pipelines |
BENCH_BAKE_DETAIL, BENCH_DETAIL=<folder>, BENCH_DETAIL_CHECK |
baked detail maps for the recipe’s detail parts, and the tile, cropped, BC and decoded paths against each other |
BENCH_OCCLUSION |
reflection occlusion, the local volumes and occlusion scales: plates against the engine’s trace at 12:00, 16:00 and 19:00, and paired timings |
BENCH_SHADOWS (BENCH_SHADOW_HOURS, BENCH_SHADOW_VARIANTS, BENCH_SHADOW_COST) |
the sun-only plate against a CPU sun mask of five rays per pixel, part by part |
BENCH_VOLUME |
the plume’s volume cuts, their cost and footprint IoU |
BENCH_TILES, BENCH_LACQUER, BENCH_FOOTPRINT |
the noise tiles, the lacquer tile and the octave footprint fade, timed and against the trace |
BENCH_TERMS |
each light term, live against the trace, and baked probes against traced ones |
BENCH_FINISHES |
the post chain of the standard finish and of every Style |
BENCH_OVERLAP |
blocking render against non-blocking submit |
BENCH_PHYSICS |
a neutral SPH tank and a beaded rope: sim ms, and two runs alike |
BENCH_FINISH=<name> |
any run with that finish |
The baseline, measured on 2026-10-06 on the RX 9060 XT under RADV, capped at 60 fps. The bench shares the GPU queue with other sessions, so it ran in turns with their jobs; the paired modes keep only pairs at the fast clock. Rows marked draft ran under heavy contention or before the bench took its light from its recipe (the analytic sky and three times the solar sun, against the recipe’s daylight fill now): their costs stand, their images and trace comparisons do not. The rest of the 4K modes have not run yet.
| Mode | 3840×2160 | 1280×800 |
|---|---|---|
| Bare, Rest, Moving (draft) | 55.9, 60.3 and 52.6 fps; frame p50 17.9, 16.6 and 19.0 ms, p99 18.9, 18.0 and 24.0 ms; opaque 6.35, 6.10 and 6.49 ms; the 16 timed passes 11.1, 10.7 and 11.3 ms | 152, 161 and 143 fps; frame p50 6.6, 6.2 and 7.0 ms, p99 7.6, 9.8 and 7.9 ms; opaque 1.06 ms; the 16 timed passes 2.70 ms |
| Video upload, 960×540 with mips (draft) | CPU submit p50 0.39 ms, max 0.96 ms | p50 0.28 ms, max 0.50 ms |
BENCH_BREAKDOWN, opaque ms each feature saves |
draft, 18 of 29 rows, the pass between 7.6 and 10.9 ms as clocks moved: baked-detail sampling 4.06, probes 2.10, wall mottle 2.02, sun shadow 0.99, soft search 0.82, soft filter 0.73, content cutout 0.35, content 0.23, SSR 0.18, local reflections 0.09 | the pass 1.63 ms, 0.22 ms with every feature out; detail 0.50, probes 0.41, sun shadow 0.28, wall 0.23, soft search 0.21, soft filter 0.18, SSR 0.07, content 0.06, content cutout 0.06; the tiled coat wobble is 0.09 ms slower to compile out than to keep |
BENCH_CUTS |
not run | 1.63 ms as before the cuts, 1.06 ms shipped (0.57 saved); the image moves 1.82/255 on average |
BENCH_TILES |
not run | the six noise kinds cost 0.31 ms as hash noise and 0.16 ms as tiles; the opaque pass p50 1.22 to 1.07 ms |
BENCH_LACQUER, BENCH_FOOTPRINT |
not run | the coat wobble’s share under 0.003 ms either way; the octave fade saves 0.02 ms here (43 of 360 pairs at the fast clock) and moves the image 0.35/255 |
BENCH_VOLUME, the plume |
not run | 0.128 ms full, 0.051 ms with the shipped early out; 12 steps 0.033 ms |
BENCH_FINISHES, post ms |
not run | standard 0.005; the costliest styles cel 0.22, neon 0.22, kuwahara 0.19, rubber hose 0.19, comic 0.17 |
BENCH_OVERLAP |
not run | blocking render 156 fps (p50 6.39 ms); submit with two frames in flight returns in 1.95 ms at the median, 463 fps over the run |
BENCH_COLD (warm) |
not run | Renderer::new 0.17 s; 21 pipelines warmed and the first frame’s ready at 3.8 s; first frame 0.16 s, then 17 ms; 42 opaque pipelines in all |
BENCH_OCCLUSION, opaque ms on − off |
not run | reflection occlusion 0.027, the two local probe volumes 0.046, occlusion scales 0.000 |
BENCH_BAKE_DETAIL |
not run | six parts on five 2048² pages in 27 s, 69.8 MB in VRAM, base PSNR 57.9 dB |
BENCH_SHADOWS, sun-only plate against five CPU sun rays (draft) |
98.37% of 633,178 pixels agree at 12:00, 98.30% at 16:00, 99.86% at 19:00 | |
BENCH_REFERENCE=compare, luma against the trace (draft) |
parts 22.2% off at the median and 98.8% at p90; screen cells 28.0% and 327% | |
BENCH_PHYSICS |
a 384×256 SPH tank with four pours and a rope of five beads: sim p50 1.17 ms, p99 1.39 ms; two runs give the same state, height field and rope | |
BENCH_TERMS |
not run | not run |
What the first runs show:
- Probes leak through the room’s walls. The live room is brighter than the trace in shade (the floor about twice, the walls 60 to 80% over), with lit scallops along the base of the left wall and the ceiling seams at the grid’s 0.3 m pitch: probes just outside the 12 cm walls see the sun and bleed in. The cavity volume’s depth moments do not reach the main grid.
- The ceiling drops out of the cascades at noon. It sits above the receiver box plus its 0.3 m caster margin, so 1,985 pixels of the table top are sunlit live where the trace has them under the ceiling.
- Petals show through the wall. The tree’s falling petals outside the window draw over the wall above it at rest, although the particle pass tests the scene’s linear depth.
- The renderer reports only the 16 costliest passes, so glass, particles, creatures and text are not timed in the full room at 4K.
Frame graph and geometry
Section titled “Frame graph and geometry”The app submits a scene snapshot: camera, time, seed, daylight, meshes or analytic primitives, instances, deformers, material IDs, print/image layers and prior transforms. The engine owns GPU resources and produces depth, velocity, HDR color and display output. Preserve stable IDs across frames for TAA and picking. Rasterize opaque surfaces first, then cutouts, then transparent surfaces and effects. Use reversed-Z depth if the selected wgpu depth format and app cameras permit it; validate precision at the actual near/far planes. Frustum/occlusion culling uses previous-frame depth conservatively so newly visible objects cannot disappear.
Ship indexed triangle meshes with normal/tangent and stable UVs. Generate several LODs offline from the same smooth source, with bounded normal-cone and UV error. Choose per object by projected geometric error: at 4K, target less than 0.5 pixel silhouette displacement and less than 1 pixel interior curvature error; use a 20% hysteresis band and crossfade or geomorph where switching would pop. This is a visual error criterion, not a fixed polygon count. Subdivide thin round branches and tubes based on projected circumference (roughly 2–4 pixels per segment at the closest supported view), so 8–10-sided branches are not the final 4K mesh. Use smooth normals only where the shape is genuinely smooth; sharp edges retain split normals. Profile culling, vertex work and shadow LOD separately, and cap visible scene triangles initially at about 2 million per view as a tuning hypothesis.
Analytic slabs, beads, lenses, icons, tubes, ropes and filleted metal become parameterized meshes generated from the analytic dimensions; tessellate fillets by the same projected-error rule. Keep analytic parameters in the shared scene so the offline path tracer can render the exact SDF shape. A liquid heightfield becomes a dynamic grid mesh with normals from the simulation texture, refined near the camera; its simulation and ripples remain live. Glass and liquid use raster entry/exit depth, thickness, Beer–Lambert absorption, Fresnel reflection and screen-space refraction. For self-overlap that two depth layers cannot represent, allow a scissored SDF fragment pass limited to glass/liquid bounds, with a hard 1.0 ms ceiling and at most two refractive crossings per pixel. If that limit fails, replace the case with explicit front/back meshes or a precomputed proxy; do not trace the whole screen. Chrome remains opaque raster PBR. A floor caustic or light bake can become an offline caustic texture for fixed optics plus a bounded live caustic or projected decal for moving lenses and liquid.
Deform content and its surface from one rest-space parameterization. A paper scroll uses a strip mesh with rest (u,v) UVs; the roller maps both its positions and tangents into world space every frame, while ink/text/image samples retain rest UVs. Subdivide by projected curvature error and at glyph edges; carry previous deformed positions into velocity output. Do not re-rasterize all print when the scroll moves. Bending, page clipping, normal maps, shadows, selection and picking must agree on that one mapping. At folds or overlaps, depth and two-sided lighting decide which side is visible; the back has its own paper/print response.
Uploaded meshes can be freed and edited. Renderer::release_mesh(handle) frees the mesh’s vertex, position and index buffers and its sway range, and returns the slot to a free list, so upload and release in a loop reuses slots and keeps mesh_stats() (live meshes, slots, buffers) bounded. MeshStats.buffers counts mesh buffers by their own life: each vertex, position and index buffer is wrapped in a counter raised when it is created and lowered when it is dropped, so a replace that grows a mesh and drops its old buffers, or a release, shows in the count as it happens rather than as three per live slot. Renderer::replace_mesh(handle, data) swaps a mesh’s data under the same handle, writing into the existing buffers when the new data fits and allocating new ones only when it is larger; it also resets TAA history, and a swaying mesh can be released but not replaced. A MeshHandle keeps its u32 shape, now a 20-bit slot and a 12-bit generation (MeshHandle(n) is slot n, generation 0): a released handle is refused by release_mesh and replace_mesh, shadow_mesh and swaying see it as absent, and an instance that still points at it makes render return an error naming a released mesh instead of drawing another mesh. A slot whose generation reaches 4095 retires, so a handle never aliases. The LOD set owns the meshes it uploads.
Glass meshes follow the same model. Renderer::upload_glass(&Mesh) returns a glass::MeshHandle, now a u32 with a 20-bit slot and a 12-bit generation like frame::MeshHandle (MeshHandle(n) is slot n, generation 0). Renderer::release_glass(handle) frees the mesh’s vertex and index buffers and puts the slot on a free list, so upload and release in a loop reuse slots. Renderer::replace_glass(handle, &Mesh) swaps the data under the same handle: it writes into the existing buffers when the new vertices and indices fit and allocates new ones, dropping the old, only when the mesh grew; it also resets TAA history. A stale handle (released, or from a retired slot) is refused by release_glass and replace_glass, glass_mesh_buffers returns None for it, and a glass::Surface that still points at it makes render return an error naming a missing or released mesh instead of drawing another mesh. A slot whose generation reaches 4095 retires. MeshStats gains glass_live, glass_slots and glass_buffers, counted by the buffers’ own life exactly as buffers is, so a flat-memory test sees a glass leak. The renderer keeps no CPU copy of a glass mesh: a resize rebuilds the glass pass’s targets but hands its mesh storage, handles and all, to the new pass, because the meshes do not depend on the frame size, so a mesh is released once and nothing else holds it.
Shared materials and ageing
Section titled “Shared materials and ageing”One versioned, linear-light material description drives the live shader and offline path tracer. Its core fields are base color, opacity, metallic, perceptual roughness, specular F0 or IOR, normal/detail, clearcoat/coat roughness, transmission, thickness, absorption color/distance, subsurface or thin-sheet translucency, emission, and UV texture references. The evaluators share parameter meanings and texture transforms, even where the live approximation differs from traced transport. Direct diffuse/specular use an energy-conserving GGX microfacet model, with multiple-scattering compensation checked against the offline reference. The path tracer owns true multi-bounce transport; live indirect diffuse comes from bakes and specular from reflection sources.
| Surface | Live interpretation |
|---|---|
| Paper | Thin-sheet transmission and backlighting controlled by thickness/fiber map; printed ink is a separate absorbing layer in rest UVs. |
| Glass | Dielectric Fresnel, thickness-dependent absorption, bounded screen refraction and fallback environment where off-screen. |
| Chrome/metal | Colored conductor F0, low roughness, reflection hierarchy; no diffuse base on fully metallic regions. |
| Liquid | Dynamic normal/thickness, dielectric reflection/refraction, dye absorption and optional scattering; caustic response is a separate light contribution. |
| Wood | Base/normal/roughness textures, grain direction and optional anisotropy; diffuse with restrained coat. |
| Lacquer | Dielectric clearcoat above substrate, distinct coat roughness and normal. |
| Emissive | Physical radiance in linear HDR; contributes to bake when static, bloom only as a display effect. |
Procedural noise layers fade the octaves a pixel can’t show. Each octave’s rate (cycles per metre) times the pixel’s footprint on the surface gives the octave’s footprint in noise cells per pixel; the derivatives are taken once per fragment before any discard (map_footprint in maps.rs). The fade is the materials crate’s octave_weight, the one fbm3 and value_noise3_gradient already use: an octave is whole while its wavelength is at least two pixels (under 0.5 cells per pixel), fades smoothly to its mean as the wavelength falls to one pixel, and is not evaluated at all below that, so a far part costs less. Close detail is unchanged; the GPU test tests/noise_footprint.rs, with the hash noise forced for the kinds that now read tiles, finds near pixels within 0.04/255 of the unfaded shader on the six parameterised noise kinds, and far pixels collapse onto their mean. Value noise fades to 0.5; nonlinear terms fade to the mean of the whole expression over the CPU noise: the plank wood’s ring term to 0.25 (its rings fade from 8/7 of the ring frequency, RING_FADE), the scratch line to 0.0897 and its specks to 0.0015, the grime’s wear to 0.3814 (the faded grime’s far mean is 0.23026 against 0.23029 unfaded). The wood’s plank tones and seams are not octaves and are not faded. The tracer averages many samples per pixel, so it sees these means. The footprint is one float per fragment, the longer of the object position’s two screen derivatives, times each octave’s largest rate; weighing every octave against both derivative vectors cost more than it saved. On a desk scene at 4K the fade saves 0.06 to 0.14 ms of the opaque pass with a mean change of 0.08/255, because a 4K pixel there covers 0.2 to 0.3 mm and few octaves are finer. OpaqueFeatures::fine_noise (off unless forced on with Frame::set_opaque_on) evaluates every octave as before, for comparison.
The coat wobble samples a tiling noise texture instead of hashing (lacquer.rs, map_lacquer in maps.rs). A CoatWobble layer carries three octave frequencies, fine then two broad, and the two broad octaves’ weights relative to the fine one, whose weight is the layer’s amplitude. The tile is 512² RGBA8: red and green hold the two broad octaves’ gradient summed with their weights over a 1 m period (the frequencies rounded to whole lattice cells so the tile wraps), blue and alpha a fine gradient over 64 cells that the shader reads at the layer’s fine frequency divided by 64. Each value is the central difference value_noise3_gradient takes (±0.35 of a cell) of a periodic 2D value noise on hash3 (Hash::Xor), in cell units, times √(26/35) = 0.862: a plane through the 3D noise blends two lattice layers by the smoothstep of its height, and 26/35 is that blend’s mean variance, so the tile keeps the bump’s strength on average over planes. The ranges are ±2.5 and ±1.5. lacquer::bytes(&coat) bakes it on the CPU; MapTextures starts with the neutral CoatWobble::default() (no broad octaves) and MapTextures::fit_coat rebuilds the layer from the first active coat wobble among a frame’s materials before the frame draws, so a product passing its own coat gets its own tile. A coat whose parameters differ from the tile’s takes the hash noise. The fine channel is shared with the other kinds’ bumps and never carries a weight. It rides a spare layer of the metal array (group 3, binding 3), so no binding is added and the floor stays at 18 sampled textures: MapTextures puts it after the app’s metal maps when there are none or they are 512², names the layer in MapTextures::noise, and MapTextures::material writes it into the map material’s packed.z. With other metal map sizes noise is None, packed.z is −1 and the coat keeps its hash noise. NormalSource::Bump no longer adds a coat of its own; a bump is a CoatWobble layer.
The shader projects the tile three ways in object space. map_footprint keeps the object position’s screen derivatives for lacquer pipelines; their cross product is the face’s object-space normal, so the projection follows the object through any motion. Each axis whose share of that normal is over 0.5 takes part, weighted by the excess and normalized, so a face within 30° of an axis reads one plane, and a face between two axes blends two with the sum divided by the root of the weights’ squares, which keeps the bump’s variance. A plane is two textureSampleGrad reads through the 8× anisotropic map sampler, one for the broad octaves at the position in metres and one for the fine octave at 14.84375 times it, with the derivatives as gradients, so the reads stay correct where neighbouring pixels take different planes. Two features specialize it: lacquer is on only for parts with a lacquer layer or the bump normal, so no other part compiles any of it, and procedural_lacquer (off unless forced on) puts the hash noise back for comparison. Measured on the CPU, the filtered tile is within 1.2% (broad) and 2.0% (fine) rms of the exact periodic gradients, and over 25,600 points on 64 random planes its bump has 1.011 times the hash noise’s rms. The GPU test tests/lacquer_tile.rs renders a lacquer plane at 24 places against the hash noise: the shading’s bump has 1.006 times its rms where every octave is resolved and 0.996 where the 950/m octave is under a pixel, and the bump’s mean darkening agrees within 10%; frames of parts without lacquer are byte-identical with the tile, without it and with the hash noise forced. The tile is a different realization of the same statistics, so it matches the hash noise in strength and grain, not pixel for pixel. Per plane the hash noise’s strength varies with the plane’s height in the lattice, between 0.71 and 1 of a 2D noise’s; the tile has the mean 0.86, so a single flat part can read a little stronger or weaker than before. The path tracer keeps the hash noise.
Four more noise kinds read their octaves from tiles (noise_tiles.rs, tile_noise and tile_wobble in maps.rs): the scratch’s line and smudge, the wall’s fine albedo octaves (420 and 1500/m) and its bump, the paper fibre’s three value octaves and its bump, and the crinkle’s bump. Two more 512² RGBA8 layers follow the lacquer’s in the metal array (at packed.z + 1 and + 2), each holding two octaves, and each octave is two channels: lattice layers A and B of a 64-cell periodic 2D value noise (8 texels per cell). A 3D value noise is the smoothstep blend of two 2D lattice layers by the fractional depth, so an octave at depth d is mix(A, B, fade(fract(d))), with A and B swapped on odd cells, which makes the tile a 3D noise periodic over two cells in depth with the hash noise’s statistics plane by plane, not only on average. The lattice values are hash3 (Hash::Xor) ranked and set to the exact uniform quantiles (rank + 0.5) / 4096, so each octave’s mean is 0.5 and its variance the uniform’s; the raw hashes of a 64² lattice sat 0.003 off and shifted the smudge’s tail by 6%. A value octave projects on the face’s planes as the lacquer does, sums the planes’ deviations from 0.5 weighted and normalized to keep the variance, and its third axis is the depth; anisotropic rates (the scratch line’s stretch, SCRATCH_STRETCH of its frequency across, and the fibre’s across and along frequencies) scale the coordinates and the gradients alike. A bump octave reads the lacquer tile’s fine gradient (blue and alpha, 64 cells) at its own rate with its own offset. Each read is one textureSampleGrad, and the arrays’ box-filtered mips take the place of the per-octave footprint fade; an octave under the pixel is still skipped where the hash noise skipped it, and the scratch’s shaped line and the grime and wood keep their fades to the expression’s mean. The specks stay a single hash. On the CPU the filtered tiles are within 1.3% rms of the exact octaves, each octave’s mean and deviation are within 0.5% and 1% of the hash noise’s over 80,000 points on 400 planes, and the scratch line covers 0.088 and 0.090 of a side and a top face against 0.089 and 0.090 hashed; a test pins both layers’ SHA-256. The GPU test tests/noise_tiles.rs renders each kind on a plane at 96 places against the hash noise: the near detail’s spread is 0.89 to 1.0 of the hash noise’s and its mean moves by at most 0.16/255, far pixels agree within 0.02/255, and without the tiles each kind is the hash noise to within 0.06/255 in a handful of values. Grime (5.5 mm blotches on 2 cm hubs), the wood’s rings (warped at 0.8 to 6 cycles per metre over the desk) and the wall’s broad blotches (2.6 and 7.5/m) keep the hash noise: on tiles each would be a different realization at the scale of its part, the grime and wood cost 0.02 and 0.04 ms, and the wall’s two broad octaves moved its mean by 0.86/255. procedural_detail (off unless forced on) puts the hash noise back for every tiled kind; without the tile layers (metal maps of another size) the kinds keep it.
A thin film (thin_film, thin_film_ior, thin_film_amount) shows on opaque, metal and glass surfaces alike. Its reflectance is Airy’s closed form over 24 wavelengths, both polarisations, weighted into linear sRGB, within 1/255 of the path tracer’s 81-wavelength film from 0 to 1 µm on dielectrics and 0 to 400 nm on metals, at 0 to 70°; a 1 µm film steps no more between neighbouring thicknesses than the tracer’s does, where the old three-wavelength film banded. Under a dielectric material the film sits on a substrate of the material’s ior (a lacquer with an oil slick), under a metal on a conductor whose complex index comes from its colour as Cycles derives it (anodised titanium). OpaqueFeatures::film compiles it only into parts that carry it, so other parts’ frames are the same bytes, and the opaque pass evaluates it twice per pixel, at the view angle and at normal incidence, for the sun, the local lights and the environment. At 4K a frame covered in film costs 1.3 ms more over a dielectric and 3.8 ms more over a metal. The tracer’s thin film has the model, the conductor’s index, the choice of 24 wavelengths and the checks.
Age is caller-supplied 0..1, never inferred from the wall clock. Material recipes map it to yellowing and loss of brightness, ink fading, coat roughening, scratch coverage and worn-edge masks from curvature/AO plus seeded texture fields. Use asset-stable seeds and object-space coordinates, so ageing does not swim during animation or vary with frame rate. The bake manifest records the age state used for static indirect light; if ageing materially changes albedo/emission, use discrete bake variants or rebake offline. Continuous age changes shade live, with a documented approximation between bake anchors.
Daylight, baked light and reflections
Section titled “Daylight, baked light and reflections”The shared daylight input supplies sun direction, angular radius, color/intensity and sky state. Render three practical-split cascaded shadow maps at an initial 2048² depth each, with stable texel snapping; cover the camera’s useful near/mid/far ranges, not an arbitrary whole world. Use PCF filtering (initially 3×3 taps) plus receiver-plane bias tuned by slope; contact hardening may use 5×5 only in near cascade. Add a low-cost contact shadow term for small gaps. Alpha-tested leaf cards cast shadows, with wind moving their vertices and velocity; deterministic wind time/seed drives animated dappled light on surfaces. Sun and moving leaf shadow are live, so turning the hour never requires a full path trace. Moving objects cast live shadows and receive both direct and baked indirect light.
pfx bake stores indirect diffuse irradiance and stationary emissive contribution, with direct sun and moving occluders excluded. Static UV-unwrapped surfaces use directional lightmaps where unique UVs exist. Movable objects and deformable paper use a leak-resistant probe volume with visibility/normal weighting and fallback sky at boundaries. A 45×12×10 grid has 5,400 probes and six directional lobes, updated from two rays per probe over time; it is a good spatial starting point, but live ray accumulation and noisy convergence are replaced by offline, higher-sample bakes. Six RGB half-float lobes padded to 8 bytes each cost about 0.25 MiB per 5,400-probe grid, before visibility data. Probe spacing follows geometry and leakage tests; a large scene needs sparse bricks, not a uniform grid across empty space.
For a changing hour, keep direct sun and shadow maps live and interpolate between precomputed indirect-light anchors. Start with four anchors across the supported daylight range, add anchors where a test image deviates visibly from the offline reference, and include night/indoor lighting states if the product uses them. An hour change selects/interpolates resident anchors and updates sun/sky uniforms; nothing is path traced during interaction. Rebuild offline when static geometry, important static material/age, sky/HDR environment or stationary emitters change. Dynamic props and leaves are never baked into static occlusion. Lightmaps must store irradiance, not already-multiplied albedo, so material edits remain meaningful within the stated bake error.
Reflection priority is: valid screen-space reflection for on-screen detail, local prefiltered reflection probes for off-screen and rough surfaces, then the sky. Capture local probes offline from the same scene/hour anchors; moving objects receive live direct highlights and approximate local reflections. Chrome/glass quality gates include edge-of-screen, disocclusion and mirrorlike surfaces. Use a small planar reflection only for a product surface that demonstrably needs it and fits the reflection budget; do not use full-scene live rays. Blend probe seams and clamp invalid SSR history to avoid black gaps.
Probe lookup
Section titled “Probe lookup”probes::PROBE_WGSL reads the baked grid for a surface at position with normal n. It offsets the point 6 mm along n and takes the eight probes of its cell, trilinearly weighted less the corner floor (set_corner_floor, 0.05). Each probe is weighed by how much it faces the surface (((cos + 1) / 2)³). A version 2 probe also needs its validity flag set (the bake disables a probe whose rays mostly hit back faces, and may first move it up to half a spacing out of the geometry), and passes a Chebyshev test of its stored depth moments in the direction of the point, with a bias and a variance floor of a tenth of the spacing. Each probe’s six lobes are evaluated as an ambient cube along n. With no probe left, the lookup falls back to the sky’s SH.
Local volumes. ProbeLighting::from_artifacts(main, locals, hour, fallback) adds up to PROBE_LOCALS (4) finer volumes, each a schema 2 artifact baked from the same recipe (another [[volumes]] entry) with the main volume’s anchors, at least three probes on every axis and no emitter layer. Their probes ride in the same anchor buffers after the main grid’s, and the probe uniform carries each one’s origin, spacing, dimensions and word offset, so the bindings are unchanged. A point inside a local volume is lit by it alone; across its outer spacing it fades into the main grid, and a local volume that finds no probe for a point leaves it to the main grid. The first local volume that holds the point wins. Without local volumes the lookup is the main grid’s, as before.
Reflection occlusion. ProbeLighting::set_reflection_occlusion(threshold) (0 by default, off) scales the opaque pass’s specular reflection by the probes’ irradiance at the surface over the irradiance its reflection source implies along n: the sky’s SH for the sky’s share, and π times each local reflection cube’s last mip for theirs. A surface whose probes see at least threshold of that keeps all its reflection; below it, the reflection fades in proportion, and an SSR hit keeps its own confidence’s share. A cube with distances (wave 68) is read along n itself for that reference: its last mip is close to an ambient cube, and a second march per pixel cost the opaque pass 0.6 ms at 4K for no visible change. Off, the opaque pass skips it entirely, so frames are as before. Renderer::set_reflection_occlusion(threshold) (and Frame’s) changes it on a live renderer, without reloading the probes.
Per instance. InstanceSurface::reflection_occlusion (1 by default) is how much of that occlusion an instance takes: 0 keeps its whole reflection, 0.5 half way. It rides in the instance’s flags word as an 8-bit release, 0 at the default, so the instance buffer and every frame are unchanged unless an instance asks; a value outside 0 to 1 or not a number is refused by set_surfaces. It is for a glossy part that faces the open room from a spot the probes cannot resolve: the occlusion trusts the probes’ irradiance, and where the probes sit inside or behind thin geometry they read dark for a surface that is in fact open.
Why, on a desk scene (wave 32). A product’s desk read 1.35 to 2.65 times its 512-sample trace in two recesses, the reel bays’ slider channel and the ruler under the timeline lip. Measured at the product’s camera with its room sky and committed bake, 162 surface points against probes traced at the points themselves: the felt channel’s probe irradiance was 0.90 to 0.93 of the truth, and the lip’s 1.17 to 1.36, all of it a 45° bevel tucked under the lip (2.7× at every hour), where all eight probes of the cell are on the far side of the lip and the nearest, 8 mm away, takes 94% of the weight because the stored moments (four rays per face) and the 5 mm bias cannot see a gap that small. Tightening the Chebyshev bias and floor to 0.02 of the spacing moved the lip by 3% at most, and even exact ray visibility to each probe left the bevel 0.28 to 1.5 of the truth. In the image, though, diffuse light was not the leak: drawn with every material’s specular off, both regions read 0.71 to 0.87 of the trace, and the sun reaches neither. The excess was the reflection cube and the sky lighting a recess as if it were open. With reflection occlusion at 0.5, the regions read 0.95, 0.88 and 0.91 (felt) and 0.89, 0.98 and 0.92 (lip) of the trace at 12, 16 and 19 h, the wall and page controls move by 0.01 at most, and over the whole frame the mean absolute error against the trace falls by 25 to 27% and the share of pixels over 1.5× the trace from 23–28% to 14–17%. A threshold of 1 overshoots (0.81 to 0.93). That product turns it on with set_reflection_occlusion(0.5); the lip’s bevel needs a local volume for its diffuse light, and a_local_volume_keeps_the_light_out_of_a_slotted_box pins that: a 4 cm box with a 1 cm slot, between the probes of a 5 cm grid, reads far above its traced interior from the main grid alone and within 1.5× with a 1 cm local volume.
What it costs and where it is wrong (wave 49). Paired at 3840×2160 on that desk with its bake (300 frame pairs, each pair one frame with the term and one without), the occlusion at 0.5 costs the opaque pass 0.32 ms at rest and 0.31 ms moving (p50 of the per-pair difference; 10.73 against 10.42 ms), the 1 cm lip volume 0.22 and 0.20 ms, and the per-instance scale nothing measurable (−0.02 ms). The term trusts the probes’ irradiance along n, and that fails for a glossy part whose mirror direction is open while its hemisphere is not, or whose cell’s probes sit behind or inside thin geometry: on that desk a card strip’s curl reads from probes in the 1 cm gap behind the strip (their +z depth is 5 mm), and a film strip’s left end from probes buried in the chrome slider block (all six lobes zero, still enabled). Neither a reference without the parallax correction nor a 1 cm volume over the film fixed them (the film’s left end stayed at 0.02 to 0.17 of the trace), because the hemisphere really is darker there; only the reflection direction is open. Those instances set reflection_occlusion to 0.
The environment BRDF (wave 69)
Section titled “The environment BRDF (wave 69)”reflection_environment scales each reflection source by the split-sum environment BRDF, f0 · A + B. It used Karis’s analytic mobile approximation, and against the tracer’s GGX (α = roughness², separable Smith, roughness floored at 0.05) that approximation loses light on every metal: A + B at F0 = 1 reads 0.945 against 1.000 at roughness 0.1, 0.81 against 0.97 at 0.35 and 0.73 against 0.89 at 0.5. A rough metal reflected up to a fifth less than the trace, which made chrome, gold and lacquered metal read dull. Dielectrics were off by up to 0.08 at grazing angles.
reflection_dfg(roughness, ndv) replaces it with a least-squares fit to the tracer’s own GGX, integrated over a 39 × 50 grid (roughness 0.05 to 1, n·v 0.02 to 1) with 8,192 samples per point. Its features are u = 2·roughness − 1 to the fifth power, s = 2·√(n·v) − 1 to the second, and each of those again times (1 − n·v)⁵: 36 coefficients for A + B and 36 for B, evaluated by Horner’s rule in u. Over 300 random points off the grid it is within 0.0185 of the integral for a metal (mean 0.0024) and 0.0175 for F0 = 0.04 (mean 0.0013), where the old approximation was within 0.189 and 0.076. reflect::DFG_FIT holds the coefficients and reflect::environment_dfg is the CPU mirror. the_environment_brdf_follows_the_tracers_ggx checks it against an independent 4,096-sample integral, to 0.02 for metals and 0.012 for dielectrics. the_shader_carries_the_same_fit checks that the WGSL holds the same numbers. At roughness 0 both forms are 1 for a metal, so mirrors don’t change. Every other specular and metallic surface gets brighter by what the old approximation lost, and diffuse takes 1 − F of the new value.
Coat reflections and sky visibility
Section titled “Coat reflections and sky visibility”The coat’s own lobe (wave 62). reflection_radiance reads the reflection sources at the coat’s roughness for the coat’s share and at the base’s for the substrate’s (reflection_fallback, with the same SSR hit and the same local cube weights), as the tracer samples each lobe at its own roughness. Before, both shares read the base’s mip, so a glossy coat over a rougher base, or over ink that pulls the base’s roughness up, reflected a broad blur of the room: on the repro below, a window half a room away from the coat’s mirror direction. A surface without a coat, or with a coat as rough as its base, reads once, as before.
Sky visibility (wave 62). The sky cube is the outside, and a surface whose probes see little of it should reflect little of it. Where a baked grid covers the point, the opaque pass scales the sky’s share of the reflection (the share no local cube takes: outside every box and across a box’s seam fade) by the probes’ irradiance along the reflection direction over the sky’s SH irradiance along it, clamped to 1. In the open the probes see the sky and the share is kept; in a closed room it falls to about the probes’ irradiance over π, the room’s own level, with the sky’s shape. It is on by default; ProbeLighting::set_sky_visibility(false) (and Frame’s and Renderer’s) turns it off; the switch rides shade.y of the probe uniform, so no binding changes. The irradiance along the reflection direction comes from the same pass over the same eight probes as the surface’s own (sample_live_irradiance_toward): their weights, depth tests and, for a version 2 probe, the block already read, with the six lobes evaluated a second time along the reflection direction and kept as a luminance. A frame without a probe grid, a point outside the grid, a surface with no specular, coat, metal, transmission or film, and a point wholly inside local cubes don’t use it, and an instance’s reflection_occlusion release keeps that much of its sky, as it does for the occlusion. Reflection occlusion’s reference takes the sky’s share after this scaling, so the two don’t darken the same sky twice. It shares the occlusion’s blind spot: irradiance can’t tell a mirror direction that sees a window from a hemisphere that doesn’t, so a glossy part looking out through an opening reads the opening dimmed unless a local cube captures it.
Why, on a coated top (wave 62). A product’s glossy lacquer box under a lit room with a bright window showed a broad white reflection over its top face, bevel and print live, where the trace kept them dark. The neutral repro is tests/support/coat_room.rs: a closed 4 × 2.8 × 4 m room with one 1.6 × 1.2 m window in a wall, a noon sun at 64° through it, and a 52 × 14 × 38 cm box with 3 cm rounded edges on the floor, base (0.66, 0.13, 0.03) at roughness 0.5 under a full coat at 0.06, with dark ink (Blend::Over, ink roughness 0.6) printed under the coat on its top. The probes are a 0.3 m grid with a 5 cm local volume over the box, the reflection cube a 128² capture at the room’s centre with the room as its box. The camera looks down at 55°, so the coat’s mirror direction from the top sees only the ceiling. examples/coat_room.rs <width> <height> <spp> <frames> traces it, renders it live in six setups, prints each region’s mean luminance against the trace, the median and 95th percentile of the per-pixel ratio and the share of pixels within 0.8–1.25, writes a contact sheet (trace, live, ratio map, difference) to tmp/coat-room/, and times the opaque pass. Live over trace at 1920×1080 against 1024 samples per pixel, mean of the region (share of pixels within 0.8–1.25):
| setup | top coat, before → after | bevel and sides | floor | |
|---|---|---|---|---|
| no local cube | 3.98 (0%) → 0.88 (89%) | 10.96 (0%) → 0.80 (58%) | 5.42 → 0.98 | 2.14 → 0.90 |
| room cube, fade 0.25 (the default) | 2.36 (0%) → 0.90 (93%) | 5.77 (0%) → 0.91 (85%) | 3.34 → 1.00 | 2.14 → 0.90 |
| room cube, fade 0 | 1.08 (88%) → 0.91 (95%) | 1.70 (5%) → 0.99 (91%) | 1.09 → 1.04 | 1.07 → 1.07 |
Candidate by candidate:
- Sky the room should block. With no local cube the sky cube reached the top at full strength (3.98, the print 10.96). With the room cube, the cube’s capture and box projection were right (fade 0: 1.08), but its default 0.25 m seam fade handed 44% of a surface 14 cm above the floor to the sky (2.36, the print 5.77). Sky visibility removes both.
- Specular occlusion.
set_reflection_occlusion(0.5)already took the top to 1.05–1.10 before the fix, but it is off by default and left the print at 1.34–1.70; after the fix it changes nothing here. - Horizon occlusion of the coat normal. The top’s mirror direction is 55° above its plane and the bevel reads 1.04–1.09 with the sky kept out, so it’s not a factor here.
- The coat’s Fresnel and mip. The Fresnel is the tracer’s Schlick at n·v. The mip was the base’s: with the sky kept out (fade 0) the print read 1.70 and the top 1.08, and the coat’s own mip takes them to 0.99 and 0.91.
- Reflection occlusion’s threshold. At 0.5 it occludes the top (its probes see the ceiling-lit room); the default 0 is off.
- Ink under the coat getting the coat’s reflection twice. The tracer also keeps the base’s F0 under the coat, so live’s two layers match it. After the coat’s mip, the print reads 0.99.
What’s left on the top is diffuse: the matte room (no specular or coat on either side) reads 0.93 of the trace on the top with the local volume (0.90 with the 0.3 m grid alone) and 0.99 on the floor, so the coated top’s 0.88–0.91 is within 3% of its probe-lit baseline. The floor’s specular reads about 40% of the trace’s after the fix (0.90 against its 0.99 matte baseline): its 0.7 roughness lobe catches the window, which an irradiance ratio can’t see; with no cube it read 2.14 before. The red band at the box’s foot in the ratio maps is the floor beside the box, brighter live in every setup alike (no contact occlusion), not a reflection. At 3840×2160 against 128 samples per pixel the means are the same to 0.01 (before: 3.97 and 10.96 with no cube, 2.35 and 5.77 with the default fade, 1.08 and 1.70 with fade 0; after: 0.88 and 0.80, 0.90 and 0.91, 0.91 and 0.99), with noisier percentiles.
What it costs (wave 62). Paired at 3840×2160, the base (main at 1f31658) against this change, built one after the other and run under pgpu, 300 frames after warm-up, opaque pass p50: the repro, where three quarters of the frame is a glossy floor whose sky share is open, goes from 2.99 to 3.34 ms with no cube and from 3.28 to 3.79 ms with the room cube (its 0.25 m fade leaves the floor’s whole sky share open); the engine’s bench scene (600 frames, its probes, local cube and a coated part) goes from 3.25 and 3.26 to 3.30 and 3.38 ms over two paired runs. A first version that ran a second full probe lookup along the reflection direction cost 0.6 to 1.35 ms more there; reading both directions from one pass over the probes, as a luminance, is what’s left.
a_coated_top_under_a_closed_ceiling_agrees_with_the_trace pins the repro at 384×216 against 384 samples (the default fade: the top within 0.85–1.15 of the trace, the print within 0.8–1.25, the floor within 0.85–1.15), and a_face_seeing_only_the_ceiling_gets_no_sky_in_its_coat_reflection draws a coat-only face (black base, no specular, coat roughness 0.06 over base roughness 0.5) under a synthetic 128² room cube whose ceiling is 0.05 and whose window, low in one wall, is 30, with a 40 sky outside: its coat must reflect under 1.5 times the ceiling alone and must not move when the sky goes to 400. It reads 0.00204 against the ceiling’s 0.00201; before the fix it read 0.0372, the window through the base’s mip.
Local cubes: distance parallax (wave 68)
Section titled “Local cubes: distance parallax (wave 68)”What a product saw. A coated warm nameplate with dark ink inside a desk-sized baked cube (box 0.9 × 0.5 × 0.55 m, the capture point 5 cm from one face) read far too bright live at noon, and better with local reflections off. The added light was specular and independent of albedo, and the cube’s brightest texels were a window.
What it wasn’t.
- The face convention.
a_baked_cube_reads_back_along_each_axis_in_the_live_shaderbakes a black-sky scene with a coloured emitter on each axis and a white one off each axis, and reads it back through a mirror in the live shader: every axis reads its own colour and every off-axis marker reads white, while the same direction flipped in either face axis reads black. Bake and GPU sampling agree. - The coat’s mip. Since wave 62 the coat lobe reads every source, the cubes included, at the coat’s roughness.
- Occlusion missing the cube. Reflection occlusion’s reference has carried π times each cube’s last mip along
nsince wave 32.
What it was: the parallax box. Box parallax treats the box as the geometry the cube saw. A desk-sized box says the walls are 5 to 50 cm away, so a ray leaving the plate toward the room crossed the box’s near face a few centimetres from the capture point and was looked up from there at a steep angle: on the neutral repro, straight down at the sunlit desk; on the product’s desk, the window. The cube itself was right: the same cube with a room-sized box, or with no parallax at all, agreed with the trace.
The fix. The bake now stores, in each cube texel’s alpha, the inverse distance from the capture point to the first surface in that direction (a CPU ray cast, 0 for open sky; Probe baking). The live renderer marches the reflection ray against it instead of the box: from 2 cm out, 9 steps growing 2.5 times each, each one comparing the point’s distance from the capture point with the stored distance in its direction, one mip level below 64 px a face; the first step that falls behind a stored surface after one in front is refined by 4 bisections, and counts as a hit only if it lies within 3% (plus the refined interval) of that surface, so a ray that passes behind a nearer object, seen from the capture point, keeps going. A ray that hits nothing reads the cube along its own direction. The box now only says where the cube applies, with its seam fade. The lookup runs once per surface and both lobes share it; a cube without distances (format v1, alpha 1) keeps its box parallax. The inverse is stored rather than the distance because the GGX mips filter alpha with the radiance: averaging distances let one sky texel push a wall beside a window thousands of metres away.
The repro. tests/support/local_cube.rs builds the neutral scenes in coat_room’s room (the window, the sky, the noon sun). The nameplate is a 24 × 8 cm plate tilted back 15° on a desk by the window, coated (base 0.66, 0.13, 0.03 at 0.5 under a full coat at 0.06) with a dark ink core and letters, inside a cube shaped like the product’s: the capture point 23 cm above the plate, the box 0.55 × 0.5 × 0.9 m with its near face 5 cm from it. examples/local_cube.rs <width> <height> <spp> <frames> [nameplate|tunnel|curl|both] traces it, renders it live in each setup, and prints each region’s mean against the trace and the mean 8-bit difference over the plate. At 960 × 540 against 256 samples, live over trace (mean |live − trace| over the plate):
| setup | box parallax (before) | distances (after) |
|---|---|---|
| desk box, fade 0 | 10.60: ink core 1.45, letters 2.11, ground 1.13 | 3.53: 0.85, 0.86, 1.00 |
| desk box, fade 0.25 (the default) | 14.72: 2.95, 1.92, 1.13 | 12.72: 2.81, 1.63, 1.10 |
| room box, fade 0 | 3.53: 0.85, 0.86, 1.01 | 3.53 |
| no parallax | 3.68: 0.83, 0.84, 1.01 | |
| no cube | 15.76: 3.41, 1.87, 1.13 |
With distances the desk box reads as the room box does. What remains at the default fade is the seam: a box 0.5 m high with a 0.25 m fade hands most of every point inside it to the sky’s share, and that share reads the plate’s ink at 3.4 times the trace here (sky visibility’s blind spot below). With distances a box no longer costs parallax, so a cube’s box can grow, or its fade shrink, to cover what it should light.
The curl. A card curled into a 9 mm roll between two flat cards (3 mm gaps), with glossy ink (roughness 0.25), probes at 7.8 mm over the strip and a cube 30 cm above it with the room as its box. At reflection occlusion 0.5 and no per-part release, flat ink and the curl’s ink read 1.27 and 1.55 of the trace before, 1.07 and 1.34 after; at occlusion 1, 1.17 and 1.20 before, 0.98 and 1.07 after. Releasing the curl (reflection_occlusion 0) changes nothing at 0.5, where the occlusion doesn’t fire here, and takes it to 1.34–1.55 at 1, so this curl needs no release. What is left on it is the cube’s: the capture point sees the roll from outside and nothing of what the roll’s inside reflects.
What was tried and dropped.
- Scaling each cube’s share by the probes’ irradiance toward the reflection direction over the cube’s own, as sky visibility does for the sky: the curl came to 1.04, but a correct room cube went darker, the coat room’s print from 0.91 to 0.80 (failing
a_coated_top_under_a_closed_ceiling_agrees_with_the_trace) and the nameplate’s ink core from 0.85 to 0.77. Applied only when reflection occlusion is on, the coat room’s print at occlusion 0.5 fell from 0.91 to 0.77; with the occlusion’s threshold as a knee it no longer reached the curl. - Keeping a surface’s reflection where the probes see its mirror direction open even though its hemisphere is closed (the curl in wave 49): on a dark glossy card under a 1.5 cm bridge whose grazing mirror ray leaves through the far end, the probes’ irradiance toward the mirror direction takes in the window above it, and with no cube the card went from 2.16 to 6.0 of the trace at occlusion 1. An irradiance can’t see a direction; a product curl that the occlusion darkens still needs its release until the probes can tell.
- A fixed-point walk instead of the march: on that bridge, where the capture point sees the window and the surface’s real ray meets the wall below it, it read the sky, 11 to 13 times the trace. The march reads 1.49 under the bridge and 0.45 in front of it (the bridge’s edge, seen from the capture point, is taken for a surface), against the box’s 1.48 and 1.77;
a_card_under_a_bridge_reads_no_worse_with_distances_than_with_the_boxholds the card’s error with distances to the box’s.
Tests. crates/live/tests/local_cube.rs: the axis test above; a_baked_cube_reads_its_box_walls_through_the_parallax_correction (markers on the walls of a 4 m box, the capture point and a mirror both off centre: the box, the distances, and the distances in a wrong, small box each read every marker); a_nameplate_in_a_desk_sized_cube_agrees_with_the_trace (384 × 216 against 256 samples, distances: ink core within 0.65–1.2, letters 0.7–1.2, ground 0.9–1.1; the same cube with box parallax must still read the letters above 1.4, the failure it fixes); a_curled_card_between_two_cards_needs_no_reflection_release (flat ink 0.85–1.2, bare card 0.9–1.15, curl ink 0.8–1.6, and releasing the curl must not bring it closer); and the bridge.
What it costs. Measured at 3840 × 2160 under pgpu, the base (main at 25595e1c) against this change, each built and run one after the other. On the nameplate, where most of the frame is the plate and the desk inside the cube’s box, the opaque pass’s p50 over 300 frames goes from 4.86 to 5.21 ms (+0.35) at reflection occlusion 0.5 and from 4.62 to 5.00 ms (+0.38) with it off; with no cube it is 4.40–4.45 ms either way. On the engine’s bench scene, whose room-sized cube covers every reflective pixel, its 600-frame opaque mean goes from 3.73 to 3.93 ms and from 3.60 to 3.86 ms over two paired runs. All of it is the march, about 10 to 14 samples of a 64 px cube level per reflective pixel inside a cube with distances; a v1 cube and a frame without cubes cost what they did.
Text, images and sampling
Section titled “Text, images and sampling”crates/text’s interface is the intended source of MSDF and exact-coverage glyphs; it is presently a placeholder, so this section defines its live sampling contract rather than claiming it already works. Shape once in app layout space. Put screen-space UI text through an exact-coverage atlas rendered after TAA (or through a history-aware text pass if it must be in the 3D image). Use MSDF for perspective text and moderate scale changes, with derivative-based screen-pixel range and a coverage fallback at very small sizes. Generate glyph atlas mip levels with distance-field-aware filtering; ordinary averaging of signed distances produces wrong edges. Use gutter padding and per-glyph UV bounds to prevent neighbors bleeding. At 4K evaluate glyphs at actual output pixel density, not twice a lower-resolution layout.
Images, ink, wood and icons have color-correct mip chains: filter color in linear light, premultiply alpha before reduction, preserve alpha coverage for cutout masks, and encode sampled color/normal data in their proper formats. Content slots and material maps sample at 16× anisotropy with a −0.25 LOD bias, both measured (see Crisp text, sharpening and texture filtering). Keep UV gradients across the scroll deformation, then select mips from screen derivatives and clamp only to prevent atlas bleed. An 8192×4096 ink atlas and plate, decal and video textures need this path; fixed or level-zero sampling is insufficient at glancing 4K views. Decode video at source resolution and display it sharply without claiming detail absent from a 720p source. Small fixed icon and text atlases give way to the shared loader and glyph path, keeping their object-space attachment. A 4K reference view of a close sheet should resolve roughly 5,800 texels/m; generate higher-resolution ink pages or vector-backed glyph layers where roughly 4,400 texels/m undersamples.
The shared glyph atlas
Section titled “The shared glyph atlas”TextEngine::render_spans still builds one atlas per paragraph. Each distinct set of glyphs gets its own atlas, kept until the frame stops using it: end_frame now drops atlases and rasters that begin_frame saw go unused, as it already did for laid-out blocks. Text that changes every frame takes the shared path instead:
- Cells per glyph.
place_spans(shaped text) andplace_number(numbers) returnPlaced:PageQuads that each name a page and carry the usualRichGlyphQuad. A cell is keyed per glyph (font, glyph, weight and optical-size coordinates, fake italic, representation, field size). Coverage cells also key their display size and quarter-pixel bin, asrender_spansdoes. MSDF cells are size-free, so one cell serves every size up toFINE_MSDF_FROM(120 device pixels) and another every size above it. - Pages. Pages are square (
DEFAULT_ATLAS_PAGE, 1024;set_atlas_pagechanges it and clears the atlas) and belong to one face and one kind: coverage (R8, four mips), MSDF (RGBA8 on the GPU, one level, since the MSDF branch reads level 0) or colour. Cells sit on shelves in 8-texel units with the 8-texel gutter, so a cell’s mips never touch a neighbour’s. - The budget.
set_atlas_budget(bytes)caps the GPU bytes of all pages together, every mip level counted (defaultDEFAULT_ATLAS_BUDGET, 32 MiB). A new page is opened only while the total stays within the budget. Otherwise the least recently used cells are evicted until the glyph fits, and a page left empty is dropped. Cells used sincebegin_frameare never evicted, so quads already handed out this frame stay valid. If the frame’s own glyphs do not fit, placing fails withError::AtlasTooLarge. A caller on the shared path callsbegin_frameandend_frameevery frame. - Uploads.
take_atlas_changesreturns the pages created and dropped and oneCellUploadper new cell and mip level: the bytes of that cell’s slot and nothing else. On the GPU,GlyphPages::synccreates blank page textures (GpuAtlas::blank) and writes each cell withGpuAtlas::write_cells. That returnsUploadRegions, which now carry the columns as well as the rows (GpuAtlas::updatereports full-width rows as before).page_quadsgroups a frame’s quads by page, oneQuadDrawper page. Colour pages stay CPU-side forprint_spans, as colour atlases already did. - The field. Shared MSDF cells use a sharp field: pseudo-distances to the nearest edge per channel, so the median keeps convex corners instead of rounding them as a plain distance field does. Edge colouring never gives the two edges at a corner the same colour, and clashing texels are flattened to their median. Both fields take the inside from the nonzero winding rule and ignore edge pieces buried inside another contour. Variable fonts keep overlapping contours (in DM Sans
#$&+@BQRefg), and the even-odd test used before drew a seam through them, such as the bar of the “e”. For glyphs without overlaps (all of IBM Plex Mono and EB Garamond)render_spanswrites the same bytes as before.
Numbers without shaping
Section titled “Numbers without shaping”place_number(span, chars, anchor, placement) lays out labels made only of the characters in chars, the glyph set the caller passes (a game’s digits and separators; the tests use 0123456789,.e+-Na), with no cosmic-text shaping. The set is part of the face’s cache key, so one face can serve several sets. The first use of a face, size and scale shapes two buffers once. One holds the set’s characters on their own lines, which gives each glyph, its advance and its baseline. The other is a sequence of n² + 1 characters (290 for 17) holding every ordered pair once, which gives the kerning of each pair. Both are shaped with tnum. A font without tnum (DM Sans has none) gets tabular digits anyway: every ASCII digit in the set takes the widest digit’s advance, centred in it, and digit pairs drop their kerning. After that a label is a walk over cached advances and kerns, cells come from the shared atlas, and the anchor moves the label by its width. A label with any other character goes through shaping with tnum on. atlas_stats() counts shapings, fast labels, rasters, evictions and uploaded bytes. Over 1,000 frames of 80 labels changing every frame (eighty_labels_change_every_frame_for_a_thousand_frames_within_the_budget), nothing is shaped, rasterized or uploaded once the first frame has seen each glyph. At 4K, 80 changing labels draw in 0.076 ms (eighty_changing_numbers_at_4k_upload_nothing_once_warm).
MSDF against exact coverage at 4K
Section titled “MSDF against exact coverage at 4K”quality::msdf_against_exact_coverage_at_4k (ignored; run it with --release --nocapture) evaluates the live MSDF branch on the CPU. It samples bilinearly with texel centres, uses 0.5 + sd / fwidth(sd) with fine 2×2 derivatives, and compares against swash’s unhinted coverage at the output size for the 17 glyphs of the test’s number set. The font is DM Sans (a variable sans with weight and optical size) at 400, 600 and 800. PITO_TEXT_SANS points it at another TTF or OTF. Each cell gives the worst error over the glyph set in 255ths and how far it sits from the nearest outline corner, the mean error over edge pixels, and the number of pixels more than half off. The “round” field is the generator render_spans uses.
| weight | output px | 64, round: worst at (corner distance), mean, pixels off | 64, sharp | 128, sharp |
|---|---|---|---|---|
| 400 | 38 | 89 (1.5 px), 12.8, 0 | 124 (0.4 px), 13.6, 0 | 124 (0.4 px), 13.2, 0 |
| 400 | 76 | 198 (1.5 px), 13.3, 2 | 145 (1.8 px), 12.9, 2 | 145 (1.8 px), 12.6, 2 |
| 400 | 112 | 196 (1.0 px), 14.5, 12 | 121 (1.4 px), 12.9, 0 | 110 (notch), 12.4, 0 |
| 400 | 150 | 255 (1.1 px), 16.0, 46 | 196 (1.9 px), 13.8, 2 | 116 (0.8 px), 12.5, 0 |
| 400 | 200 | 255 (0.7 px), 17.6, 123 | 213 (2.0 px), 14.1, 5 | 219 (0.5 px), 12.9, 3 |
| 400 | 300 | 255 (1.2 px), 19.9, 348 | 255 (4.4 px), 15.6, 17 | 236 (1.0 px), 12.8, 6 |
| 600 | 38 | 100 (2.0 px), 12.9, 0 | 153 (0.2 px), 13.1, 1 | 153 (0.2 px), 11.9, 1 |
| 600 | 76 | 176 (0.8 px), 12.9, 3 | 121 (1.0 px), 12.6, 0 | 98 (2.5 px), 12.4, 0 |
| 600 | 112 | 234 (2.1 px), 14.0, 15 | 145 (0.2 px), 12.8, 1 | 145 (0.2 px), 12.4, 1 |
| 600 | 150 | 255 (2.8 px), 14.8, 45 | 235 (1.3 px), 13.2, 6 | 108 (4.9 px), 12.3, 0 |
| 600 | 200 | 255 (2.6 px), 16.3, 116 | 255 (2.6 px), 13.7, 14 | 140 (0.2 px), 12.5, 1 |
| 600 | 300 | 255 (0.9 px), 18.3, 320 | 255 (2.0 px), 15.1, 36 | 219 (1.0 px), 12.8, 3 |
| 800 | 38 | 88 (2.0 px), 12.7, 0 | 94 (1.7 px), 12.7, 0 | 105 (0.8 px), 11.7, 0 |
| 800 | 76 | 136 (0.7 px), 12.5, 2 | 208 (notch), 12.2, 2 | 208 (notch), 12.3, 2 |
| 800 | 112 | 183 (1.3 px), 13.8, 10 | 170 (0.1 px), 12.4, 1 | 170 (0.1 px), 12.3, 1 |
| 800 | 150 | 253 (1.0 px), 14.9, 45 | 253 (1.0 px), 13.0, 6 | 104 (notch), 12.3, 0 |
| 800 | 200 | 255 (1.6 px), 16.3, 126 | 255 (1.6 px), 13.2, 13 | 150 (0.2 px), 12.4, 1 |
| 800 | 300 | 255 (2.4 px), 17.9, 338 | 255 (1.3 px), 14.0, 31 | 250 (1.3 px), 12.8, 7 |
Below 112 pixels every field lands within about 13/255 on average, and a few pixels are off at corners whatever the field. The worst error sits at a corner: within 2 pixels in most cells and within 5 in all, except the “notch” cells. Those are the “+”, where its two overlapping bars meet in a concave notch that is a corner of neither contour. A plain distance field rounds corners visibly from about 112 pixels: 46 to 348 pixels off per glyph set at 150 to 300 pixels. The sharp field at 64 cuts that to 2 to 36, and the 128 field above 120 pixels to 0 to 7. What remains is single pixels in notches narrower than a field texel, such as the acute top of the “4”’s counter. quality::a_finer_field_keeps_large_corners_sharp holds the before and after for “1”, “4” and “N” at 800 weight and 300 pixels.
Baked detail maps
Section titled “Baked detail maps”Static parts can trade their procedural layers for baked maps, one crop per part packed onto atlas pages (Probe baking, “Procedural detail maps”). Renderer::set_detail(&atlas) builds the material map arrays at group 3 from the pages: base colour at binding 0, normal at 1, roughness at 2, and at 3 the metal array, which holds only the lacquer’s and the detail noise tiles. A device with TEXTURE_COMPRESSION_BC gets the pages as stored, BC7 sRGB, BC5 and BC4 with their five mip levels. Any other device gets them decoded to RGBA8 on the CPU, so the floor does not change. The binding numbers stay the same and nothing is added.
select_baked_detail(&mut material, k) points a material at part k of the atlas. MapTextures::material turns that into the map material at binding 5: all three maps use the part’s page as their layer, and the map material’s last row, atlas, holds the part’s scale.xy and offset.xy. seeds.w says how the maps are read: 0 procedural, 1 a full-tile map, 2 an atlas with RGB normals, 3 an atlas with BC5 normals, whose z the shader rebuilds as √(1 − x² − y²). map_evaluate samples at uv × atlas.xy + atlas.zw, so a part looks as it did with its full-tile map. Under set_detail, a material that is not baked detail reads no maps. set_maps with full-tile images still works as before.
A part baked through TEXCOORD_1 samples its atlas through it. MeshData::uvs1 takes the second set (None for every mesh without one). The vertex keeps its 64 bytes: the second set rides in two lanes that were padding, uv_alpha.w (u) and normal.w (v), and a mesh without it writes 0 there as before. The opaque vertex stage passes normal on as a vec4f, so the set reaches the fragment in the two interpolants that already carry normal and uv_alpha, with no new attribute, varying, buffer or pipeline layout. That is cheaper than either alternative the format allowed: a second vertex buffer adds a binding and an 8-byte fetch per vertex for the meshes that have the set, and a second vertex format (80 bytes) needs its own pipelines and still adds the bytes; here every mesh fetches exactly what it fetched before, and the set costs nothing where it is unused. MapTextures::material writes the part’s set into the map material’s packed.y, a lane no other path reads for baked detail, and map_evaluate_with samples the atlas at uv1 × atlas.xy + atlas.zw when packed.y is 1. Everything else in the material (ageing, content, procedural layers on unbaked parts) keeps TEXCOORD_0.
The choice is specialized like the rest of the opaque pass: OpaqueFeatures::uv1 is on only for a part whose map material says set 1, and the use_uv1 override compiles the branch out of every other part’s pipeline. Without TEXCOORD_1 the frame is the same, byte for byte: a_plain_scene_draws_the_same_bytes_with_its_unused_features_compiled_out lists uv1 among the features compiled out, and a_quad_samples_detail_baked_through_its_second_uv_set_through_that_set draws a quad whose second set mirrors the first over a two-colour atlas: baked through set 0 it shows the halves one way round, through set 1 the other, the general pipeline matches the specialized one, and a mesh with a second set baked through set 0 draws the same bytes as one without it. trace::stage::Mesh does not carry the set: the path tracer evaluates procedural detail directly and never samples a baked atlas.
Measured against full-tile maps by maps::tests::cropped_compressed_detail_samples_like_the_full_tile: three parts at 0 to about 3.8 mip levels. The crop matches to 0.07/255 or better. BC7, BC5 and BC4 against uncompressed give 41 to 67 dB, and the CPU-decoded path is within 1.74/255 of the GPU’s BC decode.
Motion and effects
Section titled “Motion and effects”These are incremental GPU budget estimates, included in the 0.7 ms animation and 0.9 ms effects rows where applicable; they are initial content caps, not promised timings. All updates use caller time, fixed-step simulation state and seed. Instance positions and previous positions feed velocity and shadow passes.
| Effect | First implementation and cap | Added target cost |
|---|---|---|
| Petals and dust | GPU or CPU-authored instanced quads/meshes, depth-soft particles; 20,000 visible dust motes plus 2,000 petals, sorted into coarse bins | 0.20 ms |
| Steam and smoke | Animated density in camera-bounded volume, quarter-resolution raymarch at 8–16 steps, depth-aware upsample; no full-screen 4K march | 0.35 ms |
| Birds and fish | Deterministic fixed-step flock/school update, 1,000 agents, instanced LOD meshes and culled off-screen simulation groups | 0.15 ms update + 0.10 ms draw |
| Wind on vegetation | Shared wind field sampled by branches/leaves in vertex shader; 100,000 visible leaf cards, matching shadow deformation | 0.20 ms vertex/shadow increment |
The steam volume’s 0.5 ms (wave 43). A mug’s steam is the one volume on a desk scene, drawn by two passes at 4K: a march at quarter resolution (960×540 targets, scissored to the box’s screen bounds plus 8 px) and a depth-aware upsample into the HDR image at full resolution over the same bounds. The march is 20 steps through the box with an analytic Gaussian column, a drift and a sway, and each step with density evaluates one four-octave fbm for the density and two more, one per warp axis, for the warp (twelve value-noise lookups of eight hashes each). The steam’s footprint on the desk, where it moves the frame by 8/255 or more, is 337,879 px in a box of 569 × 1,163 px (142 × 291 low-resolution rays). Measured paired frame by frame under pgpu at the fast clock, 360 pairs per row: the whole volume was 0.75 ms at rest and 0.74 ms moving; with one step the pass is 0.06 ms, which is the two passes and the upsample, so the march was 0.69 ms, or 0.035 ms a step. The column’s Gaussian and fade were evaluated after the noise, and a step outside them returned 0 only after paying for it. VolumeCuts::early_out (on by default) returns before the noise, with the same arithmetic, so the pixels are the same bytes (the_early_out_leaves_the_steam_byte_identical) and the pass drops to 0.47 ms at rest (p10 to p90 0.44 to 0.53) and 0.45 ms moving (0.41 to 0.51). The steam keeps its look; against today’s frame the footprint’s mean is 0.001/255, within the render’s own noise floor (two renders of today differ by a mean of 0.002 and a worst of 5 there).
The other cuts are options on VolumeCuts (steps, warp_octaves, octaves), measured on top of the early out; each one changes which noise the steps sample, so the image changes in the wisps and not in the footprint. Savings are against today’s pass, the image columns against today’s frame at rest over the footprint (mean /255 over its pixels, worst /255 over its box; the two renders’ noise floor is 0.002 and 5):
| Cut, with the early out | Rest ms | Moving ms | Mean /255 | Worst /255 | Footprint covered | IoU |
|---|---|---|---|---|---|---|
| none (shipped) | 0.47 | 0.45 | 0.001 | 8 | 100.0% | 0.994 |
| 3 warp octaves | 0.39 | 0.40 | 1.23 | 27 | 99.6% | 0.990 |
| 3 octaves | 0.45 | 0.43 | 1.34 | 29 | 99.8% | 0.989 |
| 3 warp octaves and 3 octaves | 0.38 | 0.37 | 1.72 | 40 | 99.6% | 0.987 |
| 16 steps | 0.38 | 0.36 | 2.13 | 57 | 99.4% | 0.983 |
| 2 warp octaves | 0.35 | 0.33 | 3.10 | 67 | 98.8% | 0.975 |
| 12 steps | 0.30 | 0.28 | 3.19 | 73 | 98.7% | 0.977 |
| 12 steps and 3 warp octaves | 0.27 | 0.25 | 3.39 | 80 | 98.7% | 0.976 |
Two cuts lost. A tight march segment (a coarse 16-point probe of the column, then 20 steps spread over where it is occupied) took 0.58 ms against the early out’s 0.47 and moved the mean by 1.6/255 with a worst of 60, because the probe’s 16 evaluations cost more than the empty steps it removed and the steps then sample other points of the aliased noise; it is not in the code. And the pass is already at quarter resolution with its scissor, so half resolution would cost four times the rays and an eighth, which Effects doesn’t offer, would widen the upsample’s blocks over the steam’s wisps.
The steam and smoke volume has a clock of its own: Volume::time_scale multiplies Scene.time for that volume alone, in EffectFrame::scale_time, so an app runs it at half speed (time × 0.5) while the scene, the particles and the creatures keep scene time. 1.0 leaves the volume’s frame bytes as they were. A rate of 0.5 at time t draws the same pixels as 1.0 at t/2; a negative rate holds the steam still. An app sets it where it builds its Effects, Volume { time_scale: 0.5, .. }. The room haze keeps scene time.
Particle counts and overdraw are capped independently; a dense full-screen dust sheet can cost more than the count implies. Flocks/schools use product behavior parameters, while the engine provides deterministic movement, culling and drawing. An app’s noise steam and painted birds can be replaced one effect at a time. For fast transparent effects, use weighted blended transparency only where its compositing error is acceptable; sort prominent petals and glass explicitly.
Anti-aliasing and finish
Section titled “Anti-aliasing and finish”Use native-resolution TAA with an 8-frame subpixel jitter cycle, object/camera motion vectors, depth and normal/ID rejection, exposure-aware history rescaling, and neighborhood clipping in a perceptual or YCoCg space. Limit accumulation for moving glyphs, foliage, particles and specular highlights; use reactive masks for transparency, emission and changing print. Reset or reproject correctly on camera cuts, fixture changes, resize, daylight jumps and scroll uncovering. Apply a mild, bounded sharpening filter only after validation; it cannot rescue undersampled assets. Keep UI text and selection indicators after TAA to avoid ghosting. An app with its own temporal accumulation still needs explicit reprojection and disocclusion rules for raster motion.
Compare 4× MSAA at native 4K in the prototype. It helps geometric edges but multiplies color/depth bandwidth and does not resolve texture shimmer, subpixel foliage or reflections. TAA is the default if its history handling passes scrolling text, moving chrome and thin-line tests; targeted alpha-to-coverage or 2×/4× MSAA on a limited pass may be retained if a specific edge needs it. Never use TAA as a blur that hides low-detail geometry.
One shared finish works in scene-linear HDR: exposure, bloom threshold and multi-level downsample/upsample at half or quarter resolution, a single agreed tone curve, then product grade and SDR sRGB/BT.709 encoding with dither. The material and lighting output must be compared before grade as well as after. Each product’s look (a warm grade, a warmth and vignette, a brighter filmic response) is a chain the product builds from the shared passes, with regression images in its own repository. An 80-tap-per-pixel finish bloom is replaced by the mip pyramid. Keep offline still/clip color metadata consistent; do not silently double-encode gamma.
pfx_post::gpu::GpuChain plans a chain before it builds pipelines. It drops identity passes, then merges every run of per-pixel passes into one Pass::Grade, which is a single full-screen compute pass. The per-pixel passes are exposure, black, saturation, contrast, warmth, tone, vignette, grain, dither, encode, bw, noir, sepia, posterize, flicker, scanlines, aperture, duotone and gradient map. A run can also take one bloom, provided only exposures come before it. The bloom keeps its own extract and blur stages at its reduced resolution, its extract scales by the run’s exposure, and its composite goes into the grade pass. Passes that read neighbours or geometry (cel, outline, kuwahara, halation, distortion and the like) end a run.
The grade shader is generated from the same grade_* WGSL functions the standalone passes call. It rounds through f16 after every step, as each texture store between passes did before, so fused and unfused chains give the same pixels. The renderer’s runtime exposure multiplies the run’s first exposure and the bloom extract’s scale. Stage labels read post <group>:<stage> <name>, for example post 0:3 grade add, and the renderer sums them under post.
Measured on 2026-10-04 at 3840×2160 on the RX 9060 XT under pgpu, from a desk scene’s frame, as medians over 300 frames (benchmark_4k_finish_breakdown), each chain as the renderer runs it (a leading Exposure(1.0) and a trailing encode):
| Finish | Before ms | After ms | After, per stage ms |
|---|---|---|---|
| a desk finish (exposure 0.93, bloom 0.12, vignette 0.4, saturation 1.06, ACES) | 2.59 | 0.67 | bloom extract 0.17, blur x 0.12, blur y 0.12, grade with composite 0.26 |
film |
2.14 | 1.70 | the grade replaces tone, sepia, grain, vignette and encode |
crt |
2.49 | 1.89 | bloom, scanlines, aperture, vignette and encode in one grade |
noir |
1.06 | 0.28 | grade 0.28 |
neon |
2.51 | 2.18 | bloom and encode in one grade |
bw, vignette, sepia, duotone, gradient_map, posterize |
0.60–1.02 | 0.20–0.35 | one grade each |
Before, each of the desk finish’s seven full-screen passes (two exposures, bloom add, vignette, saturation, tone, encode) cost about 0.3 ms. Styles built around a neighbour pass (cel, comic, kuwahara, pixel and the like) still run their leading exposure and trailing encode as passes of their own, since a grade does not merge across a neighbour pass. Against the unfused chain at 4K on a desk frame, with runtime exposure 1.0 and 0.8, the desk finish and every built-in style are within 1/255 (fused_matches_unfused_at_4k). The desk finish, noir, vignette, sepia, film, crt and duotone differ by 1/255 in at most 0.42% of channels (the desk finish 0.26%). Every other style is identical.
Crisp text, sharpening and texture filtering (wave 69)
Section titled “Crisp text, sharpening and texture filtering (wave 69)”Text out of the history. Surface text (Text::surface and surface_quads, lit or not, deformed or not) now draws after the TAA resolve, into the crisp display image that the depth of field, the exposure meter and the post chain then read. It never enters the TAA history, so it neither softens nor ghosts. It keeps its depth test against the frame’s depth buffer, so geometry in front still hides it, and its edges stay anti-aliased by their own MSDF coverage. The caller’s text matrices were never jittered, so text drawn after the resolve sits still on the jittered frame. The resolved image itself stays as it was: the crisp display image is a copy of it (crisp copy) or its sharpened version, made only when the frame has surface text with glyphs or sharpening is on. Renderer::set_text_after_taa(false) puts text back before the resolve. With that and sharpening off, the frame runs exactly the passes it ran before, and a frame without surface text and without sharpening is the same bytes either way (the_crisp_passes_run_only_when_asked). Overlay text was already drawn after the post chain and is unchanged. The geometry under text is still jittered, so where a mesh hides text its edge follows that mesh’s anti-aliasing. A reprojected or partly redrawn frame has to take its text from the display image or redraw it, since the history holds none.
Measured at 3840×2160 on the RX 9060 XT (surface_text_out_of_the_taa_history_keeps_its_edges_at_4k): four lines of EB Garamond at weight 500 with a 40 px em, dark ink on a lit page facing the camera, read back through an encode-only finish and decoded to linear luminance. Each figure is the median 10–90% rise of every clean edge (a run from the page to the ink within 8 px along a row or a column), against the tracer’s lettering of the same paragraph at the same pixel density (a 651×218 crop at 64 samples per pixel). The drift moves the camera 0.37 px right and 0.23 px up every frame, measured after 48 frames.
| Text | Rise, median px | p90 px | Clean edges |
|---|---|---|---|
| traced lettering | 1.52 | 1.77 | 12,569 |
| in the history, still | 1.58 | 2.26 | 11,502 |
| after TAA, still | 1.57 | 2.21 | 12,205 |
| in the history, drifting | 2.42 | 3.84 | 6,431 |
| after TAA, drifting | 1.57 | 2.24 | 12,428 |
| in the history, drifting, sharpen 0.25 / 0.5 / 1 | 2.40 / 2.37 / 2.37 | 3.84 / 3.82 / 4.07 | 6,472 / 6,525 / 7,307 |
Still text was already about as sharp in the history, because unjittered glyphs over a jittered background repeat the same pixels every frame. A drifting camera reprojects the history bilinearly every frame, and that blurs text to 2.42 px and loses half the clean edges. Drawn after the resolve, drifting text matches still text and sits 3% above the trace’s lettering. Sharpening can’t bring softened text back: at full strength it only moves the median by 2%.
Sharpening. Renderer::set_sharpen(strength) (0 to 1, default 0, off) runs a contrast-adaptive sharpening pass on the resolved image, AMD’s CAS without scaling: each channel’s weight comes from the 3×3 neighbourhood’s minimum and maximum, so flat regions and strong edges are left alone and soft detail is lifted. Its negative lobe is strength / mix(8, 5, strength) (5 is CAS’s strongest). It works on HDR values squashed by c / (1 + max(c)) after the exposure and unsquashes after, so highlights keep their level, and alpha passes through. Text is drawn after it, so glyphs are never sharpened. At 0 the pass doesn’t run and frames are today’s bytes. crisp::sharpen is the CPU reference, and the GPU matches it to 0.1% relative (the_sharpen_pass_matches_its_cpu_reference).
Content and detail filtering. Content slots and material maps (full-tile and baked detail atlases) sample at 16× anisotropy, up from 8×, with a −0.25 LOD bias (maps::TextureFilter, CONTENT_LOD_BIAS and DETAIL_LOD_BIAS, set with Renderer::set_texture_filter or Frame::set_texture_filter; TextureFilter::UNBIASED is the old 8× unbiased filter). The content bias scales the gradients content_texel already passes by exp2(bias), from the map material’s content_extra.y. The map reads become textureSampleGrad with the tile coordinate’s screen derivatives scaled the same way, from content_extra.z. Neither needs a new binding. A first version read the maps through textureSampleBias, and on RADV with anisotropic filtering that bias barely moved the image (RMS 0.0295 at 0, 0.0289 at −1, flicker unchanged), while scaled gradients behave exactly as the content’s do. The noise tiles share the map sampler, so they get 16× too, without the bias.
The bias was picked by measurement (the_lod_bias_trades_sharpness_against_shimmer): a printed page (1144×2515 texels of text) on a 1.2 × 1.6 m floor, seen at a glancing angle at 960×540, against the same view rendered at 3840×2160 and averaged 4×4 as the reference. Sharpness is the mean gradient magnitude over the floor divided by the reference’s, and “far” is the 120 rows nearest the horizon, where minification is strongest. Flicker is the mean absolute second difference over time of each pixel: over 8 frames with only the jitter (still), and over 24 frames of a slow dolly (moving).
| Anisotropy, bias, sharpen | Sharpness (far) | RMS against 4× | Flicker still | Flicker moving (far) |
|---|---|---|---|---|
| 8×, 0, 0 (before) | 0.921 (0.889) | 0.0315 | 0.00641 | 0.00940 (0.00998) |
| 16×, 0, 0 | 0.927 (0.899) | 0.0295 | 0.00647 | 0.00950 (0.01016) |
| 16×, −0.25, 0 (default) | 0.931 (0.903) | 0.0272 | 0.00717 | 0.01028 (0.01157) |
| 16×, −0.5, 0 | 0.932 (0.901) | 0.0273 | 0.00793 | 0.01109 (0.01325) |
| 16×, −0.75, 0 | 0.932 (0.898) | 0.0288 | 0.00864 | 0.01187 (0.01491) |
| 16×, −1, 0 | 0.930 (0.893) | 0.0309 | 0.00929 | 0.01254 (0.01641) |
| 16×, −0.25, 0.25 | 0.953 (0.930) | 0.0242 | 0.00769 | 0.01101 (0.01259) |
| 16×, −0.25, 0.5 | 0.996 (0.980) | 0.0227 | 0.00856 | 0.01222 (0.01428) |
| 16×, −0.25, 1 | 1.387 (1.439) | 0.0803 | 0.01485 | 0.02277 (0.02823) |
TAA already averages the jittered samples, so a negative bias buys little sharpness here and mostly adds shimmer. −0.25 gives the image closest to the reference, at 11% more flicker still and 9% more moving. Past −0.5 the error grows again. 16× over 8× moves the far rows from 0.889 to 0.899 and the error by 6%, for 1% more flicker. Sharpening at 0.5 brings the page to the reference’s sharpness, with the lowest error of all, for 19% more flicker than the default. At 1 it overshoots. Products that want it set 0.25 to 0.5. The same page as a full-tile base map gives the same figures row for row, so the detail bias is −0.25 too.
Cost at 4K (3840×2160, RX 9060 XT, medians over 48 to 120 frames): surface text after TAA costs what it did before, 0.007 ms for the page above. The crisp copy costs 0.164 ms whenever a frame has surface text and no sharpening. The sharpen pass costs 0.34 ms and replaces the copy. A version that squashed a shared 18 × 18 tile once per workgroup measured 0.357 ms, so the plain nine-tap pass stayed. The TAA resolve is unchanged (0.98 ms still, 1.04 ms drifting). 16× anisotropy and the bias cost nothing measurable in the opaque pass with the floor filling the frame: 0.856 ms at 8× unbiased, 0.856 ms at 16×, 0.847 ms at 16× with a −0.5 bias.
Live and traced colour through one finish (wave 69)
Section titled “Live and traced colour through one finish (wave 69)”live_and_traced_colour_agree_through_the_same_finish traces and renders one neutral scene: a grey floor with eight lambert swatches (red, green, blue, cyan, magenta, yellow, orange, grey), a print in a content slot over white, and a gold metal at roughness 0.35, under a sun and a uniform 0.25 sky. The tracer’s HDR goes through a GpuChain built from the same chain the renderer runs. The test compares each swatch’s mean in Oklab (lightness and chroma ratios, hue angle) with an encode-only finish (linear), ACES and AgX.
| Finish | Lambert and print swatches | Gold, before → after the fit below |
|---|---|---|
| linear | lightness and chroma within 0.2%, hue within 0.15° | 0.951 → 1.000 lightness, hue 0.02° → 0° |
| ACES | within 0.1%, hue within 0.11° | 0.950 → 1.000 |
| AgX | within 0.2%, hue within 0.18° | 0.983 → 1.000 |
The finish chain itself holds no slip: no double tone map, no gamut clip and no linear or sRGB mix-up. An Rgba8UnormSrgb output (no encode in the chain, a hardware-encoding blit) and an Rgba16Float output (the chain’s encode) agree within 0.58/255. AgX’s pastel look is AgX itself, the same on both sides. The one loss was in the shading: live’s gold reflected 0.86 of the trace’s light (0.260 against 0.303 in red), with sun-only frames identical. The whole gap was the sky’s reflection, through the environment BRDF below. After the fit, every swatch agrees within 0.2% in lightness and chroma, and the test holds hue within 1° and lightness and chroma within 2%.
Tape glitch
Section titled “Tape glitch”pfx_post::Tape is the colour glitch of a VHS tape that is fast-forwarding or rewinding. It runs as one full-screen pass (Pass::Tape) after tone mapping and the grade, on the display-referred image:
- Channel split: each channel keeps part of itself in place and moves the rest as a sharp copy. Red sends 59% of itself
0.0034 × widthto one side and blue 47% of itself0.0039 × widthto the other; green stays almost whole, with a 6% trace beside the red copy and 2% beside the blue. In rewind red goes left and blue right, so glyphs split into red, green and blue images with magenta and yellow where they overlap, and “1 kg” reads “11 kg”. Forward mirrors it. No channel is blurred. On a light ground the same split shows dark glyphs as magenta with cyan and yellow copies. - Lift: blacks lift to about 19/255; saturation and sharpness are kept.
- Texture: faint grain on luma and chroma, and faint scanlines.
- Tracking bands: three bands roll through the frame. In a still they show only as a slight sideways bend of their rows, a sub-pixel jitter and occasional faint noise. Forward rolls them down and rewind rolls them up.
- Optional:
echoadds two fainter copies trailing against the travel, mixed in rather than added.smearsoftens by half a pixel, bleeds red and blue around their copies and lowers saturation. Both are off by default.
The split weights and offsets were fitted by least squares, in linear light, to a reference pair (tape-before.jpeg → tape-after.jpeg, 1600 px wide) over the formula, axis-label and grid regions. The fit found red at 0.42 in place and 0.60 at 5.5 px left, green at 0.90 in place, and blue at 0.43 in place and 0.48 at 6.3 px right. The reference’s black measures 19.3/255. At strength 1 the pass reads three horizontal bilinear samples per pixel through the chain’s linear sampler: in place, at the red copy and at the blue copy. The echo adds two more and the smear two more. The stage uses Aux::StudioBlur’s layout, a filterable source and a sampler at binding 3. The lift, grain and bands work on luma and chroma in a gamma-2 space. The pass adds no binding to the live renderer’s group 3. A first design kept each row segment in workgroup memory; it cost 0.5–3 ms at 4K on this GPU, mostly in instruction issue and occupancy, so the sampler design replaced it.
| Field | Range | Default | Effect at 1 |
|---|---|---|---|
strength |
0 to 1 | — | 0 is off and byte-identical to the chain without the tape |
direction |
Forward, Rewind |
— | the sides of the red and blue copies and of the echo, and the bands’ roll |
split |
≥ 0 | 1 | red’s copy 0.0034 × width and blue’s 0.0039 × width away (6.5 and 7.5 px at 1080p, 13 and 15 px at 4K) |
echo |
0 to 2 | 0 | copies at 0.007 and 0.016 × width, mixed in at 25% and 10% |
wash |
0 to 1 | 1 | black lifts to 0.07 in gamma 2, about 19/255 |
smear |
0 to 1 | 0 | a half-pixel softening, red and blue bleed over ±0.75 × width / 1920 pixels, saturation ×0.78 |
grain |
≥ 0 | 1 | grain of ±0.008 (triangular) and 4% scanlines, two rows per period at 1080p and four at 4K |
bands |
≥ 0 | 1 | the bands, bending their rows by up to 0.0025 × width |
Each amount is relative: it applies at strength × amount, so a ramp of strength keeps the look’s shape. Every offset scales with the image width, so 1080p and 4K look alike. Time and seed come from the chain’s frame and seed, the renderer’s round(Scene.time × 60) and Scene.seed, so the same frame and seed give the same bytes and the pass never reads the wall clock. Tape::split_pixels (red and blue), echo_pixels, band_centres and envelope give the same numbers on the CPU, and tape::apply is the CPU reference.
A finish turns it on through the preset path: in a post table, tape = 0.8 is forward at 0.8, tape = "rewind" is rewind at 1, and the table form sets everything:
style = "film"tape = { strength = 0.8, direction = "rewind", grain = 0.5 }In code, Chain::set_tape(Tape::rewind(0.8)) replaces the chain’s tape or adds one before its trailing encode. To ramp it per frame around a seek, Renderer::set_tape(Some(tape)) sets it on the live renderer’s chain on every frame, including through style changes, until set_tape(None) hands back the finish’s own tape. Outside the renderer, GpuChain::set_tape does the same. Once the tape’s pipelines exist, changing the tape rebuilds nothing.
GpuChain plans a chain that holds a tape twice. One plan is the chain without the tape, exactly as before. The other has the tape after the grade, and when the chain ends in an encode, the tape pass writes the encoded image itself, so the grade drops its encode and no extra pass runs. While the tape is off the first plan runs, so strength 0 gives the same bytes as before. The second plan’s pipelines are built when the chain is created with a tape, or on the first set_tape with a strength above 0.
Measured on 2026-10-04 at 3840×2160 on the RX 9060 XT under pgpu, as medians over 20 frames (tape::tests::benchmark_4k): the tape pass alone takes 0.26 ms, and a warm grade as the renderer runs it takes 0.43 ms with a rewind at 1 (grade 0.15 ms, tape with encode 0.29 ms) against 0.15 ms without.
pfx bake artifact contract
Section titled “pfx bake artifact contract”pfx bake implements this command. Proposed invocation: pfx bake --app <app> --scene <scene> --rev <tag-or-rev> --fixtures <set> --seed <n> --out tmp/bakes, using the same adapter/scene snapshot as live and offline stills. The product supplies content through its own adapter; the engine does not read a fixture repository or write inside an app repository. Bake scratch and outputs live under the caller’s tmp/; app build artifacts and reusable tools stay in the machine cache. The product’s owning session chooses which verified bake files to package as immutable, content-hashed assets and loads them through the shared asset loader. The live app never calls pfx bake at startup.
| Output | Disk format and estimated size | Runtime use |
|---|---|---|
| Directional lightmap pages | KTX2 BC6H for HDR irradiance, mipmapped; one 4096² page about 21.3 MiB including mips, plus UV chart metadata | Static diffuse indirect; fallback RGBA16F about 170.7 MiB/page if BC6H unsupported |
| Probe brick/volume | Versioned binary: positions, six RGB16F lobes and visibility; 5,400 probes about 0.3–0.6 MiB depending visibility/alignment, before compression | Movable/deforming indirect light |
| Reflection probes | KTX2 BC6H prefiltered cubemaps; one 256² cube about 0.5 MiB with mips | Rough/clear reflection fallback |
| Prerendered prop layers | KTX2 BC7/sRGB for albedo, BC5 normals, optional BC6H emission, mesh LODs and atlas metadata; e.g. 2048² BC7 about 5.3 MiB with mips | Static high-detail props without per-frame trace |
| Manifest | JSON schema/version, relative paths, SHA-256/bytes, color space, coordinate/UV conventions, bake anchor, sample count, variance, source IDs and hashes | Validation and deterministic loading |
Numbers use block-compression byte rates and a 4/3 mip overhead; they exclude container headers and compression variability. A first per-scene shipping target is under 256 MiB for all daylight anchors, static lighting, reflection probes and prerendered props; content can require a different cap after memory/disk measurements. Reserve at most 1 GiB VRAM for resident bakes in the 16 GiB card, stream only needed scenes/anchors, and upload outside the critical frame path. If BC texture features or KTX2 transcoding are unavailable, ship a tested uncompressed fallback only when its size is acceptable, or fail the preset validation explicitly. The manifest records format alternatives so the loader never guesses.
Hash canonical scene geometry, materials, static lights, sky, fixture contents, shader/baker version, recipe, anchor time and seed. Sort objects/assets by stable ID before hashing; use fixed sample sequences and fixed summation order where possible. Write to a temporary path inside the caller’s tmp/, checksum, then atomically rename. Same inputs on the same GPU and driver should produce the same bytes or, where floating-point compression makes bit identity impossible, the manifest must state a bounded numerical tolerance and record the GPU/driver. No wall clock or system randomness enters the bake. Validate hashes before loading, reject stale scene/material versions, and keep the previous verified package until a complete replacement is present. This design requests a bake-specific manifest stanza before the command ships, rather than changing that shared document here.
Shippable migration order
Section titled “Shippable migration order”Every stage leaves each existing app running and can be pinned independently. Keep the old live renderer behind an app-controlled fallback until a like-for-like native-4K comparison passes; the product owns the toggle and release decision. Share one scene snapshot, one material meaning and one finish color contract so offline shots continue to depict the actual app revision.
At each stage, capture the same fixture state, camera, time, age and seed in old live, new live and offline reference; compare crops and short motion sequences at native 4K. A product may ship its raster path while another stays on its older renderer. The common engine API and bake versions remain backward-compatible for already pinned products, with an explicit migration when they change.
Room haze from one amount
Section titled “Room haze from one amount”RoomHaze::from_amount(lo, hi, amount) is one default model for the room haze, for recipes that give a single number: amount is clamped to 0 to 1, where 0 is no haze and 1 a clearly hazy room, and lo and hi are the haze box. Fog is amount / 6, smoke 20 × amount, mist 0.2 × amount and floor 2 × amount; the rest comes from RoomHaze::new(lo, hi): phase 0.3, back 0.5, reach 3, gold neutral and the ambient a neutral grey of 0.03 (HAZE_AMBIENT_GREY). Every field is public, so a product sets its own phase, back, reach and gold after the call.
Bindings
Section titled “Bindings”wgpu gives a pipeline four bind groups. The opaque passes share one layout, and frame::{scene,shadow,deformer,lighting}_entries are its single source; a test parses every composed shader with naga and checks each binding an entry point uses against these tables (kind, view dimension and stage).
| Group | Binding | Resource | Stage | Owner |
|---|---|---|---|---|
| 0 scene | 0 | frame uniform: jittered, motion and previous view-projection, view, camera, sun, time and size, flags, wind now and before | vertex, fragment | frame |
| 1 | instances: model, previous model, normal matrix, material, id, opacity, cutoff, deformer, sway base, age, LOD coverage, and the skin word (vertex record base, current and previous joint palette, joint count) | vertex, fragment | frame |
|
| 2 | packed materials | fragment | frame |
|
| 3 | tree sway vertices (pivot, level, stiffness) | vertex | frame |
|
| 4 | skins: per skinned vertex its four joints and weights, then each posed instance’s joint matrices, current and previous (skin::SkinStore, Animation) |
vertex | skin |
|
| 1 shadows | 0 | cascade sample uniform, 288 bytes with the sun’s soft-shadow terms, tap counts and flags | fragment | shadow |
| 1 | cascade atlas, depth 2D array | fragment | shadow |
|
| 2 | comparison sampler | fragment | shadow |
|
| 3 | one caster view, CasterView { view_proj, mode } (80 bytes) at a dynamic offset: 27 slots, the 3 cascades then 24 local-light faces; the caster pipeline binds only this and 8 |
vertex | frame |
|
| 4 | transmission map, texture_2d_array<u32> (tint, nearest depth, instance + 1): one layer per cascade at a quarter of the atlas, then, while glass casts into shadowed local lights, the lights’ faces as tiles of a quarter of the face resolution packed into further layers (shadow::FaceTiles) |
fragment | shadow |
|
| 5 | local lights uniform, at most four | fragment | lights |
|
| 6 | local light face depths, texture_depth_2d_array, six faces per shadowed light |
fragment | lights |
|
| 7 | linear comparison sampler for the light faces | fragment | lights |
|
| 8 | caster tints, per material (storage) | vertex, fragment | lights |
|
| 2 surface | 0 | deformers, current and previous parameters | vertex | deform |
| 3 lighting | 0–3 | material map arrays: base, normal, roughness, metal (or a detail atlas’s pages, BC or RGBA8); the metal array’s last three layers are the lacquer’s noise tile and the two detail noise tiles | fragment | maps |
| 4 | anisotropic map sampler | fragment | maps |
|
| 5 | map materials, indexed like the scene materials, with each baked part’s atlas scale and offset | fragment | maps |
|
| 6, 7 | probe anchors, lower and upper | fragment | probes |
|
| 8 | probe grid uniform: the main grid, the anchor blend and corner floor, the reflection occlusion threshold and up to four local volumes; its fallback is the sentinel −1, so the shader falls back to the sky’s SH | fragment | probes |
|
| 9 | canopy cookie | fragment | canopy |
|
| 10 | cookie sampler | fragment | canopy |
|
| 11 | canopy uniform | fragment | canopy |
|
| 12 | sky light: nine L2 SH irradiance coefficients and the cube’s last mip | fragment | frame from sky |
|
| 13 | sky GGX cube | fragment | sky |
|
| 14 | local reflection probes, cube array (a black placeholder until the bake writes them) | fragment | frame |
|
| 15 | reflection sampler; with Quality::blocker_gathers above zero it also gathers the cascade atlas’s depths for the soft sun’s blocker search (cards use binding 10 for that) |
fragment | frame |
|
| 16 | last frame’s SSR result, half resolution; black after a history reset | fragment | reflect |
|
| 25 | authored ambient (probes::Ambient: the sky, a constant irradiance or nine SH coefficients) for receivers flagged authored_ambient |
fragment | frame |
|
| 23, 24 | contact field (probes::ContactField, R16Float over a world x/z rectangle) and its uniform, for receivers flagged contact |
fragment | frame |
|
| 26 | SSR guide: the resolved reflection’s depth, octahedral view normal and roughness, its sign marking a consistent 2×2 footprint for the edge-aware upsample | fragment | reflect |
|
| 27 | caustic parameters (maps::Caustic: the slot, world rectangle, plane height and strength) for receivers flagged as caustic receivers; the caustic texture itself lives in a content slot |
fragment | maps |
Group 2 holds what moves vertices, so the prepass, opaque, caster and deformed text pipelines all bind the same deformer group. Group 3 holds what shades a surface once its geometry is known; it is rebuilt each frame because the SSR history alternates. Blossom and leaf cards keep groups 0, 1 and 3 and put the canopy’s own card data at group 2. Passes that are not opaque surfaces (sky, glass, impostors, SSR, effects, TAA, post) keep their own layouts. Content layers blend as Over, Multiply or Emission; ContentLayer::from_kind(content, clearcoat, slot, &look) maps a material’s content kind onto a blend with the gains, ink roughness and emboss of the caller’s ContentLook (neutral by default: no roughness change, no emboss, unit gains). Base arrays are Rgba8UnormSrgb with premultiplied linear-light mips, and GpuMapMaterial is 464 bytes: each noise layer carries its kind row and four parameter rows, twelve values the layer’s kind defines and four the packer derives (a plank’s reciprocal width and seam span, a scratch’s stretch, the ring fade rate, a coat’s fine scale and tile match). Group 3 also holds the content slots: bindings 18 to 21 are four texture_2d content slots and 22 their clamped, mipped, 8× anisotropic sampler, owned by maps. Instance::shadow_only keeps an instance in the cascades, light faces and transmission and out of every visible pass; Instance::two_sided draws both faces with the normal turned to the viewer; InstanceSurface::face (ContentFace::{Both, Front, Back}, default Both) says which face shows its content layer, and the blend is skipped, not mirrored, on the other, so a printed strip rolled in place keeps its outside blank. The opaque pass has an opaque_lit_main variant that runs only while a local light is lit or sun transmission is set; without them the old shader runs and frames are byte-identical. Per-instance clip planes (clip0, clip1) and content placement (content_uv, content_crop) come from Frame::set_surfaces, indexed like Scene.instances; clipped shadow casters draw through a second caster pipeline with a fragment stage, and casters cut by their content (InstanceSurface::cutout) through a third that also binds group 3 for the content slots.
The opaque pass compiles one pipeline per set of features a batch uses (frame::OpaqueFeatures: the sun’s shadow, the soft sun’s blocker search and filter, cascade blending, probes, local reflection cubes, SSR, the canopy, contact, authored ambient, caustics, content layers, material maps, procedural detail, ageing, the second UV set, content cutouts, the lacquer bump and six detail kinds). Each feature is a WGSL override that defaults to on, except fine_noise, procedural_lacquer and procedural_detail, which no instance asks for: forced on, fine_noise evaluates every noise octave under the pixel footprint at full cost, as the shader did before octaves faded, procedural_lacquer hashes the lacquer bump as before the tile, and procedural_detail hashes the tiled detail kinds as before their tiles; lacquer is on only for parts whose bump is lacquer’s, and scratch, grime, wood, wall, fibre and crinkle only for parts with a layer of that kind, so each kind compiles only into the parts that use it; a feature is compiled out of a batch only where the frame or the batch’s instances leave it unused (no soft sun, a zero cascade blend, no probe grid, no local cubes, no SSR, no canopy; no instance flag, no content slot, no map index, no noise layer with an amplitude, no age), and its compiled-out path is the same arithmetic the runtime branch takes when unused, so those frames are byte-identical (tests/opaque_features.rs). A pipeline is keyed by frame::OpaqueKey (the features, the lit variant and whether the batch is two-sided, which picks back-face culling or none), made on first use and kept. Frame::warm_opaque makes both sides of one set ahead of time, and Renderer::warm_opaque forwards to the frame, because Renderer::frame only lends it; Renderer::warm makes a whole scene’s on worker threads (see Cold start below). A shadow-only batch never draws in the opaque pass, so the frame compiles no opaque pipeline for it. Frame::opaque_drawn lists the sets the last frame drew with, and Frame::set_opaque_on and set_opaque_off force features on or off for measurement. The general shader (every feature) is about 73,000 instructions and 96 VGPRs on RADV; a plain part’s is about 3,000 instructions and 60 VGPRs, and procedural detail alone is about 60,000 of those instructions. Exact early outs skip the sun’s shadow, canopy and caustic where N·L is zero or the sun is already shadowed, and every reflection sample whose weight is zero. The soft sun’s knobs are on shadow::Quality: blocker_taps (8) and filter_taps (12), nearest_blocker (the nearest cluster of blockers sets the penumbra when it holds at least half of them, so a near contact stays dark beside far casters), umbra_skip (a fully blocked search skips the filter), blend_dither (the blend band takes one cascade, picked per pixel, instead of two) and blocker_gathers (0; four gathers read sixteen depths in four fetches but darken a grazing receiver by up to 1%). ProbeLighting::set_corner_floor (default probes::PROBE_CORNER_FLOOR, 0.05) fades out trilinear probe corners whose weight is below the floor, about 42% of the corner work; 0 reproduces the old blend exactly.
Many images, one content slot: a sprite-heavy 2D scene packs its images into one slot with content_atlas::ContentAtlas instead of asking for more slots. There are four slots because the floor is 18 sampled textures per stage (The live renderer’s GPU floor): lit tree cards and lit text bind 18 in the fragment stage, four of them the content slots, and every further slot would raise the floor above 18 for every device. Each instance already picks a rectangle of its slot, so an atlas needs no new binding: ContentAtlas::surface(id) returns the InstanceSurface (uv_offset, uv_scale, crop) that puts image id on a quad whose UVs run 0 to 1. Images pack on shelves, best-fit by shelf height, so the same images in the same order always land in the same places; add_all packs a batch tallest first and returns ids in input order, and add puts a later image into free space without moving one already placed, until AtlasError::Full says which cell no longer fits. Every image sits in a cell with a gutter of 2^level texels on each side, filled with the image’s own edge texels, and its size and origin are rounded to multiples of 2^level, so no mip block at levels 0 to level mixes in a neighbour: the default level 4 (DEFAULT_BLEED_LEVEL) keeps neighbours out down to 16× minification, and a smaller level wastes less space on small images. upload writes only the cells added since the last upload and rebuilds the slot’s mips only over those cells, in one submission. For each new cell it rebuilds, at every mip level, the rectangle that reads the level above’s changed texels (maps::mip_regions: the cell’s footprint, gutters included, widened by one texel each side for the bilinear reads and rounded out), all level 1 rectangles before any level 2, so a level never reads a texel that is about to change; a rectangle is one scissored pass with the load op Load for Srgb8 and one dispatch over an origin-offset kernel for Linear16, using the same sampling and the same texel-centre arithmetic as the full rebuild, so the result equals Frame::regenerate_content byte for byte (a test reads every level back after both). upload_paced(frame, slot, &mut pacer, between) sends the same work through a Pacer (pfx_gpu::pace) as Work::Dispatches, each cell write and each rectangle being one dispatch, so the Pacer sizes each submission to its target (2 to 6 ms) and learns from the label content atlas mips; the cell writes go in just before the submission that follows them, so a first submission does not carry 64 uploads at once, and between is the usual per-submission hook (Turns::add under pgpu). It returns the cells written and the Stats (submission count, longest and median in ms). Paced and unpaced uploads give identical levels. write and copy still rebuild the whole chain. The side is at most the device’s max_texture_dimension_2d and the floor’s 16384. With level 4 a 512² card takes a 544² cell, so 225 cards fit at 8192² (341.3 MiB with mips as Srgb8, 682.7 MiB as Linear16) and 900 at 16384² (1365.3 MiB, 2730.7 MiB); with level 0 it takes 514², 225 and 961. Measured on 2026-10-04 under pgpu: 64 cards of 64² through one 1024² slot, 32 uploaded then 32 added, each show their own image within 1/255 at mip 0, and 64 cards of 512² through one 8192² slot, minified 16×, stay within 1/255 of their own image where a gutter of one texel bleeds visibly.
Instance::casts_shadow (default true in Instance::new) is the counterpart of shadow_only: with false the instance draws in every visible pass and stays out of the sun cascades (and their static cache key), sun transmission and local light shadows. An instance with shadow_only set and casts_shadow false draws nothing, and render accepts it.
Glass casts its own tinted shadow, from the sun and from every shadowed local light, when glass::Surface::casts_shadow is set; with it false, frames are unchanged. A casting surface draws into the sun transmission map (group 1 binding 4) in the same pass and depth test as transmissive instances, so the nearest caster’s tint wins, and stays out of the depth cascades, as a transmissive instance does. Its tint is glass::cast_tint(material): transmission × base, or transmission × beer(thickness, base, absorption) when thickness and absorption are both above zero, each channel clamped to 0–1, which is what a product computes for its shadow-only copies. A surface with tinted mixes toward cast_tint(tinted) by the vertex look’s tint where the look’s flag is set, so the liquid grid casts each texel’s tint from one surface, and discards where the look’s cut is below zero, as the glass front pass does; a liquid surface is displaced by the fluid and ripple heights as the glass pass displaces it. The cast writes instance 0 into the map, so no opaque receiver skips it as its own shadow. While any surface casts, the transmission map redraws every frame, because a GPU-rewritten liquid grid changes without the CPU knowing. Renderer::render builds the casts from Effects::glass before the cascades (glass::Pass::prepare_casts, then draw_casts from Frame::encode_with_casts’s transmission pass). This replaces the shadow-only Instance copy with set_transmission that an app keeps per glass part: renderer::tests::glass_casts_the_shadow_its_shadow_only_copy_cast renders a tinted slab over a floor both ways and the frames do not differ in a single byte (floor under the slab at 0.775, 0.850, 0.982 of the open floor either way). With the camera still, the copy’s transmission is cached and costs nothing while the glass redraws it every frame, 0.105 to 0.119 ms for a 1024² map per cascade at a 4096² atlas (glass_casting_costs_about_what_its_copy_costs, two runs under pgpu); with the copy moving, as GPU-rewritten shadow grids are, the copy costs 0.118 ms of transmission and 0.007 ms of cascades, so the glass costs what a moving copy costs, and saves its upload and the pass that rewrites it.
glass::Surface::shadow_only is glass’s counterpart of Instance::shadow_only: the surface stays out of the glass pass (front, back, compose and ids) and keeps casting its tinted shadow when casts_shadow is set, so a part that should only shade, a recipe’s shadow = "only" on glass, casts the drawn glass’s tint rather than an opaque cascade shadow; with casts_shadow false it draws nothing, and render accepts it. A frame whose glass is all shadow-only runs no glass pass at all (glass::drawn). shadow_only_glass_casts_the_tinted_sun_shadow_of_drawn_glass_and_draws_nothing (crates/live/tests/glass_casts.rs) renders a neutral tinted slab over a floor as shadow-only glass and as a shadow-only Instance copy with set_transmission and the frames are the same bytes, with the floor beside the slab within 1/255 of the drawn glass’s frame.
Glass occludes local lights with the same tint and transmission model as the sun. While a surface casts and a local light is shadowed, the transmission pass draws the casting surfaces once more per light face, after the cascades, into tiles of the transmission map (pass light transmission): each face gets a square tile of transmission_size(face resolution) texels (256 at the default 1024), packed as many to a layer as the cascade layer’s size allows (16 at a 4096 atlas), so two lights’ twelve faces take one extra layer. The lights uniform carries the tile size and the tiles per row in info.zw (0 while no glass casts into a light), and light_tint in LIGHTS_WGSL reads the face’s tile at the same shifted point light_shadow compares: where the shaded point lies behind the nearest glass the light passes the glass’s cast_tint, multiplied into the light’s depth visibility, in the opaque pass and in lit text alike. The light faces’ own depth maps never see glass, so a transmissive instance still blocks a light as before. glass_tints_local_light_shadows_with_its_cast_tint lights a floor with two shadowed local lights and no sun, and finds each light’s share of the floor in the glass’s shadow passed by cast_tint within 0.002 of the expected ratio (asserted within 0.02; an opaque copy blocks it within 0.003), the same to 1/255 with the glass drawn or shadow-only. On the RX 9060 XT under pgpu, at 1920×1080 with a 4096² sun atlas and two lights’ twelve 1024² faces, the light tiles cost 0.041 ms every frame, as the sun transmission does while glass casts, against the 0.078 ms the faces’ depth takes when the lights move and nothing when they stay cached; the sun transmission with the glass was 0.116 ms (glass_in_two_local_lights_costs, lights still and moving).
A glass surface takes the same two world-space clip planes as an instance surface: glass::Surface::clip (zero planes cut nothing; a point with dot(plane.xyz, p) > plane.w is cut), applied in the glass front pass, in the back pass and in the cast that writes the sun and local-light transmission. Where a back face is cut and the front kept, the back pass puts the glass’s exit on the plane along the view ray with the plane’s normal, so the part refracts and absorbs like the solid it keeps; where the cut faces the camera nothing is drawn through it, as an open instance cut shows nothing behind its plane. clipped_glass_draws_and_casts_like_the_part_it_keeps cuts a slab in half with either plane, sun and a local light on, and compares it with a slab of the kept half’s size: the frames are the same bytes with either plane, where the uncut slab differs in 6,502 of 65,536 pixels.
A content layer can cut its instance out: with InstanceSurface::cutout set, the prepass, the opaque pass and the sun and local light casters discard where the material’s content slot samples an alpha below Instance::alpha_cutoff (0.5 by default), outside the content’s crop, or everywhere when the material has no content slot, which is the tracer’s PlacementContent::cutout rule (wave 33) with the instance’s own cutoff. The alpha is read bilinearly from the slot’s level 0, so the edge follows the image’s resolution, not the mesh’s, and a card with a transparent image is one quad again, where it used to take a 128×128 grid carrying vertex alpha. The face rule (InstanceSurface::face) does not apply to the cut, as in the tracer. Vertex alpha still cuts as before, and a cut caster also tests vertex alpha × opacity, which plain casters never did. A material marked transmissive with set_transmission still casts its whole tint into the transmission map: that pass does not read content. The flag rides bit 2 of the instance’s content_face.z lane, so no binding or buffer layout changes. Batches split on it (Batch::cut, frame::surface_batches): a cut batch draws through a prepass pipeline compiled with use_content_cutout, the opaque pass compiles it in as the content_cutout feature, and its casters draw through shadow_cut_vertex and shadow_cut_fragment in the live cut shadow layout (scene, cascade, deformer and lighting groups, so the content slots and map materials keep their group 3 bindings); the cut prepass and caster pipelines are made on the first frame with a cut batch. A part without the flag compiles none of it: its prepass and opaque pipelines are built with use_content_cutout off and its casters keep the vertex-only pipeline, so its frames are byte-identical (tests/opaque_features.rs compiles the feature into a scene that does not use it and compares). Cut casters key the cascades’ static cache by their cutoff, opacity, content placement and slot and by the frame’s content epoch, which every set_content, write_content, copy_content, regenerate_content and release_content advances, so a content write redraws the cached cascades. set_opaque_off with content_cutout compiles the cut out of the opaque pass only, leaving the prepass and casters cutting, so use it to measure cost, not to look at the frame. frame::tests::content_alpha_cuts_a_ring_out_of_one_quad_in_every_pass draws a ring-shaped image on one quad: the hole and the clear corners show the floor and let the sun through, the ring and its shadow show, and every card pixel whose traced alpha is more than 0.05 from the cutoff matches the tracer’s rule (4,296 pixels, none differ). On a card scene at 3840×2160 (24 cards of 0.6 m with a 256² ring image over a floor, every card moving so the cascades redraw, card_scene_costs_with_content_cutouts_and_vertex_alpha_grids), one quad per card costs 0.364 ms of depth and velocity, 1.485 ms of opaque and 0.019 ms of cascades as plain cards; cut by content, 0.383, 1.548 and 0.032 ms; and as today’s 128×128 grids carrying vertex alpha, 0.593, 2.101 and 0.529 ms. Over the three passes the cut costs 0.10 ms more than plain cards and 1.26 ms less than the grids (0.55 ms of it in the opaque pass and 0.50 ms in the cascades). On a desk scene that cuts nothing, nothing changes: the desk built at the base commit and with this change, run one after the other under pgpu at 3840×2160 with the camera moving (600 frames, the same baked probes), reads 8.288 and 8.265 ms of opaque and 0.584 and 0.582 ms of depth and velocity, and in both the cascades stay in their static cache, since the desk’s casters draw through the same caster pipelines as before.
Ambient light takes the probe grid where a baked grid covers the point, and the sky’s L2 SH elsewhere. Indirect specular comes from reflection_radiance: SSR, then local probes, then the sky cube, weighted by the environment BRDF fitted to the tracer’s GGX (see The environment BRDF; zero where a material has no specular at all, as the tracer’s). The frame binds the two reflection probes nearest the camera’s box, and each surface weighs them by its own position in their boxes, with the seam fade; each cube is sampled at its own mip for the roughness, a coat at its own roughness’s mip, and the sky’s share is scaled by what the probes see of it (see Coat reflections and sky visibility). The sun is multiplied by the cascades and, under a tree, by the canopy cookie. In CanopyMode::Procedural the leaf shade weighs set_canopy_strength (0 to 1, default 0.88, today’s fixed weight): the shade scales by the strength over 0.88, so 0 is no gobo, 0.5 is half of the full-weight shade in linear light, and the default frame is byte-identical to before (tests/canopy_strength.rs). The cookie mode ignores it. The cascades pancake casters nearer the sun than their near plane onto it, so a receiver box fit still sees a window’s blinds a metre away; with Quality::sun_radius_deg above zero, the sample searches 16 taps for blockers within the widest penumbra the sun’s disc can throw (at most 48 texels) and filters 16 taps over the penumbra of the average blocker, else it keeps the 3×3 PCF and frames are unchanged. Against the engine tracer on a desk scene, with the bake’s triangles, materials, sky and sun on both sides, live’s mean linear luminance under sun and sky is 1.01 of the trace over the frame and 0.97 on the face, term by term; the soft sun filter still shadows sloped lit receivers a little (sun alone on the face, 0.99 with the 3×3 PCF and 0.93 with the disc), which a receiver-plane depth bias that loosens each tap’s comparison by its offset times the receiver’s slope should remove; tests/energy.rs pins a Lambert plane lit through baked probes under a uniform sky, the same plane under the sun alone, and a mirror sphere in a uniform sky to the tracer within 2%. The mirror found the tracer’s error: at roughness 0 its GGX lobe (α = 0.0004) is finer than f32 can resolve near the peak and returned 19% more light than a mirror reflects, so the tracer floors roughness and coat roughness at 0.05 (α = 0.0025, a 0.14° blur) and samples the lobe with the same α it evaluates.
Sun shadows against the trace. A product’s shadow check found three faults in the soft sun on its desk, each now a shadow::Quality field. A shadow map keeps only the surface nearest the sun, so under a window’s blinds pancaked onto the near plane a lip a few millimetres over a receiver is hidden, the blocker search finds only the blinds, and the penumbra widens to 48 texels and lets in light from between the slats. With a receiver box and the outer cascade, near_map (default on) draws cascade 1, otherwise a copy of cascade 0, without pancaking, so the depth clip drops the casters beyond the near plane and it holds the near ones; a soft sample whose search met a pancaked blocker (stored depth 0) and is not already dark also filters cascade 1 at the near blockers’ width and keeps the darker result. A receiver outside the box sampled no map and read lit: outer_scale (default shadow::OUTER_SCALE, 3) fits the last cascade to the box grown by that factor about its centre, every receiver reads cascade 0, and one outside it reads the outer cascade; inside the box frames do not change, since the cascades were identical there, and 0 restores the old fit. The soft sun’s taps already follow the receiver plane up to its slope limit, so its normal offset only pushed overhang shadow edges out by about 0.7 mm on that desk: soft_offset (default shadow::SOFT_OFFSET, 0) scales the normal offset on the soft path where N·L is at least 1/√17 (the slope limit), and more grazing receivers keep the full offset, which tests/soft_sun.rs‘s tilted planes need. layered (default off) tests each filter tap at two depths when the nearest blockers are a minority: at the near layer’s width, counting only depths within that layer’s band, and at the averaged width, counting only depths nearer the sun than the band. It darkens a thin caster’s umbra beside far casters that are inside the map, but on that desk the near map already does that and it costs 0.12 ms there. Each switch at its old value (layered false, outer_scale 0, near_map false, soft_offset 1) gives frames byte-identical to before: the desk’s sun-only plate built on main (0.18.19) and on this change with the switches off decodes to the same pixels (one SHA-256 for both, and for a second plate drawn in the same run), and the_new_switches_leave_a_single_layer_inside_the_box_unchanged compares two penumbra scenes bit for bit with the defaults. The flags ride bits 4 to 6 of the soft taps’ flag lane and soft_offset its fourth lane, so no binding changes. A caster quad long enough to cross both the near and the far plane is clamped per vertex, which tilts it, and the part past the far plane is clipped; split such geometry.
The offline tracer takes the same local lights: pfx_trace::lights::LocalLight has live’s fields and units (LocalLight::point is the conversion) plus spot and rect shapes, passed as Detail::lights. It samples every local light and one power-weighted emissive surface at each bounce, weighs area lights against BSDF sampling with the power heuristic, and traces all shadow rays through one any-hit pass; Tracer lights has the details and the checks.
The frame order is: LOD selection and upload, canopy cookie, shadow cascades (deformed, swaying and skinned casters drawn as moving, then prop impostors and creatures into each cascade), sun transmission through tinted thin casters, local light shadows, depth, velocity and ids, opaque shading (colour and view-space normal with roughness), impostors, linear depth, background motion (reprojected from depth wherever no surface wrote an id, so the sky and props carry camera motion), sky, a copy of the HDR image with seven mips, SSR (kept as next frame’s history), glass and liquid over that copy, volumes, particles with falling petals, creatures, TAA with glass’s reactive mask, then, when the frame has surface text or sharpening, the sharpen pass or a copy of the resolved image into the crisp display image and surface text drawn into that image (deformed glyphs are subdivided 8×8 and pass through deform_vertex; TextSpace::LitSurface and LitDeformed are shaded by the frame’s sun, cascades, canopy, probes and local lights in a separate lit pass), depth of field if set (exposure metered after it), the post chain, overlay text, and last the id passes for glass and text that carry an id (see Picks and ids). Text takes plain MSDF runs, rich quads from pfx_text’s layout_spans (colour, clip, rotation), and icon quads from a padded icon atlas, in surface and overlay modes alike. The sky is drawn before the copy so glass refracts it and SSR samples it. History resets when Scene.time goes back or moves on by more than the history gap (HISTORY_GAP, 0.25 s, settable with Renderer::set_history_gap), so a scripted clock at 30 fps, a fast clip or a dropped window frame keeps it, when the caller’s previous view-projection does not continue the last frame (a cut), on resize, on every set_sky (a daylight jump) and when a requested sky swaps in. A viewport change keeps it: the resolve reads the history at the rect it was written at.
Cold start
Section titled “Cold start”On an empty Mesa cache each opaque pipeline costs RADV a full compile of the ubershader, 1 to 4 s on the 9950X, and the rest of the renderer’s 61 pipelines about 0.4 s together in Renderer::new; on a warm cache an opaque pipeline takes 3.5 ms. Timed on a desk scene at 4K, before this change the first frame made six pipelines (three feature sets, each with both cull modes) without SSR, because the SSR history exists only from the second frame, and the second frame made six more with SSR; 12 compiles, 21.5 s to the second frame (median of three, empty cache, other builds running).
Three changes take it to 2.9 s, frames byte for byte the same:
- SSR is planned.
Renderer::newcallsFrame::set_reflections_planned(true), so a batch’s set carriesssrfrom the first frame. With no history the lighting group binds the black fallback for both SSR textures,ssr_resolvefinds every guide depth at zero, every weight zero, and returns exactlyvec4f(0.0), which is what the compiled-out branch assigns; so frame one draws through the second frame’s pipelines and the same bytes (the_first_frame_draws_the_same_bytes_through_the_reflection_pipelines, and the desk’s three 4K frames hash the same before and after). A camera cut or a resize, which drop the history, no longer compile a set either. AFrameused on its own keeps the old rule. - One cull mode at a time. A set no longer makes the two-sided pipeline until a two-sided batch draws with it. The desk has no two-sided batch, so it makes 3 pipelines where it made 12.
Renderer::warm(&[Scene])works out the keys each scene’s frame would make, through the same packing and feature code the frame runs (Frame::opaque_keys), and compiles them on worker threads (available_parallelism − 1by default,set_warm_workersto change it). The first scene’s keys go first, then the later scenes’ new ones. It returns in well under a millisecond with awarm::Warmingthe app polls:progress()(made and total, for the first scene and for all),first_ready(),done(), andwait()for a headless caller. Finished pipelines reach the frame through a channel it drains on the next frame; a frame that needs one still compiling waits for that one, and one the warm was not asked for compiles on the render thread as before. A worker that dies hands its key back, and the frame compiles it itself. Selected LOD instances are not part of the warm.Frame::opaque_made_on_render_threadcounts the compiles the warm did not cover. wgpu’sDeviceisSync, RADV compiles in the calling thread, and three compiles on three threads take as long as the slowest. The desk’s three are ready 2.7 to 3.2 s after the ask, and the first frame then takes 30 ms.
The pipeline cache. Renderer::use_pipeline_cache(path) opens a wgpu::PipelineCache from a file in the app’s own data folder, and every opaque pipeline made after it, on the render thread or a worker, goes through it; save_pipeline_cache() writes it back (to path.partial, then renamed), so an app saves once Warming::done(), and again on exit. The file wraps wgpu’s data in our own header: a magic, the key (wgpu’s pipeline_cache_key, which is the backend, vendor and device, then the adapter name, driver, driver info and the engine version), a SHA-256 of the data and its length. A file for another key, cut short, grown, flipped or not ours is reported as CacheLoad::Ignored and an empty cache is used, which the next save replaces. wgpu checks its own header too and falls back to an empty cache (fallback: true), and the driver checks its own. The device asks for Features::PIPELINE_CACHE wherever the adapter offers it (floor::OPTIONAL_FEATURES). wgpu supports it on Vulkan only. On Metal, or without the feature, use_pipeline_cache returns CacheLoad::Unsupported and nothing changes. create_pipeline_cache is unsafe because wgpu cannot prove the bytes came from get_data; the checksum is our proof.
What it adds over Mesa’s cache, measured on the desk with an empty Mesa cache and the saved file (817 KB): the three opaque pipelines take 4 to 5 ms each, and the first frame is ready 45 ms after the ask. With MESA_SHADER_CACHE_DISABLE=true it takes 7 ms. So on Linux it survives what empties Mesa’s cache: its size limit, shared with every other program (1 GB by default, games evict it), a user or distribution that disables it, and a cleared ~/.cache. A driver update empties both caches. On Windows, Vulkan drivers keep their own caches as well, but how they keep and evict them is up to each vendor; this file is the one cache the app owns there. Neither cache helps a buyer’s first launch. The warm and the fewer variants do. The pipelines Renderer::new makes take no cache yet. They cost 0.4 s cold, against 0.14 s warm.
Picks and ids
Section titled “Picks and ids”targets.ids is an R32Uint target, one id per pixel, 0 where nothing pickable was drawn. Renderer::pick(x, y) copies that pixel and the linear depth with the next frame, and Renderer::picked() returns them as a Picked { x, y, id, depth } once the frame is done, from a non-blocking poll. Ids are the caller’s: Instance::id for meshes, glass::Surface::id for glass and liquid, and TextItem::id and TextQuadItem::id for text. Id 0 never writes, so whatever is behind shows through to the pick.
- Opaque meshes write their id in the depth and velocity prepass, as before. TAA and the background motion pass read these ids, and only these.
- Glass writes its id where its front face is nearest: where the glass pass’s own front id is set, the opaque depth is not in front of it and the back face lies behind the front, the same test that decides where the compose pass draws. Glass with id 0 in front of another glass leaves the pick on what the frame wrote before the glass pass, not on the glass behind.
- Surface text (
Surface,LitSurface,DeformedandLitDeformed) writes its id where a glyph’s coverage passes the cutout (coverage at least 0.5,ID_CUTOUTin the glyph shader, and colour alpha above 0), behind the same depth test as its colour, so text hidden by a mesh in front of it writes nothing. The id is per run or block (TextItem::id,TextQuadItem::id): set it to the id of the object the text sits on to pick the object, or to the text’s own id to pick the text. Each text space draws its ids with the same vertex shader as its colour, so ids and ink cover the same pixels on a deformed sheet. - Overlay text with an id writes it the same way, on top of everything else.
The id writes are separate passes at the end of the frame, after TAA, the post chain and overlay text, each drawing only into targets.ids (glass’s ids pass reads the glass pass’s own targets through the compose layout, so no layout gains a binding). TAA, velocity, the reactive mask and every colour byte are the same as with no ids; a frame with no glass or text id runs none of them. Text ids are drawn after glass ids, as text’s colour is drawn over glass’s.
For picks without a GPU round trip, Renderer::text_bounds() returns a TextBounds { list, index, id, rect } for every text item of the last frame: which list it came from (TextList::Surface, SurfaceQuads, Overlay, OverlayQuads), its index there, its id, and its screen-space box in pixels, [left, top, right, bottom], clipped to the viewport. Renderer::text_at(x, y) returns the topmost box under a pixel (overlay before surface, later items before earlier). The boxes follow each glyph’s placement, rotation, clip and turn; deformed text is boxed at its rest pose, without the deformer’s offset. text::screen_bounds(space, glyphs, size) computes one box for any glyph list. Within a box, pfx_text::hit, marks and caret find the character.
Render scale and dynamic resolution
Section titled “Render scale and dynamic resolution”A game that renders the 3D frame below the window’s size keeps the Renderer at the content size and sets its viewport to ScreenReport::render_size(scale), renders into pfx_live::upscale::Upscale::source_target, and draws it into the window with Upscale::encode: a clamped Catmull-Rom upscale of the frame’s top-left rect into the content rect, the aspect policy’s bars around it, and a flat HUD at native resolution over it (0.43 ms from 1080p to 4K). pfx_gpu::screens::DynamicResolution picks the scale from this renderer’s timestamps (Submitted::arrived) against a GPU budget, and Renderer::apply_dynamic_resolution applies it each frame. Both are in Screens: modes, the device, aspects and scale.
The viewport. Renderer::new and resize allocate every screen-sized target at the given size, the capacity (capacity()); set_viewport(width, height) picks the top-left rect a frame draws into (size()), at no cost: no allocation, no pipeline, no history restart. Every pass reads and writes only the rect, with UVs, texel sizes and clamps taken from it; the scene colour mips are built over the rect with a one-texel edge border so mip taps clamp at the rect’s edge as they would at a texture’s; TAA and SSR reproject their history from the rect it was written at into the current one, so a scale step blends instead of restarting. At the full rect the bytes are unchanged; at a smaller one the frame matches a renderer resized to the rect within 0.016/255. The output need only be as large as the rect. Frames on demand and flat looks draw at the full rect. The details and measurements are in Screens: modes, the device, aspects and scale (Viewport dynamic resolution); Renderer::target_textures() lists the allocated targets, and history_restarts() counts the history’s restarts.
Submission, late timings and the sky’s settle
Section titled “Submission, late timings and the sky’s settle”Renderer::render encodes a frame, submits it, waits for the GPU and returns that frame’s pass timings, exactly as before. Renderer::submit takes the same arguments, encodes and submits the frame and returns at once with a Submitted { frame, arrived }: the frame’s number (counted per renderer from 0) and the timings of earlier frames that completed since the last call. Timestamps are copied into a ring of three readback buffers, each mapped with map_async and collected by a non-blocking poll before and after each submit; Renderer::timings() is the latest complete set with its frame number. A frame’s timings arrive one or two frames late (TIMING_LAG is 2). The ring never stalls the caller: a frame that finds every slot in flight has its timings dropped, and a set that comes back more than two frames late is dropped too; timing_drops() counts both (ring, late). frames_in_flight() is how many submitted frames the GPU has not finished, and wait_frames(n) blocks until at most n remain, for a headless caller that wants a swapchain’s pacing (wait_frames(0) drains; both return the timings that arrived). A windowed app is paced by its surface and needs neither. A submitted frame draws the same bytes as a rendered one: 120 frames through submit end on the same image as 120 through render, and paced to two frames in flight every frame’s timings arrive within two frames. An unpaced loop that outruns the GPU drops timings instead of waiting. Measured on 2026-10-03 on a desk scene at 3840×2160 under pgpu with turns, uncapped, without the light bakes (PFX_UNCAPPED=1), 600 frames each: render holds the calling thread 15.17 ms a frame (p50; p95 20.93 ms) at 61.2 fps; submit holds it 0.63 ms (p95 1.07 ms, max 1.49 ms) and, paced to two frames in flight with wait_frames(2) and drained before each turn, runs at 64.3 fps (frame p50 13.93 ms against 15.17 ms), with no timings dropped. The scene is GPU-bound, so the gain in frame rate is the CPU’s encode now overlapping the GPU; the calling thread is free for the other 13 ms. Auto exposure reads its meter whenever the reading comes back, so with submit it adapts a frame or more later than with render; a fixed exposure is identical on both paths.
Readbacks stay blocking: readback_geometry, the TAA captures and Gpu::readback_* submit a copy and wait for it, so they return the last submitted frame. A pick (pick, then picked) is copied with its frame and comes back once that frame is done, from a non-blocking poll, on either path.
Renderer::set_sky (and Frame::set_sky(.., true)) still builds the sky’s SH and GGX cube on the calling thread before it returns, with the same result as before. Renderer::request_sky(source, hour) (and Frame::set_sky(.., false)) never blocks: it records the request and returns its number. At the start of the next frame the newest request is handed to the sky’s worker thread, so many requests within one frame build only the last; while the worker builds, a newer request replaces any waiting one, so a scrubber calling it every frame keeps only its newest hour and never starves. The worker computes the SH in the stepped order and the six cube faces on six scoped threads, converts and uploads the HDR and every cube mip into new textures, and the frame that finds it done swaps the background, SH and cube in whole, sets the probe hour that came with the request and resets history. Until then the old sky lights every frame, so no frame shows a half-built cube. The stepped result is bit for bit the settled one: the same per-texel prefilter and the same SH summation order. sky_settling() says whether a request is still on its way and wait_sky() blocks until it is shown. A set_sky with settle cancels any request in flight.
Measured in a release build: a request_sky call takes about 0.6 µs on the calling thread (mean over 21 calls, 5 µs at most), and the 128-edge analytic sky swaps in about 31 ms later, where set_sky with settle blocks for 109 ms.
Frames on demand
Section titled “Frames on demand”A product whose camera barely moves should not redraw a 4K frame every vsync. With Renderer::set_frames_on_demand(Some(Limits::default())) the renderer keeps the last full frame and answers, each tick, what the next one needs; set_frames_on_demand(None) and the plain render/submit calls keep today’s behaviour (a product’s Redraw::Always). It needs an sRGB output format (the display path), and refuses Rgba16Float outputs. It draws at the renderer’s full size: it refuses a viewport smaller than the capacity, and set_viewport refuses one while it is on.
With PFX_FRAME_STATS=<path> set, every frame the renderer submits, on demand or not, writes one JSON line with its kind (full, partial, reproject, bridge or idle) and its sim, submit and GPU time (Frame stats).
let mut next = Next::new(&scene, &text, &effects, finish);next.hud = hud; // a FlatScene drawn over the frame, if anynext.content = revision; // bump when content the renderer can't see changed (content textures)next.overlay = hud_revision; // bump when the HUD changednext.moving = &animated; // screen rects of HUD animationsmatch renderer.frame_needed(&next) { Need::None => {} // present nothing; the last image stands need => { renderer.submit_needed(&next, &need, &surface)?; }}frame_needed(&next) -> Need returns one of four answers:
None: nothing differs from the frame on screen. No submission at all: the GPU idles.present_last(output)re-presents the last image for a product that must present every vsync (a blit, 0.18 ms at 4K).Reproject: only the camera moved, and by less than the limits. The cached frame is warped by its depth to the new camera.Partial(regions): the camera is where the cached frame was and only time-driven layers (steam, particles) or the overlay changed; only those rects are redrawn over the cached frame.Full: anything else.
What counts as a change
Section titled “What counts as a change”The renderer fingerprints, every tick, everything a full frame draws from, and compares it with the cached frame’s print:
- the scene: every instance (mesh, model, material, id, opacity, cutoff, deformer, age, coverage, flags), every material, every deformer, the sun, the seed and the wind’s strength and direction (not its clock);
- surface text: atlas, colour, id, glyphs and each space without its camera part (a
Surfacespace’s model is recovered asVP⁻¹·MVPand compared within 1e-4, so camera drift never reads as moved text); - glass surfaces and impostor props;
- the finish and every look setter: each
set_*on the renderer that changes the image (finish, tape, lens, haze, exposure, sky, probes, ambient, contact, reflection occlusion, sky visibility, probe emission, lights, light quality, transmission, maps, detail, props, tree, canopy, volume cuts, shadow quality, sharpening, late surface text, texture filtering, meshes, glass meshes, the LOD set, resize) bumps a revision; lens, haze, exposure, tape, sharpening, late text and texture filtering only when the value differs, so a product may set them every tick; Next::content, the product’s own revision for what the renderer can’t see.
A change makes the next frame Full and then keeps Limits::settle (8, one TAA jitter cycle) more full frames so the history converges before the frame goes still. Time-driven work that only a full frame draws is ticking and keeps every frame full: creatures, liquid, a tree (sway, petals, the canopy gobo), any wind with strength above zero, a running tape glitch, a sky still settling, and auto exposure while it moves (more than 0.1% between full frames). Room haze is held still between full frames: its drift is about 2 cm a second and its per-frame jitter is TAA’s to settle, so an idle frame keeps the last one. Time alone changes nothing.
Steam volumes and particles are layers: in on-demand mode a full frame draws them after the TAA resolve, the sharpening and the late surface text, over a copy of that image (the still), with the unjittered camera, then runs depth of field and the post on the result. Their noise is continuous in time and seeded per scene, not per frame, so they need no TAA; a partial frame redraws exactly what a full one draws. The first frame with a new layer is full, so the still exists. Overlay text and the HUD go into their own layer, drawn only when the overlay’s print (its text, spaces and Next::overlay) changes, and are composited over the frame in the same pass that presents it.
Reprojection
Section titled “Reprojection”The warp is one full-screen pass from the cached colour and its linear depth to the new camera. For each output pixel it solves F(s) = goal by fixed-point iteration, where F sends a cached pixel through its depth to the new view (one affine ray, one fused matrix: about 12 FMAs a step), stopping under 0.05 px. A min/max depth map of 4×4 tiles, each widened by its eight neighbours, built once per full frame (demand tiles), says whether the pixel is near a depth edge; there it also solves from the nearest and farthest depths and keeps the nearest hit, and where nothing converges it takes the background solution, which fills the disocclusion from its neighbours. Colour is resampled with a 5-tap Catmull-Rom clamped to the range of its own five taps, and a pixel that lands within 0.002 px of a texel centre loads that texel exactly, so a warp onto the cached camera reproduces the cached frame byte for byte. Sky pixels move as directions.
The clamp to the taps’ own range replaced a clamp to the 2×2 neighbourhood around the sample (wave 83), so the colour takes five filtered fetches where it took nine. The warp is bound by its filtered fetches, not by bytes: with one bilinear fetch in place of the Catmull-Rom it ran in 0.291 ms. Measured at 3840×2160 under pgpu run --class clip, the builds paired in one job at the same clock (1.5–1.8 GHz), three runs each, per reprojected frame without and with the HUD line: 0.487 / 0.510 ms before, 0.470 / 0.500 ms after. In reprojection_error_stays_small_inside_the_limits the mean error moves by at most 0.001/255 and mostly falls, and the visible share rises by at most 0.011 points (pitch 0.25°: 0.202 to 0.197, 0.302% to 0.296%; zoom 1%: 0.278% to 0.289%). Two variants were rejected:
- An 8-bit copy of the cached colour (
Rgba8UnormSrgb, written by the full frame’s present as a second target). With nine fetches it saved nothing (0.488 / 0.510 ms); with five it gave 0.463 / 0.491 ms, only 0.007–0.009 ms better than the float colour, and it added 0.07 ms to every full frame’s present (0.215 to 0.285 ms). It also needed the float colour beside it where an overlay pixel is partly transparent, to keep the warp onto the cached camera exact. - A packed
R32Uintcopy at gamma 2, read through four gathers (the full 4×4 kernel, four fetches). Unpacking and weighting sixteen texels made the pass ALU-bound at 0.796 ms, and quantizing in another curve than the output’s doubled the mean error (orbit 0.3°: 0.141 to 0.267).
A reprojected frame is replaced by a full one when any limit is passed:
| Limit | Default | Measured at the limit |
|---|---|---|
angle: view rotation plus translation over the median of 16 probed depths |
0.6° | 0.23–0.30% visible at 0.46–0.54° |
zoom: |ln| of the focal change |
0.03 | 0.23% visible at 0.02 |
holes: disoccluded and revealed share of the last warp |
0.5% | 0.41% at 0.54° of orbit |
frames: reprojected frames since the last full one |
90 | |
| the camera came to rest on a warped view | one full frame |
The defaults come from reprojection_error_stays_small_inside_the_limits, which warps a settled 1280×800 frame of the neutral desk and compares it with a settled full render at the same pose. TAA’s own frame-to-frame floor on that scene is a mean of 0.115/255 with 0.149% of pixels more than 12/255 apart (“visible”).
| Move from the cached view | Angle | Holes | Mean /255 | Visible | Decision |
|---|---|---|---|---|---|
| orbit 0.1° | 0.18° | 0.12% | 0.11 | 0.22% | reproject |
| orbit 0.2° | 0.36° | 0.26% | 0.13 | 0.26% | reproject |
| orbit 0.3° | 0.54° | 0.41% | 0.14 | 0.23% | reproject |
| orbit 0.45° | 0.81° | 0.60% | 0.16 | 0.28% | full |
| orbit 1.2° | 2.15° | 1.61% | 0.24 | 0.44% | full |
| pitch 0.25° | 0.46° | 0.45% | 0.19 | 0.30% | reproject |
| pitch 0.5° | 0.92° | 1.05% | 0.34 | 0.77% | full |
| push 1% | 0.48° | 0.05% | 0.14 | 0.19% | reproject |
| push 2% | 0.97° | 0.12% | 0.24 | 0.60% | full |
| zoom 2% | 0 | 0 | 0.17 | 0.23% | reproject |
| zoom 4% | 0 | 0 | 0.21 | 0.35% | full |
The worst pixel is 110–180/255 in every row: a disocclusion at a depth edge, filled from the background beside it, for one frame until the refresh.
Inside the limits, reprojection stays within twice TAA’s own floor; past them the visible share and the holes climb.
Partial redraw
Section titled “Partial redraw”A partial frame copies the still into each region, draws the layers (the volume scissored to its box’s screen bounds plus 8 px, which its region covers with a 4 px margin), runs depth of field and the post over the layer into a scratch image, and copies only the regions into the cached colour, which is then presented with the overlay. Regions closer than 16 px merge, and above 60% of the screen they become the whole screen. Each layer region also grows by the finish’s reach, GpuChain::region_reach() (below), since a finish that reads beyond the pixel changes the frame that far from the layer: a Kuwahara of radius 3 grows it by 3 px. The post over a grown rect reads up to the reach beyond it again, and those pixels of the layer image are the still, because every earlier layer draw stayed inside an earlier region and a partial frame copies the still over this frame’s regions and the last frame’s. A finish whose reach is unknown (bloom with strength, a halation, outlines, distortion, the tape) or a lens makes every layer region the whole screen: still no scene draw, only the layer and the post. A pointwise chain the post cannot run by region (a bloom at strength 0) keeps its rects and posts in full. A partial frame posts with the cached frame’s number, so grain holds still with the rest of the frame.
The post runs over the rects only, through GpuChain::run_region(…, rects, …): each stage is a compute pass over the rect (a workgroup of 8×8 covers it and the shader’s scissor, post.region, drops the pixels outside), grown by the reach of the stages after it, so a stage reads only what the one before it wrote. Whether a chain can do this is decided once per chain by GpuChain::region_reach(), which is the sum of the reaches of its passes, or None. Every group must be one stage from the image to the image and every pass must have a known reach: the per-pixel passes and the fused grade (0), Cel, Rim, Cavity and the fused cavity and rim (0: their normals come in with the frame, not from an earlier stage), the one-bit, halftone, dust and scratches passes (0), the watercolor (1, its Sobel), Kuwahara (its radius, at most 8), weave (twice its amount, rounded up, plus 1) and aberration (its amount, rounded up, plus 1). A chain with a bloom or a halation (the pyramid reads the whole frame), a neon glow, the tape, distortion or curvature (which sample anywhere), pixelate (blocks), or any pass that reads depth (outline, comic, rubber hose, modern comic, tilt shift) returns None, and run_region then runs the whole post and returns false; the caller’s copy out of the scratch image is unchanged either way. A region run writes nothing outside its rects, and inside them it is the full run’s pixels (region_post_equals_the_full_post_inside_its_rects, on a neutral synthetic image with one and several rects, at the image’s edge, overlapping and past it, over five chains; the largest difference is 0). Measured at 3840×2160, the GPU time of the post under pgpu run --class clip, median of 14:
| Chain | Full | 64×64 | 512×256 |
|---|---|---|---|
| standard finish (exposure, tone, encode: one grade) | 0.159 ms | 0.0013 ms | 0.0037 ms |
| pointwise finish (exposure, vignette, tone, grain, encode) | 0.297 ms | 0.0016 ms | 0.0061 ms |
| Kuwahara 3 and the pointwise finish | 2.378 ms | 0.0127 ms | 0.0448 ms |
| Kuwahara 2, sepia, Kuwahara 4 and the pointwise finish | 4.659 ms | 0.0239 ms | 0.0894 ms |
Renderer posts a partial frame’s layer regions this way; a reprojected frame, whose layer is the whole image, still posts in full.
Under a finish that reads beyond the pixel, a partial frame used to redraw the whole screen. Measured with frames --large --partial (the desk’s steam box fitted to a 64×64 and a 512×256 layer rect, under Kuwahara 3, a vignette and ACES, still camera), GPU time per partial frame at 3840×2160, both builds in one pgpu run --class clip job at 1.8 GHz, two runs each:
| Layer rect | Whole screen (before) | Grown by the reach (after) |
|---|---|---|
| 64×64 (posted as 70×70) | 3.14 ms | 0.35 ms |
| 512×256 (posted as 519×263) | 3.28 ms | 0.53 ms |
The post itself falls from 2.41 ms to 0.014 and 0.052 ms; what is left is the volume and the composite that presents the frame with its overlay (0.23–0.25 ms). partial_redraw_under_a_reaching_finish_grows_its_rects_and_matches_a_full_frame proves it on the neutral desk with steam, an overlay and that finish: the partial frame’s region is the steam’s rect grown by 3 px, and three partial frames in a row are byte-identical (the largest difference 0/255) to a fresh full frame inside it and to the frame before outside it; with a lens the region is the whole screen again. The test’s finish has no grain, because a partial frame keeps the cached frame’s grain and a fresh full frame draws its own.
partial_redraw_matches_a_full_frame_inside_its_rects_and_leaves_the_rest proves it on the neutral desk with steam and an overlay: three partial frames in a row, each compared with a fresh full frame at the same time and the same jitter phase, are byte-identical to it inside the regions, byte-identical to the frame before outside them, and the steam moved more than 50 pixels each time; when the steam stops, the partial frame equals a full frame without steam.
Waking the GPU
Section titled “Waking the GPU”GPU clocks follow load: on the RX 9060 XT (500, current, 2780 MHz levels in pp_dpm_sclk) a light frame rate leaves the shader clock low, so the same frame costs more milliseconds of GPU at idle-like loads than at full load. Two mitigations are the product’s choice:
Renderer::wake(groups)submits a cheap ALU compute pass (demand::WAKE_GROUPSworkgroups) the moment input arrives, before the product builds the frame.Renderer::submit_bridge(&next, output)shows the cached frame warped to the new camera at once (no limits applied), while the full frame renders into the next image.
Measured at 3840×2160 (frames --large --wake): full frames at 60 fps with the camera jumping 2° every other frame, then an idle of the given length with no submission, then the input: the variant, then a full frame. Medians of five; the clock is read from pp_dpm_sclk right after the idle and right after the frame.
| Idle | sclk after the idle | Full GPU ms, plain | with wake |
with wake ×8 |
Bridge on screen | Full frame done |
|---|---|---|---|---|---|---|
| 0 | 1.6–1.8 GHz | 4.89 | 4.93 | 4.94 | 0.9 ms (0.60 GPU) | 7.3–7.8 ms |
| 0.1 s | 1.6–1.8 GHz | 4.92 | 4.92 | 4.89 | 1.0 ms | 7.7–8.1 ms |
| 0.3 s | 1.4–1.7 GHz | 4.95 | 4.95 | 4.90 | 1.1 ms | 7.7–8.3 ms |
| 1 s | 1.4–1.6 GHz | 4.93 | 4.93 | 4.89 | 1.1 ms | 8.5–9.8 ms |
| 2.5 s | 1.4–1.6 GHz | 4.98 | 4.97 | 4.89 | 1.1 ms | 8.7–9.5 ms |
Steady full frames between the trials take 4.93 ms (p50), so on this desk scene and this machine the first frame after up to 2.5 s of idle costs no more GPU time than a steady one, and the clock read after the idle never fell below 1.4 GHz; wake (0.18 ms of GPU for 1,024 groups) changes nothing measurable. The clock does move the cost: with other work holding it near 3.4 GHz the same full frame took 3.97 ms, against 4.9 ms near 1.7 GHz, a ratio like the 5.0 against 8.4 ms a product reported from one opaque pass. Idles of 3 s and more could not be measured under pgpu, which lends an unused GPU to the next job after 3 s and returns it only after that job’s turn. The bridge is the mitigation that shows: a frame for the new camera is on screen about 1 ms after the input, 6–8 ms before the full one.
Measured with cargo run --release -p pfx-live --example frames under pgpu run --class clip on the neutral desk (crates/live/tests/support/desk.rs): a desk, legs, books, a mug, a chrome ball, a lamp stem, a card with EB Garamond text, a wall and floor under the 16:00 analytic sky, and a HUD line; steam is a plume over the mug. The drift is the sub-degree orbit of Pose::drift. GPU milliseconds are the sum of the frame’s pass timestamps, mean per tick including the full refreshes.
| Scene | 3840×2160 | 1280×800 |
|---|---|---|
| full frame, on demand, with the HUD line | 4.68 | 0.65 |
| full frame with steam | 5.18 | 0.78 |
| idle | 0 (no submission) | 0 |
idle, presenting every vsync (present_last) |
0.15 | 0.025 |
| drift: mean per tick / per reprojected frame | 0.56 / 0.50 | 0.091 / 0.080 |
| drift without the HUD line | 0.54 / 0.48 | 0.088 / 0.077 |
| drift, five fetches (wave 83) | 0.563 / 0.500 | |
| drift without the HUD line, wave 83 | 0.534 / 0.470 | |
| steam, still camera (partial) | 0.68 / 0.67 | 0.16 / 0.16 |
| drift with steam | 1.21 / 1.15 | 0.24 / 0.23 |
The paced runs held the clock at 1.5–1.8 GHz. They predate crisp-live’s merge; on the merged tree a full frame of the same desk is 5.2 ms (its crisp copy adds 0.18 ms and the opaque pass reads 1.86 ms against 1.60), and a reprojected frame is unchanged at 0.48 ms, 0.51 ms with the HUD line. In 600 ticks of drift, 592 were reprojected and 8 were full refreshes, each when the orbit passed 0.6° from the cached view. Partial frames with steam spend 0.24 ms on the volume, 0.18 ms on the post over the layer and 0.22 ms on the composite at 4K; the post then ran over the whole image, the steam’s rect being 13% of the frame; it now runs over the rect (see Partial redraw).
A full frame in on-demand mode adds the depth tiles (0.09 ms at 4K), sixteen depth probes and, with steam, the still copy (0.21 ms) to an ordinary one, and presents through the composite (0.21 ms) instead of the blit (0.18 ms).
Picks during reprojected and partial frames read the ids of the last full frame, off by the drift.
Risks and decisions still open
Section titled “Risks and decisions still open”| Risk or question | Prototype/decision needed |
|---|---|
| Native 4K shading and bandwidth exceed the estimate | Timestamp each pass on the target GPU/driver with real shipping scenes before fixing quality tiers; investigate overdraw, formats and upload stalls. |
| TAA ghosts curled text, thin foliage, liquid and chrome | Build motion-vector, disocclusion and reactive-mask reference clips; compare to native 4× MSAA. |
| Analytic refraction and caustics lose their look | Measure the scissored SDF bridge, then test explicit front/back meshes and caustic textures against offline frames. |
| Baked irradiance leaks, or daylight anchors pop | Compare a six-lobe grid with visibility-aware bricks and directional lightmaps; decide anchor times from measured image error. |
| Material parity is only nominal | Define numeric test swatches for IOR, roughness, transmission, coat and ageing in the shared material step; review linear HDR outputs before grade. |
| Asset size and adapter contracts are unsettled | Confirm KTX2/BC feature support, package budgets, scene snapshot versioning and bake manifest fields with the apps that use them. |
| Effect counts and 2-million-triangle cap may be wrong | Profile representative scenes; tune scene caps without reducing native output or hiding visible curvature. |
This document does not prove 60 fps or settle precise scene caps, final bake anchor count, probe layout, compressed-format support, scroll tessellation limits, or the exact visual tolerance for each product. Those are measured prototype decisions. The first implementation milestone is a 4K scene with GPU pass timestamps and matched offline reference frames.