diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 116efd6..b20eb4b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2140,6 +2140,28 @@ Not done here: filters sized by hit distance, which is what would let a short hi Playground and the page still open a 1280 × 720 view; the live buffers are sized by the output (about 1.7 GB at 2560 × 1440), though the traced grid rarely fills it. +**The live passes' buffers by what they hold, and `live.rs` in files (2026-11-13, after it).** The live passes' state +had grown by reuse: a pair of buffers named `alb` held the diffuse fast mean, the upscaler's flicker and lock and +`judge`'s findings over one another; the output colour history sat behind the traced grid's diffuse history in one +buffer; velocity had a ping-pong partner nothing read; the g-buffer was allocated at twice what it holds; every pair +was swapped by binding the other of two bind groups. Now what lasts from one frame to the next has a buffer of its +own, named for what it holds, in two halves (this frame's and the last; `LiveParams.state` says which is which): +`diffuse_hist`, `diffuse_mom`, `diffuse_fast`, `spec_hist`, `gbuffer`, `up_state`, `color_hist`; the transients that +share a buffer are listed at the top of `live.wgsl` with who uses each when. The upscaler's state is one `vec4` +a pixel with half of z and all of w free: the lock's depth (the fix the moving lamps wait for, above) goes there. +Storage bindings 28 → 24, one bind group, 16 bytes a pixel less (59 MB at 1440p); the merged layout binds 21 live +buffers. `live.rs` (4,931 lines) is now `live/`: `mod.rs` (`LiveCore`, its settings, camera, governor, readbacks), +`targets.rs` (pipelines, buffers, bind group), `load.rs` (a scene loaded, the sky's and emitters' workers), +`edits.rs` (edits in place), `frame.rs` (one frame), `session.rs` (the headless runner); no code changed by the move. + +Checks: `render-identity`, native and `THREERT_MERGED=1`, two runs a side and four on what it flagged: nothing +changed, by the buffers or by the move. `bench-window` (still-bistro, tokyo-turn): within a run's spread with the +radiance cache on; with it off 0.03 ΔE between frames against main's 0.01 to itself (a shader compiled from other +source rounds differently). The live passes 6.27 → 6.13 ms (Bistro, 1440p at 0.375). On the way: `render-identity` +confirms what it flags on four more runs a side, and leaves out a confirming run without an output (the +`geometry/grow-live` test sometimes fails before its picture: its frame script, adding 1,100 objects, runs past the +script's time). + ## 2. Temporal fixes Targets (2026-11-08): bench-temporal, bench-ghosting and their UE MRQ references move to Bistro night (§ 0 step 7); the diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 3190106..66fba4d 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -46,7 +46,7 @@ runtime, and drives the engine directly, the way a UE game drives its actors: | Engine API | `packages/scene/src/engine.ts` (`Engine`), `renderer/src/script.rs` (`__engine` natives) | Handles (u32) for geometries (typed arrays), textures (asset path, encoded image or RGBA8 pixels), material-function code, materials, objects (group / mesh / model / light / camera) and their transform, visibility, power, light / camera parameters; the environment and render settings. Descriptors are JSON-shaped (`renderer/src/desc.rs`). | | Scene binding | `packages/scene/src/engine.ts` `SceneBinding` | Keeps the engine in step with a DSL scene. Objects report their own changes (core.ts change tracking: transforms, visibility, power, shadow flag, mesh geometry / material, add / remove), so a sync visits only what changed. Lights, cameras and materials are compared against what was last sent. | | World | `renderer/src/world.rs` | The engine's objects, resources, environment and settings, and what changed since the last commit: parameter changes (power, light / material parameters, environment, camera) or structural ones. Asset paths must stay inside the asset root (game code is untrusted on nodes). | -| Live commit | `renderer/src/live.rs` `LiveCore::commit`, `tables.rs` | Parameter changes rewrite GPU table entries in place (materials, lights, object power, emitter CDF, wetness / rain / fog, camera), and so does hiding or showing a volume, a light without a card or a particle system that is not light-sampled by its own `visible`. Anything structural rebuilds the GPU scene from the world, in memory; history survives, validated per pixel. | +| Live commit | `renderer/src/live/edits.rs` `LiveCore::commit`, `tables.rs` | Parameter changes rewrite GPU table entries in place (materials, lights, object power, emitter CDF, wetness / rain / fog, camera), and so does hiding or showing a volume, a light without a card or a particle system that is not light-sampled by its own `visible`. Anything structural rebuilds the GPU scene from the world, in memory; history survives, validated per pixel. | | Scene runtime | `renderer/src/script.rs` `ScriptHost` | QuickJS (rquickjs) with no I/O (no fs, network or timers; what needs the outside is asked of the app, "The host channel"). Loads a bundle (one script) or ES modules (`game.js` + `threert-runtime.js`, the cloud layout). Memory capped at 1 GiB. Time budgets: setup 120 s, event 1 s, frame 50 ms; the interrupt handler aborts overruns. Every entry ends by running the promise jobs it left, inside its budget ("Promises", below). The time the engine spends inside a voxel call (`voxelExplode`, `voxelEdit`, `voxelFrame`) is the engine's, not the script's: the deadline moves by it as measured, up to 250 ms an entry; past that it counts against the script, and a voxel call made after the deadline throws "interrupted" at once. A prelude adds `TextEncoder`, `structuredClone`, `atob` and `console`. | | Node host | `packages/gameplay/src/node-host.ts` | `globalThis.__threert`: `setup(room, seed)`, `event(type, payload, ts)` (local authority: execute + commit), `commit(writes, seq)` / `setState(state, seq)` (room authority), `state()`, `frame(dt, now)`, `hostMessages(list, ts)` (the app's messages), `sync()` (the engine follows the scene once more: after an entry's promise jobs), and the editor's `outline()`, `inspect(name)`, `edit(name, path, value)`. | | World handlers | `packages/gameplay/src/world.ts` | `on[type](world, ev)`: an event handled on this node, changing the scene at once. `WorldContext`: `scene`, `camera`, read-only `state`, `object(id)`, `time`, `now`, `random()`, `emit(type, payload)`. | @@ -421,7 +421,7 @@ The apps in this repository: | app | input | `net.*` | `launch` | HUD | | --- | --- | --- | --- | --- | | `threert-render play` (`view.rs`) | keys by physical position, mouse buttons, wheel; the pointer captured for `"look"` (a click; Esc lets go, Esc again quits) | WebSockets and HTTP on a thread each (`src/net.rs`: tungstenite and ureq on rustls; text frames of at most 1 MiB, bodies of at most 4 MiB, 16 sockets, 32 requests at once), any address | `--launch '{"room":"K7Q4MZ"}'` | not drawn; `--hud-log` prints each description | -| `threert-render live` (`live.rs`) | whatever the driver sends | none: the driver is the app | `--launch` | on stdout with every other post | +| `threert-render live` (`live/session.rs`) | whatever the driver sends | none: the driver is the app | `--launch` | on stdout with every other post | | the three.js view (`tools/three/view.ts`, `host.ts`, `hud.ts`) | keyboard, mouse, the Pointer Lock API | `WebSocket`, `fetch` (the browser's rules: same-origin policy, CORS) | the page's query (`?room=K7Q4MZ&name=Ada`) | DOM over the canvas | - `live` speaks the channel as JSON lines, so a game's use of it is testable without a window: on stdin @@ -918,7 +918,7 @@ procedural objects (`trace_procs`: the ray into the object's space, a tree of it step (the A/B). **Edits in place.** The edits that hardware traversal makes in place go through one type (`gpu::Top`: the TLAS and -its BLASes, or the units), so the engine's path is the same under both traversals (`live.rs` `move_instances`, +its BLASes, or the units), so the engine's path is the same under both traversals (`live/edits.rs` `move_instances`, `update_instanced`, `edit_geometry`): | edit | under software traversal with units | @@ -1245,7 +1245,7 @@ on the device", and differs where it says so. (a tenth of the rays an iPhone 15 Pro traces). This source does not reach it: the trace costs 0.42 to 0.65 µs a traced pixel at one bounce whatever the scene, which is 249 × 140 to 333 × 187 traced at 30 fps ("Checked on the device"). -- **Dynamic resolution by the clock** (`THREERT_GOVERN_CLOCK=1|0`, default on Android; `live.rs` +- **Dynamic resolution by the clock** (`THREERT_GOVERN_CLOCK=1|0`, default on Android; `live/mod.rs` `govern_resolution`). The render scale was governed in frames (a check every 30th, the urgent one after the first 120), which is twice a second and 2 s only at 60 frames a second. A scene opens at its full scale, on this GPU at about 6 frames a second, so it was a slide show for 25 s. With the clock it is at its frame rate 3 s after its diff --git a/docs/VOXEL.md b/docs/VOXEL.md index c18990c..9a2f7e2 100644 --- a/docs/VOXEL.md +++ b/docs/VOXEL.md @@ -38,7 +38,7 @@ What the research below sorts into each layer: (`renderer/src/gpu.rs:871`). - Incremental structure: adding or removing objects and replacing geometry without the full rebuild (`World::set_geometry` sets `structural`, `renderer/src/world.rs:242`; `LiveCore::rebuild` reloads the whole scene, - `renderer/src/live.rs:1548`; ~0.7 s on Kabukichō, ROADMAP § 4). + `renderer/src/live/mod.rs`; ~0.7 s on Kabukichō, ROADMAP § 4). - Physics and scene queries: rigid bodies, contacts, joints, ray / overlap / sweep queries, native (Rust). Done (ROADMAP § 6 step 7): `renderer/physics-core` on Rapier, the three.js-style surface in `packages/scene` (`mesh.physics`, joints as objects, `Raycaster.intersectPhysics`, `Shapecaster`). @@ -178,7 +178,7 @@ one run: 4 alternated rounds, an A/A of region: ### Measured: destruction (2026-11-10) ROADMAP § 6 step 2c-4: the level loaded by the native voxel objects (renderer/voxel-core, renderer/src/voxel.rs), -explosions applied in place (live.rs `edit_geometry`), pieces split by 4³ cell (8 voxels or more), at rest. +explosions applied in place (live/edits.rs `edit_geometry`), pieces split by 4³ cell (8 voxels or more), at rest. `tools/bench-destruction.ts`: M5 Pro, 0.5 floor, camera still at the path's start, rounds alternated with an A/A of each route, `THREERT_EDIT_RESERVE=4`. An explosion's frame: the worse of the frame that fires it and the one that applies its edit (interval, what a player sees). Three routes: @@ -773,7 +773,7 @@ above.) | The stand-in light | a point light at each burning fire, its intensity and colour from `FIRE_LIGHT`, measured against offline | live secondary and shadow rays have no media code: `pathtrace.wgsl` `media_shadows()` is false live (line 796), GI and reflection rays never meet a medium | offline the fire's light counts twice unless the game passes `lights: 0` (`level-destruction-fire` rendered offline has both); live, `media_train` adds the light to the radiance cache's voxels (`point_direct`, `point_sphere`): the fireball (and its smoke) is lit by it a second time; the table holds for the defaults (density, life scaled; measured by day over a floor and a wall, the fireball 0.6 of its size above it; past 8.8 m the 8.8 m values) | live rays see media: a medium's emission lights surfaces and shadows pass through it | `lights: 0`, then `tools/bench-voxel-fire.ts --only quality`: the street and the fireball's regions at least as close to offline as with the light; `voxel/fire-light` replaced by a live test of the fire's own light | | Idle fireballs parked | at y = −10,000, scale 1e-3, instead of hidden | `World::set_visible` (`world.rs` line 750): hiding anything but particles rebuilds the live scene | parked fireballs keep their slots among the kernel's 32 volumes, and every pixel's march still tests their boxes | **now** (2026-11-13: a volume or a light hidden by its own `visible` keeps its place in the build, in place; no media pass runs while every medium is hidden; `volume/hidden-in-place-live`, `light/hidden-in-place-live` and the two `shown`). What is left is `Explosions`' own | `visible = false` instead of parking; the bench's cost runs report no rebuild after an explosion | | Idle lights reach 1 mm | an idle light's `distance` is 1 mm where it is parked | `light_reach` (`lib.rs` line 211): a black light without a range reaches without limit, and the kernel does not skip black lights before the group test (`pathtrace.wgsl` line 1578) | parked lights 10 km away share a group of 16 with lit ones, and groups are not sorted again when lights move (`lib.rs` line 277): the group's sphere is about 5 km wide while any fire burns (the per-light range test still works) | **now** (2026-11-13: black lights are in no group's sphere and are skipped first; a hidden light's entry is black; the groups are sorted again when a local light moves or goes dark or lit). What is left is `Explosions`' own | distance 0 on idle lights; the fire route's GPU time before any explosion within 0.1 ms of the smoke route's | -| The smoke kept by never rebuilding | the pool's objects movable from the start; idle ones parked, not hidden (`Explosions` has no grid now: this keeps a game's own `SimulatedSmoke`) | `LiveCore::rebuild` (`live.rs` line 3474) → `load_scene` → `Gpu::upload` makes new grid textures (`gpu.rs` line 1664): the simulated state is not carried over | any other structural change in the game (an object added, a texture, an edit's fallback) wipes the smoke and the fire burning in it | **now** (2026-11-13: a rebuild copies fields A and B of every simulated grid that is the same grid still into the new scene's textures, `Gpu::carry_grids`; `volume/grid-rebuild-live`). What is left is `Explosions`' own | the bench's smoke check with a rebuild forced at 1 s (an object added): the smoke at 3 s as without it | +| The smoke kept by never rebuilding | the pool's objects movable from the start; idle ones parked, not hidden (`Explosions` has no grid now: this keeps a game's own `SimulatedSmoke`) | `LiveCore::rebuild` (`live/mod.rs`) → `load_scene` → `Gpu::upload` makes new grid textures (`gpu.rs` line 1664): the simulated state is not carried over | any other structural change in the game (an object added, a texture, an edit's fallback) wipes the smoke and the fire burning in it | **now** (2026-11-13: a rebuild copies fields A and B of every simulated grid that is the same grid still into the new scene's textures, `Gpu::carry_grids`; `volume/grid-rebuild-live`). What is left is `Explosions`' own | the bench's smoke check with a rebuild forced at 1 s (an object added): the smoke at 3 s as without it | | Volumes must not overlap | `Explosions` has no smoke grid (it refuses one); the smoke is each fireball's own. Two fires close together still make two fireballs that overlap: nothing keeps them apart (no distance rule) | `media_track` (offline) and `media_march` (live) in `pathtrace.wgsl` take the media whole, in the order a ray enters them (at most 32, a 32-bit mask), not their coefficients summed along the ray; hence the rule (`core.ts` `VolumeMesh`, MATERIAL_FUNCTIONS.md) | a fire inside smoke is drawn behind all of it (measured: not seen at all at 0.2 and 0.5 s, "Why volumes must not overlap"); neighbouring fireballs hide each other in the same way; no smoke grid around explosions | **now** (2026-11-13: the kernel takes the ray a stretch at a time and sums the coefficients of the media it is in, offline and live; `volume/overlap-emission`, `-live`). What is left is `Explosions`' own: taking a grid again, in a PR of the voxel line's | puff a `SimulatedSmoke` around fireballs again (`Explosions` taking a grid) and run the comparison that found it (`bench-voxel-fire` at d7b4e4a, `--only smoke --ages 0.2,0.5`): the fire seen in its smoke offline and live; two overlapping fireballs both seen | ### Where the engine works round its dependencies diff --git a/games/tokyo/tokyo-tod.game.ts b/games/tokyo/tokyo-tod.game.ts index 1197ac9..917eabe 100644 --- a/games/tokyo/tokyo-tod.game.ts +++ b/games/tokyo/tokyo-tod.game.ts @@ -160,7 +160,7 @@ export function sceneFor(id: string) { win.name = `windows ${t.x} ${t.z} ${g}`; scene.add(win); // A group switches on every tile in the same frame: any power change costs the live view's commit ~13 ms (the - // emitter CDF and the light tree rebuilt over all 1.4 M emitters, renderer/src/live.rs write_emitters), so fewer + // emitter CDF and the light tree rebuilt over all 1.4 M emitters, renderer/src/live/edits.rs write_emitters), so fewer // switching frames is better until that is incremental. lamps.push({ name: win.name, at: windowSwitchAt(g) }); } diff --git a/games/voxel-level/level.game.ts b/games/voxel-level/level.game.ts index a40657d..df607c8 100644 --- a/games/voxel-level/level.game.ts +++ b/games/voxel-level/level.game.ts @@ -30,7 +30,7 @@ function heightfield(phase: number): BufferGeometry { } /** - * `edits` (ROADMAP § 6 step 2c-4, live.rs `edit_geometry`): every half second, one object's geometry is replaced: with + * `edits` (ROADMAP § 6 step 2c-4, live/edits.rs `edit_geometry`): every half second, one object's geometry is replaced: with * box representations, the procedural tile under the camera's start swaps between its own voxels and its neighbour's; * with the mesh, a `dynamicGeometry` heightfield there swaps between two shapes. The frame log's `edit` / `rebuild` * time it (tools/bench-voxel.ts --edits). diff --git a/renderer/shaders/live.wgsl b/renderer/shaders/live.wgsl index 35a349c..b33f96e 100644 --- a/renderer/shaders/live.wgsl +++ b/renderer/shaders/live.wgsl @@ -18,7 +18,7 @@ struct LiveParams { size: vec4, // width, height, frame, reset history (1) misc: vec4, // exposure scale, max history, vignette intensity, bloom on (1) / off (0) misc2: vec4, // denoise strength, sample clamp (display units at the current exposure), -, samples this frame - mode: vec4, // view mode (0 lit, 1 base color, 2 world normal, 3 lit no denoise, 4 raw sample, 5 history), -, the scene's lighting is changing (live.rs `live_lighting`), - + mode: vec4, // view mode (0 lit, 1 base color, 2 world normal, 3 lit no denoise, 4 raw sample, 5 history), -, the scene's lighting is changing (live/mod.rs `live_lighting`), - post: vec4, // chromatic aberration (UE scene fringe), film grain, bloom threshold (<= 0 off), - ae: vec4, // auto exposure: low / high fraction, histogram log2 min / max ae2: vec4, // compensation (2^bias; manual: the exposure), min / max average luminance, mode (1 = auto) @@ -31,6 +31,7 @@ struct LiveParams { bt2: vec4, // bloom stage 4, 5 tint, -, - dof: vec4, // depth of field: CoC radius of infinity (output px; 0 = off), focus distance (m), max radius (px), - jit: vec4, // this frame's sub-pixel jitter (every traced sample's: x, y in 0 .. 1 of a traced pixel), -, - + state: vec4, // which half of the lasting buffers is this frame's (0 / 1; `this_half`), -, -, - }; // color = diffuse lighting + first-hit distance, emit = directly seen emission / sky + roughness, spec = specular lighting // + reflection hit distance, normal.w = the media before the first hit (`medium_tr`, `medium_share`; see pathtrace.wgsl). @@ -38,20 +39,42 @@ struct Pixel { color: vec4, albedo: vec4, normal: vec4, emit: vec @group(0) @binding(0) var L: LiveParams; @group(0) @binding(1) var sample_px: array; // color.w = first-hit distance (0 = sky) -// Diffuse irradiance history (rgb, frames) on the traced grid (`dtemporal`; `mom_in` / `mom_out` hold its moments), -// then the output grid's colour history (rgb, samples; `out_hist`, `upscale`). -@group(0) @binding(2) var hist_in: array>; -@group(0) @binding(3) var hist_out: array>; -// The traced grid's g-buffer (`stemporal`): octahedral normal, view depth (0 = sky), roughness (`GB`). -@group(0) @binding(4) var gb_in: array>; -@group(0) @binding(5) var gb_out: array>; -// fa: the irradiance filter's ping-pong on the traced grid, then the image on the output grid (what the post chain -// reads). fb: its other half, then the composed colour of the traced grid (`compose`; w: first-hit distance), then -// depth of field's output when motion blur follows. -@group(0) @binding(6) var fa: array>; -@group(0) @binding(7) var fb: array>; -@group(0) @binding(8) var bloom_a: array>; // bloom pyramid levels, then stage sums -@group(0) @binding(9) var bloom_b: array>; // horizontal blur scratch + +// ---- The live passes' buffers, by what they hold and how long. +// +// What lasts from one frame to the next has a buffer of its own, in two halves: this frame's, which the passes write +// (`this_half`), and the previous frame's, which they read (`last_half`); live/frame.rs alternates them (`L.state.x`). A half +// is L.size.x * L.size.y elements, indexed by traced sample (by output pixel: `color_hist`). +// diffuse_hist the diffuse irradiance's history: rgb, frames (`dtemporal`; the à-trous passes read its length) +// diffuse_mom its luminance moments: mean, mean square, the fast mean's luminance, 1 where written (`dtemporal`) +// diffuse_fast its fast mean in colour, f16 rgb (`dtemporal`; `fast_load`: ReBLUR's fast history) +// spec_hist the specular history: rgb, frames (`stemporal`) +// gbuffer octahedral normal, view depth (0 = sky), roughness (`stemporal`; `GB`) +// color_hist the output grid's colour history: rgb, samples (`upscale`) +// up_state the upscaler's state per traced sample (`compose`): x, y `Flicker`; z `Lock` (its life, f16; the +// other half free); w free +// What lives within a frame shares buffers whose uses do not overlap (the passes run in live/targets.rs `Passes` order): +// sample_vel per traced sample: screen velocity (output pixels this frame), view depth, animated shading +// (`compose`; `judge`, `upscale`, depth of field and motion blur read it) +// judged `judge`'s findings per traced sample (`Judged`), in up_state's previous half: `compose` has read it +// by then, and the next frame writes the other (`judged_slot`) +// fa, fb the irradiance filter's ping-pong on the traced grid; then fb the composed colour of the traced grid +// (`compose`; w: first-hit distance) and fa the image on the output grid (what the post chain reads); +// fb depth of field's output when motion blur follows +// sb the traced surfaces for the diffuse filter (`dtemporal` writes, the à-trous passes read), then the +// upscaled image when depth of field / motion blur follow (w: the circle of confusion) +// spec_pp the specular filter's ping-pong (`stemporal` -> `sfilt0..2`: two f16 slots, the result in slot 1), +// then in slot 0 `reject`'s verdict +// lumq depth of field's tiles (`dof_tiles`, `dof`), then the quarter-resolution log2 luminance +// (`lum_down`, `lum_blur`) +@group(0) @binding(2) var diffuse_hist: array>; +@group(0) @binding(3) var color_hist: array>; +@group(0) @binding(4) var gbuffer: array>; +@group(0) @binding(5) var spec_hist: array>; +@group(0) @binding(6) var diffuse_mom: array>; +@group(0) @binding(7) var diffuse_fast: array>; +@group(0) @binding(8) var up_state: array>; +@group(0) @binding(9) var sample_vel: array>; @group(0) @binding(10) var out_px: array; @group(0) @binding(11) var lut: texture_3d; @group(0) @binding(12) var lut_samp: sampler; @@ -59,35 +82,29 @@ struct Pixel { color: vec4, albedo: vec4, normal: vec4, emit: vec @group(0) @binding(14) var ae_state: array, 2>; // exposure, target, mean log2 lum, valid @group(0) @binding(15) var lumq: array; // quarter res log2 luminance (unexposed) @group(0) @binding(16) var lumb: array; // edge-aware blur of lumq -// Per traced sample, what `judge` finds of the output grid's colour history (`Judged`, f16), in `ae_out`. Until then -// `ae_out` holds what the previous frame wrote to `ae_in`, its ping-pong partner: `dtemporal` the diffuse fast mean -// in colour (`fast_load`: x, y), `compose` how much the composed sample flickers (`Flicker`: z, w). -@group(0) @binding(17) var ae_in: array>; -@group(0) @binding(18) var ae_out: array>; -// Specular history on the traced grid (rgb + history length), diffuse luminance moments (mean, mean square, fast mean, -). -@group(0) @binding(19) var shist_in: array>; -@group(0) @binding(20) var shist_out: array>; -@group(0) @binding(21) var mom_in: array>; -@group(0) @binding(22) var mom_out: array>; -// The specular denoiser's ping-pong on the traced grid (`stemporal` -> `sfilt0` -> `sfilt1` -> `sfilt2`): (rgb, history -// length) as f16, two slots (the passes are bandwidth-bound: 8 bytes per pixel instead of 16). The result is slot 1; -// slot 0 then holds `reject`'s verdict. -@group(0) @binding(23) var spec_pp: array>; -// Scratch: the traced surfaces for the diffuse denoiser (`dtemporal`), then the upscaled image when depth of field / -// motion blur follow (they read it; w: the circle of confusion). -@group(0) @binding(24) var sb: array>; -// Reflection resolve: the trace kernel's specular-lobe rays, 2 per traced pixel ((radiance back, lobe pdf; 0 = none), -// (specular albedo E, octahedral direction)), and the resolved specular reflection per traced pixel. -@group(0) @binding(25) var refl_in: array>; -@group(0) @binding(26) var refl_res: array>; +@group(0) @binding(17) var fa: array>; +@group(0) @binding(18) var fb: array>; +@group(0) @binding(19) var bloom_a: array>; // bloom pyramid levels, then stage sums +@group(0) @binding(20) var bloom_b: array>; // horizontal blur scratch +@group(0) @binding(21) var spec_pp: array>; +@group(0) @binding(22) var sb: array>; +// What the trace leaves (the kernel's group 1, live/targets.rs `Targets::kernel`). Reflection resolve: the specular-lobe rays, +// 2 per traced pixel ((radiance back, lobe pdf; 0 = none), (specular albedo E, octahedral direction)), and the +// resolved specular reflection per traced pixel (`refl`). +@group(0) @binding(23) var refl_in: array>; +@group(0) @binding(24) var refl_res: array>; // Per traced pixel: where its surface point was the previous frame, as a world-space offset (w = 1: a moving object). -@group(0) @binding(27) var motion_in: array>; +@group(0) @binding(25) var motion_in: array>; // Directional lights' direct light (the sun: nearly deterministic) at camera hits per traced pixel: no denoiser -// (`compose` adds it as it is, UE lights directly). `xhist_out`: per traced sample, its screen velocity (output pixels -// this frame), view depth and whether its shading is animated (`compose`). (`xhist_in`: its ping-pong partner, not read.) -@group(0) @binding(28) var direct_in: array>; -@group(0) @binding(29) var xhist_in: array>; -@group(0) @binding(30) var xhist_out: array>; +// (`compose` adds it as it is, UE lights directly). +@group(0) @binding(26) var direct_in: array>; + +// Element i of this frame's half of a lasting buffer, and of the previous frame's. +fn this_half(i: u32) -> u32 { return L.state.x * L.size.x * L.size.y + i; } +fn last_half(i: u32) -> u32 { return (1u - L.state.x) * L.size.x * L.size.y + i; } +// Where `judge`'s findings for traced sample q are (see `judged` above). +fn judged_slot(q: u32) -> u32 { return last_half(q); } + fn spec_load(slot: u32, i: u32) -> vec4 { let v = spec_pp[slot * L.size.x * L.size.y + i]; return vec4(unpack2x16float(v.x), unpack2x16float(v.y)); @@ -180,7 +197,7 @@ fn oct_enc(n: vec3) -> vec2 { return (1.0 - abs(p.yx)) * select(vec2(-1.0), vec2(1.0), p >= vec2(0.0)); } -// The output-grid g-buffer (`gb_out`, next frame's `gb_in`), 8 bytes: octahedral normal (snorm16 x 2), view depth +// The g-buffer (`gbuffer`), 8 bytes: octahedral normal (snorm16 x 2), view depth // (0 = sky) and roughness (f16). The surface tests compare depths relatively (2-4 %), far coarser than f16. One fetch // per tap for the specular filter and the history tests (it was 16 bytes, and roughness a second 16-byte fetch from the // albedo / emission history): those passes are bandwidth-bound. @@ -293,10 +310,9 @@ fn medium_share(q: u32) -> f32 { } // The diffuse irradiance's fast mean in colour (f16 rgb; a quarter of each new sample, as its luminance in the -// moments), per traced sample: `dtemporal` reads the previous frame's from `ae_out` (before `judge` writes its own -// there) and writes this frame's to `ae_in`, the next frame's `ae_out`. +// moments), per traced sample (`diffuse_fast`): `dtemporal` reads the previous frame's and writes this frame's. fn fast_load(j: u32) -> vec3 { - let v = ae_out[j]; + let v = diffuse_fast[last_half(j)]; return vec3(unpack2x16float(v.x), unpack2x16float(v.y).x); } @@ -317,8 +333,6 @@ fn sample_pos(qx: i32, qy: i32) -> vec2 { return (vec2(f32(qx), f32(qy)) + L.jit.xy) * vec2(f32(L.size.x) / f32(L.isz.x), f32(L.size.y) / f32(L.isz.y)); } -// The output grid's colour history follows the traced grid's diffuse history in hist_in / hist_out (sized for both). -fn out_hist(i: u32) -> u32 { return L.size.x * L.size.y + i; } // Previous-frame pixel position of world point p (x < 0: behind the previous camera). fn prev_pixel(p: vec3) -> vec3 { @@ -341,7 +355,7 @@ fn same_surface(tx: i32, ty: i32, z: f32, n: vec3, t: f32) -> bool { // same_surface with the normals' least cosine `ncos`. fn same_surface_n(tx: i32, ty: i32, z: f32, n: vec3, t: f32, ncos: f32) -> bool { - let g = gb_unpack(gb_in[u32(ty) * L.isz.z + u32(tx)]); + let g = gb_unpack(gbuffer[last_half(u32(ty) * L.isz.z + u32(tx))]); if (t > 0.0) { return g.depth > 0.0 && abs(g.depth - z) < 0.04 * z + 0.002 && dot(g.n, n) > ncos; } return g.depth == 0.0; } @@ -407,7 +421,7 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { // Holding still the centre lands on one history pixel exactly, whose stored normal is another jittered // sample's: on normal-mapped stones and facade detail it often missed 0.8 and the history restarted // (Bistro's street and facades at 256 frames held 8-30). The centre fetch takes 0.5 (depth unchanged). - let centred = hist_in[u32(jc.y * ps.x + jc.x)].w >= HIST_CENTRE; + let centred = diffuse_hist[last_half(u32(jc.y * ps.x + jc.x))].w >= HIST_CENTRE; if (centred) { tc = tcc; } let x0 = i32(floor(tc.x)); let y0 = i32(floor(tc.y)); @@ -419,8 +433,8 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { if (tx < 0 || ty < 0 || tx >= ps.x || ty >= ps.y || !same_surface_n(tx, ty, pp.z, n, t, select(0.8, 0.5, centred))) { continue; } let bw = select(1.0 - f.x, f.x, (k & 1) == 1) * select(1.0 - f.y, f.y, (k >> 1u) == 1); let j = u32(ty * ps.x + tx); - hd += hist_in[j] * bw; - hm += mom_in[j] * bw; + hd += diffuse_hist[last_half(j)] * bw; + hm += diffuse_mom[last_half(j)] * bw; hf += fast_load(j) * bw; wsum += bw; } @@ -447,9 +461,9 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { let ty = i32(round(tc.y)) + k / 3 - 1; if (tx < 0 || ty < 0 || tx >= ps.x || ty >= ps.y || !same_surface(tx, ty, pp.z, n, t)) { continue; } let j = u32(ty * ps.x + tx); - let m = mom_in[j]; + let m = diffuse_mom[last_half(j)]; if (m.w <= 0.0 || non_finite(m.xyz)) { continue; } - slow_c += hist_in[j].rgb; + slow_c += diffuse_hist[last_half(j)].rgb; fast_c += fast_load(j); acc += vec2(m.x, m.z); acc2 += m.z * m.z; @@ -465,7 +479,7 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { } } } - // How long the history may be at most is the frame's (`misc.y`: live.rs HISTORY_LIVE while the scene's lighting + // How long the history may be at most is the frame's (`misc.y`: live/mod.rs HISTORY_LIVE while the scene's lighting // changes, far longer once it has settled); within it, each pixel's own: less where it moves (until 2026-11-12 the // whole frame's was held to 48 while the camera moved), a few frames on animated shading, and anti-lag below. let cap = mix(select(L.misc.y, min(L.misc.y, 4.0), anim), min(L.misc.y, select(HIST_MOVING, 4.0, anim)), clamp(speed, 0.0, 1.0)); @@ -537,9 +551,9 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { let mom = vec4(mix(m1, l, a), mix(m2, l * l, a), fast, 1.0); let d = mix(hist, irr, a); let fc = select(irr, mix(hf, irr, 0.25), hd.w > 0.0); - ae_in[q] = vec4(pack2x16float(min(fc.rg, vec2(6.0e4))), pack2x16float(vec2(min(fc.b, 6.0e4), 0.0)), 0u, 0u); - hist_out[q] = vec4(d, count); - mom_out[q] = mom; + diffuse_fast[this_half(q)] = vec2(pack2x16float(min(fc.rg, vec2(6.0e4))), pack2x16float(vec2(min(fc.b, 6.0e4), 0.0))); + diffuse_hist[this_half(q)] = vec4(d, count); + diffuse_mom[this_half(q)] = mom; // Denoiser input; w = variance of the accumulated mean luminance (its noise estimate). fb[q] = vec4(max(d, vec3(0.0)), max(mom.y - mom.x * mom.x, 0.0) / max(count, 1.0)); } @@ -575,7 +589,7 @@ fn atrous_gated(gid: vec3, stage: u32, step: i32, max_noise: f32) { if (gid.x >= w || gid.y >= h) { return; } let i = gid.y * w + gid.x; let c = read_stage(stage, i); - let count = hist_out[i].w; + let count = diffuse_hist[this_half(i)].w; let strength = L.misc2.x / sqrt(max(count, 1.0)); // Animated emitters (signs, screens) show their emission, which bypasses the denoiser: skipping them keeps whole // 8x8 groups on the cheap path once the rest has converged. @@ -653,7 +667,7 @@ fn atrous_gated(gid: vec3, stage: u32, step: i32, max_noise: f32) { // ------------------------------------------------------------------------------------------------ render resolution // Everything noisy is accumulated and filtered on the traced grid (the render resolution), composed there into one // colour, and one temporal upscaler takes that to the output grid (`upscale`): the structure of UE's TSR after Lumen -// and of the vendor upscalers after a denoiser. `gb_out` is the traced grid's g-buffer. +// and of the vendor upscalers after a denoiser. `gbuffer` is the traced grid's g-buffer. // Specular history at previous-frame position pp (prev_pixel: output pixels, view depth) on the previous traced grid: // bilinear over the taps that show this surface; `virt`: through a reflection (the reflecting surface's normal and @@ -671,7 +685,7 @@ fn spec_history(pp: vec3, n: vec3, t: f32, rough: f32, virt: bool) -> let ty = y0 + (k >> 1u); if (tx < 0 || ty < 0 || tx >= ps.x || ty >= ps.y) { continue; } let j = u32(ty * ps.x + tx); - let g = gb_unpack(gb_in[j]); + let g = gb_unpack(gbuffer[last_half(j)]); var ok = false; if (virt) { ok = g.depth > 0.0 && dot(g.n, n) > 0.95 && abs(g.rough - rough) < 0.1; @@ -682,7 +696,7 @@ fn spec_history(pp: vec3, n: vec3, t: f32, rough: f32, virt: bool) -> } if (!ok) { continue; } let bw = select(1.0 - f.x, f.x, (k & 1) == 1) * select(1.0 - f.y, f.y, (k >> 1u) == 1); - h += shist_in[j] * bw; + h += spec_hist[last_half(j)] * bw; wsum += bw; } if (wsum <= 1e-3) { return vec4(0.0); } @@ -791,9 +805,9 @@ fn stemporal(@builtin(global_invocation_id) gid: vec3) { s_out = mix(max(ycocg_rgb(hy), vec3(0.0)), cur, clamp(1.0 / max(count_s, 1e-4), 0.0, 1.0)); } s_out = select(max(s_out, vec3(0.0)), vec3(0.0), non_finite(s_out)); - shist_out[q] = vec4(s_out, count_s); + spec_hist[this_half(q)] = vec4(s_out, count_s); spec_store(0u, q, vec4(s_out, count_s)); - gb_out[q] = gb_pack(n, depth, rough); + gbuffer[this_half(q)] = gb_pack(n, depth, rough); } // The specular filter (`spec_filter`'s kernel) on the traced grid: three passes over `spec_pp`, slot 0 -> 1 -> 0 -> 1. @@ -804,7 +818,7 @@ fn spec_filter_traced(gid: vec3, step: i32, from_a: bool) { let i = gid.y * w + gid.x; let src = select(1u, 0u, from_a); let c = spec_load(src, i); - let g = gb_unpack(gb_out[i]); + let g = gb_unpack(gbuffer[this_half(i)]); let rough = g.rough; let fresh = c.w < 4.0; let reach = select(4.0 * clamp(rough * 8.0, 0.0, 1.0), 4.0, fresh); @@ -832,7 +846,7 @@ fn spec_filter_traced(gid: vec3, step: i32, from_a: bool) { let qy = i32(gid.y) + dy * step; if (qx < 0 || qy < 0 || qx >= i32(w) || qy >= i32(h)) { continue; } let q = u32(qy) * w + u32(qx); - let gq = gb_unpack(gb_out[q]); + let gq = gb_unpack(gbuffer[this_half(q)]); let sq = spec_load(src, q).rgb; var wt = kern[abs(dx)] * kern[abs(dy)]; wt *= pow(max(dot(g.n, gq.n), 0.0), npow); @@ -856,7 +870,7 @@ fn spec_filter_traced(gid: vec3, step: i32, from_a: bool) { // The frame at render resolution: each traced sample's colour (its own albedo times the denoised irradiance, the // denoised specular, both through the media before the surface, what the camera ray saw directly and the deterministic -// direct light) into fb (w: first-hit distance), and its motion into `xhist_out`: screen velocity (output pixels this +// direct light) into fb (w: first-hit distance), and its motion into `sample_vel`: screen velocity (output pixels this // frame; camera and object), view depth, and the upscaler's reactive mask, as FSR 2 and UE's responsive AA take one // (what changes there has no motion vector): 1 where the surface's shading is animated (signs, screens), and up to 1 // with the share of the pixel that is a medium changing with time (fire, simulated smoke). @@ -898,7 +912,7 @@ fn compose(@builtin(global_invocation_id) gid: vec3) { let pc = vec2(floor((sp - vel) * vec2(ps) / vec2(f32(L.size.x), f32(L.size.y)))); if (pc.x >= 0 && pc.y >= 0 && pc.x < ps.x && pc.y < ps.y) { f = flicker_at(u32(pc.y * ps.x + pc.x)); - lock_was = unpack2x16float(ae_out[u32(pc.y * ps.x + pc.x)].y).y; + lock_was = unpack2x16float(up_state[last_half(u32(pc.y * ps.x + pc.x))].z).x; } } let still = 1.0 - clamp(length(vel) / UP_SPEED, 0.0, 1.0); @@ -928,10 +942,9 @@ fn compose(@builtin(global_invocation_id) gid: vec3) { let thin = across && (like & 27u) != 27u && (like & 54u) != 54u && (like & 216u) != 216u && (like & 432u) != 432u; // (None on animated shading: it shows this frame's picture, and its history is not kept for it to stand by.) let reactive = max(select(0.0, 1.0, px.albedo.w > 1.5), clamp(medium_share(q) / UP_MEDIUM, 0.0, 1.0)); - ae_in[q].y = (ae_in[q].y & 0xffffu) | (pack2x16float(vec2(0.0, select(max(lock_was - 1.0 / jitter_phases(), 0.0), 1.0, thin) * (1.0 - reactive))) & 0xffff0000u); - ae_in[q].z = pack2x16float(vec2(now, select(now, f.last.x, f.n >= 1.0))); - ae_in[q].w = pack2x16float(vec2(sd * still, min(max(f.n + still, 1.0), 9.0))); - xhist_out[q] = vec4(vel, depth, reactive); + let life = select(max(lock_was - 1.0 / jitter_phases(), 0.0), 1.0, thin) * (1.0 - reactive); + up_state[this_half(q)] = vec4(pack2x16float(vec2(now, select(now, f.last.x, f.n >= 1.0))), pack2x16float(vec2(sd * still, min(max(f.n + still, 1.0), 9.0))), pack2x16float(vec2(life, 0.0)), 0u); + sample_vel[q] = vec4(vel, depth, reactive); } // The upscaler works on linear radiance and its means are plain ones. (Weighted by 1 / (1 + luminance x exposure), so @@ -992,11 +1005,10 @@ const UP_STALE: f32 = 0.05; // traced sample at this place changes from one frame to the next under the jitter, which is what a temporal // upscaler's flicker history is for (UE's TSR keeps the input's luminance over time for the same decision). -// `Flicker`, per traced sample, in the words `dtemporal` leaves free in `ae_in` (f16; `compose` keeps it): the -// composed sample's luminance of the last frame and of the one before, the deviation of its frame-to-frame change as -// a share of its level, and of how many frames that is (0: none; the deviation is then 1: unknown, as much as the -// level), all read where the sample's surface was a frame ago (in motion the place on screen would read the motion -// as flicker). +// `Flicker`, per traced sample, in `up_state`'s x and y (f16; `compose` keeps it): the composed sample's luminance of +// the last frame and of the one before, the deviation of its frame-to-frame change as a share of its level, and of how +// many frames that is (0: none; the deviation is then 1: unknown, as much as the level), all read where the sample's +// surface was a frame ago (in motion the place on screen would read the motion as flicker). // - The deviation is of the second difference (this frame's, less twice the last, plus the one before: of // independent samples its mean square is six variances), which a steady rise or fall does not have: a sky dimming // 4 % a frame is no flicker. A running mean over the first eight frames, an exponential one after. @@ -1008,20 +1020,20 @@ const UP_STALE: f32 = 0.05; // every frame), so the deviation is added to by how still the pixel is and kept by the same, none from UP_SPEED on // (TSR fades its flicker rule out with velocity too): a pixel that moved has none and earns it again at rest, // while its history, cut to UP_MOVING samples by the motion, grows back. -// `Lock`, per traced sample, in the half word the fast mean leaves free in `ae_in`'s y (f16): how much longer the -// picture here counts as a thin feature, 1 when a sample showed one, a jitter sequence later 0 (FSR 2's pixel locks). A -// thin feature is a sample no 2 x 2 block of the nine around it is like (within THIN_LIKE of its luminance: FSR 2's -// rule) and that is above both or below both of some pair of opposite neighbours: a lamp or a lit window finer than a -// traced pixel, which most frames' samples miss, and the dark sample between two of them, which is this frame's miss. -// (FSR 2 asks for a sample brighter or darker than all those unlike it, a single line's rule: of lamps strung a pixel -// apart, in several colours, most have a brighter neighbour, and Tokyo Tower's legs went unlocked. A slope is above one -// neighbour and below the other in every direction and stays out.) The rules that hold the history to this frame's -// samples stand back for a locked pixel: its history is clamped into the nine samples in motion only in part (`upscale`, -// LOCK_RELAX: the lamp no sample hit this frame is still there; Tokyo Tower in a turn lost a quarter of its light and -// its legs), and its sample's distance from the history's range is no finding (`judge`: a hit lies above the range, a -// miss within it, and the corrections went one way); nor is a lamp the samples miss this frame something gone from in -// front (`judge`'s depth test). `compose` carries the lock with the surface. UE's TSR loosens its clamp the same way -// where it detects thin geometry. +// `Lock`, per traced sample, in `up_state`'s z (f16; the other half free): how much longer the picture here counts as a +// thin feature, 1 when a sample showed one, a jitter sequence later 0 (FSR 2's pixel locks). A thin feature is a sample +// no 2 x 2 block of the nine around it is like (within THIN_LIKE of its luminance: FSR 2's rule) and that is above both +// or below both of some pair of opposite neighbours: a lamp or a lit window finer than a traced pixel, which most +// frames' samples miss, and the dark sample between two of them, which is this frame's miss. (FSR 2 asks for a sample +// brighter or darker than all those unlike it, a single line's rule: of lamps strung a pixel apart, in several colours, +// most have a brighter neighbour, and Tokyo Tower's legs went unlocked. A slope is above one neighbour and below the +// other in every direction and stays out.) The rules that hold the history to this frame's samples stand back for a +// locked pixel: its history is clamped into the nine samples in motion only in part (`upscale`, LOCK_RELAX: the lamp no +// sample hit this frame is still there; Tokyo Tower in a turn lost a quarter of its light and its legs), and its +// sample's distance from the history's range is no finding (`judge`: a hit lies above the range, a miss within it, and +// the corrections went one way); nor is a lamp the samples miss this frame something gone from in front (`judge`'s +// depth test). `compose` carries the lock with the surface. UE's TSR loosens its clamp the same way where it detects +// thin geometry. // The test runs on what the camera ray saw directly (the sample's emission: lamps, windows, signs), which has no noise: // FSR 2's test is made for a rasterised frame, and on the composed sample of a traced one (short histories in motion, // reflections without a spatial filter: more than 5 % from sample to sample) it called most of Kabukichō thin; the @@ -1036,27 +1048,27 @@ const THIN_LIKE: f32 = 1.05; // 0.5: 4.47 / 0.69; not spared (main) 5.06 / 0.71. In a slow turn (0.25 degrees a frame) 2.23 / 1.05, 2.17 / 0.80, // main 2.81 / 0.77. const LOCK_RELAX: f32 = 0.75; -// The frames of the jitter sequence (live.rs: 8 at full resolution, as many more as an output pixel has fewer samples). +// The frames of the jitter sequence (live/frame.rs: 8 at full resolution, as many more as an output pixel has fewer samples). fn jitter_phases() -> f32 { return clamp(round(8.0 * f32(L.size.x) * f32(L.size.y) / (f32(L.isz.x) * f32(L.isz.y))), 8.0, 512.0); } // (In full for the first half of its life, fading over the second.) -fn locked(q: u32) -> f32 { return clamp(2.0 * unpack2x16float(ae_in[q].y).y, 0.0, 1.0); } +fn locked(q: u32) -> f32 { return clamp(2.0 * unpack2x16float(up_state[this_half(q)].z).x, 0.0, 1.0); } struct Flicker { last: vec2, sd: f32, n: f32 }; fn flicker_at(q: u32) -> Flicker { - let v = ae_out[q]; - let b = unpack2x16float(v.w); - return Flicker(unpack2x16float(v.z), b.x, b.y); + let v = up_state[last_half(q)]; + let b = unpack2x16float(v.y); + return Flicker(unpack2x16float(v.x), b.x, b.y); } -// `judge`: per traced sample, into `ae_out` (f16): that distance (linear RGB), the sample's luminance (< 0: no +// `judge`: per traced sample, into `judged_slot` (f16): that distance (linear RGB), the sample's luminance (< 0: no // history there), the motion its output pixels reproject by: the surface nearest the camera among the nine samples // around (the sky the farthest), since an edge moves with what is in front of it; and whether its history is of // something that is gone. struct Judged { d: vec3, lum: f32, vel: vec2, gone: bool }; fn judged_at(q: u32) -> Judged { - let v = ae_out[q]; + let v = up_state[judged_slot(q)]; let b = unpack2x16float(v.y); return Judged(vec3(unpack2x16float(v.x), b.x), b.y, unpack2x16float(v.z), v.w != 0u); } @@ -1073,7 +1085,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { let qx = i32(gid.x) + k % 3 - 1; let qy = i32(gid.y) + k / 3 - 1; if (qx < 0 || qy < 0 || qx >= iw || qy >= ih) { continue; } - let mv = xhist_out[u32(qy * iw + qx)]; + let mv = sample_vel[u32(qy * iw + qx)]; let z = select(1.0e29, mv.z, mv.z > 0.0); if (z < near) { near = z; @@ -1096,7 +1108,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { let x = pp.x + k % 3 - 1; let y = pp.y + k / 3 - 1; if (x < 0 || y < 0 || x >= i32(L.size.x) || y >= i32(L.size.y)) { continue; } - let hv = hist_in[out_hist(u32(y) * L.size.x + u32(x))]; + let hv = color_hist[last_half(u32(y) * L.size.x + u32(x))]; if (hv.w <= 0.0 || non_finite(hv.rgb)) { continue; } lo = min(lo, hv.rgb); hi = max(hi, hv.rgb); @@ -1107,7 +1119,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { // (The sample is a point and the history's pixels are means: UP_FLICKER deviations of its own flicker // outside their range are the jitter's.) let c = fb[q].rgb; - let slack = UP_FLICKER * unpack2x16float(ae_in[q].w).x * c; + let slack = UP_FLICKER * unpack2x16float(up_state[this_half(q)].y).x * c; d = clamp(c - clamp(c, lo - slack, hi + slack), vec3(-6.0e4), vec3(6.0e4)); l = min(lum(c), 6.0e4); // Gone from in front: the previous frame's nearest depth around where this point was (its traced grid's @@ -1116,7 +1128,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { if (ps.x > 0) { var zr = 1.0; let t = fb[q].w; - let z = xhist_out[q].z; + let z = sample_vel[q].z; if (t > 0.0 && z > 0.0) { let sp = sample_pos(i32(gid.x), i32(gid.y)); let mv = motion_in[q]; @@ -1129,7 +1141,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { let x = pc.x + k % 3 - 1; let y = pc.y + k / 3 - 1; if (x < 0 || y < 0 || x >= ps.x || y >= ps.y) { continue; } - let zq = gb_unpack(gb_in[u32(y * ps.x + x)]).depth; + let zq = gb_unpack(gbuffer[last_half(u32(y * ps.x + x))]).depth; was_near = min(was_near, select(1.0e29, zq, zq > 0.0)); } let now_near = select(near * zr, 1.0e29, near >= 1.0e29); @@ -1157,7 +1169,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { // A locked sample's distance from the history's range is no finding (a lamp finer than a pixel lies above it // whenever a sample hits it). d *= 1.0 - locked(q); - ae_out[q] = vec4(pack2x16float(d.xy), pack2x16float(vec2(d.z, l)), pack2x16float(vel), u32(gone)); + up_state[judged_slot(q)] = vec4(pack2x16float(d.xy), pack2x16float(vec2(d.z, l)), pack2x16float(vel), u32(gone)); } // `reject`: per traced pixel, into `spec_pp`'s slot 0 (free once the specular filter is done; f16): the correction @@ -1237,7 +1249,7 @@ fn up_history(fx: f32, fy: f32) -> vec4 { let qy = u32(clamp(i32(y1) + j - 1, 0, i32(L.size.y) - 1)); for (var i = 0; i < 4; i++) { let qx = u32(clamp(i32(x1) + i - 1, 0, i32(L.size.x) - 1)); - let v = hist_in[out_hist(qy * L.size.x + qx)]; + let v = color_hist[last_half(qy * L.size.x + qx)]; h += v * (wx[i] * wy[j]); if ((i == 1 || i == 2) && (j == 1 || j == 2)) { lo = min(lo, v); @@ -1304,7 +1316,7 @@ fn upscale(@builtin(global_invocation_id) gid: vec3) { if (L.size.w == 0u) { let hp = vec2(f32(gid.x), f32(gid.y)) - vel; if (hp.x > -0.5 && hp.y > -0.5 && hp.x < f32(w) - 0.5 && hp.y < f32(h) - 0.5) { - if (vel.x == 0.0 && vel.y == 0.0) { hist = hist_in[out_hist(i)]; } else { hist = up_history(hp.x, hp.y); } + if (vel.x == 0.0 && vel.y == 0.0) { hist = color_hist[last_half(i)]; } else { hist = up_history(hp.x, hp.y); } } if (non_finite(hist.rgb) || non_finite(vec3(hist.w))) { hist = vec4(0.0); } } @@ -1322,15 +1334,15 @@ fn upscale(@builtin(global_invocation_id) gid: vec3) { if (kept > 0.0) { var hc = max(hist.rgb + verdict.xyz, vec3(0.0)); hc = mix(hc, clamp(hc, xlo, xhi), moving * (1.0 - LOCK_RELAX * lock)); - let most = min(mix(UP_SAMPLES, UP_MOVING, moving), mix(UP_SAMPLES, UP_REACTIVE, clamp(xhist_out[u32(bc.y * iw + bc.x)].w, 0.0, 1.0))); + let most = min(mix(UP_SAMPLES, UP_MOVING, moving), mix(UP_SAMPLES, UP_REACTIVE, clamp(sample_vel[u32(bc.y * iw + bc.x)].w, 0.0, 1.0))); count = min(kept + conf, most); let a = clamp(conf / max(count, 1e-4), 0.0, 1.0); outc = mix(hc, cur, a); } let lin = finite_or_zero(max(outc, vec3(0.0))); - hist_out[out_hist(i)] = vec4(lin, count); + color_hist[this_half(i)] = vec4(lin, count); // The image: into fa, or into sb for depth of field / motion blur to read (w: the CoC of the nearest sample's depth). - let img = vec4(lin, dof_coc(xhist_out[u32(bc.y * iw + bc.x)].z)); + let img = vec4(lin, dof_coc(sample_vel[u32(bc.y * iw + bc.x)].z)); if (L.misc2.z > 0.0 || L.dof.x > 0.0) { sb[i] = img; } else { fa[i] = img; } } @@ -1677,7 +1689,7 @@ fn mb_src(q: u32) -> vec3 { // Output pixel q's motion and depth: those of the traced sample under it (`compose`). fn motion_under(q: u32) -> vec4 { - return xhist_out[sample_under(q % L.size.x, q / L.size.x)]; + return sample_vel[sample_under(q % L.size.x, q / L.size.x)]; } fn velocity(q: u32) -> vec2 { @@ -1761,10 +1773,10 @@ fn post(@builtin(global_invocation_id) gid: vec3) { let a = clamp(sample_px[sample_under(gid.x, gid.y)].albedo.rgb, vec3(0.0), vec3(1.0)); d = select(pow((a + 0.055) / 1.055, vec3(1.0 / 2.4)) * 1.055 - 0.055, a * 12.92, a <= vec3(0.0031308)); } else if (mode == 2u) { - let g = gb_unpack(gb_out[sample_under(gid.x, gid.y)]); + let g = gb_unpack(gbuffer[this_half(sample_under(gid.x, gid.y))]); d = select(g.n * 0.5 + 0.5, vec3(0.0), g.depth == 0.0); } else { - let t = clamp(log2(max(hist_out[sample_under(gid.x, gid.y)].w, 1.0)) / 12.0, 0.0, 1.0); // 1 .. 4096 frames (diffuse) + let t = clamp(log2(max(diffuse_hist[this_half(sample_under(gid.x, gid.y))].w, 1.0)) / 12.0, 0.0, 1.0); // 1 .. 4096 frames (diffuse) d = clamp(vec3(1.5 - abs(4.0 * t - 3.0), 1.5 - abs(4.0 * t - 2.0), 1.5 - abs(4.0 * t - 1.0)), vec3(0.0), vec3(1.0)); } let q = vec3(clamp(d, vec3(0.0), vec3(1.0)) * 255.0 + 0.5); @@ -1776,7 +1788,7 @@ fn post(@builtin(global_invocation_id) gid: vec3) { // denoised signals apart: diffuse (6: the composed colour less the rest), specular (7), deterministic direct light (8). // (6 does not hold with depth of field and motion blur both on: the composed colour's buffer is theirs then.) let tq = sample_under(gid.x, gid.y); - if (mode == 3u) { src = (hist_out[tq].rgb * max(sample_px[tq].albedo.rgb, vec3(0.02)) + shist_out[tq].rgb) * medium_tr(tq) + sample_parts(tq)[2] + direct_in[tq].rgb; } + if (mode == 3u) { src = (diffuse_hist[this_half(tq)].rgb * max(sample_px[tq].albedo.rgb, vec3(0.02)) + spec_hist[this_half(tq)].rgb) * medium_tr(tq) + sample_parts(tq)[2] + direct_in[tq].rgb; } if (mode == 4u) { src = sample_color(tq); } if (mode == 6u) { src = max(fb[tq].rgb - spec_load(1u, tq).rgb * medium_tr(tq) - sample_parts(tq)[2] - direct_in[tq].rgb, vec3(0.0)); } if (mode == 8u) { src = direct_in[tq].rgb; } diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index 23e224b..c5e2144 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -2503,7 +2503,7 @@ fn oct_enc(n: vec3) -> u32 { // bounce); each vertex's estimate is added. Sums are fixed point with stochastic rounding (`cache_acc`); // `cache_resolve` folds them into `cache_val` once per frame. -const CACHE_SIZE: u32 = 262144u; // entries, a power of two (live.rs CACHE_SIZE) +const CACHE_SIZE: u32 = 262144u; // entries, a power of two (gpu.rs CACHE_SIZE) const CACHE_PROBE: u32 = 8u; // linear probing window const CACHE_ANGLE: f32 = 0.02; // voxel size / distance to the camera const CACHE_MIN_VOXEL: f32 = 0.0625; // m @@ -2839,7 +2839,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // Raster primary: one visibility sample per pixel at the frame's jitter (lens sampling would need its own rays). let raster = LIVE && P.raster.x > 0.5 && P.cam_right.w <= 0.0; - // Live: one sub-pixel jitter a frame for the whole image (live.rs: Halton 2, 3), which the temporal upscaler + // Live: one sub-pixel jitter a frame for the whole image (live/frame.rs: Halton 2, 3), which the temporal upscaler // resolves (live.wgsl `upscale`) and the raster pass draws at. Offline, every sample draws its own. var jx = rnd(); var jy = rnd(); @@ -3072,7 +3072,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // Live: the path ends in the radiance cache at its first bounce. Surfaces seen in mirror-like reflections // (specular lobe below roughness 0.4, where UE traces reflections) get exact direct light, as UE's hit // lighting does, and the cache's indirect part; others reflect the cache's total. Rotating tiles train the - // cache. For the first frames after a reset or a camera cut (live.rs COLD_FALLBACK_FRAMES; flag 1 later), + // cache. For the first frames after a reset or a camera cut (live/mod.rs COLD_FALLBACK_FRAMES; flag 1 later), // pixels that find their voxel cold trace a fresh estimate and use it meanwhile, without inserting the voxel: // the low-sample entries they inserted kept later frames slower until evicted (the crowd's square ~2 ms). let cfl = u32(P.cache.w); diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index cc58c08..a92217a 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -118,7 +118,7 @@ pub struct Params { pub background: [f32; 4], } -/// Room the live view's in-place geometry edits grow into (live.rs `edit_geometry`), past the scene's own data: +/// Room the live view's in-place geometry edits grow into (live/edits.rs `edit_geometry`), past the scene's own data: /// triangles (verts, attrs, BLAS vertices), kernel records and TLAS instances, object-table entries, procedural words. /// Only scenes with editable geometry (procedural meshes, `own_blas` meshes) under hardware RT get any: a quarter of /// what they have, at least a minimum; `THREERT_EDIT_RESERVE` scales it (0: none). Past it an edit rebuilds the scene. @@ -582,7 +582,7 @@ pub struct Accel { pool_first: usize, /// The TLAS index of model instance 0 (the traversal groups' instances come first). pub inst_base: usize, - /// The build's triangle BLAS bytes per triangle (Metal's sizes): edits' memory estimates (live.rs `room_stats`). + /// The build's triangle BLAS bytes per triangle (Metal's sizes): edits' memory estimates (live/mod.rs `room_stats`). pub blas_per_tri: f64, /// The TLAS index of the first procedural object's instance (after the model instances; each has its own AABB /// BLAS, which its TLAS instance keeps). @@ -1062,7 +1062,7 @@ impl CacheBuffers { } /// The kernel's group-1 buffers: the radiance cache, and what the live passes read of a trace -/// (live.rs `Targets`): the specular-lobe rays, the motion vectors, the directional lights' direct light. In the +/// (live/targets.rs `Targets`): the specular-lobe rays, the motion vectors, the directional lights' direct light. In the /// merged layout they are ranges of one buffer, the arrays of the kernel's `MergedLive` (merged.rs `KERNEL_LIVE`). pub struct KernelBufs { pub cache: CacheBuffers, @@ -1839,7 +1839,7 @@ impl Gpu { // by the same figures.) let room = EditRoom::for_scene(scene, tri_verts.len() / 3, records.len(), self.hwrt, scene.units.is_some()); let st = wgpu::BufferUsages::STORAGE; - // (COPY_SRC: the live view's in-place edits grow these past their room by a copy, live.rs `edit_geometry`.) + // (COPY_SRC: the live view's in-place edits grow these past their room by a copy, live/edits.rs `edit_geometry`.) let edit = st | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC; let b_verts = buf_room("verts", bytemuck::cast_slice(tri_verts), room.tris * 48, edit | wgpu::BufferUsages::COPY_SRC); let dynamic_first: usize = groups[..4].iter().sum(); @@ -2769,7 +2769,7 @@ impl Gpu { /// Rebuilds the BLASes of the dynamic groups (posed models, after their vertices changed) and the TLAS. /// Rebuilds the TLAS alone (instance transforms changed, no triangles). /// `old`'s contents in a new buffer of `size` bytes (its usage and label), copied on the GPU and submitted now, so - /// writes queued after this land on the copy (live.rs `edit_geometry` growing past its room). + /// writes queued after this land on the copy (live/edits.rs `edit_geometry` growing past its room). pub fn grown(&self, old: &wgpu::Buffer, size: u64, label: &str) -> wgpu::Buffer { let b = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size, usage: old.usage(), mapped_at_creation: false }); let mut e = self.device.create_command_encoder(&Default::default()); @@ -2819,7 +2819,7 @@ impl Gpu { (blas, (parts, sizes)) } - /// A new instanced mesh over `count` triangles from GPU-order index `first` (in the edit room; live.rs + /// A new instanced mesh over `count` triangles from GPU-order index `first` (in the edit room; live/edits.rs /// `edit_geometry`), its first `opaque` ones opaque: its BLAS, which `rebuild_meshes` builds. Returns its index. pub fn add_mesh(&self, accel: &mut Accel, first: usize, count: u32, opaque: u32) -> u32 { // Built fast rather than traced fast (edits come every frame under destruction; the batches of pieces are rebuilt @@ -2835,7 +2835,7 @@ impl Gpu { &accel.meshes[k as usize] } - /// The AABB BLAS of a procedural geometry's boxes (live.rs `edit_geometry`): the one kept by its hash or built + /// The AABB BLAS of a procedural geometry's boxes (live/edits.rs `edit_geometry`): the one kept by its hash or built /// earlier in this edit (`fresh`), or a new one whose build goes into `e` and into `fresh`. The edit keeps `fresh` /// (`keep_proc_blases`) only once `e` is submitted: an edit that falls back never submits, and a BLAS never built /// must not be reused. @@ -2865,7 +2865,7 @@ impl Gpu { } } - /// The scene bind group again, from `up`'s buffers as they are now (a buffer that grew: live.rs `write_emitters`). + /// The scene bind group again, from `up`'s buffers as they are now (a buffer that grew: live/edits.rs `write_emitters`). pub fn rebind_scene(&self, up: &mut Uploaded) { fn e(binding: u32, b: &wgpu::Buffer) -> wgpu::BindGroupEntry<'_> { wgpu::BindGroupEntry { binding, resource: b.as_entire_binding() } @@ -3019,7 +3019,7 @@ impl Gpu { /// The BLASes of instanced meshes `meshes` and, with `tlas`, the TLAS in the same build (the TLAS reads the BLASes' /// bounds through their resource ids, which Metal does not order a later build after: a TLAS built apart from a - /// BLAS it references, in the same encoder, could see it unbuilt and lose its instance; live.rs `edit_geometry`). + /// BLAS it references, in the same encoder, could see it unbuilt and lose its instance; live/edits.rs `edit_geometry`). pub fn build_meshes(&self, accel: &Accel, meshes: &[u32], e: &mut wgpu::CommandEncoder, tlas: bool) { self.build_edit(accel, meshes, &[], e, tlas); } diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs index 1f027d8..c39ea82 100644 --- a/renderer/src/lib.rs +++ b/renderer/src/lib.rs @@ -74,13 +74,13 @@ pub struct Prepared { pub light_tree: Option, /// Material -> runs of its flattened static triangles (first, end in the traversal order, object), and the /// materials that also cover pool or dynamic triangles: a material that starts emitting gets emitter entries in - /// place from its runs (live.rs `add_material_emitters`), unless fixed. + /// place from its runs (live/edits.rs `add_material_emitters`), unless fixed. pub mat_runs: std::collections::HashMap>, pub fixed_mats: std::collections::HashSet, /// The flattened triangles' build indices in traversal order (`Scene::tris` order: positions for new entries). pub tri_order: Vec, /// The brightest sky texel (luminance) from which the environment is light-sampled (`Params::counts2[1]`): 0 when - /// there are no emitters. A dynamic sky re-decides with each bake (live.rs `DynamicSky`). + /// there are no emitters. A dynamic sky re-decides with each bake (live/load.rs `DynamicSky`). pub env_nee_floor: f32, } @@ -402,7 +402,7 @@ fn units_said(var: Option<&str>, live: bool) -> bool { } /// Whether `prepare_live` will keep the scene's instances as units (software traversal, and it has instances), asked -/// before it runs (live.rs `load_scene`: the light tree's part for emitters edits add). +/// before it runs (live/load.rs `load_scene`: the light tree's part for emitters edits add). pub fn units_wanted(hwrt: bool, sc: &scene::Scene) -> bool { !hwrt && units_on(true) && !sc.instances.is_empty() && sc.instances.len() <= units::MAX_UNITS } @@ -675,7 +675,7 @@ pub fn prepare_as(hwrt: bool, units: bool, sc: &mut scene::Scene, timings: &mut let procs = [env_cdf.len() as u32, sc.procs.len() as u32, sc.gpu_instances.len() as u32, sc.vcolor_base]; // Environment light sampling (one shadow ray per vertex) is skipped when the sky is negligible next to the emitters // (night streets): escaping BSDF rays still pick it up, with full MIS weight. The emitters as if all on: a - // time-of-day room loaded by day decides at dusk as one loaded at night does (live.rs `put_sky`). + // time-of-day room loaded by day decides at dusk as one loaded at night does (live/edits.rs `put_sky`). let env_max = env.texels.iter().map(|t| 0.2126 * t[0] + 0.7152 * t[1] + 0.0722 * t[2]).fold(0.0f32, f32::max).max(0.2126 * sky_color.x + 0.7152 * sky_color.y + 0.0722 * sky_color.z); let em_area: f32 = emitters[..emitter_tris.len()].iter().map(|e| e[3]).sum(); let env_nee_floor = if emitter_tris.is_empty() { 0.0 } else { 0.01 * all_power / em_area.max(1e-6) }; diff --git a/renderer/src/live.rs b/renderer/src/live.rs deleted file mode 100644 index db8f5a5..0000000 --- a/renderer/src/live.rs +++ /dev/null @@ -1,4933 +0,0 @@ -//! Real-time renderer (LiveCore), modelled on UE5's real-time path with hardware RT: every frame traces 1+ spp -//! (primary visibility, GI, shadows, reflections), reprojects and accumulates history (converging to the offline -//! reference while the camera holds still), denoises and applies the UE post chain. Drawn by the native window viewer -//! (`view`) and the playground client; `live` runs it headless at a fixed frame rate for benchmarks: -//! -//! threert-render live [--assets DIR] [--room id] [--width 1280 --height 720 --fps 60 --bounces 4 -//! --scale 1|0.6|auto --display-scale 1|2|3] [--frame-log frames.jsonl] -//! -//! `--scale` is the render scale (UE screen percentage): the frame is traced, denoised and composed at that fraction -//! of the output resolution and the temporal upscaler takes it to the output; `auto` = dynamic resolution. -//! `--display-scale`: the output is a display's physical pixels, that many a point (a Retina window: 2), as the -//! window host's is; dynamic resolution then starts from the points' resolution and goes down to half of it. -//! -//! stdin (JSON lines): {"cmd":"camera","pos":[x,y,z],"target":[x,y,z]} | {"cmd":"event","type":t,"payload":{..}} -//! | {"cmd":"click","u":u,"v":v} | {"cmd":"reset"} | {"cmd":"time","t":s} (set the scene time) -//! | {"cmd":"snapshot","path":p,"after":k (history length ≥ k),"frames":n (the n-th frame from now, default 1)} -//! (a path ending in .raw: the linear radiance, RGB f32 at output size; ending in .signals: -//! its diffuse / specular / emission apart, -{diffuse,specular,emission}.raw, as -//! `render --signals` writes them) -//! | {"cmd":"sync","id":s,"frames":n} (stderr "live: sync s" after the n-th frame from now) -//! | {"cmd":"lockstep","dt":s | "on":false} (render only the frames steps ask for, at frame time s) -//! | {"cmd":"camera",...,"frames":n,"sync":id | "snapshot":p} | {"cmd":"frames","n":n,"sync":id | "snapshot":p} -//! (a step: camera, frames, and the sync answer / snapshot at its last frame, in one command) -//! | {"cmd":"scale","scale":s (0 = auto)} | {"cmd":"quit"} - -use crate::gpu::Buf; -use crate::world::World; -use crate::{gpu, post, scene, tables}; -// (The headless runner below, native only, is what uses all of it.) -#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] -use anyhow::{anyhow, Result}; -use glam::Vec3; -#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] -use std::io::BufRead; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::mpsc; -use std::sync::Arc; -#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] -use std::time::Duration; -use web_time::Instant; - -pub struct LiveOpts { - pub width: u32, - pub height: u32, - pub fps: u32, - pub bounces: u32, - /// Render scale (traced / output resolution); None = dynamic resolution. - pub scale: Option, - /// Game bundle (game code + runtime) that builds and drives the scene in-process. - pub bundle: PathBuf, - /// Asset root its model / texture paths resolve against. - pub assets: PathBuf, - pub room: String, - /// JSON line per frame (wall interval, CPU work, GPU time, scale, history): hitch statistics (tools/bench-hitches.ts). - /// `interval` runs from the end of one frame's work to the next's, so it carries the variation of the work itself; - /// `start` runs from one frame's start to the next's, which the pacer sets: the frame rate a player sees. - pub frame_log: Option, - /// The output's pixels a point of its display (1: the output is in points). - pub display_scale: f32, - /// `--launch`: the `params` of the host channel's `launch` message (docs/RUNTIME.md "The host channel"). - pub launch: Option, -} - -/// Lowest dynamic-resolution scale (UE r.DynamicRes.MinScreenPercentage default) of a view whose pixels are the -/// display's points; a host whose view is in the display's physical pixels divides it by the display's scale factor -/// (`LiveCore::min_scale`), so the fewest rays a window traces do not depend on the display's density. -pub const MIN_SCALE: f32 = 0.5; -/// The lowest render scale any view takes (a view of 3 pixels a point at `MIN_SCALE`, rounded down). -pub const SCALE_FLOOR: f32 = 0.125; - -#[repr(C)] -#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] -struct CamU { - pos: [f32; 4], - fwd: [f32; 4], - right: [f32; 4], - up: [f32; 4], -} - -#[repr(C)] -#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] -struct LiveParams { - cur: CamU, - prev: CamU, - size: [u32; 4], - misc: [f32; 4], - misc2: [f32; 4], - mode: [u32; 4], - post: [f32; 4], - /// Auto exposure (UE histogram): low / high fraction, histogram log2 min / max. - ae: [f32; 4], - /// Compensation (2^bias), min / max average luminance, mode (0 manual, 1 auto). - ae2: [f32; 4], - /// Speed up / down (EV/s), frame time (s), snap to target (1). - ae3: [f32; 4], - /// Local exposure: highlight contrast, shadow contrast, blurred-luminance blend, detail strength. - lx: [f32; 4], - /// Traced (internal) width, height. - isz: [u32; 4], - /// Bloom stage radii (texels of each stage's level) and tints, stages 0..3 then 4, 5. - br: [f32; 4], - br2: [f32; 4], - bt: [f32; 4], - bt2: [f32; 4], - /// Depth of field: CoC radius of infinity (output px; 0 = off), focus distance (m), max radius (px), -. - dof: [f32; 4], - /// This frame's sub-pixel jitter (every camera ray's: x, y in 0 .. 1 of a traced pixel), -, -. - jit: [f32; 4], -} - -/// Bloom pyramid level j (0..4: 1/4 .. 1/64 resolution) size; stage k (0..5) runs on level max(k - 1, 0). -fn bloom_dim(w: u32, h: u32, j: u32) -> (u32, u32) { - (w.div_ceil(4).div_ceil(1 << j), h.div_ceil(4).div_ceil(1 << j)) -} - -fn bloom_stage_level(k: u32) -> u32 { - k.max(1) - 1 -} - -/// UE Standard bloom per stage: Gaussian radius in texels of its level (BloomNSize % of the stage width * size scale -/// / 2, as `post::bloom`) and tint (BloomNTint * intensity / 6). -fn bloom_stages(pp: &crate::desc::PostPreset, w: u32, h: u32) -> ([f32; 6], [f32; 6]) { - let (mut r, mut t) = ([0f32; 6], [0f32; 6]); - for k in 0..6 { - let (lw, _) = bloom_dim(w, h, bloom_stage_level(k as u32)); - r[k] = lw as f32 * pp.bloom.sizes.get(k).copied().unwrap_or(0.0) * pp.bloom.size_scale * 0.01 * 0.5; - t[k] = pp.bloom.tints.get(k).copied().unwrap_or(0.0) * pp.bloom.intensity / 6.0; - } - (r, t) -} - -/// Auto / local exposure settings of the loaded scene (UE PostProcessVolume semantics). -#[derive(Clone, Copy, Default)] -struct ExposureSettings { - ae: [f32; 4], - ae2: [f32; 4], - speed: [f32; 2], - lx: [f32; 4], -} - -impl ExposureSettings { - fn new(exp: &crate::desc::Exposure, pp: &crate::desc::PostPreset) -> Self { - let a = &pp.auto_exposure; - let le = &pp.local_exposure; - let (ae2, comp) = match exp { - crate::desc::Exposure::Manual { ev100 } => ([2f32.powf(-ev100), 0.0, 0.0, 0.0], 2f32.powf(-ev100)), - crate::desc::Exposure::Auto { compensation, min_ev100, max_ev100 } => { - let c = 2f32.powf(*compensation); - ([c, 0.18 * 2f32.powf(*min_ev100), 0.18 * 2f32.powf(*max_ev100), 1.0], c) - } - }; - let _ = comp; - ExposureSettings { - ae: [a.low_percent.clamp(1.0, 99.0) / 100.0, a.high_percent.clamp(1.0, 99.0) / 100.0, a.histogram_log_min, a.histogram_log_max.max(a.histogram_log_min + 1.0)], - ae2, - speed: [a.speed_up.max(0.01), a.speed_down.max(0.01)], - lx: [le.highlight_contrast, le.shadow_contrast, le.blurred_luminance_blend, le.detail_strength], - } - } -} - -#[derive(Clone, Copy, PartialEq)] -pub struct Camera { - pub pos: Vec3, - pub fwd: Vec3, - pub up: Vec3, - pub right: Vec3, - pub tan_half_fov: f32, -} - -impl Camera { - pub fn look_at(pos: Vec3, target: Vec3, tan_half_fov: f32) -> Camera { - let fwd = (target - pos).normalize(); - let right = fwd.cross(Vec3::Y).normalize(); - Camera { pos, fwd, up: right.cross(fwd), right, tan_half_fov } - } - fn uniform(&self, aspect: f32) -> CamU { - CamU { - pos: [self.pos.x, self.pos.y, self.pos.z, self.tan_half_fov], - fwd: [self.fwd.x, self.fwd.y, self.fwd.z, aspect], - right: [self.right.x, self.right.y, self.right.z, 0.0], - up: [self.up.x, self.up.y, self.up.z, 0.0], - } - } -} - -const LOG_OFFSET: f32 = 0.0026883; // must match live.wgsl lin_to_log input offset -const LUT: u32 = 32; - -/// UE-style 32^3 LUT: log-encoded linear input -> display sRGB, built from the exact CPU port of the filmic tonemapper. -fn tonemap_lut(p: &crate::desc::PostPreset) -> Vec { - let tm = post::Tonemapper::new(p); - let mut out = Vec::with_capacity((LUT * LUT * LUT * 4) as usize); - let log_to_lin = |v: f32| (2f32.powf((v - 444.0 / 1023.0) * 14.0) * 0.18 - LOG_OFFSET).max(0.0); - for b in 0..LUT { - for g in 0..LUT { - for r in 0..LUT { - let enc = |k: u32| log_to_lin(k as f32 / (LUT - 1) as f32); - let d = tm.apply(Vec3::new(enc(r), enc(g), enc(b))); - for c in [d.x, d.y, d.z, 1.0] { - out.push(half::f16::from_f32(post::linear_to_srgb(c.clamp(0.0, 1.0))).to_bits()); - } - } - } - } - out -} - -/// The live passes' source: live.wgsl as it is, or in the merged layout its buffers merged into slabs (merged.rs). -pub(crate) fn live_source(merged: bool) -> std::borrow::Cow<'static, str> { - let src = include_str!("../shaders/live.wgsl"); - if merged { - crate::merged::live(src).into() - } else { - src.into() - } -} - -struct Passes { - layout: wgpu::BindGroupLayout, - pipes: Vec<(&'static str, wgpu::ComputePipeline)>, -} - -impl Passes { - fn new(g: &gpu::Gpu) -> Passes { - let d = &g.device; - let module = d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("live"), source: wgpu::ShaderSource::Wgsl(live_source(g.merged).into()) }); - let buf = |binding: u32, read_only: bool| wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only }, has_dynamic_offset: false, min_binding_size: None }, - count: None, - }; - let mut entries = vec![wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, - count: None, - }]; - entries.extend([buf(1, true), buf(2, true), buf(3, false), buf(4, true), buf(5, false), buf(6, false), buf(7, false), buf(8, false), buf(9, false), buf(10, false)]); - entries.push(wgpu::BindGroupLayoutEntry { - binding: 11, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D3, multisampled: false }, - count: None, - }); - entries.push(wgpu::BindGroupLayoutEntry { binding: 12, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None }); - entries.extend([buf(13, false), buf(14, false), buf(15, false), buf(16, false), buf(17, false), buf(18, false)]); - entries.extend([buf(19, true), buf(20, false), buf(21, true), buf(22, false), buf(23, false), buf(24, false), buf(25, true), buf(26, false), buf(27, true), buf(28, true), buf(29, true), buf(30, false)]); - // The merged layout: the buffers that stay, the slabs, and the offsets' uniform (merged.rs). - if g.merged { - use crate::merged::{LIVE_KEPT, OFFSETS_BINDING, SLABS}; - entries.retain(|e| !matches!(e.ty, wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { .. }, .. }) || LIVE_KEPT.iter().any(|k| k.0 == e.binding)); - entries.extend(SLABS.iter().map(|s| buf(s.binding, s.read_only))); - entries.push(wgpu::BindGroupLayoutEntry { binding: OFFSETS_BINDING, ..entries[0] }); - } - let layout = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("live"), entries: &entries }); - let pl = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); - let pipes = ["refl", "dtemporal", "atrous0", "atrous1", "atrous2", "atrous3", "atrous4", "stemporal", "sfilt0", "sfilt1", "sfilt2", "compose", "judge", "reject", "upscale", "dof_tiles", "dof", "mblur", "lum_down", "exposure", "lum_blur", "bloom_down", "bloom_dn1", "bloom_dn2", "bloom_dn3", "bloom_dn4", - "bloom_h5", "bloom_v5", "bloom_h4", "bloom_v4", "bloom_h3", "bloom_v3", "bloom_h2", "bloom_v2", "bloom_h1", "bloom_v1", "bloom_h0", "bloom_v0", "post"] - .into_iter() - .map(|name| { - (name, d.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(name), layout: Some(&pl), module: &module, entry_point: Some(name), compilation_options: Default::default(), cache: g.cache() })) - }) - .collect(); - g.save_pipeline_cache(); - Passes { layout, pipes } - } -} - -/// Screen-size targets shared across scene reloads. -pub struct Targets { - params: wgpu::Buffer, - /// The diffuse irradiance history of the traced grid, then the output grid's colour history (ping-pong). - hist: [Buf; 2], - /// The traced grid's g-buffer (ping-pong: this frame's, the previous frame's). - gb: [Buf; 2], - /// Ping-pong, per traced sample: what `judge` finds of the colour history (live.wgsl `Judged`) in the one a frame - /// writes, the diffuse fast mean in colour (`fast_load`) in the other, which the next frame reads before `judge`. - alb: [Buf; 2], - /// Specular history, diffuse luminance moments (ping-pong, traced grid), the specular filter's two slots, and the - /// scratch `dtemporal` and the upscaled image share. - shist: [Buf; 2], - mom: [Buf; 2], - sa: Buf, - sb: Buf, - /// Reflection resolve: the resolved specular reflection per traced pixel (live.wgsl `refl`), from the trace - /// kernel's specular-lobe rays (2 vec4 per traced pixel, pathtrace.wgsl `refl`: `kernel.refl`). - refl_res: Buf, - /// Per traced sample: screen velocity, view depth, animated shading (live.wgsl `compose`); the frame's parity - /// picks which of the two is written, the other is not read. - xhist: [Buf; 2], - fa: Buf, - fb: Buf, - bloom: [Buf; 2], - pub out: wgpu::Buffer, - /// Auto exposure: 64 histogram buckets (fixed point), state (exposure, target, mean log2 luminance, valid). - ae_bins: wgpu::Buffer, - ae_state: Buf, - /// Quarter-resolution log2 luminance and its edge-aware blur (local exposure base). - lumq: Buf, - lumb: Buf, - /// The kernel's group 1 (`gpu::KernelBufs`): the radiance cache, and what the passes here read of - /// a trace: the specular-lobe rays (`refl`), motion vectors per traced pixel (pathtrace.wgsl `motion_out`) and the - /// deterministic direct light per traced pixel (`direct_out`). - pub kernel: gpu::KernelBufs, - /// The merged layout (merged.rs): the slabs the buffers above are ranges of (`SLABS`' first four). - slabs: Option<[wgpu::Buffer; 4]>, - lut_tex: wgpu::Texture, - lut_view: wgpu::TextureView, - sampler: wgpu::Sampler, -} - -impl Targets { - /// For a view of `w` x `h` and a radiance cache of `cache` entries. - fn new(g: &gpu::Gpu, w: u32, h: u32, lut: &[u16], cache: u64) -> Targets { - use crate::merged::{SLABS, SLAB_F1, SLAB_U2, SLAB_U4, SLAB_V4}; - let d = &g.device; - let n = (w * h) as u64; - let sb = |label: &str, bytes: u64| d.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size: bytes, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }); - let bn = (w.div_ceil(4) * h.div_ceil(4)) as u64 * 16; - let texels = |j: u32| bloom_dim(w, h, j).0 as u64 * bloom_dim(w, h, j).1 as u64; - let bloom_texels = (0..5).map(texels).sum::() + (0..6).map(|k| texels(bloom_stage_level(k))).sum::(); - let lut_tex = d.create_texture(&wgpu::TextureDescriptor { - label: Some("tonemap lut"), - size: wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D3, - format: wgpu::TextureFormat::Rgba16Float, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - g.queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &lut_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - bytemuck::cast_slice(lut), - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT * 8), rows_per_image: Some(LUT) }, - wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, - ); - // The buffers, in the order of the fields below: a buffer each, or in the merged layout (merged.rs) a range of - // the slab of its element type (each range starts at a multiple of 16 bytes: of every slab's element). - let specs: [(&str, u64, usize); 22] = [ - ("hist0", n * 32, SLAB_V4), // traced grid, then output grid (live.wgsl `out_hist`) - ("hist1", n * 32, SLAB_V4), - ("gb0", n * 16, SLAB_U2), - ("gb1", n * 16, SLAB_U2), - ("alb0", n * 16, SLAB_U4), - ("alb1", n * 16, SLAB_U4), - ("shist0", n * 16, SLAB_V4), - ("shist1", n * 16, SLAB_V4), - ("mom0", n * 16, SLAB_V4), - ("mom1", n * 16, SLAB_V4), - ("spec_a", n * 16, SLAB_U2), - ("spec_b", n * 16, SLAB_V4), - ("refl_res", n * 16, SLAB_V4), - ("direct_hist_a", n * 16, SLAB_V4), - ("direct_hist_b", n * 16, SLAB_V4), - ("fa", n * 16, SLAB_V4), - ("fb", n * 16, SLAB_V4), - ("bloom_a", bloom_texels * 16, SLAB_V4), - ("bloom_b", bn, SLAB_V4), - ("ae_state", 32, SLAB_V4), - ("lumq", bn / 4, SLAB_F1), - ("lumb", bn / 4, SLAB_F1), - ]; - let slabs: Option<[wgpu::Buffer; 4]> = g.merged.then(|| std::array::from_fn(|k| sb(SLABS[k].name, specs.iter().filter(|s| s.2 == k).map(|s| s.1.next_multiple_of(16)).sum()))); - let mut at = [0u64; 4]; - let bufs: Vec = specs - .iter() - .map(|&(label, bytes, slab)| match &slabs { - None => Buf::whole(sb(label, bytes)), - Some(s) => { - let b = Buf { buffer: s[slab].clone(), offset: at[slab], size: bytes }; - at[slab] += bytes.next_multiple_of(16); - b - } - }) - .collect(); - let mut bufs = bufs.into_iter(); - let mut b = || bufs.next().expect("a buffer of `specs`"); - Targets { - params: d.create_buffer(&wgpu::BufferDescriptor { label: Some("live params"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), - hist: [b(), b()], - gb: [b(), b()], - alb: [b(), b()], - shist: [b(), b()], - mom: [b(), b()], - sa: b(), - sb: b(), - refl_res: b(), - xhist: [b(), b()], - fa: b(), - fb: b(), - bloom: [b(), b()], - out: sb("out", n * 4), - ae_bins: sb("ae_bins", 64 * 4), - ae_state: b(), - lumq: b(), - lumb: b(), - kernel: gpu::KernelBufs::new(d, g.merged, g.guard_room(), cache, n * 32, n * 16, n * 16), - slabs, - lut_view: lut_tex.create_view(&Default::default()), - lut_tex, - sampler: d.create_sampler(&wgpu::SamplerDescriptor { mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, ..Default::default() }), - } - } - - /// What live.wgsl's buffer bindings hold in the bind group of parity `a` (besides the uniforms at 0, the trace's - /// samples at 1, `out` at 10 and the histogram at 13): the history and the g-buffer swap roles with the parity. - fn buffers(&self, a: usize) -> [(u32, &Buf); 25] { - let b = 1 - a; - [ - (2, &self.hist[a]), - (3, &self.hist[b]), - (4, &self.gb[a]), - (5, &self.gb[b]), - (6, &self.fa), - (7, &self.fb), - (8, &self.bloom[0]), - (9, &self.bloom[1]), - (14, &self.ae_state), - (15, &self.lumq), - (16, &self.lumb), - (17, &self.alb[a]), - (18, &self.alb[b]), - (19, &self.shist[a]), - (20, &self.shist[b]), - (21, &self.mom[a]), - (22, &self.mom[b]), - (23, &self.sa), - (24, &self.sb), - (25, &self.kernel.refl), - (26, &self.refl_res), - (27, &self.kernel.motion), - (28, &self.kernel.direct), - (29, &self.xhist[a]), - (30, &self.xhist[b]), - ] - } - - /// Two bind groups that swap history / g-buffer ping-pong roles on alternate frames. In the merged layout - /// (merged.rs) they bind the same slabs, and differ in the offsets the passes read (`LO`). - fn bind_groups(&self, d: &wgpu::Device, layout: &wgpu::BindGroupLayout, sample: &wgpu::Buffer) -> [wgpu::BindGroup; 2] { - let mk = |a: usize| { - fn e(binding: u32, buf: &wgpu::Buffer) -> wgpu::BindGroupEntry<'_> { - wgpu::BindGroupEntry { binding, resource: buf.as_entire_binding() } - } - let mut entries = vec![ - e(0, &self.params), - e(1, sample), - e(10, &self.out), - wgpu::BindGroupEntry { binding: 11, resource: wgpu::BindingResource::TextureView(&self.lut_view) }, - wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::Sampler(&self.sampler) }, - e(13, &self.ae_bins), - ]; - let buffers = self.buffers(a); - let offsets; - match (&self.slabs, &self.kernel.merged) { - (Some(slabs), Some((live, _))) => { - use crate::merged::{LIVE_BUFS, OFFSETS_BINDING, SLABS, SLAB_K}; - // Each merged buffer's (first element, length) in its slab, in `MergedOffsets`' order. - let lo: Vec<[u32; 2]> = LIVE_BUFS - .iter() - .map(|m| { - let buf = buffers.iter().find(|b| b.0 == m.binding).expect("a merged buffer's binding").1; - let stride = SLABS[m.slab].stride; - debug_assert!(buf.offset % stride == 0 && buf.size % stride == 0); - [(buf.offset / stride) as u32, (buf.size / stride) as u32] - }) - .collect(); - // (And the struct's unused last pair: `merged::OFFSETS_BYTES`.) - let mut lo = lo; - lo.push([0, 0]); - debug_assert_eq!(lo.len() * 8, crate::merged::OFFSETS_BYTES); - offsets = wgpu::util::DeviceExt::create_buffer_init(d, &wgpu::util::BufferInitDescriptor { label: Some("live offsets"), contents: bytemuck::cast_slice(&lo), usage: wgpu::BufferUsages::UNIFORM }); - entries.extend(slabs.iter().zip(&SLABS).map(|(s, m)| e(m.binding, s))); - entries.push(e(SLABS[SLAB_K].binding, live)); - entries.push(e(OFFSETS_BINDING, &offsets)); - } - _ => entries.extend(buffers.iter().map(|(binding, buf)| wgpu::BindGroupEntry { binding: *binding, resource: buf.binding() })), - } - d.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("live"), layout, entries: &entries }) - }; - [mk(0), mk(1)] - } -} - -struct LoadedScene { - /// Load phases (parse, bvh, sky, accel build, upload) in ms. - timings: Vec<(String, f64)>, - /// World-space triangles + per-node ranges, kept on the CPU for picking and selection outlines; instanced meshes - /// (model space) and their placements likewise. - tris: Vec<[glam::Vec3A; 3]>, - tri_nodes: Vec<(String, u32, u32)>, - /// Flattened instances of instanced objects: (first, end, instance index), for picking. - tri_instances: Vec<(u32, u32, u32)>, - pool_tris: Vec<[glam::Vec3A; 3]>, - meshes: Vec, - instances: Vec, - /// The instances' kernel records as uploaded (movable ones are rewritten when they move), then the procedural - /// objects' (from `proc_first`). - gpu_instances: Vec, - /// Procedural geometry (moves, picking), by node in `proc_of`; its records start at `proc_first`. - procs: Vec, - proc_of: std::collections::HashMap, - proc_first: usize, - /// The scene has instanced meshes or procedural geometry (the raster experiment draws only flattened triangles: - /// primary visibility is traced). - instanced: bool, - /// Kernel features of the instanced meshes (gpu::instance_features), which edits cannot change. - instance_features: u32, - name: String, - triangles: usize, - up: gpu::Uploaded, - base: gpu::Params, - bgs: [wgpu::BindGroup; 2], - exposure: f32, - exposure_settings: ExposureSettings, - tan_half_fov: f32, - scene_camera: Camera, - /// The camera's depth of field (UE: off unless it sets a focus distance). - dof: Option, - /// GPU table copies that parameter edits rewrite in place (`commit`). - tables: tables::Tables, - /// CPU copy of the dynamic triangles (posed models: the tail of the GPU triangle order from `dynamic_first`): - /// vertices (kernel layout), shading attributes, BLAS vertices. - dynamic: (usize, Vec<[f32; 4]>, Vec, Vec<[f32; 3]>), - /// Kernel feature inputs edits cannot change: textures, material functions, traversal groups. - fixed_features: (bool, bool, [usize; crate::GROUPS]), - /// Where in-place geometry edits write next (`edit_geometry`); a rebuild starts it afresh. - edit: EditState, - /// The atmosphere's sky once its sun has moved in place (`move_sun` starts it), and what it bakes with: the - /// environment map's size, the viewer's altitude and the environment's light-sampling floor, as the load had them. - sky: Option, - /// Power edits' emitter rebuilds on a worker (`EmitterWorker`), for scenes with many emitters. - emit: Option, - sky_inputs: ((u32, u32), f32, f32), - /// For materials that start emitting (`add_material_emitters`): their flattened static triangles' runs (`prepare`) - /// and the flattened triangles' build indices in traversal order (positions in `tris`). - mat_runs: std::collections::HashMap>, - tri_order: Vec, -} - -/// Cores the sky's worker thread bakes with: the rest stay with the frame (scripts, uploads, the GPU's driver). -const SKY_THREADS: usize = 3; - -/// An atmosphere whose sun moves while the scene runs (time of day). The sun's light is rewritten in place at every -/// move, its direction and its colour through the atmosphere (`sky::sun_illuminance`, no bake needed), which carries -/// the direct light and the shadows. The sky's radiance (the environment map and its sampling CDF) is baked again on a -/// worker thread with `SKY_THREADS` cores, the newest sun first, and lands with a later frame (`take_sky`): the sky -/// trails the sun by one bake (~10–40 ms at `sky::DYNAMIC_SKY`). Lockstep runs wait for it, so they repeat. -struct DynamicSky { - atm: Arc, - /// (sun direction, sun illuminance above the atmosphere, cloud cover) - ask: mpsc::Sender<(glam::Vec3, glam::Vec3, f32)>, - done: mpsc::Receiver<(gpu::EnvMap, Vec, f32)>, - /// The brightest sky texel from which the environment is light-sampled (`Prepared::env_nee_floor`). - env_nee_floor: f32, -} - -impl DynamicSky { - fn new(world: &World, (w, h): (u32, u32), altitude: f32, env_nee_floor: f32) -> Option { - let crate::desc::Sky::Atmosphere { rayleigh_scale, mie_scale, mie_anisotropy, ground_albedo, glow, horizon_glow, sun: Some(_), .. } = &world.environment.sky else { - return None; - }; - let atm = Arc::new(crate::sky::Atmosphere::earth(*rayleigh_scale, *mie_scale, *mie_anisotropy, glam::Vec3::from(*ground_albedo))); - let glow = crate::sky::glow_of(glow, horizon_glow); - let (ask, asked) = mpsc::channel::<(glam::Vec3, glam::Vec3, f32)>(); - let (tx, done) = mpsc::channel(); - let a = atm.clone(); - std::thread::Builder::new() - .name("sky bake".into()) - .spawn(move || { - let pool = rayon::ThreadPoolBuilder::new().num_threads(SKY_THREADS).build().ok(); - let tables = match &pool { - Some(p) => p.install(|| crate::sky::SkyTables::new(&a)), - None => crate::sky::SkyTables::new(&a), - }; - let bake = |sun: (glam::Vec3, glam::Vec3, f32)| { - let b = crate::sky::bake_atmosphere_tables(&a, &tables, sun.0, sun.1, altitude, w, h, glow, sun.2); - let env = gpu::EnvMap { width: b.width, height: b.height, texels: b.texels }; - let cdf = crate::build_env_cdf(&env); - let max = env.texels.iter().map(|t| 0.2126 * t[0] + 0.7152 * t[1] + 0.0722 * t[2]).fold(0.0f32, f32::max); - (env, cdf, max) - }; - // The newest sun only: bakes asked for while one ran are stale. - while let Ok(mut sun) = asked.recv() { - while let Ok(s) = asked.try_recv() { - sun = s; - } - let out = match &pool { - Some(p) => p.install(|| bake(sun)), - None => bake(sun), - }; - if tx.send(out).is_err() { - break; - } - } - }) - .ok()?; - Some(DynamicSky { atm, ask, done, env_nee_floor }) - } -} - -/// Scenes with at least this many emitters rebuild their emitter CDF for power edits on a worker (`EmitterWorker`). -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] -const ASYNC_EMITTERS: usize = 100_000; - -/// Power edits in a scene with many emitters (a city's lamps and windows switching with the clock): the emitter CDF and -/// the light tree's node powers are rebuilt on a worker thread (~13 ms over 1.4 M emitters, which held up the frame in -/// `write_emitters`), and the edits land with them, in a later frame (`take_emitters`): the objects' powers, the CDF -/// and the tree's powers together, so sampling and shading switch at once. The worker writes the CDF and the tree's -/// nodes into a mapped staging buffer; the frame only copies it on the GPU (a 22 MB `write_buffer` cost it ~5 ms). -/// Lockstep frames wait for it, so they repeat. Scenes whose emitters change in place (added in place: the tree's slot; -/// moving emitters, whose nodes the frame refits) keep `write_emitters`; so does any other emitter edit (materials), -/// which first lands the edits held back. -struct EmitterWorker { - ask: mpsc::Sender, - done: mpsc::Receiver, - /// The emitters it was made for: a list that grew (added in place) makes another. - count: usize, - /// The edits of the job it is on (they land with its result). - flight: Option>, -} - -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] -struct EmitterJob { - materials: Vec, - objects: Vec<[f32; 4]>, - lod: [f32; 4], - cull: crate::EmitterCull, -} - -struct EmitterBuilt { - /// The CDF then the tree's nodes, as the emitters buffer holds them (`put_emitters`), and their bytes. - staging: wgpu::Buffer, - bytes: u64, - /// The CDF's entries (its guide table included). - entries: usize, - total: f32, - power: f32, - /// The light tree's node powers (`nodes[i][2]`), for the frame's copy of the tree. - node_powers: Vec, -} - -impl EmitterWorker { - /// wasm32: none, and power edits rebuild in the frame as before (the browser's WebGPU device and buffers stay on - /// the thread that made them). - #[cfg(target_arch = "wasm32")] - fn new(_e: &tables::Tables, _device: &wgpu::Device) -> Option { - None - } - - #[cfg(not(target_arch = "wasm32"))] - fn new(e: &tables::Tables, device: &wgpu::Device) -> Option { - let changing = e.light_tree.as_ref().is_some_and(|t| t.dynamic.is_some() || t.moving_count > 0); - if e.emitter_tris.len() < ASYNC_EMITTERS || changing { - return None; - } - let device = device.clone(); - let (tris, mut tree, stride) = (e.emitter_tris.clone(), e.light_tree.clone(), e.stride); - let (ask, asked) = mpsc::channel::(); - let (tx, done) = mpsc::channel(); - std::thread::Builder::new() - .name("emitters".into()) - .spawn(move || { - while let Ok(j) = asked.recv() { - let (em, total, power) = crate::emitter_cdf(&tris, &j.materials, &j.objects, stride, j.lod, j.cull); - let node_powers = tree.as_mut().map_or(vec![], |t| { - let w: Vec = t.order.iter().map(|&i| crate::emitter_power(&tris[i as usize], &j.materials, &j.objects, stride)).collect(); - t.refit(&|k| w[k]); - t.nodes.iter().map(|n| n[2]).collect() - }); - let nodes: &[[f32; 4]] = tree.as_ref().map_or(&[], |t| &t.nodes); - let bytes = ((em.len() + nodes.len()) * 16) as u64; - let staging = device.create_buffer(&wgpu::BufferDescriptor { label: Some("emitters staging"), size: bytes.max(16), usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: true }); - let at = em.len() as u64 * 16; - for (range, data) in [(0..at, bytemuck::cast_slice::<_, u8>(&em)), (at..bytes, bytemuck::cast_slice(nodes))] { - if !data.is_empty() { - let Ok(mut m) = staging.slice(range).get_mapped_range_mut() else { break }; - m.copy_from_slice(data); - } - } - staging.unmap(); - if tx.send(EmitterBuilt { staging, bytes, entries: em.len(), total, power, node_powers }).is_err() { - break; - } - } - }) - .ok()?; - Some(EmitterWorker { ask, done, count: e.emitter_tris.len(), flight: None }) - } -} - -/// Writes to one buffer gathered during an edit and sent merged: adjacent ranges as one `write_buffer` (an edit writes -/// hundreds of small records and meshes, contiguous in the room). Overlapping writes go in the order they came. -#[derive(Default)] -struct Gather { - parts: Vec<(u64, Vec)>, -} - -impl Gather { - fn put(&mut self, at: u64, bytes: &[u8]) { - self.parts.push((at, bytes.to_vec())); - } - - fn flush(self, q: &wgpu::Queue, b: &wgpu::Buffer) { - let mut parts = self.parts; - let mut sorted = parts.clone(); - sorted.sort_by_key(|p| p.0); - if sorted.windows(2).any(|w| w[0].0 + w[0].1.len() as u64 > w[1].0) { - for (at, bytes) in parts.drain(..) { - q.write_buffer(b, at, &bytes); - } - return; - } - let mut run: Option<(u64, Vec)> = None; - for (at, bytes) in sorted { - match &mut run { - Some((start, data)) if *start + data.len() as u64 == at => data.extend_from_slice(&bytes), - _ => { - if let Some((start, data)) = run.take() { - q.write_buffer(b, start, &data); - } - run = Some((at, bytes)); - } - } - } - if let Some((start, data)) = run { - q.write_buffer(b, start, &data); - } - } -} - -/// Where an in-place edit's CPU time goes (ms) and what it did: building the triangles' GPU form, uploading them, -/// creating and recording BLASes and the TLAS; meshes added and rewritten in place, objects added or removed. Frame log -/// `edit_split`. -#[derive(Default, Clone, Copy, Debug)] -pub struct EditSplit { - pub tris: f64, - pub upload: f64, - pub blas: f64, - pub added: usize, - pub deformed: usize, - pub objects: usize, -} - -impl EditSplit { - fn add(&mut self, o: &EditSplit) { - (self.tris, self.upload, self.blas) = (self.tris + o.tris, self.upload + o.upload, self.blas + o.blas); - (self.added, self.deformed, self.objects) = (self.added + o.added, self.deformed + o.deformed, self.objects + o.objects); - } -} - -/// The edit room in use (gpu.rs `EditRoom`): the next free triangle (GPU order), kernel record, object-table entry, -/// procedural word (counted from the region's start) and TLAS instance, and where they started. -#[derive(Default)] -struct EditState { - /// Triangles (GPU order), kernel records (a record's TLAS instance is `inst_base` + it) and object-table entries - /// edits can place: holes left by what they no longer draw, then the room, which grows past its end (ROADMAP § 6 - /// step 5). - tris: Holes, - records: Holes, - objects: Holes, - /// The next procedural word, and where the region (grown as needed) ends. - word: usize, - word_cap: usize, - /// The instanced mesh each object edits manage draws (`own_blas`: the build's and theirs), each mesh's objects, the - /// meshes edits may free (own-BLAS meshes: shared only by objects of one geometry and material), and the slots of - /// freed ones, reused by the next new mesh. - mesh_of: std::collections::HashMap, - users: std::collections::HashMap, - managed: std::collections::HashSet, - dead_meshes: Vec, - /// Times the room grew (frame log `grows`), and whether the scene bind group must be made again. - grows: u32, - rebind: bool, - /// The uniform tables (`gpu::Gpu::uniform_tables`): an edit placed entries past what their arrays hold, and it - /// was said (once a scene, as its upload says it of the scene's own). - uniform_said: bool, - /// (triangles, records, objects, words, TLAS instances) the scene itself has: the room starts there. - base: (usize, usize, usize, usize, usize), - /// The first pool triangle (instanced meshes' triangles are counted from it). - pool_first: usize, - /// Procedural blobs in the region by hash: their first word (the build's and those edits wrote). - blobs: std::collections::HashMap<[u8; 32], usize>, - /// Objects edits added: their (TLAS instance, record); instanced meshes edits added: their first record, capacity - /// and first object entry (contiguous, as `update_instanced` rewrites them). - placed: std::collections::HashMap, - instanced: std::collections::HashMap, - /// The build's model instances (records below are theirs, in `LoadedScene::instances` order). - inst_at_load: usize, - /// What clicks pick among the objects edits added (`LiveCore::pick`; the build's are `instances` and `procs`). - added: std::collections::BTreeMap, -} - -/// Ranges of one kind of the edit room: free holes (start -> length; first fit, neighbours merged) below `end`, the -/// part in use, which may reach `cap`, what the buffers hold (more by growing them). -#[derive(Default, Debug)] -struct Holes { - free: std::collections::BTreeMap, - end: usize, - cap: usize, - /// Where the room starts (the scene's own data before it). - base: usize, -} - -impl Holes { - fn new(end: usize, cap: usize) -> Holes { - Holes { free: Default::default(), end, cap, base: end } - } - - /// `n` contiguous: the first hole that fits, else at the end if within `cap`. - fn take(&mut self, n: usize) -> Option { - if let Some((&at, &len)) = self.free.iter().find(|(_, &len)| len >= n) { - self.free.remove(&at); - if len > n { - self.free.insert(at + n, len - n); - } - return Some(at); - } - (self.end + n <= self.cap).then(|| { - self.end += n; - self.end - n - }) - } - - /// `n` from `at` free again: merged with the holes beside it; at the end, the end moves back. - fn give(&mut self, at: usize, n: usize) { - if n == 0 { - return; - } - let (mut a, mut len) = (at, n); - if let Some((&pa, &pl)) = self.free.range(..at).next_back().filter(|(&pa, &pl)| pa + pl == at) { - self.free.remove(&pa); - (a, len) = (pa, len + pl); - } - if let Some(nl) = self.free.remove(&(at + n)) { - len += nl; - } - if a + len == self.end { - self.end = a; - } else { - self.free.insert(a, len); - } - } - - /// In use (below `end`, less the holes). - fn used(&self) -> usize { - self.end - self.free.values().sum::() - } -} - -/// An object an edit placed, as `LiveCore::pick` tests it. -enum Picked { - Mesh(scene::Instance), - Proc(scene::ProcObject), -} - -/// What an edit places at an object's slot: a procedural object's BLAS (hardware traversal), or instanced mesh `k`. -enum Placed { - Proc(wgpu::Blas), - Mesh(u32), -} - -/// One object fewer draws instanced mesh `k`: a mesh edits manage that none draws any more is freed (its triangles a -/// hole the next new mesh can take, its slot reused, its BLAS dropped with it). -fn drop_mesh_user(k: u32, edit: &mut EditState, tables: &mut tables::Tables, meshes: &[scene::InstMesh]) { - let n = edit.users.entry(k).or_default(); - *n = n.saturating_sub(1); - if *n == 0 && edit.managed.remove(&k) { - let im = meshes[k as usize]; - edit.tris.give(edit.pool_first + im.first as usize, im.count as usize); - tables.retained.own_mesh.retain(|_, v| *v != k); - edit.dead_meshes.push(k); - } -} - -/// The room's buffers past their end, doubled at least (ROADMAP § 6 step 5): new buffers, the old contents copied on -/// the GPU; the scene bind group is made again after the edit (`EditState::rebind`). Not with particles, whose pass -/// keeps a bind group of its own over these buffers. -struct Grow<'a> { - g: &'a gpu::Gpu, - ok: bool, -} - -impl Grow<'_> { - /// The room twice as large (the scene's own data before it stays as it is), at least `extra` more at the end. - fn cap(h: &Holes, extra: usize) -> usize { - h.base + ((h.cap - h.base) * 2).max(h.end + extra - h.base).max(1024) - } - - /// Whether buffers of these sizes (bytes) can be bound (the device's storage binding and buffer limits): past them - /// the edit rebuilds instead (Bugbot on #131). - fn fits(&self, sizes: &[u64]) -> bool { - let l = self.g.device.limits(); - let max = (l.max_storage_buffer_binding_size as u64).min(l.max_buffer_size); - sizes.iter().all(|&s| s <= max) - } - - /// Triangles: vertices, attributes, the BLASes' vertices, vertex colours (after the previous vertices). - fn tris(&self, h: &mut Holes, extra: usize, verts: &mut wgpu::Buffer, attrs: &mut wgpu::Buffer, prev: &mut wgpu::Buffer, vcolor_base: Option, accel: &mut gpu::Accel) -> bool { - if !self.ok { - return false; - } - let cap = Self::cap(h, extra); - let prev_bytes = vcolor_base.map_or(0, |base| (base + cap as u64 * 12).next_multiple_of(16)); - if !self.fits(&[(cap * 48) as u64, (cap * std::mem::size_of::()) as u64, (cap * 36) as u64, prev_bytes]) { - return false; - } - *verts = self.g.grown(verts, (cap * 48) as u64, "verts"); - *attrs = self.g.grown(attrs, (cap * std::mem::size_of::()) as u64, "attrs"); - self.g.grow_blas_verts(accel, (cap * 36) as u64); - if vcolor_base.is_some() { - *prev = self.g.grown(prev, prev_bytes, "prev verts + vertex colours"); - } - h.cap = cap; - true - } - - /// Kernel records and the TLAS (a record's instance is `inst_base` + it). - fn records(&self, h: &mut Holes, extra: usize, instances: &mut wgpu::Buffer, accel: &mut gpu::Accel) -> bool { - if !self.ok { - return false; - } - let cap = Self::cap(h, extra); - if !self.fits(&[(cap * std::mem::size_of::()) as u64]) { - return false; - } - *instances = self.g.grown(instances, (cap * std::mem::size_of::()) as u64, "instances"); - self.g.grow_tlas(accel, accel.inst_base + cap); - h.cap = cap; - true - } - - /// Object-table entries (`stride` vec4s each). - fn objects(&self, h: &mut Holes, extra: usize, objects: &mut wgpu::Buffer, stride: usize) -> bool { - if !self.ok { - return false; - } - let cap = Self::cap(h, extra); - if !self.fits(&[(cap * stride * 16) as u64]) { - return false; - } - *objects = self.g.grown(objects, (cap * stride * 16) as u64, "objects"); - h.cap = cap; - true - } - - /// Procedural words, after the environment's CDF (`cdf_words` of them). - fn words(&self, cap: &mut usize, need: usize, cdf: &mut wgpu::Buffer, cdf_words: usize) -> bool { - if !self.ok { - return false; - } - let new = (*cap * 2).max(need); - if !self.fits(&[((cdf_words + new) * 4) as u64]) { - return false; - } - *cap = new; - *cdf = self.g.grown(cdf, ((cdf_words + *cap) * 4) as u64, "env_cdf + procedural"); - true - } -} - -/// An object removed or hidden in place: no longer picked until the next rebuild (`LiveCore::pick`). -fn unpick(id: u32, edit: &mut EditState, tables: &tables::Tables, instances: &mut [scene::Instance], procs: &mut [scene::ProcObject], proc_of: &std::collections::HashMap) { - edit.added.remove(&id); - if let Some(i) = tables.retained.own_instance.get(&id).and_then(|&k| instances.get_mut(k as usize)) { - i.hidden = true; - } - if let Some(p) = proc_of.get(&id).and_then(|&j| procs.get_mut(j)) { - p.hidden = true; - } -} - -fn load_scene(g: &gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32, h: u32, targets: &Targets, passes: &Passes) -> Result<(LoadedScene, crate::desc::PostPreset)> { - let t0 = Instant::now(); - let mut sc = scene::build(world, assets, presets, None)?; - sc.width = w; - sc.height = h; - // Emitters its edits add get a light-tree subtree of their own (ROADMAP § 6 step 4). - // (Software traversal with units edits in place as hardware traversal does: `units_wanted`.) - sc.emitter_slot = gpu::EditRoom::editable(&sc, g.hwrt || crate::units_wanted(g.hwrt, &sc)); - let mut timings = vec![("build_ms".to_string(), t0.elapsed().as_secs_f64() * 1000.0)]; - let mut prep = crate::prepare_live(g, &mut sc, &mut timings); - let mut tables = tables::Tables::new(&sc, std::mem::take(&mut prep.emitter_tris), prep.light_tree.take()); - // (THREERT_EDIT_REBUILD=1: every edit rebuilds, a material that starts emitting too: the A/B.) - tables.emit_in_place = std::env::var("THREERT_EDIT_REBUILD").map_or(true, |v| v != "1"); - tables.fixed_mats = std::mem::take(&mut prep.fixed_mats); - let emit = EmitterWorker::new(&tables, &g.device); - tables.defer_powers = emit.is_some(); - let (mat_runs, tri_order) = (std::mem::take(&mut prep.mat_runs), std::mem::take(&mut prep.tri_order)); - let fixed_features = (!sc.textures.is_empty(), !sc.shaders.is_empty(), prep.groups); - let mut up = g.upload(&sc, w, h, &prep.tri_verts, prep.groups, bytemuck::cast_slice(&prep.nodes), &prep.emitters, &prep.env, &prep.env_cdf, &prep.energy, &mut timings)?; - // The merged layout: the kernel written for this view's group-1 buffers (merged.rs). - if let (Some(caps), Some((_, live))) = (&mut up.kernel.caps, &targets.kernel.merged) { - caps.live = *live; - } - let _ = g.pipeline(&up.kernel, true); // compile the scene's kernel variant now, not in the first frame - let sky_inputs = ((prep.env.width, prep.env.height), sc.camera.position.y.max(0.0), prep.env_nee_floor); - let mut base = prep.params; - // The live viewport resamples fewer light-tree candidates at camera hits than offline renders (ltree.rs). - if base.ltree[3] > 0.5 { - base.ltree[3] = crate::ltree::live_candidates() as f32; - } - base.cam_fwd[3] = w as f32 / h as f32; - let bgs = targets.bind_groups(&g.device, &passes.layout, &up.accum); - let exposure_settings = ExposureSettings::new(&sc.camera.exposure, &sc.preset.post); - // Manual: 2^-EV100. Auto: a first guess for the firefly clamp (the GPU histogram takes over from frame 1). - let exposure = match &sc.camera.exposure { - crate::desc::Exposure::Manual { ev100 } => 2f32.powf(-ev100), - crate::desc::Exposure::Auto { compensation, max_ev100, .. } => 2f32.powf(*compensation) / 2f32.powf(*max_ev100), - }; - let c = &sc.camera; - let scene_camera = Camera { pos: c.position, fwd: c.forward, up: c.up, right: c.right, tan_half_fov: c.tan_half_fov }; - let (tris, tri_nodes, tri_instances) = (std::mem::take(&mut sc.tris), std::mem::take(&mut sc.tri_nodes), std::mem::take(&mut sc.tri_instances)); - let instanced = !sc.gpu_instances.is_empty() || !sc.procs.is_empty(); - let instance_features = gpu::instance_features(&sc); - let (pool_tris, meshes, instances) = (std::mem::take(&mut sc.pool_tris), std::mem::take(&mut sc.meshes), std::mem::take(&mut sc.instances)); - let mut gpu_instances = std::mem::take(&mut sc.gpu_instances); - let proc_first = gpu_instances.len(); - gpu_instances.extend_from_slice(&sc.proc_records); - let procs = std::mem::take(&mut sc.procs); - let proc_of = procs.iter().enumerate().filter(|(_, p)| p.sub.is_none()).map(|(j, p)| (p.node, j)).collect(); - let triangles = sc.stats.get("triangles").map_or(tris.len(), |t| *t as usize); - let first = prep.groups[..4].iter().sum::(); - // The kernel's first dynamic triangle (motion vectors, prev_verts) is group 4's first. - debug_assert_eq!(prep.params.group_first[1][0] as usize, first); - // The dynamic tail: groups 4..8 (the instanced pool follows them). - let end = prep.groups.iter().sum::(); - let blas = prep.tri_verts[first * 3..end * 3].chunks(3).flat_map(|t| [0, 1, 2].map(|k| if k == 0 { [t[0][0], t[0][1], t[0][2]] } else { [t[0][0] + t[k][0], t[0][1] + t[k][1], t[0][2] + t[k][2]] })).collect(); - let dynamic = (first, prep.tri_verts[first * 3..end * 3].to_vec(), sc.attrs[first..end].to_vec(), blas); - let blobs = sc.proc_layout.as_ref().map_or_else(Default::default, |l| l.blobs.iter().map(|(b, off)| (b.hash, l.head.len() + off)).collect()); - let slot = up.accel.as_ref().map_or(0, |a| a.proc_base + procs.len()); - let room_base = (prep.tri_verts.len() / 3, gpu_instances.len(), tables.objects.len() / tables.stride, up.region_words, slot); - // A record's TLAS instance is `inst_base` + it (model instances, then procedural objects, then the room's). - debug_assert!(up.accel.as_ref().is_none_or(|a| room_base.4 == a.inst_base + room_base.1)); - let room = up.room; - let mut users: std::collections::HashMap = Default::default(); - for i in &instances { - *users.entry(i.mesh).or_default() += 1; - } - let mesh_of = tables.retained.own_instance.iter().filter_map(|(&id, &k)| instances.get(k as usize).map(|i| (id, i.mesh))).collect(); - let managed = tables.retained.own_mesh.values().chain(tables.retained.emitter_mesh.values()).copied().collect(); - let edit = EditState { - tris: Holes::new(room_base.0, room_base.0 + room.tris), - records: Holes::new(room_base.1, room_base.1 + room.records), - objects: Holes::new(room_base.2, room_base.2 + room.objects), - word: room_base.3, - word_cap: room_base.3 + room.proc_words, - mesh_of, - users, - managed, - dead_meshes: vec![], - grows: 0, - rebind: false, - uniform_said: false, - base: room_base, - pool_first: prep.groups.iter().sum(), - blobs, - placed: Default::default(), - instanced: Default::default(), - inst_at_load: instances.len(), - added: Default::default(), - }; - Ok((LoadedScene { timings, triangles, tris, tri_nodes, tri_instances, pool_tris, meshes, instances, gpu_instances, procs, proc_of, proc_first, instanced, instance_features, name: sc.name.clone(), up, base, bgs, exposure, exposure_settings, tan_half_fov: c.tan_half_fov, scene_camera, dof: c.dof.clone(), tables, fixed_features, dynamic, edit, sky: None, emit, sky_inputs, mat_runs, tri_order }, sc.preset.post)) -} - -pub enum Cmd { - Camera(Camera), - /// An event for the in-process game (type, payload). - Event(String, serde_json::Value), - /// A click at (u, v) for the in-process game (picks the node under it). - Click(f32, f32), - /// Messages of the host channel for the in-process game (`{"cmd":"host","messages":[{"t":...}, ...]}`: what an - /// app would send, its input among them; docs/RUNTIME.md "The host channel"). - Host(Vec), - /// Snapshot (path) of the first frame whose history length reaches `after` (None = any) and that is at least the - /// `frames`-th frame from now (None = the next). - Snapshot(PathBuf, Option, Option), - /// Answer `live: sync ` on stderr once the n-th frame from now is rendered (benchmarks step the camera frame by - /// frame, so what they measure does not depend on how many frames a busy machine renders per second). - Sync(String, u32), - /// Lockstep (benchmarks): with Some(frame time, s) the runner renders only the frames `Step`s ask for, at that frame - /// time (motion blur, exposure, frame script); None = real time again. - Lockstep(Option), - /// A step: an optional camera, `frames` frames to render (lockstep), and a sync answer / snapshot at the last of them, - /// registered with the camera in one command so neither can land a frame late. - Step { cam: Option, frames: u32, sync: Option, snapshot: Option }, - /// Drop all accumulated history (benchmarks measure convergence from here). - Reset, - /// Set the scene time, s (benchmarks: with lockstep, frame k from here renders at t + k x its frame time; frame - /// time 0 holds it there). - Time(f32), - /// View mode (see LiveCore::view_mode). - Mode(u32), - Bounces(u32), - /// Max samples per frame (adaptive below it). - SppCap(u32), - /// Viewer status text (e.g. the game score), shown in the window title. - Status(String), - /// Render scale (0 = dynamic resolution). - Scale(f32), - Quit, -} - -/// The real-time renderer: scene + screen targets + camera/history state. Records one frame at a time; the output -/// (sRGB BGRA8 per pixel) lands in `targets.out`. Used by the headless runner, the native window viewer and the playground. -pub struct LiveCore { - /// The game's particle spawns since the last frame (`World::particle_spawns`), for the GPU particle pass. - particle_spawns: std::collections::BTreeMap>, - /// The sources the game gave its density grids since the last frame (`World::grid_sources`), for the grid pass. - grid_sources: std::collections::BTreeMap>, - pub g: gpu::Gpu, - passes: Passes, - pub targets: Targets, - sc: LoadedScene, - presets: PathBuf, - /// Asset root (models, file textures). - assets: PathBuf, - pub w: u32, - pub h: u32, - /// Committed state sequence number the image shows (set by the game session). - pub seq: i64, - aspect: f32, - pub cam: Camera, - prev_cam: Camera, - pub frame: u32, - pub history_len: f32, - reset: bool, - /// Bloom stage radii / tints (`bloom_stages`). - bloom: ([f32; 6], [f32; 6]), - vignette: f32, - /// Chromatic aberration, film grain, bloom threshold, longest motion blur (output pixels). - post: [f32; 4], - /// Motion blur (UE MotionBlurAmount, MotionBlurTargetFPS; amount 0 = off). - blur: (f32, f32), - /// Last submission of each frame still on the GPU (at most two frames in flight). - inflight: std::collections::VecDeque, - pub gpu_ms: f32, - /// The frame `gpu_ms` timed (it lags by the frames in flight). - gpu_frame: u32, - /// That frame's trace pass alone (the path-tracing kernel: intersection and shading), ms; 0 without timestamps. - /// Frame log `trace`: the per-frame cost a geometry representation changes, without THREERT_PROFILE's stalls. - pub gpu_trace_ms: f32, - /// This frame's geometry edits applied in place (`edit_geometry`: CPU ms, its BLAS / TLAS builds submitted), and - /// scene rebuilds (ms) and, when edits fell back to one, why: summed over the frame's commits (a game event's and the - /// frame script's), reset when the frame is logged. Fallbacks since the viewport opened. - /// Frame log `edit`, `rebuild`, `fallback`, `fallbacks`. - pub edit_ms: f32, - /// CPU time of this frame's in-place instance updates (`update_instanced`), ms. - pub instanced_ms: f32, - pub edit_split: EditSplit, - pub rebuild_ms: f32, - pub fallback: Option, - pub fallbacks: u32, - /// Path-tracing bounces for real-time frames (the offline preset uses more). - pub bounces: u32, - /// Ray tracing (true). Off (`set_rays`), the kernel traces the camera ray of each pixel and no other: no shadows, - /// no bounce light, reflections of the sky only; analytic lights and emissive surfaces light unoccluded. The - /// fallback for GPUs that trace too slowly (no hardware ray queries), and the "what tracing adds" comparison on - /// those that do: the same scene, materials, exposure and post either way. - rays: bool, - /// 0 lit, 1 base color, 2 world normal, 3 lit without denoiser, 4 raw sample, 5 history length, 6 diffuse, - /// 7 specular, 8 deterministic direct light (THREERT_VIEW_MODE at start: e.g. bench-temporal per signal). - pub view_mode: u32, - /// Lockstep frame time (s; benchmarks): motion blur and exposure adaptation use it instead of the wall clock. - pub fixed_dt: Option, - /// A/B switch (playground key N): motion blur off whatever the scene's post settings say. - pub motion_blur_off: bool, - pub triangles: usize, - pub scene_name: String, - /// Scene time, s: what `advance_time` added up, the dt each frame script gets, so it is the game's clock (`f.time`) - /// and a burst fired at a game time starts then; lockstep runs repeat exactly. Animated shading, the particle pass - /// and power edits' time stamps use it (`shader_time`). - time: f32, - /// Time between rendered frames (auto exposure adaptation), s. - frame_dt: f32, - last_frame: Instant, - /// The post preset the LUT / bloom / grain settings were built from (scenes may override it). - post_key: String, - /// Traced / output resolution (UE screen percentage); the temporal pass upsamples below 1. - pub render_scale: f32, - /// Adjust `render_scale` to the frame budget (`govern_resolution`). - pub dynamic_res: bool, - /// Dynamic resolution's lowest scale: `MIN_SCALE` over the display's scale factor. - pub min_scale: f32, - /// The view's pixels a point of its display (`set_display_scale`): dynamic resolution's steps are a point's. - display_scale: f32, - /// Dynamic resolution: consecutive measured frames well over the frame time, and the first frame at the current - /// scale. - over_budget: (u32, u32), - /// When this view was made, and when its render scale was last checked (`govern_resolution` by the clock). - born: Instant, - governed: Instant, - /// THREERT_PROFILE=1: GPU time per pass (timestamp queries), printed every 2 s. - profile: Option, - /// GPU time per frame without stalls (timestamp queries; None: measured by draining the queue twice a second). - timer: Option, - /// Rasterized primary visibility (None: the kernel traces camera rays). - raster: Option, - /// World-space radiance cache (pathtrace.wgsl; None: THREERT_CACHE=0, paths trace every bounce). - cache: Option, - /// Kernel group 1: primary visibility + radiance cache + what the live passes read of a trace. - kernel_bg: wgpu::BindGroup, - /// Span (relative to the dynamic tail) re-transformed by the last in-place update whose previous-frame vertices - /// (`Uploaded::prev_verts`) still hold the frame before it: synced to the current ones next frame. - prev_span: Option<(usize, usize)>, - /// An in-place update happened this frame (it keeps `prev_verts` itself). - reposed: bool, - /// Dynamic triangles moved in this commit: the light tree's moving subtree is refit once at its end. - emitters_moved: bool, - /// Frames since the last reset or camera cut (`COLD_FALLBACK_FRAMES`). - cold_frames: u32, - /// Likewise for the deforming meshes of shared poses (`repose_groups`): the span they took (relative to the dynamic - /// tail, past its end) and whether this frame re-skinned any. - pose_span: Option<(usize, usize)>, - pose_reposed: bool, - /// Movable instances moved by the last update (their records' previous-frame rows differ), and whether it was this - /// frame's. - inst_moving: Vec, - inst_moved: bool, - /// The previous frame's traced size (its traced-grid diffuse history; 0 before the first frame). - prev_isz: (u32, u32), - /// Frame of the last lighting edit (power, light / material parameters, environment): the temporal pass looks - /// for changed lighting (anti-lag) only shortly after one. - lit_changed: u32, - /// The scene has light-sampled particles (emitter table entries with a nominal area). - spark_lights: bool, - /// Samples per pixel of the last frame rendered (the kernel's sample buffer holds their sums). - shown_spp: u32, - /// CPU time of the last commit (ms): skinning and re-transforming moving objects (`reskin`, rigid instance poses), and - /// uploading them with the dynamic BLAS / TLAS rebuild submitted (frame log `skin`, `upload`). - pub cpu_skin_ms: f32, - pub cpu_upload_ms: f32, - /// THREERT_PROFILE_BLAS=1: GPU time of the last dynamic BLAS / TLAS rebuild (ms; frame log `blas`), measured by draining - /// the queue before it and waiting for it after (it stalls the frame: measurement runs only). The pass profile has - /// no timestamps for it (Metal cannot write them between acceleration-structure builds). - pub gpu_blas_ms: f32, - /// CPU time the last `render` spent waiting for the oldest frame in flight (ms; frame log `wait`): GPU-bound frames. - pub cpu_wait_ms: f32, - /// The cloud cover the sky and the sun light were made with (`move_sun`). - sky_cover: f32, - /// The loop guard (merged.rs `guard`), if a kernel of this session may have it: the invocations it has stopped at - /// its limit since the view was made, as last read back (a frame or two behind, without waiting). Above zero: - /// frames with wrong pixels, a traversal that did not end on the scene's data. None without the guard. - pub guard_trips: Option, - /// Its readback: the buffer and its state (0 free, 1 mapping, 2 mapped), as the frame timer's. - guard_read: Option<(wgpu::Buffer, Arc)>, -} - -/// GPU time per frame from timestamp queries (start of the first trace pass .. end of the post pass), read back a few -/// frames later without waiting, so measuring never drains the pipeline. -struct FrameTimer { - queries: wgpu::QuerySet, - resolve: wgpu::Buffer, - /// Per slot: readback buffer and its state (0 free, 1 mapping, 2 mapped). - reads: Vec<(wgpu::Buffer, Arc)>, - /// Per slot: the frame it timed, and that frame's samples per pixel (its traces; only the first is timestamped). - frames: [u32; TIMER_SLOTS], - spps: [u32; TIMER_SLOTS], - slot: usize, -} - -const TIMER_SLOTS: usize = 4; - -/// Gap (ms) past which a slot's post pass is not its trace's frame's (`frame_gpu_ms`). -const TIMER_GAP_MS: f32 = 2.0; - -/// A frame's GPU time (ms) from its slot's timestamps (trace, post, raster, particle pass pairs; zero: not run): from -/// its first pass (particles, raster or trace) to the end of its post pass. The post pass follows the trace's command -/// buffer within ~0.2 ms, after the frame's further samples (`spp` - 1 more traces, untimed, each about as long as -/// the first). In 2–3 % of the crowd scene's frames (Metal) a slot read the post pair of the frame that used the slot -/// next, 4 frames on: the span then read 70–80 ms while frames came every 16.7 ms. A post pass that begins more than -/// TIMER_GAP_MS past where those traces would end (twice their length) is not this frame's: the frame is counted as -/// its passes, the further traces at the first one's length. -fn frame_gpu_ms(t: &[u64; 8], spp: u32, period_ns: f32) -> Option { - let start = [t[6], t[4], t[0]].into_iter().filter(|&v| v > 0).min()?; - if t[3] <= start { - return None; - } - let ms = |ticks: u64| ticks as f32 * period_ns * 1e-6; - let (trace, more) = (ms(t[1].saturating_sub(t[0])), spp.max(1) - 1); - if t[2] >= t[1] && ms(t[2] - t[1]) > TIMER_GAP_MS + 2.0 * more as f32 * trace { - return Some(ms(t[1] - start) + more as f32 * trace + ms(t[3].saturating_sub(t[2]))); - } - Some(ms(t[3] - start)) -} - -impl FrameTimer { - fn new(d: &wgpu::Device) -> FrameTimer { - let n = TIMER_SLOTS as u64; - FrameTimer { - // Per slot: trace pass begin / end, post pass begin / end, raster pass begin / end, particle pass begin / end - // (Metal writes a pass's timestamps in pairs). - queries: d.create_query_set(&wgpu::QuerySetDescriptor { label: Some("frame timer"), ty: wgpu::QueryType::Timestamp, count: (n * 8) as u32 }), - resolve: d.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 4 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), - reads: (0..n).map(|_| (d.create_buffer(&wgpu::BufferDescriptor { label: None, size: 64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), Arc::default())).collect(), - frames: [0; TIMER_SLOTS], - spps: [1; TIMER_SLOTS], - slot: 0, - } - } - - /// Latest finished measurement (frame ms, the frame it timed, its trace pass ms), and a free slot for this frame - /// (None: all readbacks still pending, skip). - fn harvest(&mut self, period_ns: f32) -> (Option<(f32, u32, f32)>, Option) { - let mut latest: Option<(f32, u32, f32)> = None; - for (k, (buf, state)) in self.reads.iter().enumerate() { - if state.load(Ordering::Acquire) == 2 { - let t: [u64; 8] = bytemuck::pod_read_unaligned(&buf.slice(..).get_mapped_range().unwrap()[..64]); - buf.unmap(); - state.store(0, Ordering::Release); - let frame = self.frames[k]; - if let Some(ms) = frame_gpu_ms(&t, self.spps[k], period_ns).filter(|_| latest.is_none_or(|(_, f, _)| frame.wrapping_sub(f) as i32 > 0)) { - latest = Some((ms, frame, t[1].saturating_sub(t[0]) as f32 * period_ns * 1e-6)); - } - } - } - let slot = (0..TIMER_SLOTS).map(|k| (self.slot + k) % TIMER_SLOTS).find(|&k| self.reads[k].1.load(Ordering::Acquire) == 0); - if let Some(k) = slot { - self.slot = (k + 1) % TIMER_SLOTS; - } - (latest, slot) - } -} - -/// Primary visibility by rasterization (the decision experiment of docs/ROADMAP.md § 1): every triangle drawn into a -/// triangle-index target (+ depth) at the frame's sub-pixel jitter; the kernel starts each live path at that triangle -/// instead of tracing the camera ray. `live --raster` / THREERT_RASTER=1. -struct Raster { - pipeline: wgpu::RenderPipeline, - layout: wgpu::BindGroupLayout, - uniform: wgpu::Buffer, - vis: wgpu::TextureView, - depth: wgpu::TextureView, -} - -const RASTER_WGSL: &str = r#" -struct R { pos: vec4, fwd: vec4, right: vec4, up: vec4, jit: vec4 }; // pos.w = tan(fov/2), fwd.w = aspect, jit = ndc offset xy, near -@group(0) @binding(0) var r: R; -@group(0) @binding(1) var tri_verts: array>; // per triangle: a, b - a, c - a (kernel layout) -struct V { @builtin(position) p: vec4, @location(0) @interpolate(flat) id: u32 }; -@vertex fn vs(@builtin(vertex_index) vi: u32) -> V { - let ti = vi / 3u; - let k = vi % 3u; - var p = tri_verts[ti * 3u].xyz; - if (k != 0u) { p += tri_verts[ti * 3u + k].xyz; } - let d = p - r.pos.xyz; - let z = dot(d, r.fwd.xyz); - // Reversed infinite depth: near / z. - return V(vec4(dot(d, r.right.xyz) / (r.pos.w * r.fwd.w) + r.jit.x * z, dot(d, r.up.xyz) / r.pos.w + r.jit.y * z, r.jit.z, z), ti + 1u); -} -@fragment fn fs(v: V) -> @location(0) u32 { return v.id; } -"#; - -impl Raster { - fn new(g: &gpu::Gpu, w: u32, h: u32) -> Raster { - let d = &g.device; - let module = d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("raster"), source: wgpu::ShaderSource::Wgsl(RASTER_WGSL.into()) }); - let layout = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("raster"), - entries: &[ - wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None }, - wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None }, - ], - }); - let pl = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); - let pipeline = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("raster vis"), - layout: Some(&pl), - vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, - fragment: Some(wgpu::FragmentState { module: &module, entry_point: Some("fs"), targets: &[Some(wgpu::TextureFormat::R32Uint.into())], compilation_options: Default::default() }), - primitive: wgpu::PrimitiveState { cull_mode: None, ..Default::default() }, - depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth32Float, depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::Greater), stencil: Default::default(), bias: Default::default() }), - multisample: Default::default(), - multiview_mask: None, - cache: g.cache(), - }); - g.save_pipeline_cache(); - let tex = |format, usage| { - d.create_texture(&wgpu::TextureDescriptor { label: Some("raster"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format, usage, view_formats: &[] }) - .create_view(&Default::default()) - }; - let vis = tex(wgpu::TextureFormat::R32Uint, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING); - let depth = tex(wgpu::TextureFormat::Depth32Float, wgpu::TextureUsages::RENDER_ATTACHMENT); - let uniform = d.create_buffer(&wgpu::BufferDescriptor { label: Some("raster"), size: 80, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - Raster { pipeline, layout, uniform, vis, depth } - } - - /// Records the visibility pass for `tris` triangles at internal size (iw, ih), sampling at jitter (jx, jy). - #[allow(clippy::too_many_arguments)] - fn record(&self, g: &gpu::Gpu, e: &mut wgpu::CommandEncoder, verts: &wgpu::Buffer, tris: u32, cam: &Camera, aspect: f32, (iw, ih): (u32, u32), (jx, jy): (f32, f32), timestamps: Option) { - let u: [[f32; 4]; 5] = [ - [cam.pos.x, cam.pos.y, cam.pos.z, cam.tan_half_fov], - [cam.fwd.x, cam.fwd.y, cam.fwd.z, aspect], - [cam.right.x, cam.right.y, cam.right.z, 0.0], - [cam.up.x, cam.up.y, cam.up.z, 0.0], - // Pixel centres sample at +0.5; shift the image so they sample at the jittered position instead. - [2.0 * (0.5 - jx) / iw as f32, -2.0 * (0.5 - jy) / ih as f32, 0.01, 0.0], - ]; - g.queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&u)); - let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &self.layout, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: self.uniform.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: verts.as_entire_binding() }], - }); - let mut pass = e.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("raster vis"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.vis, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store } })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), store: wgpu::StoreOp::Discard }), stencil_ops: None }), - timestamp_writes: timestamps, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_viewport(0.0, 0.0, iw as f32, ih as f32, 0.0, 1.0); - pass.set_scissor_rect(0, 0, iw, ih); - pass.set_pipeline(&self.pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..tris * 3, 0..1); - } -} - -/// THREERT_CACHE_FLAGS: experiment switches of the radiance cache (pathtrace.wgsl `cfl`), 0 = defaults: 1 = no cold -/// fallback (cold voxels read as they are), 2 = no hit lighting (reflection hits use the cache's total), 4 = no -/// training tiles, 8 = the exact light loop at reflection hits. THREERT_CACHE=0 turns the cache off. -/// Frames after a reset or a camera cut during which pixels whose radiance-cache voxel is cold trace a fresh estimate -/// (pathtrace.wgsl, cache flag 1 off). Later the cold voxels left are mostly where bounce rays land off screen, -/// scattered, sparsely trained: their traces cost each SIMD group nearly a whole extra trace (Bistro's path 0.7-1.2 -/// ms, the crowd's square 2.3 ms) for ~1 % of the image (bench-crowd ΔE 13.1 -> 13.3). -const COLD_FALLBACK_FRAMES: u32 = 30; - -/// Ceiling for one live lighting sample, in display units at the current exposure (live.wgsl `sample_clamp`): fireflies -/// far above white. 64 until 2026-11-09, when it cut the light of small strong lights that the denoisers now keep: the -/// Bistro crowd in motion (lockstep at 10 s) diffuse 0.87 -> 0.90 of our offline render, specular 0.83 -> 0.89, the -/// pavement under the headlights 0.71 -> 0.77. 1024 adds nothing more. -const SAMPLE_CEILING: f32 = 256.0; - -/// THREERT_GOVERN_CLOCK=1|0 (default 1 on Android): dynamic resolution also checks by the clock, so a view whose -/// frames are far slower than 60 a second comes down to its frame rate in seconds (`LiveCore::govern_resolution`). -fn govern_by_clock() -> bool { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_GOVERN_CLOCK").map(|v| v == "1").unwrap_or(cfg!(target_os = "android"))) -} - -fn cache_flags() -> u32 { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_CACHE_FLAGS").ok().and_then(|v| v.parse().ok()).unwrap_or(0)) -} - -/// THREERT_FREEZE_TIME=1: animation holds still (shader time 0, the frame script gets dt 0: signs, screens, flicker groups), -/// so frames taken at different times compare (tools/bench-ghosting.ts). -fn freeze_time() -> bool { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_FREEZE_TIME").is_ok_and(|v| v == "1")) -} - -/// Seconds of animated shading (sign flicker, screens, ripples) and particles: the scene time; 0 under THREERT_FREEZE_TIME. -fn shader_time(time: f32) -> f32 { - if freeze_time() { 0.0 } else { time } -} - -/// THREERT_EXPOSURE_SNAP=1: auto exposure jumps to its target every frame (no adaptation lag), so a frame taken while the -/// view moves has the exposure a settled view of that pose would have (tools/bench-ghosting.ts). -fn exposure_snap() -> bool { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_EXPOSURE_SNAP").is_ok_and(|v| v == "1")) -} - -/// UE's physical CoC model (DiaphragmDOFUtils.cpp, FPhysicalCocModel::Compile): focal length from the sensor height -/// (sensor width / aspect) and the vertical FOV, the CoC of infinity on the sensor f^2 / (N (focus - f)), as a radius in -/// output pixels; max radius r.DOF.Kernel.Max{Fore,Back}groundRadius = 2.5 % of the width. Off without a focus distance. -fn dof_params(dof: Option<&crate::desc::Dof>, tan_half_fov: f32, w: u32, h: u32) -> [f32; 4] { - let Some(d) = dof.filter(|d| d.focus_distance > 0.0 && d.f_stop > 0.0 && d.sensor_width > 0.0) else { return [0.0; 4] }; - let aspect = w as f32 / h as f32; - let sensor_h = d.sensor_width / aspect; - let f = 0.5 * sensor_h / tan_half_fov; - let diameter = f * f / (d.f_stop * (d.focus_distance * 1000.0 - f).max(1e-3)); - [diameter * 0.5 / sensor_h / aspect * w as f32, d.focus_distance, 0.025 * w as f32, 0.0] -} - -/// Live passes that run on the traced grid (the rest on the output grid). -fn traced_pass(name: &str) -> bool { - name == "refl" || name == "dtemporal" || name.starts_with("atrous") || name == "stemporal" || name.starts_with("sfilt") || name == "compose" || name == "judge" || name == "reject" -} - - -/// Light LOD (`emitter_lod`): emitters within this distance of the camera keep their sampling weight, farther ones -/// lose it as (d0 / d)^2. -const LIGHT_LOD_D0: f32 = 10.0; - -/// Emitters farther than this from the light LOD's reference are not proposed (`emitter_lod`); BSDF rays bring their -/// light. -const EMITTER_CULL: f32 = 60.0; - -/// The cull's reach inside the view cone (`EmitterCull`), in cull radii: emitters ahead of the camera are kept out to -/// this. -const EMITTER_CULL_VIEW: f32 = 2.0; -/// The view cone: the frustum's half-diagonal plus this margin (degrees); the CDF is rebuilt when the camera turns by -/// LIGHT_LOD_TURN, so the frustum stays inside the cone between rebuilds. -const EMITTER_CULL_MARGIN: f32 = 20.0; -const LIGHT_LOD_TURN: f32 = 15.0; - - - -/// Halton(base) of index i (sub-pixel jitter sequence). -fn halton(mut i: u32, base: u32) -> f32 { - let (mut f, mut r) = (1.0f32, 0.0f32); - while i > 0 { - f /= base as f32; - r += f * (i % base) as f32; - i /= base; - } - r -} - -/// The live kernel's world-space radiance cache (docs/ROADMAP.md § 1, step 1): its buffers and per-frame resolve. -struct RadianceCache { - bufs: gpu::CacheBuffers, - resolve: wgpu::ComputePipeline, -} - -/// History caps of the cache: samples (noise), frames (lag; shorter for a while after a lighting edit). -const CACHE_SAMPLES: f32 = 256.0; -const CACHE_FRAMES: f32 = 32.0; -const CACHE_FRAMES_LIT: f32 = 4.0; -/// Share of the pixels showing translucent particles whose nearest hit trains the volume voxels (up to -/// gpu::VOLUME_TRAIN_CAP points per frame). -const VOLUME_TRAIN_SHARE: f32 = 1.0 / 8.0; -/// Media voxel training points per frame (pathtrace.wgsl MEDIA_TRAIN_POINTS). -const MEDIA_TRAIN_POINTS: u32 = 4096; -/// The most frames a pixel's history holds while the scene's lighting changes: short enough to follow the change by -/// itself (a real-time denoiser's bound: NRD's default is 30), so no edit has to cut it. Against main's rules (24 -/// frames for good in a scene with spark lights, every pixel cut to 16 at a lighting edit, an on / off anti-lag), dE -/// against the offline render or the converged frame: the Bistro slice half a second after its explosion 6.04 for -/// 6.58, at rest 2.82 for 2.79; Kabukichō 2 s and 4 s after its signs close 6.70 and 2.92 for 6.56 and 2.82. At 64 -/// frames the explosion read 5.72 and the signs 7.82 and 3.37. -const HISTORY_LIVE: f32 = 32.0; -/// The most once the scene has settled: holding still, the frame converges on the offline render. -const HISTORY_FRAMES: f32 = 4096.0; - -/// THREERT_TL_HIDE (diagnostics): the translucency pass trains the volume voxels but draws nothing, so frames show what is -/// behind translucent particles; `odd` hides them on odd frames only (rain on / off pairs from one run). -fn translucent_hidden(frame: u32) -> bool { - static F: std::sync::OnceLock> = std::sync::OnceLock::new(); - match F.get_or_init(|| std::env::var("THREERT_TL_HIDE").ok().map(|v| v == "odd")) { - None => false, - Some(false) => true, - Some(true) => frame % 2 == 1, - } -} - -struct Profile { - queries: wgpu::QuerySet, - resolve: wgpu::Buffer, - read: wgpu::Buffer, - sums: Vec, - frames: u32, - /// Timed apart from the slots above: the grid programs' two passes (`gridsim`, queries 0..4) and the media passes - /// (`media`, queries 4..6: the camera rays' march and, at one sample a frame, the volume voxels' training, which - /// are otherwise in the trace's pass). Their queries, the resolve and read buffers, the sums. - grid: (wgpu::QuerySet, wgpu::Buffer, wgpu::Buffer), - grid_sum: f64, - media_sum: f64, -} - -impl LiveCore { - /// Advances the scene time by a frame's dt (the frame script's: at most 0.1 s). - pub fn advance_time(&mut self, dt: f32) { - self.time += dt.clamp(0.0, 0.1); - } - - /// Sets the scene time: a game session's frame script clock (`GameSession::frame`). - pub fn set_time(&mut self, t: f32) { - self.time = t; - } - - /// The scene time animation sees (0 under THREERT_FREEZE_TIME). - pub fn scene_time(&self) -> f32 { - shader_time(self.time) - } - - /// A live view of the engine's world; asset files resolve against `assets`. - pub fn new(g: gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32, h: u32) -> Result { - let passes = Passes::new(&g); - // The scene's post preset replaces this LUT below (apply_post). - let cache_on = !std::env::var("THREERT_CACHE").is_ok_and(|v| v == "0"); - // (Without the cache the kernel binds one of one entry.) - let targets = Targets::new(&g, w, h, &vec![0u16; (LUT * LUT * LUT * 4) as usize], if cache_on { gpu::CACHE_SIZE } else { 1 }); - let (sc, pp) = load_scene(&g, world, assets, presets, w, h, &targets, &passes)?; - let cam = sc.scene_camera; - let (triangles, scene_name) = (sc.triangles, sc.name.clone()); - let profile = (g.timestamps && std::env::var("THREERT_PROFILE").is_ok()).then(|| { - // trace, the post passes, cache resolve, then the particle pass, the BLAS rebuild, the translucency pass and - // the volume training. - let n = (passes.pipes.len() as u64 + 6) * 2; - Profile { - queries: g.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("profile"), ty: wgpu::QueryType::Timestamp, count: n as u32 }), - resolve: g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 8, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }), - read: g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 8, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), - sums: vec![0.0; passes.pipes.len() + 6], - frames: 0, - grid: ( - g.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("profile grid"), ty: wgpu::QueryType::Timestamp, count: 6 }), - g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 2 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }), - g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 48, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), - ), - grid_sum: 0.0, - media_sum: 0.0, - } - }); - let timer = (g.timestamps && profile.is_none()).then(|| FrameTimer::new(&g.device)); - let raster = std::env::var("THREERT_RASTER").is_ok_and(|v| v == "1").then(|| Raster::new(&g, w, h)); - let cache = cache_on.then(|| RadianceCache { bufs: targets.kernel.cache.clone(), resolve: g.cache_resolve_pipeline(&targets.kernel) }); - let kernel_bg = gpu::vis_bind_group(&g.device, &g.vis_layout, raster.as_ref().map_or(&g.no_vis_view, |r| &r.vis), &targets.kernel); - // (Read whenever a kernel of this session may have the guard: under software traversal a scene's first unit - // brings it, `gpu::loop_guard_of`.) - let guard_read = (gpu::loop_guard_of(!g.hwrt).is_some() && targets.kernel.guard.is_some()) - .then(|| (g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("loop guard read"), size: 4, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), Arc::new(AtomicU32::new(0)))); - let mut core = LiveCore { particle_spawns: Default::default(), grid_sources: Default::default(), g, passes, targets, sc, presets: presets.to_path_buf(), assets: assets.to_path_buf(), w, h, seq: 0, aspect: w as f32 / h as f32, cam, prev_cam: cam, frame: 0, history_len: 0.0, reset: true, bloom: ([0.0; 6], [0.0; 6]), vignette: 0.0, post: [0.0; 4], blur: (0.0, 0.0), inflight: Default::default(), gpu_ms: 0.0, gpu_frame: 0, gpu_trace_ms: 0.0, edit_ms: 0.0, instanced_ms: 0.0, edit_split: EditSplit::default(), rebuild_ms: 0.0, fallback: None, fallbacks: 0, bounces: 4, rays: true, view_mode: std::env::var("THREERT_VIEW_MODE").ok().and_then(|v| v.parse().ok()).unwrap_or(0), fixed_dt: None, motion_blur_off: false, triangles, scene_name, time: 0.0, frame_dt: 1.0 / 60.0, last_frame: Instant::now(), post_key: String::new(), render_scale: 1.0, dynamic_res: false, min_scale: MIN_SCALE, display_scale: 1.0, over_budget: (0, 0), born: Instant::now(), governed: Instant::now(), profile, timer, raster, cache, kernel_bg, prev_span: None, reposed: false, emitters_moved: false, cold_frames: 0, pose_span: None, pose_reposed: false, inst_moving: vec![], inst_moved: false, prev_isz: (0, 0), lit_changed: 0, spark_lights: false, shown_spp: 1, cpu_skin_ms: 0.0, cpu_upload_ms: 0.0, gpu_blas_ms: 0.0, cpu_wait_ms: 0.0, sky_cover: 0.0, guard_trips: guard_read.as_ref().map(|_| 0), guard_read }; - core.spark_lights = core.sc.tables.emitter_tris.iter().any(|e| e.nominal); - core.sky_cover = core.sc.tables.cloud_cover; - core.apply_post(&pp); - Ok(core) - } - - pub fn scene_camera(&self) -> Camera { - self.sc.scene_camera - } - - /// Applies what scene code changed in the world since the last commit. Parameter changes (power, light / material - /// parameters, wetness / rain / fog, the camera) rewrite GPU table entries in place; anything structural rebuilds - /// the GPU scene from the world (history survives, validated per pixel by depth / normal). Returns true on a - /// rebuild. - pub fn commit(&mut self, world: &mut World) -> Result { - self.cpu_skin_ms = 0.0; - self.cpu_upload_ms = 0.0; - self.gpu_blas_ms = 0.0; - // The game's particle spawns, for the next frame's pass; the voxel occupancy grid's changes, uploaded. - for (id, v) in std::mem::take(&mut world.particle_spawns) { - self.particle_spawns.entry(id).or_default().extend(v); - } - if let Some(o) = &world.voxel_occupancy { - let mut o = o.write().unwrap(); - if !o.dirty.is_empty() { - self.g.upload_occupancy(&self.sc.up, &mut o); - } - } - // The density grids: the game's sources for the next frame's grid pass, and new data uploaded in place. - for (id, v) in std::mem::take(&mut world.grid_sources) { - self.grid_sources.entry(id).or_default().extend(v); - } - for id in std::mem::take(&mut world.grid_dirty) { - if let Some(d) = world.grids.get(&id).and_then(|g| g.data.as_ref()) { - self.g.upload_grid(&self.sc.up, id, d); - } - } - let mut changes = world.take_changes(); - if !changes.any() { - return Ok(false); - } - // Objects shown or hidden by their own `visible`: in place when the build has them (a rain curtain the weather - // turns off, a pool's idle fireball and its light cost nothing while off), else with the rebuild. - if !changes.structural { - for id in std::mem::take(&mut changes.shown) { - let hide = !world.shown(id); - let done = match world.objects.get(&id).map(|o| &o.kind) { - Some(crate::world::Kind::Particles { .. }) => self.g.set_particles_hidden(&self.sc.up, id, hide), - Some(crate::world::Kind::Volume { shape, .. }) => self.g.set_medium_hidden(&self.sc.up, id, *shape, hide), - // (Its table entry, black while hidden: `Tables::update_light`.) - Some(crate::world::Kind::Light(_)) if self.sc.tables.light_index.contains_key(&id) => { - changes.lights.insert(id); - true - } - _ => false, - }; - if !done { - changes.structural = true; - } - } - } - // New materials without textures: entries after the scene's, before the edits that place objects drawn with - // them (and the GPU's table with them, grown if it is full). - if !changes.new_materials.is_empty() && !changes.structural { - if self.sc.tables.add_materials(world, &changes.new_materials) { - self.upload_materials(); - // (A clear coat, an unlit material: the kernel variant with it, compiled once.) - let (e, (tex, func, groups)) = (&self.sc.tables, self.sc.fixed_features); - self.sc.up.kernel.features = gpu::features_of(&e.materials, e.fog.as_ref(), tex, func, groups, self.sc.instance_features) | if e.aerial[0] > 0.0 { gpu::F_AERIAL } else { 0 }; - } else { - self.fallback = Some("a new material that cannot be added in place (it emits, is glass or a cutout)".into()); - self.fallbacks += 1; - changes.structural = true; - } - } - // Objects added in place are not in the move tables (their records follow the scene's): a move rebuilds. Not a - // mesh a body drives: it is an instance, and its record moves as the build's do (`placed_movable`). - if !changes.structural && changes.moved.iter().any(|id| self.sc.edit.placed.contains_key(id) && self.placed_movable(world, *id).is_none()) { - self.fallback = Some("an object added in place moved".into()); - self.fallbacks += 1; - changes.structural = true; - if std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1") { - eprintln!("edit: rebuild (an object added in place moved)"); - } - } - // Instanced objects rewrite their instances in place (`update_instanced`); one that cannot rebuilds the scene. Before - // the geometry edits, whose TLAS build (always, or the rebuild they fall back to) then carries the instances too - // (a voxel scene's debris and batches: one build of a 20 k instance TLAS a frame, not two); else they build it. - // (The instances the last update moved, taken first: what this one moves, from here on, keeps its motion.) - let moved_before = std::mem::take(&mut self.inst_moving); - let mut instanced_early = false; - // (Not those added this commit: the geometry edits place them with their instances as they are.) - let early: Vec = std::mem::take(&mut changes.instanced).into_iter().filter(|id| !changes.added.contains(id)).collect(); - if !early.is_empty() && !changes.structural { - instanced_early = self.instanced_in_place(world, &early, !changes.edits(), &mut changes); - } - // Geometry edits in place when nothing else is structural; what they cannot apply rebuilds the scene. - if changes.edits() && !changes.structural { - let t = Instant::now(); - let r = self.edit_geometry(world, &changes); - if std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1") { - eprintln!("edit: {} geometries, {} added, {} removed: {}", changes.geometries.len(), changes.added.len(), changes.removed.len(), r.as_ref().map_or_else(|e| format!("rebuild ({e})"), |_| "in place".into())); - } - match r { - Ok(()) => { - self.edit_ms += t.elapsed().as_secs_f32() * 1e3; - // As after a rebuild: history validated per pixel. - self.lit_changed = self.frame.max(1); - } - Err(why) => { - self.fallback = Some(why); - self.fallbacks += 1; - changes.structural = true; - } - } - } - if !changes.promoted_geometry.is_empty() { - eprintln!("live: {}: geometry changed, now with its own BLAS (this once rebuilds; set `dynamicGeometry` or the attribute's DynamicDrawUsage to avoid it)", changes.promoted_geometry.join(", ")); - self.fallback = Some("promote".into()); - self.fallbacks += 1; - } - if !changes.promoted.is_empty() { - eprintln!("live: {} moved, now movable (this once rebuilds; set `movable = true` in the scene code to avoid it)", changes.promoted.join(", ")); - } - // Moved movable subtrees: their drawables are re-transformed with the posed models, their lights rewritten in - // the light table. - let mut dynamic = changes.poses.clone(); - let mut placed = vec![]; - let mut procs_moved = vec![]; - let mut instanced_moved = vec![]; - let children = if changes.moved.is_empty() { Default::default() } else { world.children() }; - for id in &changes.moved { - for k in world.subtree_with(*id, &children) { - match &world.objects[&k].kind { - crate::world::Kind::Light(crate::desc::Light::Rect { visible: true, .. }) | crate::world::Kind::Mesh { .. } | crate::world::Kind::Model { .. } => { - if world.shown(k) { - // Movable models placed as instances move their transform (with a shared pose too); the rest - // re-transform triangles. - let r = &self.sc.tables.retained; - match r.instance_of.get(&k).copied().or_else(|| self.placed_movable(world, k)) { - Some(i) if !changes.poses.contains(&k) || r.pose_member.contains_key(&k) => placed.push((k, i)), - _ => { - dynamic.insert(k); - } - } - } - } - crate::world::Kind::Light(_) => { - changes.lights.insert(k); - } - // Particle functions place their particles around the node (`i.pivot`). - crate::world::Kind::Particles { .. } => { - let p = world.world_matrix(k).transform_point3(glam::Vec3::ZERO); - self.g.set_particle_pivot(&self.sc.up, k, p.to_array()); - } - crate::world::Kind::GpuParticles { .. } => { - let p = world.world_matrix(k).transform_point3(glam::Vec3::ZERO); - self.g.set_gpu_particle_pivot(&self.sc.up, k, p.to_array()); - } - // Media: the box / sphere follows the node. - crate::world::Kind::Volume { .. } => { - self.g.set_medium(&self.sc.up, k, Some(scene::medium_rows(world.world_matrix(k))), None); - } - crate::world::Kind::Procedural { .. } if world.shown(k) => procs_moved.push(k), - crate::world::Kind::Instanced { .. } => instanced_moved.push(k), - _ => {} - } - } - } - // Instanced objects under a moved ancestor (or movable themselves) rewrite their instances too, unless the update - // above did (with the same world matrices; twice would take the new rows for the previous frame's: no motion). - // The TLAS is built by whatever moves after them, or there. - let mut instanced_done = instanced_early; - instanced_moved.retain(|k| !early.contains(k)); - if !instanced_moved.is_empty() && !changes.structural { - let others = !placed.is_empty() || !dynamic.is_empty() || !procs_moved.is_empty(); - instanced_done |= self.instanced_in_place(world, &instanced_moved, !others, &mut changes); - } - // Posed rigid models move their nodes' instances (`repose_rigid`), not triangles. - let rigid: Vec = dynamic.iter().copied().filter(|k| self.sc.tables.retained.rigid.contains_key(k)).collect(); - for k in &rigid { - dynamic.remove(k); - } - // Models in a shared pose re-skin their group's mesh (`repose_groups`, its BLAS only); their moves place instances. - let shared: Vec = dynamic.iter().copied().filter(|k| self.sc.tables.retained.pose_member.contains_key(k)).collect(); - for k in &shared { - dynamic.remove(k); - } - let shared_ok = shared.is_empty() || (!changes.structural && self.repose_groups(world, &shared)?); - // Procedural geometry moves its records and TLAS transforms; the TLAS is built by whatever moves after it, or here. - if !procs_moved.is_empty() && !changes.structural { - self.move_procs(world, &procs_moved, placed.is_empty() && dynamic.is_empty() && rigid.is_empty() && shared.is_empty()); - } - let placed_ok = shared_ok && (placed.is_empty() || (!changes.structural && self.move_instances(world, &placed, dynamic.is_empty() && rigid.is_empty()))); - let t_rigid = Instant::now(); - let rigid_ok = placed_ok && (rigid.is_empty() || (!changes.structural && self.repose_rigid(world, &rigid, dynamic.is_empty())?)); - self.cpu_skin_ms += t_rigid.elapsed().as_secs_f32() * 1000.0; - // Instances the last update moved and this one does not: their motion ends, the previous-frame rows catch up - // (written merged: an instanced object's thousands of records are contiguous). - if moved_before != self.inst_moving { - let size = std::mem::size_of::() as u64; - let now_moving: std::collections::HashSet = self.inst_moving.iter().copied().collect(); - let mut catch_up = Gather::default(); - for i in moved_before.into_iter().filter(|i| !now_moving.contains(i)) { - if let Some(r) = self.sc.gpu_instances.get_mut(i as usize) { - r.p = r.m; - catch_up.put(i as u64 * size, bytemuck::bytes_of(r)); - } - } - catch_up.flush(&self.g.queue, &self.sc.up.instances); - } - if rigid_ok && (dynamic.is_empty() || (!changes.structural && self.repose(world, &dynamic)?)) { - // A new shared pose and nothing else that rebuilds the TLAS (no move): rebuild it for the meshes' new bounds. - if !shared.is_empty() && placed.is_empty() && rigid.is_empty() && dynamic.is_empty() { - if let Some(accel) = self.sc.up.accel.as_ref() { - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_tlas(accel, &mut e); - self.g.queue.submit([e.finish()]); - } - } - // Moved emitters: the light tree's moving subtree around them, once (after every in-place update). - if std::mem::take(&mut self.emitters_moved) { - self.refit_moving_tree(); - } - changes.poses.clear(); - changes.moved.clear(); - // Moving objects move their shadows and reflections over static surfaces: let anti-lag look for it. - if !dynamic.is_empty() || !placed.is_empty() || !rigid.is_empty() || !shared.is_empty() || !procs_moved.is_empty() || instanced_done { - self.lit_changed = self.frame.max(1); - } - } - // Particle nodes' customData: their particle function's `i.custom` (the table has no object for the node). - for id in &changes.customs { - if let Some(o) = world.objects.get(id).filter(|o| matches!(o.kind, crate::world::Kind::Particles { .. })) { - self.g.set_particle_custom(&self.sc.up, *id, o.custom); - } - } - // Volume nodes' customData: their volume function's `i.custom` (the media table, not the objects table). - for id in &changes.customs { - if let Some(o) = world.objects.get(id).filter(|o| matches!(o.kind, crate::world::Kind::Volume { .. })) { - self.g.set_medium(&self.sc.up, *id, None, Some(o.custom)); - } - } - let now = shader_time(self.time); - let applied = self.sc.tables.apply(world, &changes, now).ok().flatten(); - let Some(d) = applied else { - let t = Instant::now(); - self.rebuild(world)?; - self.rebuild_ms += t.elapsed().as_secs_f32() * 1e3; - if changes.camera { - self.cam = self.sc.scene_camera; - } - return Ok(true); - }; - if d.materials { - self.upload_materials(); - } - let (q, up, e) = (&self.g.queue, &self.sc.up, &mut self.sc.tables); - if d.lights && !e.lights.is_empty() { - if d.lights_moved { - e.regroup_lights(); - } - q.write_buffer(&up.lights, 0, bytemuck::cast_slice(&crate::lights_with_groups(&e.lights))); - } - if d.objects { - q.write_buffer(&up.objects, 0, bytemuck::cast_slice(&e.objects)); - } - if d.materials || d.emitters { - self.add_material_emitters(); - self.write_emitters(); - } else if d.deferred { - self.send_powers(); - } - let (e, b) = (&mut self.sc.tables, &mut self.sc.base); - if d.environment { - b.time[1] = e.wetness; - b.time[2] = e.rain; - b.background = e.background.map_or([0.0; 4], |c| [c[0], c[1], c[2], 1.0]); - b.aerial = e.aerial; - [b.fog, b.fog_color, b.vol, b.vol2] = crate::fog_params(e.fog.as_ref()); - } - if d.materials || d.environment { - // e.g. fog appearing needs the fog kernel variant (compiled once, then cached) - let (tex, func, groups) = self.sc.fixed_features; - self.sc.up.kernel.features = gpu::features_of(&e.materials, e.fog.as_ref(), tex, func, groups, self.sc.instance_features) | if e.aerial[0] > 0.0 { gpu::F_AERIAL } else { 0 }; - } - if let Some(c) = e.camera.take() { - self.sc.scene_camera = Camera { pos: c.position, fwd: c.forward, up: c.up, right: c.right, tan_half_fov: c.tan_half_fov }; - self.sc.tan_half_fov = c.tan_half_fov; - self.sc.dof = c.dof.clone(); - self.cam = self.sc.scene_camera; - } - // Lighting changed: for 45 frames the histories hold HISTORY_LIVE frames at most (`live_lighting`), and where the - // change is strong the temporal pass cuts the history locally (anti-lag). - if d.materials || d.lights || d.objects || d.environment { - self.lit_changed = self.frame.max(1); - } - // (A new cloud cover re-bakes the sky and dims the sun as a sun move does.) - if changes.sun || self.sc.tables.cloud_cover != self.sky_cover { - self.sky_cover = self.sc.tables.cloud_cover; - self.move_sun(world); - } - if changes.exposure { - self.apply_exposure(world); - } - Ok(false) - } - - /// The atmosphere's sun moved (time of day): its light entry is rewritten now (direction, and its colour through - /// the atmosphere), and the sky's re-bake is asked of the worker thread (`DynamicSky`, started here on the first - /// move). Lockstep frames wait for the bake, so a lockstep run sees the sky that goes with each sun. - fn move_sun(&mut self, world: &World) { - let Some((id, dir, lux)) = scene::atmosphere_sun(world) else { return }; - if self.sc.sky.is_none() { - let (size, alt, floor) = self.sc.sky_inputs; - self.sc.sky = DynamicSky::new(world, size, alt, floor); - } - let Some(sky) = self.sc.sky.as_ref() else { return }; - let cover = self.sc.tables.cloud_cover; - let colour = crate::sky::sun_illuminance(&sky.atm, dir, lux, cover); - let e = &mut self.sc.tables; - if let (Some(idx), Some(o)) = (e.light_index.get(&id), world.objects.get(&id)) { - if let crate::world::Kind::Light(l) = &o.kind { - if let Some(mut gl) = scene::gpu_light(l, world.world_matrix(id), o.power) { - // (The sun: hidden with a rebuild, `World::set_visible`.) - gl.color = [colour.x, colour.y, colour.z, 0.0]; - for &i in idx { - e.lights[i] = gl; - } - self.g.queue.write_buffer(&self.sc.up.lights, 0, bytemuck::cast_slice(&crate::lights_with_groups(&e.lights))); - } - } - } - let _ = sky.ask.send((dir, lux, cover)); - if self.fixed_dt.is_some() { - if let Ok(b) = sky.done.recv() { - self.put_sky(b); - } - } - self.lit_changed = self.frame.max(1); - } - - /// A sky the worker thread has baked since the last frame (the newest), into the environment map and its CDF. - fn take_sky(&mut self) { - let Some(sky) = self.sc.sky.as_ref() else { return }; - let mut last = None; - while let Ok(b) = sky.done.try_recv() { - last = Some(b); - } - if let Some(b) = last { - self.put_sky(b); - } - } - - fn put_sky(&mut self, (env, cdf, max): (gpu::EnvMap, Vec, f32)) { - let Some(sky) = self.sc.sky.as_ref() else { return }; - self.g.write_env(&self.sc.up, &env, &cdf); - // As the load decides it: the sky is light-sampled unless it is negligible next to the emitters (night), or a - // baked sky is not light-sampled at all (`env_nee_on`). - self.sc.base.counts2[1] = (max >= sky.env_nee_floor && crate::env_nee_on()) as u32; - self.lit_changed = self.frame.max(1); - } - - /// The camera's manual exposure changed value (an exposure that follows the time of day), in place: the post pass's - /// exposure and the kernel's firefly clamp, which is relative to it. - fn apply_exposure(&mut self, world: &World) { - let Some(cid) = world.settings.camera else { return }; - let Some(crate::world::Kind::Camera(c)) = world.objects.get(&cid).map(|o| &o.kind) else { return }; - let crate::desc::Exposure::Manual { ev100 } = c.exposure else { return }; - let e = 2f32.powf(-ev100); - if self.sc.exposure > 0.0 { - self.sc.base.misc[0] *= self.sc.exposure / e; - } - self.sc.exposure = e; - self.sc.exposure_settings.ae2 = [e, 0.0, 0.0, 0.0]; - } - - /// Moves movable instances: their kernel records (the old transform kept as the previous frame's, for motion - /// vectors), their object entries' pivots and their TLAS transforms (hardware traversal; with `build_tlas` it - /// rebuilds the TLAS, otherwise the dynamic rebuild that follows does) or their units and the top tree over them - /// (software traversal, units.rs). No triangles change. False when it cannot (software traversal without units, - /// or an instance that is no unit; a top tree that cannot be made): the caller rebuilds. - fn move_instances(&mut self, world: &World, placed: &[(u32, u32)], build_tlas: bool) -> bool { - // (Hardware traversal's TLAS instances, or software traversal's units; neither: the caller rebuilds.) - let Some(mut top) = gpu::Top::of(&mut self.sc.up.accel, &mut self.sc.up.units) else { return false }; - let size = std::mem::size_of::() as u64; - let q = &self.g.queue; - // (Many records, a rigid body world's thousands a frame, are written as the one range that holds them: the - // records between are as the GPU has them.) - let merged = placed.len() > 32; - let (mut lo, mut hi) = (usize::MAX, 0usize); - // (Instances the last update moved and this one does not catch up in `commit`.) - for &(node, i) in placed { - let w = world.world_matrix(node); - // (A mesh an edit added in place keeps its instance with the edit's, `placed_movable`; its record and TLAS - // instance are where the build's are.) - let inst = match self.sc.edit.added.get_mut(&node) { - Some(Picked::Mesh(inst)) => inst, - _ => &mut self.sc.instances[i as usize], - }; - // (Software traversal: an instance of a mesh that deforms is no unit; its triangles are among the still - // scene's, in the world, and a rebuild moves them.) - if !top.has_mesh(inst.mesh) { - return false; - } - let old = self.sc.gpu_instances[i as usize]; - inst.world = w; - let mut r = top.record(inst.gpu(old.info[0], old.info[3]), inst.mesh, inst.cast_shadow); - r.p = old.m; - self.sc.gpu_instances[i as usize] = r; - if merged { - (lo, hi) = (lo.min(i as usize), hi.max(i as usize)); - } else { - q.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(&r)); - } - let pivot = w.transform_point3(glam::Vec3::ZERO); - let st = self.sc.tables.stride; - let o = &mut self.sc.tables.objects[inst.object as usize * st]; - (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); - let slot = top.inst_base() + i as usize; - top.moved(slot, w, None); - self.inst_moving.push(i); - } - if merged { - q.write_buffer(&self.sc.up.instances, lo as u64 * size, bytemuck::cast_slice(&self.sc.gpu_instances[lo..=hi])); - } - q.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - self.inst_moved = true; - match top { - gpu::Top::Hw(accel) => { - if build_tlas { - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_tlas(accel, &mut e); - q.submit([e.finish()]); - } - true - } - // (The top tree is built here whoever else changes units this commit: it is CPU work and one write, and a - // tree that cannot be made must send the caller to a rebuild now.) - gpu::Top::Sw(units) => self.g.retop(units, &self.sc.up.bg_parts.nodes).map_err(|e| eprintln!("units: {e}: rebuilding the scene")).is_ok(), - } - } - - /// The material table to the GPU: written in place, or, with more materials than its buffer holds (materials added - /// while the game runs), into a buffer twice the size, the scene's bind group made over it. - fn upload_materials(&mut self) { - let bytes: &[u8] = bytemuck::cast_slice(&self.sc.tables.materials); - if bytes.len() as u64 > self.sc.up.materials.size() { - let usage = self.sc.up.materials.usage(); - self.sc.up.materials = self.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("materials"), size: (bytes.len() as u64 * 2).next_multiple_of(16), usage, mapped_at_creation: false }); - self.g.rebind_scene(&mut self.sc.up); - } - self.g.queue.write_buffer(&self.sc.up.materials, 0, bytes); - } - - /// The record of a mesh an edit added in place that moves as an instance does (`move_instances`): one a rigid body - /// drives, placed by its node's transform (an emitter mesh's triangles are in world space: it cannot). - fn placed_movable(&self, world: &World, id: u32) -> Option { - let &(_, record) = self.sc.edit.placed.get(&id)?; - let mesh = matches!(self.sc.edit.added.get(&id), Some(Picked::Mesh(_))); - (mesh && world.objects.get(&id).is_some_and(|o| o.driven) && !self.sc.tables.retained.emitter_mesh.contains_key(&id)).then_some(record as u32) - } - - /// `update_instanced` with its fallback (the reason in the frame log, the scene rebuilt) and its CPU time; whether it - /// changed anything. - fn instanced_in_place(&mut self, world: &World, ids: &[u32], build_tlas: bool, changes: &mut crate::world::Changes) -> bool { - let t = Instant::now(); - let r = self.update_instanced(world, ids, build_tlas); - self.instanced_ms += t.elapsed().as_secs_f32() * 1e3; - let log = std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1"); - match r { - Ok(n) => { - if log { - eprintln!("edit: {n} instanced objects in place"); - } - n > 0 - } - Err(why) => { - if log { - eprintln!("edit: rebuild ({why})"); - } - self.fallback = Some(why); - self.fallbacks += 1; - changes.structural = true; - false - } - } - } - - /// Emitter entries for the materials that started emitting (`Tables::started_emitting`): their flattened static - /// triangles (`mat_runs`), positions from `tris`, in the power-share part of the CDF (path code 0, as the light - /// tree's moving emitters; ROADMAP § 6 step 3). `write_emitters` uploads them. - fn add_material_emitters(&mut self) { - let sc = &mut self.sc; - for mat in std::mem::take(&mut sc.tables.started_emitting) { - for &[first, end, obj] in sc.mat_runs.get(&mat).map_or(&[][..], |v| v.as_slice()) { - for k in first..end { - let Some(&[a, b, c]) = sc.tri_order.get(k as usize).and_then(|&i| sc.tris.get(i as usize)) else { continue }; - let area = 0.5 * (b - a).cross(c - a).length(); - if area > 0.0 { - let centre = (a + b + c) / 3.0; - sc.tables.emitter_tris.push(crate::EmitterTri { tri: k, mat, obj, area, c: centre.to_array(), moves: false, nominal: false, in_tree: false }); - } - } - } - } - } - - /// The emitter CDF from `Tables::emitter_tris` (weights from materials and powers; entries added in place at its - /// end, removed ones at area 0), the light tree refit to the new weights and its nodes after the CDF, the CDF's - /// share of power, its count. With the tree's slot for emitters added in place (scenes that can edit geometry), their - /// subtree is grown again first and their entries go right after the static ones (ROADMAP § 6 step 4); without it - /// they stay at the end, by power share. The buffer grows (and the scene bind group is made again) when entries - /// were added past it: a scene that adds none keeps its layout. - fn write_emitters(&mut self) { - self.land_powers_now(); - let g = &self.g; - let LoadedScene { tables: e, base: b, up, pool_tris, tris: flat, tri_order, edit, .. } = &mut self.sc; - // Emitters added in place, with the tree's slot for them (`ltree::build_slotted`): its subtree grown again over - // those alive, their entries right after the static ones in the CDF (in its leaf order: each leaf's entry is its - // position) and their path codes in their triangles' attrs (written over the code 0 an edit gave them). - let mut perm: Option> = None; - if let Some(t) = e.light_tree.as_mut() { - if let Some((_, ns)) = t.dynamic { - let ns = ns as usize; - let (mats, objs, st, pf) = (&e.materials, &e.objects, e.stride, edit.pool_first); - let verts = |k: usize| if k >= pf { pool_tris.get(k - pf).copied() } else { tri_order.get(k).and_then(|&i| flat.get(i as usize)).copied() }; - let mut items = vec![]; - for (i, x) in e.emitter_tris.iter().enumerate().skip(ns) { - let w = crate::emitter_power(x, mats, objs, st); - let Some([va, vb, vc]) = verts(x.tri as usize).filter(|_| !x.in_tree && !x.nominal && !x.moves && w > 0.0) else { continue }; - items.push(crate::ltree::DynItem { e: i as u32, lo: va.min(vb).min(vc).to_array(), hi: va.max(vb).max(vc).to_array(), c: x.c, w }); - } - t.set_dynamic(items); - let at = std::mem::offset_of!(scene::GpuTriAttr, x) + 12; - let mut codes = Gather::default(); - let mut placed = vec![false; e.emitter_tris.len()]; - let mut p: Vec = (0..ns).collect(); - for k in ns..t.order.len() { - let i = t.order[k] as usize; - codes.put((e.emitter_tris[i].tri as usize * std::mem::size_of::() + at) as u64, bytemuck::bytes_of(&t.code[k])); - placed[i] = true; - p.push(i); - } - codes.flush(&g.queue, &up.attrs); - p.extend((ns..e.emitter_tris.len()).filter(|&i| !placed[i])); - perm = Some(p); - } - } - let permuted: Vec; - let list = match &perm { - Some(p) => { - permuted = p.iter().map(|&i| e.emitter_tris[i]).collect(); - &permuted[..] - } - None => &e.emitter_tris[..], - }; - let (em, total, power) = crate::emitter_cdf(list, &e.materials, &e.objects, e.stride, b.light_lod, crate::EmitterCull::of(b)); - if let Some(t) = e.light_tree.as_mut() { - let (tris, mats, objs, st) = (&e.emitter_tris, &e.materials, &e.objects, e.stride); - let w: Vec = t.order.iter().map(|&i| crate::emitter_power(&tris[i as usize], mats, objs, st)).collect(); - t.refit(&|k| w[k]); - } - self.put_emitters(&em, total, power); - // Emitters added in place since the worker was made: another, over them. - if self.sc.emit.as_ref().is_some_and(|w| w.count != self.sc.tables.emitter_tris.len()) { - self.sc.emit = EmitterWorker::new(&self.sc.tables, &self.g.device); - self.sc.tables.defer_powers = self.sc.emit.is_some(); - } - } - - /// The emitter CDF `em` (its total weight and the emitters' power), then the light tree's nodes, into the emitters - /// buffer (grown, and the scene bind group made again, when they no longer fit), and the kernel's parameters. - fn put_emitters(&mut self, em: &[[f32; 4]], total: f32, power: f32) { - let g = &self.g; - let LoadedScene { tables: e, base: b, up, .. } = &mut self.sc; - let nodes: &[[f32; 4]] = e.light_tree.as_ref().map_or(&[], |t| &t.nodes); - let need = ((em.len() + nodes.len()) * 16) as u64; - if need > up.emitters.size() { - // (The usage it was made with: the merged layout copies the tables each frame, `Gpu::sync_tables`.) - let usage = up.emitters.usage(); - up.emitters = g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("emitters"), size: need + need / 2, usage, mapped_at_creation: false }); - g.rebind_scene(up); - } - if !em.is_empty() { - g.queue.write_buffer(&up.emitters, 0, bytemuck::cast_slice(em)); - } - if let Some(t) = e.light_tree.as_ref() { - g.queue.write_buffer(&up.emitters, em.len() as u64 * 16, bytemuck::cast_slice(&t.nodes)); - } - let _ = b; - self.emitter_params(em.len(), total, power); - } - - /// The kernel's emitter parameters for a CDF of `entries` (its total weight; the emitters' power) and the tree. - fn emitter_params(&mut self, entries: usize, total: f32, power: f32) { - let LoadedScene { tables: e, base: b, .. } = &mut self.sc; - b.emit_power[0] = power; - b.misc[1] = total; - if let Some(t) = e.light_tree.as_ref() { - b.ltree[0] = f32::from_bits(entries as u32); - // The tree's entries (with the slot: the static ones and those added in place), the rest by power share. - if t.dynamic.is_some() { - b.ltree[1] = f32::from_bits(t.order.len() as u32); - } - b.ltree[2] = if power > 0.0 { ((power - t.power) / power).max(0.0) } else { 0.0 }; - } - b.counts[1] = if total > 0.0 { e.emitter_tris.len() as u32 } else { 0 }; - } - - /// Power edits held back (`Tables::deferred`) to the emitter worker, unless it is on a job (they go with the next, - /// when that one lands). Lockstep frames wait for the result. - fn send_powers(&mut self) { - let Some(w) = self.sc.emit.as_mut() else { return }; - if w.flight.is_some() || self.sc.tables.deferred.is_empty() { - return; - } - let e = &mut self.sc.tables; - let powers = std::mem::take(&mut e.deferred); - let mut objects = e.objects.clone(); - for &(row, power, _) in &powers { - objects[row][3] = power; - } - let b = &self.sc.base; - let job = EmitterJob { materials: e.materials.clone(), objects, lod: b.light_lod, cull: crate::EmitterCull::of(b) }; - if w.ask.send(job).is_err() { - // (The worker is gone: write them now.) - e.put_powers(&powers); - self.sc.emit = None; - e.defer_powers = false; - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - self.write_emitters(); - return; - } - w.flight = Some(powers); - if self.fixed_dt.is_some() { - if let Ok(r) = self.sc.emit.as_ref().unwrap().done.recv() { - self.land_emitters(r); - } - } - } - - /// The emitter worker's result, if it has one: its edits land with it, and the edits held back since go out. - fn take_emitters(&mut self) { - let Some(r) = self.sc.emit.as_ref().and_then(|w| w.done.try_recv().ok()) else { return }; - self.land_emitters(r); - self.send_powers(); - } - - /// A job's edits and its emitter CDF and tree powers, together: the objects' powers, the CDF, the tree. - fn land_emitters(&mut self, r: EmitterBuilt) { - let Some(powers) = self.sc.emit.as_mut().and_then(|w| w.flight.take()) else { return }; - let e = &mut self.sc.tables; - e.put_powers(&powers); - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&e.objects)); - if let Some(t) = e.light_tree.as_mut() { - for (n, &p) in t.nodes.iter_mut().zip(&r.node_powers) { - n[2] = p; - } - t.power = t.nodes.first().map_or(0.0, |n| n[2]); - } - // (Same entries and nodes as the CDF the scene was loaded with: the buffer holds them.) - let mut enc = self.g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("emitters") }); - enc.copy_buffer_to_buffer(&r.staging, 0, &self.sc.up.emitters, 0, r.bytes.min(self.sc.up.emitters.size())); - self.g.queue.submit([enc.finish()]); - self.emitter_params(r.entries, r.total, r.power); - self.lit_changed = self.frame.max(1); - } - - /// Before another emitter edit (`write_emitters`, a light-LOD rebuild): the job in flight waited for and its result - /// dropped, its edits and those held back written into the objects (and uploaded), so the rebuild sees them all. - fn land_powers_now(&mut self) { - let Some(w) = self.sc.emit.as_mut() else { return }; - let mut powers = vec![]; - if let Some(f) = w.flight.take() { - let _ = w.done.recv(); - powers = f; - } - let e = &mut self.sc.tables; - powers.append(&mut e.deferred); - if !powers.is_empty() { - e.put_powers(&powers); - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&e.objects)); - // The lighting changed (as when a result lands): `live_lighting` for the next 45 frames. - self.lit_changed = self.frame.max(1); - } - } - - /// Instanced objects whose instances changed (`World::instanced`, the node's transform, power or customData): their - /// kernel records (the old transforms kept as the previous frame's, for motion vectors), object entries (pivot, - /// bounds, power, data, colour), TLAS transforms and masks (`scene::instance_world`: a hidden instance 0), each - /// object's records and entries written as one range; with `build_tlas` the TLAS is rebuilt here. No triangles or - /// BLASes change. Returns how many objects it updated; Err: why it cannot (software traversal, an object the build - /// flattened or did not place this way, an instance now mirrored), and the caller rebuilds. - /// `THREERT_EDIT_REBUILD=1` always rebuilds (A/B). - fn update_instanced(&mut self, world: &World, ids: &[u32], build_tlas: bool) -> std::result::Result { - use crate::world::Kind; - if std::env::var("THREERT_EDIT_REBUILD").is_ok_and(|v| v == "1") { - return Err("THREERT_EDIT_REBUILD=1".into()); - } - let size = std::mem::size_of::(); - let now = shader_time(self.time); - let empty = crate::world::InstanceTable::default(); - let sc = &mut self.sc; - let Some(mut top) = gpu::Top::of(&mut sc.up.accel, &mut sc.up.units) else { return Err("software traversal".into()) }; - let mut done = 0; - for &id in ids { - // (Removed or hidden: nothing is drawn; a structural change rebuilds.) - let Some(n) = world.objects.get(&id).filter(|_| world.shown(id)) else { continue }; - let Kind::Instanced { capacity, procedural, .. } = n.kind else { continue }; - let Some(&place) = sc.tables.retained.instanced.get(&id) else { return Err("an instanced mesh the build flattened".into()) }; - if place.capacity != capacity || place.procedural != procedural { - return Err("an instanced mesh the build placed otherwise".into()); - } - let t = world.instanced.get(&id).unwrap_or(&empty); - let m = world.world_matrix(id); - // Past both the old and the new count every instance is hidden and stays so: left as it is. - let (first, cap) = (place.first as usize, place.drawn.max(t.count).min(capacity) as usize); - // (A procedural one's hidden instances are out of hardware traversal by their TLAS masks; software - // traversal walks every record there is: the build leaves them out.) - if procedural && top.sw() { - return Err("a procedural instanced mesh (software traversal)".into()); - } - let (rec0, slot0) = if procedural { (sc.proc_first + first, top.proc_base(sc.proc_first) + first) } else { (first, top.inst_base() + first) }; - for i in 0..cap { - let (wm, hidden) = scene::instance_world(m, t, i); - if !procedural && !hidden && wm.determinant() < 0.0 { - return Err("an instance is mirrored".into()); - } - let old = sc.gpu_instances[rec0 + i]; - let (mut rec, entry) = if procedural { - let p = &mut sc.procs[first + i]; - (p.world, p.hidden) = (wm, hidden); - (p.gpu(old.info[0]), p.entry(n.power, t.custom(i, n.custom), t.color(i))) - } else { - let inst = &mut sc.instances[first + i]; - if !top.has_mesh(inst.mesh) { - return Err("an instanced mesh that is no unit (software traversal)".into()); - } - (inst.world, inst.hidden) = (wm, hidden); - let (lo, hi) = sc.meshes[inst.mesh as usize].placed_bounds(wm); - (top.record(inst.gpu(old.info[0], old.info[3]), inst.mesh, n.cast_shadow), scene::object_row(wm.transform_point3(glam::Vec3::ZERO), n.power, hi - lo, t.custom(i, n.custom), t.color(i))) - }; - rec.p = old.m; - sc.gpu_instances[rec0 + i] = rec; - // The time of the last power edit: now for a new power. - let st = sc.tables.stride; - let at = (place.object as usize + i) * st; - let e = &mut sc.tables.objects[at..at + st]; - let edited = if e[0][3] != entry[0][3] { now } else { e[1][3] }; - e.copy_from_slice(&entry[..st]); - e[1][3] = edited; - top.moved(slot0 + i, wm, Some((n.cast_shadow, hidden))); - } - let q = &self.g.queue; - q.write_buffer(&sc.up.instances, (rec0 * size) as u64, bytemuck::cast_slice(&sc.gpu_instances[rec0..rec0 + cap])); - let st = sc.tables.stride; - let at = place.object as usize * st; - q.write_buffer(&sc.up.objects, (at * 16) as u64, bytemuck::cast_slice(&sc.tables.objects[at..at + cap * st])); - self.inst_moving.extend(rec0 as u32..(rec0 + cap) as u32); - sc.tables.retained.instanced.get_mut(&id).unwrap().drawn = t.count; - done += 1; - } - if done > 0 { - self.inst_moved = true; - match top { - gpu::Top::Hw(accel) => { - if build_tlas { - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_tlas(accel, &mut e); - self.g.queue.submit([e.finish()]); - } - } - gpu::Top::Sw(units) => self.g.retop(units, &sc.up.bg_parts.nodes)?, - } - } - Ok(done) - } - - /// Moves procedural objects: their kernel records (the old transform kept as the previous frame's, for motion - /// vectors), object pivots and, with hardware RT, TLAS transforms (`build_tlas`: the TLAS rebuilt here). Software - /// traversal reads the records alone, so neither path rebuilds the scene. - fn move_procs(&mut self, world: &World, ids: &[u32], build_tlas: bool) { - let size = std::mem::size_of::() as u64; - for id in ids { - let Some(&j) = self.sc.proc_of.get(id) else { continue }; - let w = world.world_matrix(*id); - let p = &mut self.sc.procs[j]; - p.world = w; - let i = self.sc.proc_first + j; - let old = self.sc.gpu_instances[i]; - let mut r = p.gpu(old.info[0]); - r.p = old.m; - self.sc.gpu_instances[i] = r; - self.g.queue.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(&r)); - let pivot = w.transform_point3(glam::Vec3::ZERO); - let st = self.sc.tables.stride; - let o = &mut self.sc.tables.objects[p.object as usize * st]; - (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); - if let Some(accel) = self.sc.up.accel.as_mut() { - if let Some(t) = accel.tlas[accel.proc_base + j].as_mut() { - t.transform = gpu::tlas_rows(w); - } - } - self.inst_moving.push(i as u32); - } - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - if let (true, Some(accel)) = (build_tlas, self.sc.up.accel.as_ref()) { - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_tlas(accel, &mut e); - self.g.queue.submit([e.finish()]); - } - self.inst_moved = true; - } - - /// Posed rigid models (`scene::RigidModel`): each moving node's instance takes object x node transform from the clip - /// (previous rows kept for motion vectors) and their flattened triangles are re-transformed in place; then the dynamic - /// BLASes (if they have such triangles) and, when `build_tlas`, the TLAS are rebuilt (`repose` rebuilds both after - /// it otherwise). False when it cannot (no hardware RT, a model hidden or changed): the caller rebuilds. - fn repose_rigid(&mut self, world: &World, ids: &[u32], build_tlas: bool) -> Result { - if self.sc.up.accel.is_none() { - return Ok(false); - } - let mut placements: Vec<(u32, glam::Mat4)> = vec![]; - let (mut lo, mut hi) = (usize::MAX, 0usize); - for id in ids { - let Some(o) = world.objects.get(id) else { return Ok(false) }; - let crate::world::Kind::Model { uri, animation, .. } = &o.kind else { return Ok(false) }; - if !world.shown(*id) { - return Ok(false); - } - let globals = scene::model_gltf(uri, &self.assets)?.node_globals(animation.as_ref())?; - let m = world.world_matrix(*id); - let r = &self.sc.tables.retained.rigid[id]; - placements.extend(r.instances.iter().map(|&(node, i)| (i, m * globals[node]))); - let d = &r.rest; - if d.finals.len() != d.mats.len() { - return Ok(false); - } - let (first, verts, attrs, blas) = &mut self.sc.dynamic; - for (j, (node, p, n, uv, tg)) in r.rest_local.iter().enumerate() { - let w = m * globals[*node]; - let nm = glam::Mat3::from_mat4(w).inverse().transpose(); - let pw = p.map(|v| w.transform_point3(v)); - let nw = n.map(|v| (nm * v).normalize_or_zero()); - let tw = tg.map(|(v, s)| (w.transform_vector3(v).normalize_or_zero(), s)); - let a = scene::tri_attr(pw, nw, *uv, tw, d.mats[j]); - let k = d.finals[j] as usize - *first; - let (e1, e2) = (pw[1] - pw[0], pw[2] - pw[0]); - verts[k * 3..k * 3 + 3].copy_from_slice(&[[pw[0].x, pw[0].y, pw[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); - attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; - blas[k * 3..k * 3 + 3].copy_from_slice(&pw.map(|v| v.to_array())); - self.sc.tris[(d.first + j as u32) as usize] = pw.map(glam::Vec3A::from); - (lo, hi) = (lo.min(k), hi.max(k + 1)); - } - let pivot = m.transform_point3(glam::Vec3::ZERO); - let o = &mut self.sc.tables.objects[d.object as usize * self.sc.tables.stride]; - (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); - } - // The instances: rows, previous rows (motion vectors), TLAS transforms. - let size = std::mem::size_of::() as u64; - let accel = self.sc.up.accel.as_mut().unwrap(); - let (mut ilo, mut ihi) = (usize::MAX, 0usize); - for &(i, w) in &placements { - let inst = &mut self.sc.instances[i as usize]; - let old = self.sc.gpu_instances[i as usize]; - inst.world = w; - let mut r = inst.gpu(old.info[0], old.info[3]); - r.p = old.m; - self.sc.gpu_instances[i as usize] = r; - (ilo, ihi) = (ilo.min(i as usize), ihi.max(i as usize + 1)); - let row = |k: usize| [w.x_axis[k], w.y_axis[k], w.z_axis[k], w.w_axis[k]]; - if let Some(t) = accel.tlas[accel.inst_base + i as usize].as_mut() { - t.transform = [row(0), row(1), row(2)].concat().try_into().unwrap(); - } - // (Rigid models' instances are theirs alone: no other update this commit placed them.) - self.inst_moving.push(i); - } - if lo < hi { - self.emitters_moved = true; - } - // A model's node instances are contiguous: one write. - if ilo < ihi { - self.g.queue.write_buffer(&self.sc.up.instances, ilo as u64 * size, bytemuck::cast_slice(&self.sc.gpu_instances[ilo..ihi])); - } - self.inst_moved = true; - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - if lo < hi { - // Motion vectors of the flattened triangles: the previous frame's vertices, as `repose` keeps them. - let both = self.prev_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi))); - self.keep_prev(both); - self.prev_span = Some((lo, hi)); - self.reposed = true; - let (q, up, (first, verts, attrs, blas)) = (&self.g.queue, &self.sc.up, &self.sc.dynamic); - let accel = up.accel.as_ref().unwrap(); - let k = (first + lo) as u64; - q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&verts[lo * 3..hi * 3])); - q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&attrs[lo..hi])); - q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&blas[lo * 3..hi * 3])); - } - if build_tlas { - let accel = self.sc.up.accel.as_ref().unwrap(); - let mut e = self.g.device.create_command_encoder(&Default::default()); - if lo < hi { - self.g.rebuild_dynamic(accel, &mut e); - } else { - self.g.rebuild_tlas(accel, &mut e); - } - self.g.queue.submit([e.finish()]); - } - Ok(true) - } - - /// Models in a shared pose (`scene::PoseGroup`): each group whose pose changed is skinned once, in model space (the - /// groups in parallel), into its mesh's pool triangles, and their BLASes are rebuilt; the TLAS is left to the moves - /// that follow (or `commit`). False when a group's members no longer share one pose, or one is hidden or changed: - /// the caller rebuilds, which groups them again. (Deforming meshes get no motion vectors of their own yet: an - /// instance's are its transform's.) - fn repose_groups(&mut self, world: &World, members: &[u32]) -> Result { - use rayon::prelude::*; - if self.sc.up.accel.is_none() { - return Ok(false); - } - let r = &self.sc.tables.retained; - let mut touched: Vec = members.iter().map(|k| r.pose_member[k]).collect(); - touched.sort_unstable(); - touched.dedup(); - let mut jobs: Vec<(u32, crate::desc::Animation)> = vec![]; - for &g in &touched { - let pg = &r.pose_groups[g as usize]; - let mut pose: Option<&crate::desc::Animation> = None; - for (id, _) in &pg.members { - let Some(o) = world.objects.get(id) else { return Ok(false) }; - let crate::world::Kind::Model { uri, nodes, animation: Some(a), .. } = &o.kind else { return Ok(false) }; - if !world.shown(*id) || *uri != pg.uri || *nodes != pg.nodes || pose.is_some_and(|p| p != a) { - return Ok(false); - } - pose = Some(a); - } - if let Some(a) = pose.filter(|a| **a != pg.animation) { - jobs.push((g, a.clone())); - } - } - if jobs.is_empty() { - return Ok(true); - } - let t_skin = Instant::now(); - let assets = &self.assets; - type Skinned = (Vec<[f32; 4]>, Vec, Vec<[f32; 3]>, Vec<[glam::Vec3A; 3]>); - let skinned: Vec>> = jobs - .par_iter() - .map(|(g, a)| { - let pg = &r.pose_groups[*g as usize]; - let tris = scene::model_gltf(&pg.uri, assets)?.triangles(glam::Mat4::IDENTITY, pg.nodes.as_deref(), Some(a))?; - if tris.len() != pg.order.len() { - return Ok(None); - } - let n = pg.order.len(); - let (mut v, mut at, mut b, mut t) = (Vec::with_capacity(n * 3), Vec::with_capacity(n), Vec::with_capacity(n * 3), Vec::with_capacity(n)); - for &(k, mat) in &pg.order { - let q = &tris[k as usize]; - let (e1, e2) = (q.p[1] - q.p[0], q.p[2] - q.p[0]); - // The flag word keeps TRI_DYNAMIC: the kernel takes a deforming mesh's motion from `prev_verts`. - v.extend_from_slice(&[[q.p[0].x, q.p[0].y, q.p[0].z, f32::from_bits(scene::TRI_DYNAMIC)], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); - at.push(scene::tri_attr(q.p, q.n, q.uv, q.tangent, mat)); - b.extend(q.p.map(|p| p.to_array())); - t.push(q.p.map(glam::Vec3A::from)); - } - Ok(Some((v, at, b, t))) - }) - .collect(); - self.cpu_skin_ms += t_skin.elapsed().as_secs_f32() * 1000.0; - let t_up = Instant::now(); - // Motion vectors: the GPU still holds the previous pose of these meshes (and of the last re-skin's, if others): - // keep it in `prev_verts` before uploading the new one. - let df = self.sc.dynamic.0; - let (mut lo, mut hi) = (usize::MAX, 0usize); - for (g, _) in &jobs { - let pg = &self.sc.tables.retained.pose_groups[*g as usize]; - let k = self.sc.gpu_instances[pg.members[0].1 as usize].info[0] as usize - df; - (lo, hi) = (lo.min(k), hi.max(k + pg.order.len())); - } - self.keep_prev(self.pose_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi)))); - self.pose_span = Some((lo, hi)); - self.pose_reposed = true; - let (q, up) = (&self.g.queue, &self.sc.up); - let accel = up.accel.as_ref().unwrap(); - let mut meshes = Vec::with_capacity(jobs.len()); - for ((g, a), res) in jobs.into_iter().zip(skinned) { - let Some((v, at, b, t)) = res? else { return Ok(false) }; - let pg = &mut self.sc.tables.retained.pose_groups[g as usize]; - // The mesh's first triangle in the GPU order: any member's record has it. - let k = self.sc.gpu_instances[pg.members[0].1 as usize].info[0] as u64; - q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&v)); - q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&at)); - q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&b)); - let m = self.sc.meshes[pg.mesh as usize]; - self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].copy_from_slice(&t); - pg.animation = a; - meshes.push(pg.mesh); - } - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_meshes(accel, &meshes, &mut e); - let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); - if profile_blas { - let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); - } - let t_blas = Instant::now(); - q.submit([e.finish()]); - if profile_blas { - let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); - self.gpu_blas_ms += t_blas.elapsed().as_secs_f32() * 1000.0; - } - self.cpu_upload_ms += t_up.elapsed().as_secs_f32() * 1000.0; - Ok(true) - } - - /// Re-skins posed models and re-transforms movable objects in place (hardware RT): their triangles, shading - /// attributes and dynamic BLASes, then the TLAS. Their directly seen surfaces keep a short history for a moment, - /// like a power edit (no per-object motion vectors). False when it cannot (not dynamic at build, no hardware RT, a - /// different triangle count): the caller rebuilds. - fn repose(&mut self, world: &World, ids: &std::collections::HashSet) -> Result { - if self.sc.up.accel.is_none() || ids.iter().any(|id| !self.sc.tables.retained.dynamic.contains_key(id)) { - return Ok(false); - } - let models = std::mem::take(&mut self.sc.tables.retained.dynamic); - let t_skin = Instant::now(); - let res = self.reskin(world, ids, &models); - self.cpu_skin_ms += t_skin.elapsed().as_secs_f32() * 1000.0; - self.sc.tables.retained.dynamic = models; - let Some((lo, hi)) = res? else { return Ok(false) }; - self.emitters_moved = true; - let t_up = Instant::now(); - // Motion vectors: the GPU still holds the previous frame's vertices for this span (and for last update's span, - // if different): keep them as `prev_verts` before uploading the new ones. - let both = self.prev_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi))); - self.keep_prev(both); - self.prev_span = Some((lo, hi)); - self.reposed = true; - // Upload the touched span of the dynamic tail, then rebuild the dynamic BLASes and the TLAS. - let (q, up, (first, verts, attrs, blas)) = (&self.g.queue, &self.sc.up, &self.sc.dynamic); - let accel = up.accel.as_ref().unwrap(); - let k = (first + lo) as u64; - q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&verts[lo * 3..hi * 3])); - q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&attrs[lo..hi])); - q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&blas[lo * 3..hi * 3])); - q.write_buffer(&up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_dynamic(accel, &mut e); - let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); - if profile_blas { - let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); - } - let t_blas = Instant::now(); - q.submit([e.finish()]); - if profile_blas { - let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); - self.gpu_blas_ms += t_blas.elapsed().as_secs_f32() * 1000.0; - } - self.cpu_upload_ms += t_up.elapsed().as_secs_f32() * 1000.0; - Ok(true) - } - - /// `guard_trips` for the frame log: None while the scene's kernel has no guard. Natively with nothing set that is - /// a scene without units (`gpu::loop_guard_of`): under software traversal the first unit brings the guard. - pub fn guard_count(&self) -> Option { - self.guard_trips.filter(|_| gpu::loop_guard_of(self.sc.up.kernel.features & gpu::F_UNITS != 0).is_some()) - } - - /// The edit room as the frame log reports it (`room`): triangles, records and object entries in use and held (GPU - /// order counts, holes not in use), its growths, and the GPU bytes of the buffers edits write and of the triangle - /// BLASes the live meshes need (estimated from the build's bytes per triangle), MB. - pub fn room_stats(&self) -> serde_json::Value { - let (e, up) = (&self.sc.edit, &self.sc.up); - // Software traversal's (it does not grow): the same counts, and of the node buffer the mesh trees' range (in - // use, held) and the units the top tree has against the most its range holds. - if let Some(u) = up.units.as_ref() { - return serde_json::json!({ - "tris": [e.tris.used(), e.tris.cap], "records": [e.records.used(), e.records.cap], "objects": [e.objects.used(), e.objects.cap], - "holes": e.tris.free.len(), "grows": e.grows, "nodes": [u.layout.mesh_cap as usize - u.layout.free_nodes(), u.layout.mesh_cap], "units": [u.count, u.layout.max_units()], - }); - } - let Some(a) = up.accel.as_ref() else { return serde_json::Value::Null }; - let buffers = [&up.verts, &up.attrs, &up.prev_verts, &up.instances, &up.objects, &up.cdf, &up.emitters, &a.verts].iter().map(|b| b.size()).sum::(); - let live: u64 = self.sc.meshes.iter().enumerate().filter(|(k, _)| e.users.get(&(*k as u32)).copied().unwrap_or(0) > 0).map(|(_, m)| m.count as u64).sum::() + e.pool_first as u64; - let mb = |b: f64| (b / (1 << 20) as f64 * 10.0).round() / 10.0; - serde_json::json!({ - "tris": [e.tris.used(), e.tris.cap], "records": [e.records.used(), e.records.cap], "objects": [e.objects.used(), e.objects.cap], - "holes": e.tris.free.len(), "grows": e.grows, "buffers_mb": mb(buffers as f64), "blas_mb": mb(live as f64 * a.blas_per_tri), - }) - } - - /// Software traversal's top tree so far (the frame log's `top`): how many times it was built, kept with leaves - /// taken out and put in, and kept with its boxes brought up to date, and the ms all of that took. Null without - /// units. - pub fn top_stats(&self) -> serde_json::Value { - self.sc.up.units.as_ref().map_or(serde_json::Value::Null, |u| serde_json::json!([u.kept.builds, u.kept.changes, u.kept.refits, (u.kept.ms * 1e3).round() / 1e3])) - } - - /// now (the CPU copy of the dynamic tail), its nodes and the root uploaded (`ltree::LightTree::refit_moving`). - fn refit_moving_tree(&mut self) { - let first = self.sc.dynamic.0; - let (verts, tables) = (&self.sc.dynamic.1, &mut self.sc.tables); - let tris = &tables.emitter_tris; - let Some(t) = tables.light_tree.as_mut().filter(|t| t.moving_count > 0) else { return }; - t.refit_moving(&|k| { - let r = (tris[k].tri as usize - first) * 3; - crate::ltree::tri_vertices(&verts[r..r + 3]) - }); - let (q, up, off) = (&self.g.queue, &self.sc.up, self.sc.base.ltree[0].to_bits() as u64); - q.write_buffer(&up.emitters, off * 16, bytemuck::cast_slice(&t.nodes[..1])); - q.write_buffer(&up.emitters, (off + 2) * 16, bytemuck::cast_slice(&t.nodes[2..3])); - q.write_buffer(&up.emitters, (off + t.moving_nodes as u64) * 16, bytemuck::cast_slice(&t.nodes[t.moving_nodes..])); - } - - /// Profile slots after the cache resolve that this scene writes: particle pass + BLAS rebuild (2; stateless or GPU - /// particles), translucency + volume training (2 more). - fn profile_extra(&self) -> u32 { - if self.sc.up.translucent.is_some() { - 4 - } else if self.sc.up.particles.is_some() || self.sc.up.gpu_particles.is_some() { - 2 - } else { - 0 - } - } - - /// Copies the GPU's current vertices of a dynamic-tail span into `prev_verts` (submitted before any write queued - /// after it, so it reads what the last frame traced). - fn keep_prev(&self, (lo, hi): (usize, usize)) { - let up = &self.sc.up; - let first = self.sc.dynamic.0; - let mut e = self.g.device.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(&up.verts, ((first + lo) * 48) as u64, &up.prev_verts, (lo * 48) as u64, ((hi - lo) * 48) as u64); - self.g.queue.submit([e.finish()]); - } - - /// Skins / transforms the objects into the CPU copy of the dynamic tail; the touched span (relative to the tail), or - /// None when one cannot be updated in place. - fn reskin(&mut self, world: &World, ids: &std::collections::HashSet, models: &std::collections::HashMap) -> Result> { - use rayon::prelude::*; - if ids.iter().any(|id| !world.shown(*id)) { - return Ok(None); - } - // Objects whose triangles form one run of the dynamic tail (one traversal group) are skinned / re-transformed in - // parallel, one task per object, straight into their own spans of the tail and of the CPU triangles (one core - // took 17 ms for the crowd scene's 150 walkers, docs/ROADMAP.md § 0): ordered by span, the objects split the - // arrays into disjoint slices. The others (e.g. opaque and cutout materials in different groups) one by one. - let run = |d: &scene::DynamicModel| !d.finals.is_empty() && d.finals.iter().enumerate().all(|(j, &f)| f == d.finals[0] + j as u32); - let (mut runs, scattered): (Vec, Vec) = ids.iter().copied().partition(|id| run(&models[id])); - runs.sort_by_key(|id| models[id].finals[0]); - let assets = &self.assets; - let sc = &mut self.sc; - let (first, verts, attrs, blas) = &mut sc.dynamic; - let first = *first; - let (mut lo, mut hi) = (usize::MAX, 0); - // The CPU triangles in build order: each object's slice. - let mut by_build = runs.clone(); - by_build.sort_by_key(|id| models[id].first); - let mut tris_of: std::collections::HashMap = std::collections::HashMap::with_capacity(runs.len()); - let (mut tr, mut toff): (&mut [[glam::Vec3A; 3]], usize) = (&mut sc.tris[..], 0); - for id in &by_build { - let d = &models[id]; - let (t0, n) = (d.first as usize, d.finals.len()); - let (_, r) = std::mem::take(&mut tr).split_at_mut(t0 - toff); - let (t, r) = r.split_at_mut(n); - tr = r; - toff = t0 + n; - tris_of.insert(*id, t); - } - let (mut vr, mut ar, mut br): (&mut [[f32; 4]], &mut [scene::GpuTriAttr], &mut [[f32; 3]]) = (&mut verts[..], &mut attrs[..], &mut blas[..]); - let mut voff = 0usize; - let mut jobs = Vec::with_capacity(runs.len()); - for id in &runs { - let d = &models[id]; - let (k0, n) = (d.finals[0] as usize - first, d.finals.len()); - let (_, r) = std::mem::take(&mut vr).split_at_mut(k0 * 3 - voff * 3); - let (v, r) = r.split_at_mut(n * 3); - vr = r; - let (_, r) = std::mem::take(&mut ar).split_at_mut(k0 - voff); - let (a, r) = r.split_at_mut(n); - ar = r; - let (_, r) = std::mem::take(&mut br).split_at_mut(k0 * 3 - voff * 3); - let (b, r) = r.split_at_mut(n * 3); - br = r; - voff = k0 + n; - (lo, hi) = (lo.min(k0), hi.max(k0 + n)); - jobs.push((*id, v, a, b, tris_of.remove(id).unwrap())); - } - let ok: Vec> = jobs - .into_par_iter() - .map(|(id, v, a, b, t)| { - let d = &models[&id]; - let Some(tris) = scene::dynamic_tris(world, id, d, assets)? else { return Ok(false) }; - if tris.len() != d.finals.len() { - return Ok(false); - } - for (j, (p, at)) in tris.into_iter().enumerate() { - let (e1, e2) = (p[1] - p[0], p[2] - p[0]); - v[j * 3..j * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); - // (x.w: an emitter's light-tree code, which stays with its triangle.) - a[j] = scene::GpuTriAttr { x: [at.x[0], f32::from_bits(d.object), at.x[2], a[j].x[3]], ..at }; - b[j * 3..j * 3 + 3].copy_from_slice(&p.map(|q| q.to_array())); - t[j] = p.map(glam::Vec3A::from); - } - Ok(true) - }) - .collect(); - for r in ok { - if !r? { - return Ok(None); - } - } - for id in &scattered { - let d = &models[id]; - let Some(tris) = scene::dynamic_tris(world, *id, d, assets)? else { return Ok(None) }; - if tris.len() != d.finals.len() { - return Ok(None); - } - for (j, (p, a)) in tris.into_iter().enumerate() { - let k = d.finals[j] as usize - first; - let (e1, e2) = (p[1] - p[0], p[2] - p[0]); - verts[k * 3..k * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); - attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; - blas[k * 3..k * 3 + 3].copy_from_slice(&p.map(|v| v.to_array())); - sc.tris[(d.first + j as u32) as usize] = p.map(glam::Vec3A::from); - (lo, hi) = (lo.min(k), hi.max(k + 1)); - } - } - for id in ids { - let d = &models[id]; - // Pivot (material functions' object position). Motion vectors carry its history. - let pivot = world.world_matrix(*id).transform_point3(glam::Vec3::ZERO); - let st = sc.tables.stride; - let o = &mut sc.tables.objects[d.object as usize * st]; - (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); - } - Ok((lo < hi).then_some((lo, hi))) - } - - /// Rebuilds the GPU scene from the world. - /// Applies geometry edits in place (`Changes::geometries`, `added`, `removed`; objects with their own BLAS: - /// procedural meshes and `own_blas` meshes): each touched object's data is written into the edit room - /// (gpu.rs `EditRoom`) as the build would make it (`scene::proc_object`, `scene::own_mesh_tris`), new BLASes are - /// built for new geometry only, its TLAS instance is set (cleared for a removed or hidden object) and the TLAS - /// rebuilt, one submit. Nothing is moved: what an edit replaces stays behind until the next rebuild. Err: why it - /// cannot (software traversal, the room is full, a kernel feature the scene lacks, an emitting / glass / function - /// material, ...); the caller rebuilds the scene then. `THREERT_EDIT_REBUILD=1` always rebuilds (A/B). - fn edit_geometry(&mut self, world: &World, ch: &crate::world::Changes) -> std::result::Result<(), String> { - use crate::world::Kind; - if std::env::var("THREERT_EDIT_REBUILD").is_ok_and(|v| v == "1") { - return Err("THREERT_EDIT_REBUILD=1".into()); - } - let LoadedScene { up, tables, edit, meshes, pool_tris, instances, gpu_instances, procs, proc_of, proc_first, .. } = &mut self.sc; - let (g, assets) = (&self.g, &self.assets); - let features = up.kernel.features; - let grow = Grow { g: &self.g, ok: up.particles.is_none() && up.gpu_particles.is_none() }; - // (Hardware traversal's TLAS and BLASes, or software traversal's units: `gpu::Top`.) - let Some(mut top) = gpu::Top::of(&mut up.accel, &mut up.units) else { - // Software traversal without units. With units on, that is a scene without an instance yet: the first - // one brings the unit walk into the kernel (F_UNITS), as hardware traversal's first brings F_INST. - let first = crate::units_on(true) && ch.added.iter().any(|id| matches!(world.objects.get(id).map(|o| &o.kind), Some(Kind::Mesh { own_blas: true, .. } | Kind::Instanced { procedural: false, .. }))); - return Err(if first { "the scene's first instanced mesh (kernel feature)" } else { "software traversal" }.into()); - }; - // The objects to place again: those added, and those whose geometry was replaced. - let mut todo: std::collections::BTreeSet = ch.added.clone(); - if !ch.geometries.is_empty() { - for (id, o) in &world.objects { - match &o.kind { - Kind::Procedural { geometry, .. } | Kind::Mesh { geometry, own_blas: true, .. } if ch.geometries.contains(geometry) => { - todo.insert(*id); - } - Kind::Mesh { geometry, .. } if ch.geometries.contains(geometry) => return Err("a flattened mesh's geometry changed".into()), - // (One added this commit is placed with its geometry as it is.) - Kind::Instanced { geometry, .. } if ch.geometries.contains(geometry) && !ch.added.contains(id) => return Err("an instanced mesh's geometry changed".into()), - _ => {} - } - } - } - let (inst_base, proc_base, proc_first) = (top.inst_base(), top.proc_base(*proc_first), *proc_first); - let slot_of = |id: u32, edit: &EditState, tables: &tables::Tables, proc_of: &std::collections::HashMap| -> Option<(usize, usize)> { - if let Some(&v) = edit.placed.get(&id) { - Some(v) - } else if let Some(&k) = tables.retained.own_instance.get(&id) { - Some((inst_base + k as usize, k as usize)) - } else { - proc_of.get(&id).map(|&j| (proc_base + j, proc_first + j)) - } - }; - let q = &g.queue; - let mut e = g.device.create_command_encoder(&Default::default()); - // An emitter mesh's entries go (area 0: their weight; the light tree keeps its shape) when it is removed, hidden - // or placed again. - let mut emitters_changed = false; - let release = |id: u32, tables: &mut tables::Tables, meshes: &[scene::InstMesh], pool_first: usize| -> bool { - let Some(k) = tables.retained.emitter_mesh.remove(&id) else { return false }; - let im = meshes[k as usize]; - let (lo, hi) = ((pool_first + im.first as usize) as u32, (pool_first + (im.first + im.count) as usize) as u32); - for e in tables.emitter_tris.iter_mut().filter(|e| (lo..hi).contains(&e.tri)) { - e.area = 0.0; - } - true - }; - for id in &ch.removed { - // An instanced mesh an edit added: its instances, records and entries. - if let Some((r0, cap, o0)) = edit.instanced.remove(id) { - for r in r0..r0 + cap { - top.clear(inst_base + r); - if let Some(i) = instances.get_mut(r) { - i.hidden = true; - } - } - edit.records.give(r0, cap); - edit.objects.give(o0, cap); - tables.retained.instanced.remove(id); - tables.retained.objects.remove(id); - if let Some(k) = edit.mesh_of.remove(id) { - drop_mesh_user(k, edit, tables, meshes); - } - continue; - } - // An instanced mesh the build placed: its instances out (records and entries stay until the next rebuild). - if let Some(place) = tables.retained.instanced.get(id).filter(|p| !p.procedural) { - for r in place.first as usize..(place.first + place.capacity) as usize { - top.clear(inst_base + r); - if let Some(i) = instances.get_mut(r) { - i.hidden = true; - } - } - tables.retained.instanced.remove(id); - tables.retained.objects.remove(id); - continue; - } - emitters_changed |= release(*id, tables, meshes, edit.pool_first); - let at = slot_of(*id, edit, tables, proc_of); - // Placed by the build otherwise (a model, a procedural instanced mesh): a rebuild takes it out. - if at.is_none() && tables.retained.objects.contains_key(id) { - return Err("an object the build placed otherwise (a model, a procedural instanced mesh) removed".into()); - } - if let Some((slot, _)) = at { - top.clear(slot); - } - unpick(*id, edit, tables, instances, procs, proc_of); - // What it held goes back to the room (the build's records and entries stay until the next rebuild). - if let Some((_, r)) = edit.placed.remove(id) { - edit.records.give(r, 1); - } - if let Some(k) = edit.mesh_of.remove(id) { - drop_mesh_user(k, edit, tables, meshes); - } - tables.retained.own_instance.remove(id); - tables.retained.instance_of.remove(id); - if let Some(k) = tables.retained.objects.remove(id).filter(|&k| k as usize >= edit.base.2) { - edit.objects.give(k as usize, 1); - } - proc_of.remove(id); - } - let mut new_meshes = vec![]; - let mut fresh_blases = vec![]; - let mut pending_procs = vec![]; - let mut split = EditSplit::default(); - let (mut w_cdf, mut w_verts, mut w_attrs, mut w_blas, mut w_records, mut w_objects) = (Gather::default(), Gather::default(), Gather::default(), Gather::default(), Gather::default(), Gather::default()); - // Vertex colours (F_VCOLOR), in `prev_verts` after the previous vertices. - let mut w_colors = Gather::default(); - let vcolor_base = up.vcolor_base; - // (geometry, material) -> the mesh built in this batch (objects sharing a replaced geometry share it again). - let mut fresh: std::collections::HashMap<(u32, u32), u32> = Default::default(); - let mask = |shadow: bool| if shadow { 1 } else { 2 }; - for id in todo { - let Some(o) = world.objects.get(&id) else { continue }; - let at = slot_of(id, edit, tables, proc_of); - emitters_changed |= release(id, tables, meshes, edit.pool_first); - if !world.shown(id) { - if let Some((slot, _)) = at { - top.clear(slot); - } - if edit.instanced.contains_key(&id) { - return Err("an instanced mesh added in place hidden".into()); - } - unpick(id, edit, tables, instances, procs, proc_of); - if let Some(k) = edit.mesh_of.remove(&id) { - drop_mesh_user(k, edit, tables, meshes); - } - continue; - } - // Drawn but not by a TLAS instance of its own: the build flattened it (movable, or a material instances skip). - if at.is_none() && tables.retained.objects.contains_key(&id) { - return Err("an object the build flattened".into()); - } - let m = world.world_matrix(id); - let object = match tables.retained.objects.get(&id) { - Some(&k) => k as usize, - None => match edit.objects.take(1) { - Some(k) => k, - None if grow.objects(&mut edit.objects, 1, &mut up.objects, tables.stride) => { - edit.grows += 1; - edit.rebind = true; - edit.objects.take(1).ok_or("edit room: objects")? - } - None => return Err("edit room: objects (particles, or the device's limits)".into()), - }, - }; - // Where its TLAS instance places it: the node, or identity for an emitter mesh (world-space triangles). - let mut placement = m; - let (record, gpu, blas, entry) = match &o.kind { - Kind::Procedural { .. } => { - // (Software traversal walks procedural objects by their count and their own trees in the region: - // the build lays those out.) - if top.sw() { - return Err("a procedural object (software traversal)".into()); - } - if features & gpu::F_PROC == 0 { - return Err("the scene's first procedural object (kernel feature)".into()); - } - let Some(p) = scene::proc_object(world, id, m, object as u32, &tables.retained.materials, assets).map_err(|e| format!("{e:#}"))? else { - if let Some((slot, _)) = at { - top.clear(slot); - } - unpick(id, edit, tables, instances, procs, proc_of); - continue; - }; - placement = m; - // Room for `used` words: the region grown if it lacks it. - let mut words_left = |used: usize, edit: &mut EditState| { - if used <= edit.word_cap { - return true; - } - let ok = grow.words(&mut edit.word_cap, used, &mut up.cdf, up.cdf_words); - if ok { - edit.grows += 1; - edit.rebind = true; - } - ok - }; - let boxes = match edit.blobs.get(&p.blob.hash) { - Some(&w) => w, - None => { - let w = edit.word; - if !words_left(w + p.blob.boxes.len() + p.blob.data.len(), edit) { - return Err("edit room: procedural words".into()); - } - let at = |word: usize| ((up.cdf_words + word) * 4) as u64; - w_cdf.put(at(w), bytemuck::cast_slice(&p.blob.boxes)); - w_cdf.put(at(w + p.blob.boxes.len()), bytemuck::cast_slice(&p.blob.data)); - edit.word += p.blob.boxes.len() + p.blob.data.len(); - edit.blobs.insert(p.blob.hash, w); - w - } - }; - let header = edit.word; - let words = p.header(header, boxes, (0, 0)); - if !words_left(header + words.len(), edit) { - return Err("edit room: procedural words".into()); - } - w_cdf.put(((up.cdf_words + header) * 4) as u64, bytemuck::cast_slice(&words)); - edit.word += words.len(); - let blas = g.proc_blas(&p.blob, &mut pending_procs, &mut fresh_blases); - let entry = p.object_entry(o); - let gpu = p.gpu(header as u32); - let record = at.map(|(_, r)| r); - match proc_of.get(&id) { - Some(&j) => procs[j] = p, - None => { - edit.added.insert(id, Picked::Proc(p)); - } - } - (record, gpu, Placed::Proc(blas), entry) - } - Kind::Mesh { materials, .. } if materials.len() > 1 => return Err("a mesh with several materials".into()), - Kind::Model { uri, nodes, animation, functions } => { - // A static model already in the scene: another instance of its mesh (one not in it brings materials - // and textures, which load with a rebuild). - if animation.is_some() { - return Err("an animated model".into()); - } - if !functions.is_empty() { - return Err("a model with material functions (its materials load with a rebuild)".into()); - } - if features & gpu::F_INST == 0 || m.determinant() <= 0.0 || std::env::var("THREERT_INSTANCING").is_ok_and(|v| v == "0") { - return Err("a model the build would flatten (mirrored, instancing off, or the scene's first instance)".into()); - } - let mesh = *tables.retained.model_mesh.get(&(uri.clone(), nodes.clone())).ok_or("a model not in the scene (its materials and textures load with a rebuild)")?; - let im = meshes[mesh as usize]; - if !top.has_mesh(mesh) { - return Err("a model whose mesh is no unit (it deforms; software traversal flattens it)".into()); - } - *edit.users.entry(mesh).or_default() += 1; - if let Some(k) = edit.mesh_of.insert(id, mesh) { - drop_mesh_user(k, edit, tables, meshes); - } - let inst = scene::Instance { mesh, world: m, object: object as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: None, hidden: false }; - let gpu = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); - let (lo, hi) = im.placed_bounds(m); - let entry = scene::object_row(m.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, o.custom, [1.0; 3]); - edit.added.insert(id, Picked::Mesh(inst)); - (at.map(|(_, r)| r), gpu, Placed::Mesh(mesh), entry) - } - Kind::Mesh { own_blas: true, .. } | Kind::Instanced { procedural: false, .. } => { - // An own-BLAS mesh, or (added in place) an instanced mesh: their mesh made alike. - let (geometry, material, capacity) = match &o.kind { - Kind::Mesh { geometry, material, .. } => (*geometry, *material, None), - Kind::Instanced { geometry, materials, capacity, .. } => (*geometry, *materials.first().ok_or("an instanced mesh without a material")?, Some(*capacity as usize)), - _ => unreachable!(), - }; - let (geometry, material) = (&geometry, &material); - if features & gpu::F_INST == 0 { - return Err("the scene's first instanced mesh (kernel feature)".into()); - } - // What the build flattens instead (scene.rs: mirrored, or instancing off). - if m.determinant() <= 0.0 || std::env::var("THREERT_INSTANCING").is_ok_and(|v| v == "0") { - return Err("a mirrored mesh, or instancing off (the build flattens it)".into()); - } - let mat = *tables.retained.materials.get(material).ok_or("unknown material")?; - let gm = tables.materials[mat as usize]; - // An emitting material (no glass, no function): an emitter mesh, its triangles in world space and its - // own (never shared), its emitter entries added (scene.rs; ROADMAP § 6 step 3). - let emits = scene::emits_only(&gm); - let class = if emits { u32::from(gm.uv2[3] > 0.0) } else { scene::material_class(&gm) }; - if class == 2 || (emits && capacity.is_some()) { - return Err("a glass or function material (an instanced mesh: emitting too)".into()); - } - // (An emitter mesh's triangles are in world space: one a body moves is flattened by the build.) - if emits && o.driven { - return Err("an emitting mesh a rigid body drives (the build flattens it)".into()); - } - if class == 1 && features & gpu::F_MASKED == 0 { - return Err("the scene's first cutout (kernel feature)".into()); - } - let colored = world.materials.get(material).is_some_and(|m| m.vertex_colors) && world.geometries.get(geometry).is_some_and(|g| !g.colors.is_empty()); - if colored && vcolor_base.is_none() { - return Err("the scene's first vertex colours (kernel feature)".into()); - } - let key = if emits { (u32::MAX - id, mat) } else { (*geometry, mat) }; - let kept = (!emits && !ch.geometries.contains(geometry)).then(|| tables.retained.own_mesh.get(&key).copied()).flatten(); - let mesh = match fresh.get(&key).copied().or(kept) { - Some(k) => k, - None => { - let geo = world.geometries.get(geometry).ok_or("unknown geometry")?; - let t0 = Instant::now(); - let (mut t, mut a) = if emits { scene::emitter_mesh_tris(geo, m, mat, object as u32) } else { scene::own_mesh_tris(geo, mat) }; - if t.is_empty() { - if let Some((slot, _)) = at { - top.clear(slot); - } - unpick(id, edit, tables, instances, procs, proc_of); - continue; - } - // Software traversal: the mesh's tree, and its triangles (their attributes, further down their - // colours) in the tree's order, as `prepare` lays a unit's mesh out. - let tree = match &top { - gpu::Top::Hw(_) => None, - gpu::Top::Sw(_) => { - let tree = crate::units::mesh_tree(&t)?; - t = tree.order.iter().map(|&i| t[i as usize]).collect(); - a = tree.order.iter().map(|&i| a[i as usize]).collect(); - Some(tree) - } - }; - // (A unit's cutout triangles carry their flag; hardware traversal has them in a geometry apart.) - let flags = f32::from_bits(if tree.is_some() && class == 1 { scene::TRI_MASKED } else { 0 }); - // As `prepare` lays the pool out: (a, flags), e1, e2; attributes; the BLAS's vertices. - let verts: Vec<[f32; 4]> = t.iter().flat_map(|&[a, b, c]| [[a.x, a.y, a.z, flags], [b.x - a.x, b.y - a.y, b.z - a.z, 0.0], [c.x - a.x, c.y - a.y, c.z - a.z, 0.0]]).collect(); - let blas_verts: Vec<[f32; 3]> = t.iter().flat_map(|&[a, b, c]| [[a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z]]).collect(); - split.tris += t0.elapsed().as_secs_f64() * 1e3; - // The same triangle count as the mesh it replaces: rewritten where it is, its BLAS rebuilt (no - // room used; a geometry that moves every frame, three.js's dynamic draw usage). Otherwise new - // triangles in the room and a new BLAS. - let same = tables.retained.own_mesh.get(&key).copied().filter(|&k| meshes[k as usize].count as usize == t.len() && meshes[k as usize].emits == emits); - let (k, first, im) = match same { - Some(k) => { - let im = meshes[k as usize]; - split.deformed += 1; - // (Its tree again: the records of the objects that draw it, all placed again by - // this edit, take the root it has now.) - if let (gpu::Top::Sw(units), Some(tree)) = (&mut top, &tree) { - if let Some(old) = units.drop_tree(k) { - g.fill_tree(&up.bg_parts.nodes, old); - } - let root = units.add_tree(k, tree).ok_or("edit room: tree nodes")?; - g.write_tree(&up.bg_parts.nodes, root, tree); - } - (k, edit.pool_first + im.first as usize, im) - } - None => { - // A hole that fits, else the room's end (grown if it lacks room). - let first = match edit.tris.take(t.len()) { - Some(f) => f, - // (Software traversal's room does not grow: the rebuild makes a larger one.) - None if match &mut top { gpu::Top::Hw(accel) => grow.tris(&mut edit.tris, t.len(), &mut up.verts, &mut up.attrs, &mut up.prev_verts, vcolor_base, accel), gpu::Top::Sw(_) => false } => { - edit.grows += 1; - edit.rebind = true; - edit.tris.take(t.len()).ok_or("edit room: triangles")? - } - None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: triangles (software traversal's does not grow)" } else { "edit room: triangles (particles, or the device's limits)" }.into()), - }; - let mut im = scene::own_inst_mesh(&t, (first - edit.pool_first) as u32, class); - im.emits = emits; - let t1 = Instant::now(); - // A freed mesh's slot, else a new one. - let dead = edit.dead_meshes.pop(); - let k = match &mut top { - gpu::Top::Hw(accel) => match dead { - Some(k) => { - g.set_mesh(accel, k, first, im.count, im.opaque); - k - } - None => g.add_mesh(accel, first, im.count, im.opaque), - }, - // Its tree into the mesh trees' room (a freed mesh's slot gives its tree back first). - gpu::Top::Sw(units) => { - let k = dead.unwrap_or(meshes.len() as u32); - let tree = tree.as_ref().expect("software traversal made the tree"); - if let Some(old) = units.drop_tree(k) { - g.fill_tree(&up.bg_parts.nodes, old); - } - let Some(root) = units.add_tree(k, tree) else { - edit.dead_meshes.extend(dead); - edit.tris.give(first, t.len()); - return Err("edit room: tree nodes".into()); - }; - g.write_tree(&up.bg_parts.nodes, root, tree); - k - } - }; - if k as usize == meshes.len() { - meshes.push(im); - } else { - meshes[k as usize] = im; - } - edit.managed.insert(k); - split.blas += t1.elapsed().as_secs_f64() * 1e3; - split.added += 1; - (k, first, im) - } - }; - let t2 = Instant::now(); - w_verts.put((first * 48) as u64, bytemuck::cast_slice(&verts)); - w_attrs.put((first * std::mem::size_of::()) as u64, bytemuck::cast_slice(&a)); - if !top.sw() { - w_blas.put((first * 36) as u64, bytemuck::cast_slice(&blas_verts)); - } - if let Some(base) = vcolor_base { - // White where it has none: the room's slots may hold an earlier mesh's. - let mut c = if colored { scene::tri_colors(geo, false, None) } else { vec![scene::WHITE; t.len()] }; - if let (Some(tree), true) = (&tree, colored) { - c = tree.order.iter().map(|&i| c[i as usize]).collect(); - } - w_colors.put(base + (first * 12) as u64, bytemuck::cast_slice(&c)); - } - split.upload += t2.elapsed().as_secs_f64() * 1e3; - let rel = first - edit.pool_first; - if pool_tris.len() < rel + t.len() { - pool_tris.resize(rel + t.len(), [glam::Vec3A::ZERO; 3]); - } - pool_tris[rel..rel + t.len()].copy_from_slice(&t); - if emits { - // Its emitter entries, in the power-share part of the CDF (path code 0). Rewritten where - // it was (a mesh that moves every frame: flying pieces), it takes back the entries it had - // there (`release` left them at area 0), so the CDF does not grow frame by frame; those - // in the light tree stay out (their triangles now carry code 0). - let range = first as u32..(first + t.len()) as u32; - let mut free: Vec = if same.is_some() { tables.emitter_tris.iter().enumerate().filter(|(_, e)| !e.in_tree && range.contains(&e.tri)).map(|(i, _)| i).rev().collect() } else { vec![] }; - for (j, tri) in t.iter().enumerate() { - let [ta, tb, tc] = *tri; - let area = 0.5 * (tb - ta).cross(tc - ta).length(); - if area > 0.0 { - let e = crate::EmitterTri { tri: (first + j) as u32, mat, obj: object as u32, area, c: ((ta + tb + tc) / 3.0).to_array(), moves: false, nominal: false, in_tree: false }; - match free.pop() { - Some(i) => tables.emitter_tris[i] = e, - None => tables.emitter_tris.push(e), - } - } - } - tables.retained.emitter_mesh.insert(id, k); - tables.emissive.insert(mat); - emitters_changed = true; - } - let _ = im; - new_meshes.push(k); - fresh.insert(key, k); - tables.retained.own_mesh.insert(key, k); - k - } - }; - let im = meshes[mesh as usize]; - // It draws `mesh` now (and no longer the one it drew, if another). - *edit.users.entry(mesh).or_default() += 1; - if let Some(k) = edit.mesh_of.insert(id, mesh) { - drop_mesh_user(k, edit, tables, meshes); - } - if let Some(cap) = capacity { - // An instanced mesh added in place: `cap` records (their TLAS instances `inst_base` + them) and - // object entries, contiguous, as the build places one (`update_instanced` then rewrites them). - let empty = crate::world::InstanceTable::default(); - let t = world.instanced.get(&id).unwrap_or(&empty); - if t.colors.is_some() && features & gpu::F_TINT == 0 { - return Err("the scene's first instance colours (kernel feature)".into()); - } - let placed: Vec<(glam::Mat4, bool)> = (0..cap).map(|i| scene::instance_world(m, t, i)).collect(); - if placed.iter().any(|(wm, hidden)| !hidden && wm.determinant() < 0.0) { - return Err("a mirrored instance (the build flattens them)".into()); - } - if !tables.retained.objects.contains_key(&id) { - edit.objects.give(object, 1); - } - let r0 = match edit.records.take(cap) { - Some(r) => r, - None if match &mut top { gpu::Top::Hw(accel) => grow.records(&mut edit.records, cap, &mut up.instances, accel), gpu::Top::Sw(_) => false } => { - edit.grows += 1; - edit.rebind = true; - edit.records.take(cap).ok_or("edit room: records")? - } - None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: records (software traversal's does not grow)" } else { "edit room: records (particles, or the device's limits)" }.into()), - }; - let o0 = match edit.objects.take(cap) { - Some(k) => k, - None if grow.objects(&mut edit.objects, cap, &mut up.objects, tables.stride) => { - edit.grows += 1; - edit.rebind = true; - edit.objects.take(cap).ok_or("edit room: objects")? - } - None => return Err("edit room: objects (particles, or the device's limits)".into()), - }; - let (st, size) = (tables.stride, std::mem::size_of::()); - if instances.len() < r0 + cap { - let filler = scene::Instance { mesh, world: glam::Mat4::IDENTITY, object: 0, cast_shadow: false, name: String::new(), sub: None, hidden: true }; - instances.resize(r0 + cap, filler); - } - if gpu_instances.len() < r0 + cap { - gpu_instances.resize(r0 + cap, scene::GpuInstance::default()); - } - if tables.objects.len() < (o0 + cap) * st { - tables.objects.resize((o0 + cap) * st, [0.0; 4]); - } - for (i, &(wm, hidden)) in placed.iter().enumerate() { - let inst = scene::Instance { mesh, world: wm, object: (o0 + i) as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: Some(i as u32), hidden }; - let rec = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); - w_records.put(((r0 + i) * size) as u64, bytemuck::bytes_of(&rec)); - gpu_instances[r0 + i] = rec; - top.place(inst_base + r0 + i, mesh, wm, gpu::INSTANCE_BIT | (r0 + i) as u32, o.cast_shadow, hidden); - let (lo, hi) = im.placed_bounds(wm); - let entry = scene::object_row(wm.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, t.custom(i, o.custom), t.color(i)); - w_objects.put(((o0 + i) * st * 16) as u64, bytemuck::cast_slice(&entry[..st])); - tables.objects[(o0 + i) * st..(o0 + i + 1) * st].copy_from_slice(&entry[..st]); - instances[r0 + i] = inst; - } - tables.retained.instanced.insert(id, scene::InstancedPlace { first: r0 as u32, capacity: cap as u32, object: o0 as u32, procedural: false, drawn: t.count }); - tables.retained.objects.insert(id, o0 as u32); - edit.instanced.insert(id, (r0, cap, o0)); - continue; - } - if emits { - placement = glam::Mat4::IDENTITY; - } - let inst = scene::Instance { mesh, world: placement, object: object as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: None, hidden: false }; - let gpu = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); - let (lo, hi) = im.placed_bounds(placement); - let entry = scene::object_row(m.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, o.custom, [1.0; 3]); - let record = at.map(|(_, r)| r); - match record.filter(|&r| r < edit.inst_at_load) { - Some(r) => instances[r] = inst, - None => { - edit.added.insert(id, Picked::Mesh(inst)); - } - } - (record, gpu, Placed::Mesh(mesh), entry) - } - _ => return Err("not an object with its own BLAS".into()), - }; - // Its record, TLAS instance and object entry: where it had them, or new ones from the room. - let (slot, record) = match (at, record) { - (Some((slot, _)), Some(r)) => (slot, r), - _ => { - let r = match edit.records.take(1) { - Some(r) => r, - None if match &mut top { gpu::Top::Hw(accel) => grow.records(&mut edit.records, 1, &mut up.instances, accel), gpu::Top::Sw(_) => false } => { - edit.grows += 1; - edit.rebind = true; - edit.records.take(1).ok_or("edit room: records")? - } - None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: records (software traversal's does not grow)" } else { "edit room: records (particles, or the device's limits)" }.into()), - }; - let v = (inst_base + r, r); - edit.placed.insert(id, v); - v - } - }; - w_records.put((record * std::mem::size_of::()) as u64, bytemuck::bytes_of(&gpu)); - if record >= gpu_instances.len() { - gpu_instances.resize(record + 1, scene::GpuInstance::default()); - } - gpu_instances[record] = gpu; - let custom = if matches!(o.kind, Kind::Procedural { .. }) { record as u32 } else { gpu::INSTANCE_BIT | record as u32 }; - match blas { - Placed::Proc(blas) => top.place_blas(slot, &blas, placement, custom, mask(o.cast_shadow)), - Placed::Mesh(k) => top.place(slot, k, placement, custom, o.cast_shadow, false), - } - let st = tables.stride; - w_objects.put((object * st * 16) as u64, bytemuck::cast_slice(&entry[..st])); - let at = object * st; - if at >= tables.objects.len() { - tables.objects.resize(at + st, [0.0; 4]); - } - tables.objects[at..at + st].copy_from_slice(&entry[..st]); - tables.retained.objects.insert(id, object as u32); - } - // The gathered writes, merged, before the builds that read them. - let tw = Instant::now(); - w_cdf.flush(q, &up.cdf); - w_verts.flush(q, &up.verts); - w_attrs.flush(q, &up.attrs); - if let gpu::Top::Hw(accel) = &top { - w_blas.flush(q, &accel.verts); - } - w_colors.flush(q, &up.prev_verts); - w_records.flush(q, &up.instances); - w_objects.flush(q, &up.objects); - split.upload += tw.elapsed().as_secs_f64() * 1e3; - let t3 = Instant::now(); - new_meshes.sort_unstable(); - new_meshes.dedup(); - // The new meshes' and procedural BLASes and the TLAS in one build (`Gpu::build_meshes`). - match &mut top { - gpu::Top::Hw(accel) => g.build_edit(accel, &new_meshes, &pending_procs, &mut e, true), - // The trees are written; the meshes this edit freed give theirs back; the top tree over the units as they - // are now (one that cannot be made: the scene is rebuilt). - gpu::Top::Sw(units) => { - for &k in &edit.dead_meshes { - if let Some(old) = units.drop_tree(k) { - g.fill_tree(&up.bg_parts.nodes, old); - } - } - g.retop(units, &up.bg_parts.nodes)?; - } - } - split.blas += t3.elapsed().as_secs_f64() * 1e3; - // THREERT_PROFILE_BLAS=1: the BLAS / TLAS builds' GPU time (the queue drained before and after; frame log - // `blas`), as for the dynamic rebuilds. - let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); - if profile_blas { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - } - let t = Instant::now(); - q.submit([e.finish()]); - g.keep_proc_blases(fresh_blases); - // Buffers that grew: the scene bind group over them. - if std::mem::take(&mut edit.rebind) { - g.rebind_scene(up); - } - if profile_blas { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - self.gpu_blas_ms += t.elapsed().as_secs_f32() * 1000.0; - } - // The uniform tables: entries the edits placed past what a uniform buffer holds read as its last one. - if g.uniform_tables && !edit.uniform_said { - let notes = gpu::uniform_overflow([0, (edit.objects.end * tables.stride) as u64, edit.records.end as u64, 0]); - for note in ¬es { - eprintln!("threert: {note}"); - } - edit.uniform_said = !notes.is_empty(); - } - split.objects = ch.added.len() + ch.removed.len(); - self.edit_split.add(&split); - if emitters_changed { - self.write_emitters(); - } - Ok(()) - } - - pub fn rebuild(&mut self, world: &World) -> Result<()> { - let (s, pp) = load_scene(&self.g, world, &self.assets, &self.presets, self.w, self.h, &self.targets, &self.passes)?; - self.apply_post(&pp); - self.triangles = s.triangles; - self.scene_name = s.name.clone(); - // The scene's camera changed (a new lens rebuilds, and a move in the same commit comes with it): the view takes it. - // Otherwise the view stays where it is. - if s.scene_camera != self.sc.scene_camera { - self.cam = s.scene_camera; - } - // What the grid programs had simulated goes with the grids that are still the same. - self.g.carry_grids(&self.sc.up, &s.up); - self.sc = s; - self.spark_lights = self.sc.tables.emitter_tris.iter().any(|e| e.nominal); - self.inst_moving.clear(); - self.prev_span = None; - self.pose_span = None; - self.emitters_moved = false; - self.lit_changed = self.frame.max(1); - Ok(()) - } - - /// Rebuilds the tonemap LUT and bloom / vignette / fringe / grain settings when the scene's post preset changed. - fn apply_post(&mut self, pp: &crate::desc::PostPreset) { - let key = format!("{pp:?}"); - if key == self.post_key { - return; - } - let lut = tonemap_lut(pp); - self.g.queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &self.targets.lut_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - bytemuck::cast_slice(&lut), - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT * 8), rows_per_image: Some(LUT) }, - wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, - ); - self.bloom = bloom_stages(pp, self.w, self.h); - self.vignette = pp.vignette; - // THREERT_NO_GRAIN=1: no film grain (a fresh random pattern every frame; comparisons between frames see it as error). - let grain = if std::env::var("THREERT_NO_GRAIN").is_ok_and(|v| v == "1") { 0.0 } else { pp.film_grain }; - // THREERT_MOTION_BLUR=1: UE's default motion blur whatever the preset says; =amount,max,targetFps: those. - let mb = match std::env::var("THREERT_MOTION_BLUR").ok().as_deref() { - Some("1") => crate::desc::MotionBlur::default(), - Some(v) => match v.split(',').map(|x| x.trim().parse::()).collect::, _>>().as_deref() { - Ok([amount, max, target_fps]) => crate::desc::MotionBlur { amount: *amount, max: *max, target_fps: *target_fps }, - _ => pp.motion_blur.clone(), - }, - None => pp.motion_blur.clone(), - }; - self.post = [pp.chromatic_aberration, grain, pp.bloom.threshold, mb.max / 100.0 * self.w as f32]; - self.blur = (if mb.max > 0.0 { mb.amount.max(0.0) } else { 0.0 }, mb.target_fps); - self.post_key = key; - } - - /// Switches to a different world: camera from it, history cleared. - pub fn open(&mut self, world: &World, assets: &Path) -> Result<()> { - self.assets = assets.to_path_buf(); - self.rebuild(world)?; - if let Some(c) = &self.cache { - let mut e = self.g.device.create_command_encoder(&Default::default()); - c.bufs.clear(&mut e); - self.g.queue.submit([e.finish()]); - } - self.cam = self.sc.scene_camera; - self.prev_cam = self.cam; - self.reset = true; - Ok(()) - } - - /// The view is a display's physical pixels, `k` a point: dynamic resolution, from the points' resolution (the rays - /// a view in points starts with) down to `MIN_SCALE` of it. A fixed scale is set after it (`set_scale`). - pub fn set_display_scale(&mut self, k: f32) { - let k = k.max(1.0); - self.display_scale = k; - self.min_scale = (MIN_SCALE / k).max(SCALE_FLOOR); - self.render_scale = 1.0 / k; - self.dynamic_res = true; - } - - /// The scene's lighting is changing: something moved or was edited in the last 45 frames, or the scene has spark - /// lights (light-sampled particles: they move every frame, and no edit says so). - fn live_lighting(&self) -> bool { - self.spark_lights || (self.lit_changed > 0 && self.frame.wrapping_sub(self.lit_changed) < 45) - } - - /// Fixed render scale (`s` in SCALE_FLOOR ..= 1), or dynamic resolution (`s` = 0). - pub fn set_scale(&mut self, s: f32) { - self.dynamic_res = s <= 0.0; - if s > 0.0 { - self.render_scale = s.clamp(SCALE_FLOOR, 1.0); - } - } - - pub fn reset_history(&mut self) { - self.reset = true; - } - - /// Ray tracing on or off (see `rays`). The history starts over: the two modes light a pixel differently. - pub fn set_rays(&mut self, on: bool) { - if self.rays != on { - self.rays = on; - self.reset = true; - } - } - - pub fn rays(&self) -> bool { - self.rays - } - - pub fn set_camera(&mut self, mut c: Camera) { - if c.tan_half_fov <= 0.0 { - c.tan_half_fov = self.sc.tan_half_fov; - } - self.cam = c; - } - - /// Renders one frame with `spp` samples per pixel; returns the command buffers to submit after the samples. - /// Path-trace dispatches are submitted inside (each needs its own uniform contents). - /// Traced resolution at the current render scale. - pub fn internal_size(&self) -> (u32, u32) { - let s = self.render_scale.clamp(SCALE_FLOOR, 1.0); - (((self.w as f32 * s).round() as u32).clamp(8, self.w), ((self.h as f32 * s).round() as u32).clamp(8, self.h)) - } - - /// Dynamic resolution (UE r.DynamicRes): twice a second, move the render scale toward the frame budget (traced - /// pixels ~ GPU time), within [`min_scale`, 1], in damped steps with hysteresis so it does not oscillate. - /// Two frames in a row well over the frame time (x 1.25) act at once (UE - /// r.DynamicRes.MaxConsecutiveOverbudgetGPUFrameCount = 2): turning from a light view at a raised scale toward a - /// heavy one otherwise cost up to 30 frames at 35-48 ms (the 3 x 3 district's drive, its far end). Just over the - /// frame time waits for the regular check, as before. After a change, only frames rendered at the new scale count: - /// GPU time lags the scale by the frames in flight, and acting on the old scale's time overshot to the floor. - /// - /// "Twice a second" and "the first 2 s" are counted in frames (every 30th, the first 120), which is the same thing - /// at 60 frames a second. Where frames are much slower (`govern_by_clock`: a phone's GPU, on which a scene opens - /// at its full scale at 6 frames a second) they are also read off the clock: counted in frames alone the first - /// check that may act at once came after 20 s and the regular one every 5 s, and a game opened as a slide show - /// for half a minute (Adreno 740, OpenStrikeRT: 25 s to its frame rate; 3 s with the clock). - pub fn govern_resolution(&mut self, fps: u32) { - if !self.dynamic_res || self.gpu_ms <= 0.0 { - return; - } - if (self.gpu_frame.wrapping_sub(self.over_budget.1) as i32) < 0 { - return; - } - let clock = govern_by_clock(); - // Not in the first 2 s: those frames run while caches fill (Kabukichō's first frames at 0.7 take ~47 ms), and - // the regular check settles the start as before. - let warm = self.frame >= 120 || (clock && self.born.elapsed().as_secs_f32() >= 2.0); - self.over_budget.0 = if warm && self.gpu_ms > 1250.0 / fps as f32 && self.render_scale > self.min_scale { self.over_budget.0 + 1 } else { 0 }; - let urgent = self.over_budget.0 >= 2; - // (By the clock only when 30 frames take longer than 0.75 s: at 40 frames a second and above the frame count - // comes first, as it always did.) - let due = self.frame % 30 == 1 || (clock && self.governed.elapsed().as_secs_f32() >= 0.75); - if !due && !urgent { - return; - } - self.governed = Instant::now(); - self.over_budget.0 = 0; - let before = self.render_scale; - let budget = 1000.0 / fps as f32 * 0.9; - let target = (self.render_scale * (budget / self.gpu_ms).sqrt()).clamp(self.min_scale, 1.0); - // The hysteresis (0.03) does not hold the scale just short of either end: 0.525 with the floor as the target - // kept the district's return drive at 21-22 ms. - let to_end = (target == 1.0 && self.render_scale < 1.0 && self.gpu_ms < budget * 0.8) || (target == self.min_scale && self.render_scale > self.min_scale); - // (The hysteresis, the steps and the grid of scales are a view in points'; on a display of k pixels a point the - // same share of the rays is 1 / k of the scale. Left absolute, a Retina view held 0.325 at 17.3 ms: its target, - // 0.30, was inside the hysteresis.) - let k = self.display_scale; - if (target - self.render_scale).abs() > 0.03 / k || to_end { - // Down fast when far over budget (a heavy scene just opened), up slowly; straight to the target when urgent. - let step = if urgent { target - self.render_scale } else { (target - self.render_scale).clamp(if self.gpu_ms > budget * 1.5 { -0.3 } else { -0.15 } / k, 0.1 / k) }; - self.render_scale = ((self.render_scale + step) * 40.0 * k).round() / (40.0 * k); - } - if self.render_scale != before { - // `frame` is already the next frame's index (render advanced it): the first one at the new scale. - self.over_budget.1 = self.frame; - } - } - - /// Light LOD: the emitter CDF weighted by distance to the camera (`emitter_lod`) and the emitter cull with its view - /// cone (`EmitterCull`), rebuilt when the camera is 15 m from where it was last built or has turned LIGHT_LOD_TURN - /// from that direction (the kernel's emitter pdfs read the same reference from the parameters). - fn update_light_lod(&mut self) { - let d0 = LIGHT_LOD_D0; - let (w, h) = (self.w, self.h); - let b = &mut self.sc.base; - // The light tree weighs emitters by their distance to each shading point itself: no light LOD, no cull. - if d0 <= 0.0 || self.sc.tables.emitter_tris.is_empty() || b.ltree[3] > 0.5 { - return; - } - let r = glam::Vec3::new(b.light_lod[0], b.light_lod[1], b.light_lod[2]); - let dir = glam::Vec3::new(b.lod_view[0], b.lod_view[1], b.lod_view[2]); - let turned = b.emit_power[2] > 0.0 && dir.dot(self.cam.fwd) < LIGHT_LOD_TURN.to_radians().cos(); - if b.light_lod[3] > 0.0 && r.distance(self.cam.pos) < 15.0 && !turned { - return; - } - b.light_lod = [self.cam.pos.x, self.cam.pos.y, self.cam.pos.z, d0]; - b.emit_power[1] = EMITTER_CULL; - b.emit_power[2] = EMITTER_CULL * EMITTER_CULL_VIEW; - // The frustum's half-diagonal: tan = tan(fov / 2) x sqrt(1 + aspect^2). - let half = (self.cam.tan_half_fov * (1.0 + (w as f32 / h as f32).powi(2)).sqrt()).atan() + EMITTER_CULL_MARGIN.to_radians(); - b.lod_view = [self.cam.fwd.x, self.cam.fwd.y, self.cam.fwd.z, half.min(std::f32::consts::PI).cos()]; - // Power edits held back for the emitter worker land first (its job in flight was built for the old reference: - // dropped), so the CDF built here has the powers the objects hold and the reference the kernel reads. - self.land_powers_now(); - let (e, b) = (&self.sc.tables, &mut self.sc.base); - let (em, total, power) = crate::emitter_cdf(&e.emitter_tris, &e.materials, &e.objects, e.stride, b.light_lod, crate::EmitterCull::of(b)); - self.g.queue.write_buffer(&self.sc.up.emitters, 0, bytemuck::cast_slice(&em)); - b.misc[1] = total; - b.emit_power[0] = power; - } - - pub fn render(&mut self, spp: u32, extra: impl FnOnce(&mut wgpu::CommandEncoder, &wgpu::Buffer)) { - // A dynamic sky's bake that finished since the last frame (time of day). - self.take_sky(); - self.take_emitters(); - let (w, h) = (self.w, self.h); - let (iw, ih) = self.internal_size(); - let now = Instant::now(); - // Lockstep (benchmarks) renders at a fixed frame time, whatever the wall clock says. - self.frame_dt = self.fixed_dt.unwrap_or_else(|| now.duration_since(self.last_frame).as_secs_f32()); - self.last_frame = now; - let moved = self.cam != self.prev_cam; - self.update_light_lod(); - // A camera cut (a jump no camera makes in one frame: 2 m or 30 degrees) is not motion: no blur (UE: bCameraCut). - let cut = (self.cam.pos - self.prev_cam.pos).length() > 2.0 || self.cam.fwd.dot(self.prev_cam.fwd) < 0.866; - self.cold_frames = if self.reset || cut { 0 } else { self.cold_frames.saturating_add(1) }; - // (Objects moved this frame: re-posed triangles or moved instances, e.g. walkers in a shared pose.) - let blur = !self.motion_blur_off && self.blur.0 > 0.0 && (moved || self.reposed || self.pose_reposed || self.inst_moved) && !cut; - // Camera motion is reprojected exactly (disocclusions are rejected per pixel), so history survives motion; - // the cap only bounds smearing of view-dependent shading (glossy reflections). - // Diffuse lighting reprojects exactly with the camera; view-dependent specular is handled per pixel (temporal pass). - // The most frames a pixel's history holds: HISTORY_LIVE while the scene's lighting changes, HISTORY_FRAMES once - // it has settled. Within it the length is each pixel's own (live.wgsl `dtemporal`: less where it moves, anti-lag - // where its lighting changes, restarted where its surface is new). No edit cuts it: until 2026-11-12 a - // lighting edit left every pixel 16 frames and a geometry edit 4, the whole frame for a change in one corner. - let cap = if self.reset { 1.0 } else if self.live_lighting() { HISTORY_LIVE } else { HISTORY_FRAMES }; - self.history_len = if self.reset { 1.0 } else { (self.history_len + 1.0).min(cap) }; - let cam = self.cam; - let g = &self.g; - // At most two frames in flight: beyond that the CPU waits, so latency stays bounded when GPU-bound. - // Wait for the oldest frame only (waiting for all work would leave the GPU idle while this frame records). - let t_wait = Instant::now(); - while self.inflight.len() >= 2 { - let oldest = self.inflight.pop_front().unwrap(); - let _ = g.device.poll(wgpu::PollType::Wait { submission_index: Some(oldest), timeout: None }); - } - let waited = t_wait.elapsed().as_secs_f32() * 1000.0; - // The loop guard's count, read back since an earlier frame. - let mut guard_free = false; - if let Some((read, state)) = &self.guard_read { - if state.load(Ordering::Acquire) == 2 { - let n = u32::from_le_bytes(read.slice(..).get_mapped_range().unwrap()[..4].try_into().expect("four bytes")); - read.unmap(); - state.store(0, Ordering::Release); - if self.guard_trips != Some(n) { - eprintln!("live: the loop guard has stopped {n} of the kernel's invocations: frames with wrong pixels (a traversal that does not end on this scene's data)"); - } - self.guard_trips = Some(n); - } - guard_free = state.load(Ordering::Acquire) == 0; - } - // GPU time drives adaptive resolution / samples per frame: timestamps when available (no stall), else twice a - // second a frame timed in isolation (idle GPU -> submit -> wait). - let period = g.queue.get_timestamp_period(); - let timed = match &mut self.timer { - Some(t) => { - let (latest, slot) = t.harvest(period); - if let Some((ms, frame, trace)) = latest { - (self.gpu_ms, self.gpu_frame, self.gpu_trace_ms) = (ms, frame, trace); - } - if let Some(k) = slot { - t.frames[k] = self.frame; - t.spps[k] = spp.max(1); - } - slot - } - None => None, - }; - let measure = self.timer.is_none() && self.frame % 30 == 0; - if measure { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - } - // Nothing moved since the last in-place update: its span's previous-frame vertices become the current ones. - if let (false, Some(span)) = (self.reposed, self.prev_span) { - self.keep_prev(span); - self.prev_span = None; - } - self.reposed = false; - if let (false, Some(span)) = (self.pose_reposed, self.pose_span) { - self.keep_prev(span); - self.pose_span = None; - } - self.pose_reposed = false; - // Likewise the instances the last update moved: their previous-frame rows become the current ones. - if !self.inst_moved && !self.inst_moving.is_empty() { - let size = std::mem::size_of::() as u64; - for i in std::mem::take(&mut self.inst_moving) { - let r = &mut self.sc.gpu_instances[i as usize]; - r.p = r.m; - self.g.queue.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(r)); - } - } - self.inst_moved = false; - let g = &self.g; - let t_submit = Instant::now(); - let pipeline = g.pipeline(&self.sc.up.kernel, true); - // (No media pass while every medium is hidden: a pool of idle fireballs costs the frame nothing.) - let media = self.sc.up.kernel.features & gpu::F_MEDIA != 0 && self.sc.up.media_hidden.borrow().iter().any(|h| !h); - let media_train = (media && self.cache.is_some()).then(|| g.media_pipeline(&self.sc.up.kernel, "media_train")); - let media_composite = media.then(|| g.media_pipeline(&self.sc.up.kernel, "media_composite")); - // This frame's sub-pixel jitter (Halton 2, 3), every camera ray's and the raster visibility pass's: 8 phases at - // full resolution, as many more as an output pixel has fewer traced ones (8 / scale^2: the upscaler needs each - // output pixel visited), in the 12 bits a side the samples carry it in. - let phases = ((8.0 * (w as f32 / iw as f32) * (h as f32 / ih as f32)).round() as u32).clamp(8, 512); - let quant = |v: f32| (v * 4095.0).round() / 4095.0; - let jitter = (quant(halton(self.frame % phases + 1, 2)), quant(halton(self.frame % phases + 1, 3))); - for k in 0..spp.max(1) { - let mut p = self.sc.base; - // Software traversal's units as edits left them: the top tree's root and how many it holds (trace_units.wgsl). - if let Some(u) = &self.sc.up.units { - (p.motion[2], p.motion[3]) = (f32::from_bits(u.layout.top_first), f32::from_bits(u.count)); - } - p.raster = [(self.raster.is_some() && !self.sc.instanced) as u32 as f32, jitter.0, jitter.1, 0.0]; - p.raster[3] = if self.rays { 0.0 } else { 1.0 }; - if (!self.sc.dynamic.1.is_empty() || !self.sc.tables.retained.instance_of.is_empty() || !self.sc.tables.retained.rigid.is_empty()) { - // The kernel finds the first dynamic triangle in Params::group_first (group 4), a u32. - p.motion[0] = 1.0; - } - if self.cache.is_some() { - // Light-sampled particles (sparks: Particles emitterArea) move their light every frame: the cache and - // the temporal pass keep the short history of a lighting edit while the scene has them. - let lit = self.live_lighting(); - let cold = if self.cold_frames >= COLD_FALLBACK_FRAMES { 1 } else { 0 }; - p.cache = [1.0, CACHE_SAMPLES, if lit { CACHE_FRAMES_LIT } else { CACHE_FRAMES }, (cache_flags() | cold) as f32]; - } - p.time[0] = shader_time(self.time); - p.cam_pos = [cam.pos.x, cam.pos.y, cam.pos.z, cam.tan_half_fov]; - p.cam_fwd = [cam.fwd.x, cam.fwd.y, cam.fwd.z, self.aspect]; - p.cam_right = [cam.right.x, cam.right.y, cam.right.z, 0.0]; - p.cam_up = [cam.up.x, cam.up.y, cam.up.z, 1.0]; - p.size = [iw, ih, self.frame.wrapping_mul(16).wrapping_add(k), crate::rng_seed()]; - p.counts2[2] = if ih < h { h } else { 0 }; - p.misc[3] = k as f32; - p.counts[2] = p.counts[2].min(self.bounces); - p.counts[3] = p.counts[3].min(2); - g.queue.write_buffer(&self.sc.up.params, 0, bytemuck::bytes_of(&p)); - let mut e = g.device.create_command_encoder(&Default::default()); - // Particles: placed for this frame's time and camera, then the dynamic BLASes rebuilt (before the raster and - // the trace). - // The grid programs (density grids changed on the GPU), before anything reads the grids. - if k == 0 && gpu::Gpu::has_grid_sim(&self.sc.up) { - let sources = std::mem::take(&mut self.grid_sources); - g.run_grids(&self.sc.up, shader_time(self.time), self.frame_dt, &sources, &mut e, self.profile.as_ref().map(|p| (&p.grid.0, 0))); - if let Some(p) = &self.profile { - e.resolve_query_set(&p.grid.0, 0..4, &p.grid.1, 0); - e.copy_buffer_to_buffer(&p.grid.1, 0, &p.grid.2, 0, 32); - } - } - // Stateful GPU particles first (their emissions, the game's spawns): with stateless ones the pass below - // rebuilds the dynamic BLASes for both, else this one does. - if let (Some(_), 0) = (&self.sc.up.gpu_particles, k) { - let n = self.passes.pipes.len() as u32 + 2; - let stateless = self.sc.up.particles.is_some(); - let (pass_ts, blas_ts) = match (&self.profile, &self.timer, timed, stateless) { - (Some(p), _, _, false) => (Some((&p.queries, n * 2)), Some((&p.queries, n * 2 + 2))), - (None, Some(t), Some(slot), false) => (Some((&t.queries, slot as u32 * 8 + 6)), None), - _ => (None, None), - }; - let spawns = std::mem::take(&mut self.particle_spawns); - g.run_gpu_particles(&self.sc.up, cam.pos.to_array(), cam.fwd.to_array(), shader_time(self.time), self.frame_dt, &spawns, !stateless, &mut e, pass_ts, blas_ts); - } - if let (Some(_), 0) = (&self.sc.up.particles, k) { - let b = &self.sc.base; - let n = self.passes.pipes.len() as u32 + 2; - let pass_ts = match (&self.profile, &self.timer, timed) { - (Some(p), _, _) => Some((&p.queries, n * 2)), - (None, Some(t), Some(slot)) => Some((&t.queries, slot as u32 * 8 + 6)), - _ => None, - }; - let blas_ts = self.profile.as_ref().map(|p| (&p.queries, n * 2 + 2)); - g.run_particles(&self.sc.up, cam.pos.to_array(), cam.fwd.to_array(), shader_time(self.time), self.frame_dt, b.time[2], b.time[1], &mut e, pass_ts, blas_ts); - } - // (The merged layout and the uniform tables: the tables as this frame's edits and the particle passes above - // left them, where the kernel reads them; merged.rs.) - if k == 0 { - g.sync_tables(&self.sc.up, &mut e); - } - if let (Some(r), 0, false) = (&self.raster, k, self.sc.instanced) { - let tw = self.timer.as_ref().zip(timed).map(|(t, slot)| wgpu::RenderPassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8 + 4), end_of_pass_write_index: Some(slot as u32 * 8 + 5) }); - r.record(g, &mut e, &self.sc.up.verts, self.sc.tris.len() as u32, &cam, self.aspect, (iw, ih), jitter, tw); - } - { - let tw = match (&self.profile, &self.timer, timed) { - (Some(p), _, _) if k == 0 => Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(0), end_of_pass_write_index: Some(1) }), - (None, Some(t), Some(slot)) if k == 0 => Some(wgpu::ComputePassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8), end_of_pass_write_index: Some(slot as u32 * 8 + 1) }), - _ => None, - }; - let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("live trace"), timestamp_writes: tw }); - pass.set_pipeline(&pipeline); - pass.set_bind_group(0, &self.sc.up.bg, &[]); - pass.set_bind_group(1, &self.kernel_bg, &[]); - pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); - // Participating media: their volume voxels trained after the trace (pathtrace.wgsl media_train), and - // camera rays through media once the frame's samples are traced (media_composite). In the trace's pass - // and timing; profiled, the frame's last sample's media passes are a pass of their own (`media`). - let last = k + 1 == spp.max(1); - if let (Some(p), true, true) = (&self.profile, last, media_composite.is_some()) { - drop(pass); - pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("media"), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.grid.0, beginning_of_pass_write_index: Some(4), end_of_pass_write_index: Some(5) }) }); - pass.set_bind_group(0, &self.sc.up.bg, &[]); - pass.set_bind_group(1, &self.kernel_bg, &[]); - } - if let (Some(mt), 0) = (&media_train, k) { - pass.set_pipeline(mt); - pass.dispatch_workgroups(MEDIA_TRAIN_POINTS.div_ceil(64), 1, 1); - } - if let (Some(mc), true) = (&media_composite, last) { - pass.set_pipeline(mc); - pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); - } - } - if let (Some(p), true, true) = (&self.profile, k + 1 == spp.max(1), media_composite.is_some()) { - e.resolve_query_set(&p.grid.0, 4..6, &p.grid.1, wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT); - e.copy_buffer_to_buffer(&p.grid.1, wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, &p.grid.2, 32, 16); - } - // Each pair is resolved in the command buffer that wrote it (Metal reads zeros otherwise). - if let (None, Some(t), Some(slot), 0) = (&self.profile, &self.timer, timed, k) { - let q = slot as u32 * 8; - let at = slot as u64 * 4 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; - e.resolve_query_set(&t.queries, q..q + 2, &t.resolve, at); - e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, 0, 16); - // Raster and particle pairs (pairs 2 and 3); zeros when the pass did not run. - for (pair, ran) in [(2u32, self.raster.is_some() && !self.sc.instanced), (3, self.sc.up.particles.is_some() || self.sc.up.gpu_particles.is_some())] { - let at = at + pair as u64 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; - if ran { - e.resolve_query_set(&t.queries, q + pair * 2..q + pair * 2 + 2, &t.resolve, at); - } else { - e.clear_buffer(&t.resolve, at, Some(16)); - } - e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, pair as u64 * 16, 16); - } - } - g.queue.submit([e.finish()]); - } - let (r, t) = self.bloom; - let lp = LiveParams { - cur: cam.uniform(self.aspect), - prev: self.prev_cam.uniform(self.aspect), - size: [w, h, self.frame, self.reset as u32], - misc: [self.sc.exposure, cap, self.vignette, (self.bloom.1.iter().sum::() > 0.0) as u32 as f32], - // misc2.z: motion blur, this frame's motion (pixels) -> blur length. UE (PostProcessMotionBlur.cpp, - // UpdateMotionBlurTimeScale) blurs over +- amount / 2 x the motion x (1 / target FPS) / frame time, the - // actual frame time without a target. 0 while nothing moves (the pass then costs nothing). - misc2: [1.0, SAMPLE_CEILING, if !blur { 0.0 } else if self.blur.1 > 0.0 { self.blur.0 / (self.blur.1 * self.frame_dt.clamp(1.0 / 240.0, 0.25)) } else { self.blur.0 }, spp.max(1) as f32], - mode: [self.view_mode, 0, self.live_lighting() as u32, 0], - post: self.post, - ae: self.sc.exposure_settings.ae, - ae2: self.sc.exposure_settings.ae2, - ae3: [self.sc.exposure_settings.speed[0], self.sc.exposure_settings.speed[1], self.frame_dt.clamp(1.0 / 240.0, 0.25), (self.reset || self.frame == 0 || exposure_snap()) as u32 as f32], - lx: self.sc.exposure_settings.lx, - isz: [iw, ih, self.prev_isz.0, self.prev_isz.1], - br: [r[0], r[1], r[2], r[3]], - br2: [r[4], r[5], 0.0, 0.0], - bt: [t[0], t[1], t[2], t[3]], - bt2: [t[4], t[5], 0.0, 0.0], - dof: dof_params(self.sc.dof.as_ref(), cam.tan_half_fov, w, h), - jit: [jitter.0, jitter.1, 0.0, 0.0], - }; - g.queue.write_buffer(&self.targets.params, 0, bytemuck::bytes_of(&lp)); - let bg = &self.sc.bgs[(self.frame % 2) as usize]; - // Translucent particles: drawn over the upscaled image right after `upscale` (in sb when depth of field / motion - // blur follow, else fa: they have no motion vectors, so they are no part of the colour history; UE draws its - // translucency after the upscaler the same way), then their volume voxels trained. - // (Nothing to draw while every translucent particle system is hidden in place.) - let tl = self.sc.up.translucent.as_ref().filter(|_| g.translucent_particles_live(&self.sc.up)).map(|t| { - let pipes = g.translucent_pipelines(&self.sc.up.kernel, true); - let target = if lp.misc2[2] > 0.0 || lp.dof[0] > 0.0 { &self.targets.sb.buffer } else { &self.targets.fa.buffer }; - // Below full resolution the particles are drawn per traced pixel, then laid over the output (`translucent_up`). - let low = (iw, ih) != (w, h); - let tp = gpu::TranslucentParams { size: [w, h, t.first, 1], view: [iw, ih, gpu::VOLUME_TRAIN_CAP, low as u32], misc: [VOLUME_TRAIN_SHARE, translucent_hidden(self.frame) as u32 as f32, 0.0, 0.0] }; - g.queue.write_buffer(&t.params, 0, bytemuck::bytes_of(&tp)); - (pipes, g.translucent_bind_group(t, target), &t.count) - }); - // The pass (train = false) or the volume training (train = true). - let tl_dispatch = |pass: &mut wgpu::ComputePass, train: bool| { - if let Some((pipes, tbg, _)) = &tl { - pass.set_bind_group(0, &self.sc.up.bg, &[]); - pass.set_bind_group(1, &self.kernel_bg, &[]); - pass.set_bind_group(2, tbg, &[]); - if train { - pass.set_pipeline(&pipes.train); - pass.dispatch_workgroups(gpu::VOLUME_TRAIN_CAP / 64, 1, 1); - } else if (iw, ih) != (w, h) { - pass.set_pipeline(&pipes.draw); - pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); - pass.set_pipeline(&pipes.up); - pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1); - } else { - pass.set_pipeline(&pipes.draw); - pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1); - } - } - }; - let mut e = g.device.create_command_encoder(&Default::default()); - if let Some((_, _, count)) = &tl { - e.clear_buffer(count, 0, None); - } - if let Some(c) = &self.cache { - let n = self.passes.pipes.len() as u32 + 1; - let tw = self.profile.as_ref().map(|p| wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(n * 2), end_of_pass_write_index: Some(n * 2 + 1) }); - let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("cache resolve"), timestamp_writes: tw }); - pass.set_pipeline(&c.resolve); - pass.set_bind_group(0, &self.sc.up.bg, &[]); - pass.set_bind_group(1, &self.kernel_bg, &[]); - pass.dispatch_workgroups((gpu::CACHE_SIZE / 256) as u32, 1, 1); - } - if let Some(p) = &self.profile { - // One compute pass per stage, each timed. - for (k, (name, pipe)) in self.passes.pipes.iter().enumerate() { - let q = (k as u32 + 1) * 2; - let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(name), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(q), end_of_pass_write_index: Some(q + 1) }) }); - pass.set_pipeline(pipe); - pass.set_bind_group(0, bg, &[]); - let (gw, gh) = if *name == "exposure" { (8, 8) } else if *name == "dof_tiles" { (w.div_ceil(16), h.div_ceil(16)) } else if traced_pass(name) { (iw, ih) } else if name.starts_with("bloom") || name.starts_with("lum_") { (w.div_ceil(4), h.div_ceil(4)) } else { (w, h) }; - pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1); - drop(pass); - if *name == "upscale" && tl.is_some() { - for (j, train) in [(0u32, false), (1, true)] { - let q = (self.passes.pipes.len() as u32 + 4 + j) * 2; - let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(if train { "vtrain" } else { "translucent" }), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(q), end_of_pass_write_index: Some(q + 1) }) }); - tl_dispatch(&mut pass, train); - } - } - } - let n = self.passes.pipes.len() as u32 + 2 + self.profile_extra(); - e.resolve_query_set(&p.queries, 0..n * 2, &p.resolve, 0); - e.copy_buffer_to_buffer(&p.resolve, 0, &p.read, 0, p.read.size()); - } else { - let tw = self.timer.as_ref().zip(timed).map(|(t, slot)| wgpu::ComputePassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8 + 2), end_of_pass_write_index: Some(slot as u32 * 8 + 3) }); - let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("live post"), timestamp_writes: tw }); - for (name, pipe) in &self.passes.pipes { - pass.set_pipeline(pipe); - pass.set_bind_group(0, bg, &[]); - if *name == "exposure" { - pass.dispatch_workgroups(1, 1, 1); - continue; - } - let (gw, gh) = if *name == "dof_tiles" { (w.div_ceil(16), h.div_ceil(16)) } else if traced_pass(name) { (iw, ih) } else if name.starts_with("bloom") || name.starts_with("lum_") { (w.div_ceil(4), h.div_ceil(4)) } else { (w, h) }; - pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1); - if *name == "upscale" { - tl_dispatch(&mut pass, false); - tl_dispatch(&mut pass, true); - } - } - drop(pass); - if let (Some(t), Some(slot)) = (&self.timer, timed) { - let q = slot as u32 * 8 + 2; - let at = (slot as u64 * 4 + 1) * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; - e.resolve_query_set(&t.queries, q..q + 2, &t.resolve, at); - e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, 16, 16); - } - } - if let (Some((read, _)), Some(count), true) = (&self.guard_read, &self.targets.kernel.guard, guard_free) { - e.copy_buffer_to_buffer(&count.buffer, count.offset, read, 0, 4); - } - extra(&mut e, &self.targets.out); - let done = g.queue.submit([e.finish()]); - self.inflight.push_back(done); - self.cpu_wait_ms = waited; - if let (Some((read, state)), true) = (&self.guard_read, guard_free) { - let state = state.clone(); - state.store(1, Ordering::Release); - read.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { 2 } else { 0 }, Ordering::Release)); - } - if let (Some(t), Some(slot)) = (&self.timer, timed) { - let state = t.reads[slot].1.clone(); - state.store(1, Ordering::Release); - t.reads[slot].0.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { 2 } else { 0 }, Ordering::Release)); - } - // Without timestamps: GPU time ~ submit -> completion (the queue is idle at submit when the frame fits the budget). - if measure { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - (self.gpu_ms, self.gpu_frame) = (t_submit.elapsed().as_secs_f32() * 1000.0, self.frame); - } - let extra = self.profile_extra() as usize; - if let Some(p) = &mut self.profile { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - p.read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - let t: Vec = bytemuck::cast_slice::(&p.read.slice(..).get_mapped_range().unwrap()).to_vec(); - p.read.unmap(); - let period = g.queue.get_timestamp_period() as f64 * 1e-6; - // The particle, BLAS, translucency and volume training slots (the last four) are written only when the scene - // has particles / translucent ones. - let n = self.passes.pipes.len() + 2 + extra; - for (k, s) in p.sums.iter_mut().enumerate().take(n) { - *s += t[k * 2 + 1].saturating_sub(t[k * 2]) as f64 * period; - } - let (sim, media) = (gpu::Gpu::has_grid_sim(&self.sc.up), media_composite.is_some()); - if sim || media { - p.grid.2.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - let t: Vec = bytemuck::cast_slice::(&p.grid.2.slice(..).get_mapped_range().unwrap()).to_vec(); - p.grid.2.unmap(); - if sim { - p.grid_sum += (t[1].saturating_sub(t[0]) + t[3].saturating_sub(t[2])) as f64 * period; - } - if media { - p.media_sum += t[5].saturating_sub(t[4]) as f64 * period; - } - } - p.frames += 1; - if p.frames == 120 { - let names: Vec<&str> = std::iter::once("trace").chain(self.passes.pipes.iter().map(|(n, _)| *n)).chain(["cache", "particles", "blas", "translucent", "vtrain"]).collect(); - let mut rows: Vec = names.iter().zip(&p.sums).map(|(n, s)| format!("{n} {:.2}", s / p.frames as f64)).collect(); - rows.push(format!("gridsim {:.2}", p.grid_sum / p.frames as f64)); - rows.push(format!("media {:.2}", p.media_sum / p.frames as f64)); - rows.retain(|r| !r.ends_with(" 0.00")); - eprintln!("profile ms/frame: total {:.2} | {}", (p.sums.iter().sum::() + p.grid_sum + p.media_sum) / p.frames as f64, rows.join(", ")); - (p.grid_sum, p.media_sum) = (0.0, 0.0); - p.sums.iter_mut().for_each(|s| *s = 0.0); - p.frames = 0; - } - } - self.prev_cam = cam; - self.shown_spp = spp.max(1); - self.prev_isz = (iw, ih); - self.reset = false; - self.frame = self.frame.wrapping_add(1); - } - - #[cfg(not(target_arch = "wasm32"))] - /// The last frame's linear HDR radiance (nits, before exposure and post), RGB per output pixel. Waits for the GPU. - pub fn read_hdr(&self) -> Result> { - let d = &self.g.device; - let size = self.w as u64 * self.h as u64 * 16; - let read = d.create_buffer(&wgpu::BufferDescriptor { label: Some("hdr read"), size, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let mut e = d.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(&self.targets.fa.buffer, self.targets.fa.offset, &read, 0, size); - self.g.queue.submit([e.finish()]); - read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - d.poll(wgpu::PollType::wait_indefinitely())?; - let px = bytemuck::cast_slice::(&read.slice(..).get_mapped_range().unwrap()).iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); - Ok(px) - } - - #[cfg(not(target_arch = "wasm32"))] - /// The last frame's signals apart, linear RGB f32 per output pixel: diffuse (the composed colour less the rest, so - /// with the sun's direct light, which view mode 6 leaves out), specular (the specular denoiser's output: mode 7) and - /// emission (what the camera ray saw directly). They are the render resolution's (the frame as `compose` hands it to - /// the upscaler), each output pixel taking the traced sample under it; not with depth of field and motion blur both - /// on (the composed colour's buffer is theirs then). Waits for the GPU. - pub fn read_signals(&self) -> Result<[Vec; 3]> { - let d = &self.g.device; - let (w, h) = (self.w as usize, self.h as usize); - let (iw, ih) = (self.prev_isz.0 as usize, self.prev_isz.1 as usize); - let (n, tn) = ((w * h) as u64, (iw * ih) as u64); - let t = &self.targets; - let read = |buffer: &wgpu::Buffer, offset: u64, size: u64| -> Result> { - let r = d.create_buffer(&wgpu::BufferDescriptor { label: Some("signals read"), size, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let mut e = d.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(buffer, offset, &r, 0, size); - self.g.queue.submit([e.finish()]); - r.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - d.poll(wgpu::PollType::wait_indefinitely())?; - let v = r.slice(..).get_mapped_range()?.to_vec(); - Ok(v) - }; - let half = |b: u32, hi: bool| half::f16::from_bits(if hi { (b >> 16) as u16 } else { b as u16 }).to_f32(); - let composed = read(&t.fb.buffer, t.fb.offset, tn * 16)?; - let spec = read(&t.sa.buffer, t.sa.offset + n * 8, tn * 8)?; // spec_pp slot 1: (rgb, history) as two pairs of f16 - let samples = read(&self.sc.up.accum, 0, tn * 80)?; // the kernel's samples (live.wgsl `Pixel`): emit at 48 - let (composed, spec, samples): (&[[f32; 4]], &[[u32; 2]], &[[f32; 20]]) = (bytemuck::cast_slice(&composed), bytemuck::cast_slice(&spec), bytemuck::cast_slice(&samples)); - let (mut dif, mut spe, mut emi) = (Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3)); - let finite = |v: f32| if v.is_finite() { v } else { 0.0 }; - for y in 0..h { - for x in 0..w { - // live.wgsl `sample_under`. - let q = (((y as f32 + 0.5) * ih as f32 / h as f32) as usize).min(ih - 1) * iw + (((x as f32 + 0.5) * iw as f32 / w as f32) as usize).min(iw - 1); - // The media before the surface (live.wgsl `medium_tr`): the specular as the frame shows it. - let tr = (samples[q][11].fract() * 2.0).clamp(0.0, 1.0); - let s = [half(spec[q][0], false), half(spec[q][0], true), half(spec[q][1], false)].map(|v| v * tr); - for c in 0..3 { - let e = finite(samples[q][12 + c] / self.shown_spp as f32); - spe.push(s[c]); - emi.push(e); - dif.push((composed[q][c] - s[c] - e).max(0.0)); - } - } - } - Ok([dif, spe, emi]) - } - - #[cfg(not(target_arch = "wasm32"))] - /// The exposure the last frame was shown at, EV100 (auto exposure: its adapted value). - pub fn read_ev100(&self) -> Result { - if self.sc.exposure_settings.ae2[3] <= 0.5 { - return Ok(-self.sc.exposure.log2()); - } - let d = &self.g.device; - let read = d.create_buffer(&wgpu::BufferDescriptor { label: Some("ae read"), size: 16, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let mut e = d.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(&self.targets.ae_state.buffer, self.targets.ae_state.offset, &read, 0, 16); - self.g.queue.submit([e.finish()]); - read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - d.poll(wgpu::PollType::wait_indefinitely())?; - let s = bytemuck::cast_slice::(&read.slice(..).get_mapped_range().unwrap())[0]; - Ok(-s.max(1e-30).log2()) - } - - /// Phases of the last scene (re)load, ms. - pub fn load_timings(&self) -> &[(String, f64)] { - &self.sc.timings - } - - pub fn aspect(&self) -> f32 { - self.aspect - } - - /// Primary ray through (u, v) of the image (0..1, v down), as the path tracer generates it. - pub fn ray(&self, u: f32, v: f32) -> (Vec3, Vec3) { - let c = &self.cam; - let d = c.fwd + c.right * ((u * 2.0 - 1.0) * c.tan_half_fov * self.aspect) + c.up * ((1.0 - v * 2.0) * c.tan_half_fov); - (c.pos, d.normalize()) - } - - /// Closest surface under (u, v): (node id, world-space hit point). CPU ray cast over the scene triangles, and over - /// each instance's model-space mesh with the ray in its space (same ray parameter t). - /// The object under view point (u, v): its name, the hit point, and for an instanced object the instance's index. - pub fn pick(&self, u: f32, v: f32) -> Option<(String, Vec3, Option)> { - let (o, d) = self.ray(u, v); - let (o, d) = (glam::Vec3A::from(o), glam::Vec3A::from(d)); - // Moller-Trumbore, two-sided: the ray parameter of the closest hit in `tris` beyond `near`. - let closest = |tris: &[[glam::Vec3A; 3]], o: glam::Vec3A, d: glam::Vec3A| { - let mut best = (f32::INFINITY, usize::MAX); - for (i, [a, b, c]) in tris.iter().enumerate() { - let (e1, e2) = (*b - *a, *c - *a); - let p = d.cross(e2); - let det = e1.dot(p); - if det.abs() < 1e-12 { - continue; - } - let inv = 1.0 / det; - let s = o - *a; - let bu = s.dot(p) * inv; - if !(0.0..=1.0).contains(&bu) { - continue; - } - let q = s.cross(e1); - let bv = d.dot(q) * inv; - if bv < 0.0 || bu + bv > 1.0 { - continue; - } - let t = e2.dot(q) * inv; - if t > 1e-4 && t < best.0 { - best = (t, i); - } - } - best - }; - let (mut t, i) = closest(&self.sc.tris, o, d); - let mut node = self.sc.tri_nodes.iter().find(|(_, a, b)| (*a as usize..*b as usize).contains(&i)).map(|n| n.0.as_str()); - let mut sub = self.sc.tri_instances.iter().find(|(a, b, _)| (*a as usize..*b as usize).contains(&i)).map(|r| r.2); - let added = |want_mesh: bool| self.sc.edit.added.values().filter_map(move |p| match p { - Picked::Mesh(i) if want_mesh => Some(Ok(i)), - Picked::Proc(q) if !want_mesh => Some(Err(q)), - _ => None, - }); - for inst in self.sc.instances.iter().filter(|i| !i.hidden).chain(added(true).filter_map(|x| x.ok())) { - let m = self.sc.meshes[inst.mesh as usize]; - let inv = inst.world.inverse(); - let (lo, ld) = (inv.transform_point3a(o), inv.transform_vector3a(d)); - // Slab test against the mesh bounds before its triangles. - let (t0, t1) = ((m.lo - lo) / ld, (m.hi - lo) / ld); - let (near, far) = (t0.min(t1).max_element(), t0.max(t1).min_element()); - if far < near.max(0.0) || near > t { - continue; - } - let (ti, _) = closest(&self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize], lo, ld); - if ti < t { - t = ti; - node = Some(inst.name.as_str()); - sub = inst.sub; - } - } - // Procedural geometry: its boxes (the geometry function runs on the GPU only). - for p in self.sc.procs.iter().filter(|p| !p.hidden).chain(added(false).filter_map(|x| x.err())) { - let inv = p.world.inverse(); - let (lo, ld) = (inv.transform_point3a(o), inv.transform_vector3a(d)); - let slab = |a: glam::Vec3A, b: glam::Vec3A| { - let (t0, t1) = ((a - lo) / ld, (b - lo) / ld); - let (near, far) = (t0.min(t1).max_element().max(1e-4), t0.max(t1).min_element()); - (near <= far).then_some(near) - }; - if slab(p.lo.into(), p.hi.into()).is_none_or(|near| near > t) { - continue; - } - for b in p.blob.boxes.chunks(6) { - if let Some(near) = slab(glam::Vec3A::new(b[0], b[1], b[2]), glam::Vec3A::new(b[3], b[4], b[5])).filter(|&near| near < t) { - t = near; - node = Some(p.name.as_str()); - sub = p.sub; - } - } - } - Some((node?.to_string(), Vec3::from(o + d * t), sub)) - } - - /// World-space triangles of a node (empty for lights without geometry, cameras, hidden nodes). - pub fn node_tris(&self, id: &str) -> Vec<[glam::Vec3A; 3]> { - // World-space triangles (an instance's glass / emissive part among them), then its instanced mesh. - let mut out: Vec<[glam::Vec3A; 3]> = scene::node_ranges(&self.sc.tri_nodes, id).flat_map(|r| self.sc.tris[r].iter().copied()).collect(); - // A model's first instance; every drawn instance of an instanced object. - let mut of_node = self.sc.instances.iter().filter(|i| i.name == id && !i.hidden); - let first = of_node.next(); - for inst in first.into_iter().chain(of_node.filter(|i| i.sub.is_some())) { - let m = self.sc.meshes[inst.mesh as usize]; - out.extend(self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].iter().map(|t| inst.tri(t))); - } - // Objects added in place (Bugbot on #131: `pick` saw them, this did not). - for p in self.sc.edit.added.values() { - if let Picked::Mesh(inst) = p { - if inst.name != id { - continue; - } - let m = self.sc.meshes[inst.mesh as usize]; - out.extend(self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].iter().map(|t| inst.tri(t))); - } - } - out - } - - /// Frame metadata (camera pose for picking, convergence, timings). - pub fn meta(&self) -> serde_json::Value { - let c = &self.cam; - serde_json::json!({ - "seq": self.seq, "frame": self.frame, "history": self.history_len, "gpu_ms": self.gpu_ms, - "scene": self.scene_name, "triangles": self.triangles, "bounces": self.bounces, "mode": self.view_mode, "width": self.w, "height": self.h, "scale": self.render_scale, - "cam": { "pos": c.pos.to_array(), "fwd": c.fwd.to_array(), "up": c.up.to_array(), "right": c.right.to_array(), "tan": c.tan_half_fov, "aspect": self.aspect }, - }) - } -} - -/// Adaptive samples per frame: fill ~75% of the frame budget with path tracing. -pub fn next_spp(spp: u32, gpu_ms: f32, fps: u32) -> u32 { - if gpu_ms <= 0.0 { - return spp; - } - let budget = 750.0 / fps as f32; - let per_sample = gpu_ms / spp as f32; - let target = (budget / per_sample).floor() as u32; - target.clamp(1, 16).min(spp + 1).max(spp.saturating_sub(2).max(1)) -} - -#[cfg(not(target_arch = "wasm32"))] -/// Headless runner: renders at a fixed frame rate and saves snapshots on request (tools/bench-live.ts). -pub fn run(presets: &Path, opts: LiveOpts) -> Result<()> { - let (w, h) = (opts.width, opts.height); - let (cmd_tx, cmd_rx) = mpsc::channel::(); - spawn_stdin_reader(cmd_tx); - let (mut game, mut core) = crate::script::GameSession::start(&opts.bundle, &opts.room, &opts.assets, gpu::Gpu::new()?, presets, w, h)?; - // The host channel: this runner is the app. How the game was opened, once after setup; what the game posts goes - // out on stdout (`emit_posts`), and whoever drives the runner answers with `{"cmd":"host"}`. - if let Err(e) = game.host_messages(&mut core, &[crate::host::launch_message(opts.launch.as_ref())]) { - eprintln!("live: launch: {e:#}"); - } - emit_posts(&mut game); - // Frame-script cost (script + commit), reported every 2 s. - let (mut fs_sum, mut fs_max, mut fs_n) = (0.0f32, 0.0f32, 0u32); - core.bounces = opts.bounces; - core.set_display_scale(opts.display_scale); - if let Some(s) = opts.scale { - core.set_scale(s); - } - eprintln!("live: load {}", serde_json::Value::Object(core.load_timings().iter().map(|(k, v)| (k.clone(), (*v).into())).collect())); - eprintln!("live: {}x{} @{} fps on {} ({})", w, h, opts.fps, core.g.adapter_info.name, if core.g.hwrt { "HW RT" } else { "BVH" }); - let staging = core.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("snapshot"), size: (w * h * 4) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let frame_dt = Duration::from_secs_f64(1.0 / opts.fps as f64); - let mut next = Instant::now(); - let mut snapshots: Vec<(PathBuf, u32, u32)> = vec![]; // path, min history, first eligible frame - let mut syncs: Vec<(String, u32)> = vec![]; // id, frame after which to answer - let mut spp = 1u32; - let mut last = Instant::now(); - let start = Instant::now(); - let mut log = opts.frame_log.as_ref().map(|p| std::fs::File::create(p).map(std::io::BufWriter::new)).transpose()?; - let mut last_frame = Instant::now(); - let mut prev_start = Instant::now(); - let mut lockstep: Option = None; - let mut budget = 0u32; // lockstep: frames the commands so far asked for and not yet rendered - loop { - let t_frame = Instant::now(); - let mut cmds: Vec = vec![]; - if lockstep.is_some() && budget == 0 { - match cmd_rx.recv() { - Ok(c) => cmds.push(c), - Err(_) => return Ok(()), - } - } - cmds.extend(std::iter::from_fn(|| cmd_rx.try_recv().ok())); - for c in cmds { - match c { - Cmd::Camera(c) => core.set_camera(c), - Cmd::Lockstep(dt) => { - lockstep = dt; - core.fixed_dt = dt; - budget = 0; - } - Cmd::Step { cam, frames, sync, snapshot } => { - if let Some(c) = cam { - core.set_camera(c); - } - let n = frames.max(1); - if lockstep.is_some() { - budget += n; - } - let last = core.frame + n - 1; - if let Some(p) = snapshot { - snapshots.push((p, 0, last)); - } - if let Some(id) = sync { - syncs.push((id, last)); - } - } - Cmd::Snapshot(path, after, frames) => snapshots.push((path, after.unwrap_or(0), core.frame + frames.unwrap_or(1).max(1) - 1)), - Cmd::Sync(id, frames) => syncs.push((id, core.frame + frames.max(1) - 1)), - Cmd::Reset => core.reset_history(), - Cmd::Time(t) => core.set_time(t), - Cmd::Mode(m) => core.view_mode = m, - Cmd::Bounces(b) => core.bounces = b, - Cmd::Scale(s) => core.set_scale(s), - Cmd::Event(kind, payload) => { - if let Err(e) = game.event(&mut core, &kind, payload) { - eprintln!("live: event {kind} failed: {e:#}"); - } - } - Cmd::Click(u, v) => { - if let Err(e) = game.click(&mut core, u, v) { - eprintln!("live: click failed: {e:#}"); - } - } - Cmd::Host(messages) => { - if let Err(e) = game.host_messages(&mut core, &messages) { - eprintln!("live: host messages failed: {e:#}"); - } - } - Cmd::SppCap(_) | Cmd::Status(_) => {} - Cmd::Quit => return Ok(()), - } - } - // The frame script runs on wall-clock time (frames slower than the target rate still animate at real speed). - if lockstep.is_some() && budget == 0 { - emit_posts(&mut game); - continue; // commands without frames (events, switches): wait for a step - } - let dt = if freeze_time() { 0.0 } else { lockstep.unwrap_or_else(|| last.elapsed().as_secs_f32().min(0.1)) }; - last = Instant::now(); - core.advance_time(dt); - if let Err(e) = game.frame(&mut core, dt) { - eprintln!("live: frame script: {e:#}"); - } - emit_posts(&mut game); - if game.host.has_frame() { - (fs_sum, fs_max, fs_n) = (fs_sum + game.frame_ms, fs_max.max(game.frame_ms), fs_n + 1); - if fs_n == opts.fps * 2 { - eprintln!("live: frame script {:.3} ms avg, {:.3} ms max (script + commit)", fs_sum / fs_n as f32, fs_max); - (fs_sum, fs_max, fs_n) = (0.0, 0.0, 0); - } - } - // Snapshot metadata is this frame's (history / spp at render time), so copy the output whenever one is pending. - let pending = !snapshots.is_empty(); - core.render(spp, |e, out| { - if pending { - e.copy_buffer_to_buffer(out, 0, &staging, 0, (w * h * 4) as u64); - } - }); - game.click_shown(); - let (hist, fno) = (core.history_len as u32, core.frame); - syncs.retain(|(id, from)| { - if fno > *from { - eprintln!("live: sync {id}"); - } - fno <= *from - }); - if snapshots.iter().any(|(_, after, from)| fno > *from && hist >= *after) { - staging.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - core.g.device.poll(wgpu::PollType::wait_indefinitely())?; - let px = staging.slice(..).get_mapped_range()?.to_vec(); - staging.unmap(); - let mut hdr: Option> = None; - snapshots.retain(|(path, after, from)| { - if fno > *from && hist >= *after { - if path.extension().is_some_and(|e| e == "signals") { - // The signals apart (`read_signals`), as `render --signals` writes them. - if let Ok([d, s, e]) = core.read_signals() { - let stem = path.with_extension(""); - for (name, v) in [("diffuse", d), ("specular", s), ("emission", e)] { - let _ = std::fs::write(format!("{}-{name}.raw", stem.display()), bytemuck::cast_slice(&v)); - } - } - } else if path.extension().is_some_and(|e| e == "raw") { - // Linear radiance (RGB f32, output size), as `serve` live jobs write it. - if hdr.is_none() { - hdr = core.read_hdr().ok(); - } - let _ = std::fs::write(path, bytemuck::cast_slice(hdr.as_deref().unwrap_or(&[]))); - } else { - save_bgra(path, &px, w, h); - } - eprintln!("live: snapshot {} at history {hist} ({spp} spp/frame, GPU {:.2} ms, scale {:.2})", path.display(), core.gpu_ms, core.render_scale); - false - } else { - true - } - }); - } - if let Some(l) = &mut log { - use std::io::Write; - let line = serde_json::json!({ - "t": start.elapsed().as_secs_f64() * 1e3, "interval": last_frame.elapsed().as_secs_f64() * 1e3, "start": t_frame.duration_since(prev_start).as_secs_f64() * 1e3, - "cpu": t_frame.elapsed().as_secs_f64() * 1e3, "script": game.frame_ms, "js": game.js_ms, "commit": game.commit_ms, "skin": core.cpu_skin_ms, "upload": core.cpu_upload_ms, "blas": core.gpu_blas_ms, "wait": core.cpu_wait_ms, "gpu": core.gpu_ms, "trace": core.gpu_trace_ms, "edit": core.edit_ms, "instanced": core.instanced_ms, "edit_split": [core.edit_split.tris, core.edit_split.upload, core.edit_split.blas, core.edit_split.added, core.edit_split.deformed, core.edit_split.objects], "rebuild": core.rebuild_ms, "fallback": core.fallback, "fallbacks": core.fallbacks, "room": core.room_stats(), "top": core.top_stats(), "voxel": std::mem::take(&mut game.host.voxels.borrow_mut().ticks), "physics": game.host.physics.borrow_mut().as_mut().map(|p| { let s = p.take_stats(); serde_json::json!({ "ticks": s.ticks, "step": s.step_ms, "sync": s.sync_ms, "bodies": s.bodies, "moved": s.moved, "kicks": s.kicks }) }), "scale": core.render_scale, "history": core.history_len, "guard": core.guard_count(), - }); - writeln!(l, "{line}")?; - } - // (Summed over the frame's commits, a game event's and the frame script's: reset once logged.) - (core.edit_ms, core.instanced_ms, core.edit_split, core.rebuild_ms, core.fallback) = (0.0, 0.0, EditSplit::default(), 0.0, None); - prev_start = t_frame; - last_frame = Instant::now(); - core.govern_resolution(opts.fps); - spp = if core.render_scale < 1.0 || lockstep.is_some() { 1 } else { next_spp(spp, core.gpu_ms, opts.fps) }; - let _ = core.g.device.poll(wgpu::PollType::Poll); - if lockstep.is_some() { - budget -= 1; - continue; // no pacing: frames as fast as the GPU renders them - } - // Pace to the frame rate; when the GPU is the bottleneck the two-frames-in-flight limit paces instead (sleeping - // after a frame that did not wait would alternate long and short frames). - next += frame_dt; - let now = Instant::now(); - if next > now && core.gpu_ms < frame_dt.as_secs_f32() * 1000.0 * 0.95 { - sleep_until(next); - } else { - next = now; - } - } -} - -#[cfg(not(target_arch = "wasm32"))] -/// What the game posted on the host channel since the last call, as one line on stdout: `{"host":[{"t":...}, ...]}` -/// (nothing when it posted nothing). -fn emit_posts(game: &mut crate::script::GameSession) { - use std::io::Write; - let posts = game.take_posts(); - if !posts.is_empty() { - let mut out = std::io::stdout().lock(); - let _ = writeln!(out, "{}", serde_json::json!({ "host": posts })); - let _ = out.flush(); - } -} - -#[cfg(not(target_arch = "wasm32"))] -/// Sleeps until `t` without trusting the timer's precision: macOS gives a process that is not a foreground app timer -/// leeway of half the interval (a 16 ms sleep returned after 24, measured from a terminal-launched viewport and from -/// plain Python alike). Pacing sleeps of the headless viewport overslept by ~5 ms once the GPU fit the frame: Bistro's -/// camera path read frame interval p95 21.9 ms at GPU p95 16.0. Sleep 60 % of what is left, again, and yield through -/// the last 1.5 ms. -fn sleep_until(t: Instant) { - loop { - let now = Instant::now(); - if now >= t { - return; - } - let rem = t - now; - if rem > Duration::from_micros(1500) { - std::thread::sleep(rem.mul_f64(0.6)); - } else { - std::thread::yield_now(); - } - } -} - -#[cfg(not(target_arch = "wasm32"))] -pub fn save_bgra(path: &Path, px: &[u8], w: u32, h: u32) { - let rgba: Vec = px.chunks(4).flat_map(|p| [p[2], p[1], p[0], 255]).collect(); - if let Err(e) = image::save_buffer(path, &rgba, w, h, image::ColorType::Rgba8) { - eprintln!("live: snapshot failed: {e}"); - } -} - -#[cfg(not(target_arch = "wasm32"))] -fn spawn_stdin_reader(cmd_tx: mpsc::Sender) { - std::thread::spawn(move || { - for line in std::io::stdin().lock().lines() { - let Ok(line) = line else { break }; - let Some(cmd) = parse_cmd(&line) else { continue }; - if cmd_tx.send(cmd).is_err() { - break; - } - } - let _ = cmd_tx.send(Cmd::Quit); - }); -} - -#[cfg(not(target_arch = "wasm32"))] -pub fn parse_cmd(line: &str) -> Option { - let v: serde_json::Value = serde_json::from_str(line).ok()?; - let vec = |k: &str| v[k].as_array().map(|a| Vec3::new(a[0].as_f64().unwrap_or(0.0) as f32, a[1].as_f64().unwrap_or(0.0) as f32, a[2].as_f64().unwrap_or(0.0) as f32)); - Some(match v["cmd"].as_str()? { - "camera" if v.get("sync").is_some() || v.get("snapshot").is_some() || v.get("frames").is_some() => Cmd::Step { - cam: Some(Camera::look_at(vec("pos")?, vec("target")?, v["tan"].as_f64().unwrap_or(0.0) as f32)), - frames: v["frames"].as_u64().unwrap_or(1) as u32, - sync: v.get("sync").map(|s| s.as_str().map(str::to_string).unwrap_or_else(|| s.to_string())), - snapshot: v["snapshot"].as_str().map(PathBuf::from), - }, - "camera" => Cmd::Camera(Camera::look_at(vec("pos")?, vec("target")?, v["tan"].as_f64().unwrap_or(0.0) as f32)), - "frames" => Cmd::Step { - cam: None, - frames: v["n"].as_u64().unwrap_or(1) as u32, - sync: v.get("sync").map(|s| s.as_str().map(str::to_string).unwrap_or_else(|| s.to_string())), - snapshot: v["snapshot"].as_str().map(PathBuf::from), - }, - "lockstep" => Cmd::Lockstep(if v["on"].as_bool().unwrap_or(true) { Some(v["dt"].as_f64().unwrap_or(1.0 / 60.0) as f32) } else { None }), - "event" => Cmd::Event(v["type"].as_str()?.to_string(), if v["payload"].is_null() { serde_json::json!({}) } else { v["payload"].clone() }), - "click" => Cmd::Click(v["u"].as_f64()? as f32, v["v"].as_f64()? as f32), - "host" => Cmd::Host(v["messages"].as_array()?.clone()), - "snapshot" => Cmd::Snapshot(PathBuf::from(v["path"].as_str().unwrap_or("snapshot.png")), v["after"].as_u64().map(|a| a as u32), v["frames"].as_u64().map(|a| a as u32)), - "reset" => Cmd::Reset, - "time" => Cmd::Time(v["t"].as_f64()? as f32), - "mode" => Cmd::Mode(v["mode"].as_u64()? as u32), - "bounces" => Cmd::Bounces(v["bounces"].as_u64()?.clamp(1, 16) as u32), - "spp" => Cmd::SppCap(v["max"].as_u64()?.clamp(1, 16) as u32), - "scale" => Cmd::Scale(v["scale"].as_f64()? as f32), - "sync" => Cmd::Sync(v["id"].as_str().map(str::to_string).unwrap_or_else(|| v["id"].to_string()), v["frames"].as_u64().unwrap_or(1) as u32), - "quit" => Cmd::Quit, - _ => return None, - }) -} - -#[cfg(not(target_arch = "wasm32"))] -pub fn parse_opts(args: &[String]) -> Result { - let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); - let num = |name: &str, d: u32| -> Result { flag(name).map(|v| v.parse().map_err(|_| anyhow!("{name} must be a number"))).transpose().map(|v| v.unwrap_or(d)) }; - Ok(LiveOpts { - width: num("--width", 1280)?, - height: num("--height", 720)?, - fps: num("--fps", 60)?, - bounces: num("--bounces", 4)?, - // (Without `--scale`: full resolution, or dynamic resolution on a display's physical pixels.) - scale: parse_scale(flag("--scale").as_deref(), if flag("--display-scale").is_some() { None } else { Some(1.0) })?, - bundle: PathBuf::from(args.get(2).filter(|a| !a.starts_with("--")).ok_or_else(|| anyhow!("game bundle required"))?), - assets: PathBuf::from(flag("--assets").unwrap_or_else(|| ".".into())), - room: flag("--room").unwrap_or_else(|| "local".into()), - frame_log: flag("--frame-log").map(PathBuf::from), - display_scale: flag("--display-scale").map(|v| v.parse::()).transpose()?.unwrap_or(1.0), - launch: parse_launch(flag("--launch").as_deref())?, - }) -} - -#[cfg(not(target_arch = "wasm32"))] -/// `--launch '{"room":"K7Q4MZ","name":"Ada"}'`: the `params` of the host channel's `launch` message. -pub fn parse_launch(v: Option<&str>) -> Result> { - v.map(|t| serde_json::from_str::(t).ok().filter(|p| p.is_object()).ok_or_else(|| anyhow!("--launch must be a JSON object, as in --launch '{{\"room\":\"K7Q4MZ\"}}'"))).transpose() -} - -#[cfg(not(target_arch = "wasm32"))] -/// `--scale`: a fraction (0.25 .. 1) or `auto` (dynamic resolution). -pub fn parse_scale(v: Option<&str>, default: Option) -> Result> { - match v { - None => Ok(default), - Some("auto") => Ok(None), - Some(v) => v.parse::().map(|s| Some(s.clamp(SCALE_FLOOR, 1.0))).map_err(|_| anyhow!("--scale must be a number or auto")), - } -} - -#[cfg(test)] -mod timer_tests { - use super::*; - - #[test] - fn a_view_in_physical_pixels_starts_with_dynamic_resolution() { - let opts = |extra: &[&str]| parse_opts(&["threert-render", "live", "game.js"].iter().chain(extra).map(|a| a.to_string()).collect::>()).unwrap(); - assert_eq!(opts(&[]).scale, Some(1.0)); - assert_eq!(opts(&["--display-scale", "2"]).scale, None); - assert_eq!(opts(&["--display-scale", "2", "--scale", "0.2"]).scale, Some(0.2)); - assert_eq!(opts(&["--scale", "auto"]).scale, None); - } - - #[test] - fn a_post_pass_from_another_frame_counts_by_its_own_length() { - // Two frames of the crowd scene (ns, period 1): frame 453 as read, and frame 454, whose slot read frame 458's - // post pair (74.5 ms after its trace began). - let t453 = [35929577531333, 35929585495833, 35929585700583, 35929591610875, 0, 0, 0, 0]; - let t454 = [35929593765500, 35929601614666, 35929668312875, 35929673973041, 0, 0, 0, 0]; - let ms = |t: [u64; 8]| frame_gpu_ms(&t, 1, 1.0).unwrap(); - assert_eq!(ms(t453), (t453[3] - t453[0]) as f32 * 1e-6); - assert!((ms(t454) - (7.849166 + 5.660166)).abs() < 1e-3, "{}", ms(t454)); - // The first pass is the frame's start; nothing timed, no measurement. - assert_eq!(frame_gpu_ms(&[0, 0, 0, 0, 0, 0, 0, 0], 1, 1.0), None); - let particles_first = [2_000_000, 9_000_000, 9_200_000, 14_000_000, 0, 0, 1_000_000, 1_900_000]; - assert_eq!(frame_gpu_ms(&particles_first, 1, 1.0), Some(13.0)); - // 3 samples: two more traces (untimed) between the first and the post pass, a 16.2 ms gap: the span. The post - // pair of frame 458 under 3 samples: still out of place (trace 7.85 ms, gap 66.7 ms), counted as the passes. - let three = [1_000_000, 9_000_000, 25_200_000, 31_000_000, 0, 0, 0, 0]; - assert_eq!(frame_gpu_ms(&three, 3, 1.0), Some(30.0)); - assert!((frame_gpu_ms(&t454, 3, 1.0).unwrap() - (3.0 * 7.849166 + 5.660166)).abs() < 1e-3); - } -} - -#[cfg(test)] -mod instanced_tests { - use super::*; - use crate::world::{Geometry, Kind}; - use glam::Mat4; - - /// Clicks pick what is drawn (ROADMAP § 6 step 5): an object added in place is picked, one removed in place no - /// longer is, and a rebuild agrees. (Skipped without a GPU with hardware RT.) - #[test] - fn picks_follow_in_place_edits() { - let g = match gpu::Gpu::new() { - Ok(g) if g.hwrt => g, - other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), - }; - let mut w = World::default(); - w.set_material(1, serde_json::from_value(serde_json::json!({ "name": "m", "color": [0.5, 0.5, 0.5], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": true })).unwrap()); - let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; - w.set_geometry(2, quad(1.0)).unwrap(); - w.set_geometry(3, quad(0.5)).unwrap(); - let mesh = |geometry| Kind::Mesh { geometry, material: 1, own_blas: true, materials: vec![] }; - w.set_object(10, "a".into(), None, mesh(2), true, false).unwrap(); - let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); - w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); - w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); - let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); - w.take_changes(); - let picked = |core: &LiveCore| core.pick(0.5, 0.5).map(|p| p.0); - assert_eq!(picked(&core).as_deref(), Some("a")); - // Added in place, in front of it: picked. - w.set_object(11, "b".into(), None, mesh(3), true, false).unwrap(); - w.set_transform(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - assert!(!core.commit(&mut w).unwrap(), "added in place"); - assert_eq!(picked(&core).as_deref(), Some("b")); - assert_eq!(core.node_tris("b").len(), 2, "its triangles under its name"); - // Removed in place: the one behind it is picked again; both removed: nothing. - w.remove(11); - assert!(!core.commit(&mut w).unwrap(), "removed in place"); - assert_eq!(picked(&core).as_deref(), Some("a")); - assert!(core.node_tris("b").is_empty(), "removed: none"); - w.remove(10); - assert!(!core.commit(&mut w).unwrap(), "removed in place"); - assert_eq!(picked(&core), None); - core.rebuild(&w).unwrap(); - assert_eq!(picked(&core), None, "a rebuild agrees"); - } - - /// A mesh a rigid body drives, added while the game runs with a material the scene did not have (ROADMAP § 6 step - /// 7): placed in place, and its body's moves are its instance's transform, with no rebuild; a rebuild places it the - /// same and it goes on moving in place. (Skipped without a GPU with hardware RT.) - #[test] - fn driven_meshes_added_in_place_move_as_instances() { - let g = match gpu::Gpu::new() { - Ok(g) if g.hwrt => g, - other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), - }; - let mut w = World::default(); - let mat = |r: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [r, r, r], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": true })).unwrap(); - w.set_material(1, mat(0.5)); - let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; - w.set_geometry(2, quad(1.0)).unwrap(); - w.set_geometry(3, quad(0.5)).unwrap(); - let mesh = |geometry, material| Kind::Mesh { geometry, material, own_blas: true, materials: vec![] }; - w.set_object(10, "a".into(), None, mesh(2, 1), true, false).unwrap(); - let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); - w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); - w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); - let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); - w.take_changes(); - let picked = |core: &LiveCore, u: f32| core.pick(u, 0.5).map(|p| p.0); - // The box and its material arrive together, as scene code adds them; its body is told of it right after. - let materials = core.sc.tables.materials.len(); - w.set_material(5, mat(0.9)); - w.set_object(11, "b".into(), None, mesh(3, 5), true, false).unwrap(); - w.set_driven(11).unwrap(); - w.set_transform(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - assert!(!core.commit(&mut w).unwrap(), "added in place, its material too"); - assert_eq!(core.sc.tables.materials.len(), materials + 1); - assert_eq!(picked(&core, 0.5).as_deref(), Some("b")); - // Its body moves it 3 m to the side (u 0.95 of the image, 4 m from the camera): no rebuild, its record moved - // with the rows it had the frame before. - let record = core.sc.edit.placed[&11].1; - assert!(w.place_driven(11, Vec3::new(3.0, 0.0, 1.0), glam::Quat::IDENTITY)); - assert!(!core.commit(&mut w).unwrap(), "moved as an instance"); - assert_eq!(core.fallbacks, 0); - assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95).as_deref()), (Some("a"), Some("b"))); - let r = core.sc.gpu_instances[record]; - assert_eq!((r.m[0][3], r.p[0][3]), (3.0, 0.0), "moved: last frame's rows kept"); - // A rebuild places it as an instance too, and it moves on in place. - core.rebuild(&w).unwrap(); - assert!(core.sc.tables.retained.instance_of.contains_key(&11) && core.sc.edit.placed.is_empty()); - assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95).as_deref()), (Some("a"), Some("b"))); - assert!(w.place_driven(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY)); - assert!(!core.commit(&mut w).unwrap(), "moved as an instance"); - assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95)), (Some("b"), None)); - // Removed in place. - w.remove(11); - assert!(!core.commit(&mut w).unwrap(), "removed in place"); - assert_eq!(picked(&core, 0.5).as_deref(), Some("a")); - } - - /// An instanced mesh's moved instances keep their previous-frame rows (motion vectors) through the commit that moves - /// them, and catch up at the next commit that does not move them. (Skipped without a GPU with hardware RT.) - #[test] - fn moved_instances_keep_their_motion() { - let g = match gpu::Gpu::new() { - Ok(g) if g.hwrt => g, - other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), - }; - let mut w = World::default(); - let mat = |r: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [r, r, r], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": false })).unwrap(); - w.set_material(1, mat(0.5)); - w.set_geometry(2, Geometry { positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(3), uvs: vec![0.0; 6], indices: vec![0, 1, 2], groups: vec![], colors: vec![] }).unwrap(); - w.set_object(3, "copies".into(), None, Kind::Instanced { geometry: 2, procedural: false, materials: vec![1], capacity: 2 }, true, false).unwrap(); - let at = |x: f32| [Mat4::IDENTITY.to_cols_array(), Mat4::from_translation(Vec3::new(x, 0.0, 0.0)).to_cols_array()].concat(); - w.set_instances(3, 2, Some(at(1.0)), None, None).unwrap(); - let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); - w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); - w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); - let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); - w.take_changes(); - let first = core.sc.tables.retained.instanced[&3].first as usize; - w.set_instances(3, 2, Some(at(2.0)), None, None).unwrap(); - assert!(!core.commit(&mut w).unwrap(), "in place"); - let r = core.sc.gpu_instances[first + 1]; - assert_eq!((r.m[0][3], r.p[0][3]), (2.0, 1.0), "moved: last frame's rows kept"); - w.set_material(1, mat(0.6)); - core.commit(&mut w).unwrap(); - let r = core.sc.gpu_instances[first + 1]; - assert_eq!(r.p, r.m, "not moved since: caught up"); - // Movable, moved and given new instances in one commit: updated once (twice would lose the motion). - w.set_object(3, "copies".into(), None, Kind::Instanced { geometry: 2, procedural: false, materials: vec![1], capacity: 2 }, true, true).unwrap(); - assert!(core.commit(&mut w).unwrap(), "made movable: rebuilt"); - let first = core.sc.tables.retained.instanced[&3].first as usize; - w.set_transform(3, Vec3::new(0.0, 1.0, 0.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_instances(3, 2, Some(at(3.0)), None, None).unwrap(); - let c = w.changes.clone(); - assert!(c.moved.contains(&3) && c.instanced.contains(&3), "{c:?}"); - assert!(!core.commit(&mut w).unwrap(), "in place"); - let r = core.sc.gpu_instances[first + 1]; - assert_eq!((r.m[0][3], r.m[1][3], r.p[0][3], r.p[1][3]), (3.0, 1.0, 2.0, 0.0), "moved: last frame's rows kept"); - } - - /// The uniform tables (`gpu::GpuOptions::uniform_tables`: what a device is opened with on Android), on this - /// machine's GPU: a lit scene with an instanced mesh renders offline to the default device's image, bit for bit; - /// live, the four uniform buffers hold what their tables hold when a frame is traced, and after an edit in place - /// what the tables hold then. (Skipped without a GPU.) - #[test] - fn the_uniform_tables_render_what_the_tables_do() { - let open = |uniform: bool| gpu::Gpu::new_with(gpu::GpuOptions { uniform_tables: Some(uniform), ..Default::default() }); - let (plain, uniform) = match (open(false), open(true)) { - (Ok(a), Ok(b)) => (a, b), - (Err(e), _) | (_, Err(e)) => return eprintln!("skipped: {e}"), - }; - if uniform.merged { - return eprintln!("skipped: the merged layout has no uniform tables"); - } - assert!(uniform.uniform_tables && !plain.uniform_tables); - let mut w = World::default(); - let mat = |color: f32, emissive: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [color, color * 0.5, color], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [1, 1, 1], "emissiveIntensity": emissive, "doubleSided": true })).unwrap(); - w.set_material(1, mat(0.8, 0.0)); - w.set_material(5, mat(0.0, 40.0)); - let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: [0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; - w.set_geometry(2, quad(1.0)).unwrap(); - w.set_geometry(3, quad(0.25)).unwrap(); - let mesh = |geometry, material, own_blas| Kind::Mesh { geometry, material, own_blas, materials: vec![] }; - // A wall, a small movable panel in front of it, and an emissive quad that lights both from the side. - w.set_object(10, "wall".into(), None, mesh(2, 1, false), true, false).unwrap(); - w.set_object(11, "panel".into(), None, mesh(3, 1, true), true, true).unwrap(); - w.set_transform(11, Vec3::new(-0.4, 0.0, 0.5), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_object(12, "lamp".into(), None, mesh(3, 5, false), true, false).unwrap(); - w.set_transform(12, Vec3::new(0.6, 0.3, 1.5), glam::Quat::from_rotation_y(2.6), Vec3::ONE).unwrap(); - let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); - w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); - w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); - let presets = crate::default_presets_dir(); - - // Offline: the same bytes. - let offline = |g: &gpu::Gpu| { - let mut sc = crate::scene::build(&w, Path::new("."), &presets, None).unwrap(); - (sc.width, sc.height) = (64, 36); - let prep = crate::prepare(g, &mut sc, &mut vec![]); - let frame = g.render(&sc, &prep.tri_verts, prep.groups, bytemuck::cast_slice(&prep.nodes), &prep.emitters, &prep.env, &prep.env_cdf, &prep.energy, prep.params, 16, &mut vec![]).unwrap(); - frame.color.iter().flatten().map(|v| v.to_bits()).collect::>() - }; - let (a, b) = (offline(&plain), offline(&uniform)); - let lit = a.iter().filter(|v| f32::from_bits(**v) > 0.0).count(); - assert!(lit > a.len() / 16, "the scene is lit ({lit} of {} numbers)", a.len()); - let differ = a.iter().zip(&b).filter(|(x, y)| x != y).count(); - assert_eq!(differ, 0, "{differ} of {} numbers differ between the storage tables and the uniform ones", a.len()); - drop(plain); - - // Live: the uniform buffers follow the tables. - let mut core = LiveCore::new(uniform, &w, Path::new("."), &presets, 64, 36).unwrap(); - w.take_changes(); - let read = |core: &LiveCore, buf: &wgpu::Buffer, bytes: u64| -> Vec { - let d = &core.g.device; - let to = d.create_buffer(&wgpu::BufferDescriptor { label: None, size: bytes, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let mut e = d.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(buf, 0, &to, 0, bytes); - core.g.queue.submit([e.finish()]); - to.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - d.poll(wgpu::PollType::wait_indefinitely()).unwrap(); - let bytes = to.slice(..).get_mapped_range().unwrap().to_vec(); - bytes - }; - // Each table's bytes and its uniform copy's, over what both hold. - let pairs = |core: &LiveCore| -> Vec<(Vec, Vec)> { - let up = &core.sc.up; - let copies = up.uniform.as_ref().expect("the uniform tables' buffers"); - [&up.bg_parts.texinfo, &up.objects, &up.instances, &up.media].into_iter().zip(copies).map(|(t, c)| { - let n = t.size().min(c.size()) & !3; - (read(core, t, n), read(core, c, n)) - }).collect() - }; - core.render(1, |_, _| {}); - let first = pairs(&core); - for (k, (table, copy)) in first.iter().enumerate() { - assert!(!table.is_empty() && table == copy, "table {k} and its uniform buffer differ after a frame"); - } - assert!(first[1].0.iter().any(|b| *b != 0), "the objects' table has the scene's objects"); - // The panel moves in place: its object's entry and its instance's record change, and the next frame's - // uniform buffers have them. - w.set_transform(11, Vec3::new(0.3, 0.2, 0.75), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - assert!(!core.commit(&mut w).unwrap(), "moved in place"); - core.render(1, |_, _| {}); - let moved = pairs(&core); - for (k, (table, copy)) in moved.iter().enumerate() { - assert!(table == copy, "table {k} and its uniform buffer differ after an edit"); - } - assert!(moved[1].1 != first[1].1 || moved[2].1 != first[2].1, "the move reached the uniform buffers"); - } -} diff --git a/renderer/src/live/edits.rs b/renderer/src/live/edits.rs new file mode 100644 index 0000000..8fbf505 --- /dev/null +++ b/renderer/src/live/edits.rs @@ -0,0 +1,2099 @@ +//! The world's edits applied in place (`LiveCore::commit` and what it calls): moved instances and bodies, posed +//! skins, emitters, the sun and the sky, and geometry added, removed or changed (`edit_geometry`, `EditState`). + +use super::*; + +/// Writes to one buffer gathered during an edit and sent merged: adjacent ranges as one `write_buffer` (an edit writes +/// hundreds of small records and meshes, contiguous in the room). Overlapping writes go in the order they came. +#[derive(Default)] +pub(super) struct Gather { + pub(super) parts: Vec<(u64, Vec)>, +} + +impl Gather { + pub(super) fn put(&mut self, at: u64, bytes: &[u8]) { + self.parts.push((at, bytes.to_vec())); + } + + pub(super) fn flush(self, q: &wgpu::Queue, b: &wgpu::Buffer) { + let mut parts = self.parts; + let mut sorted = parts.clone(); + sorted.sort_by_key(|p| p.0); + if sorted.windows(2).any(|w| w[0].0 + w[0].1.len() as u64 > w[1].0) { + for (at, bytes) in parts.drain(..) { + q.write_buffer(b, at, &bytes); + } + return; + } + let mut run: Option<(u64, Vec)> = None; + for (at, bytes) in sorted { + match &mut run { + Some((start, data)) if *start + data.len() as u64 == at => data.extend_from_slice(&bytes), + _ => { + if let Some((start, data)) = run.take() { + q.write_buffer(b, start, &data); + } + run = Some((at, bytes)); + } + } + } + if let Some((start, data)) = run { + q.write_buffer(b, start, &data); + } + } +} + +/// Where an in-place edit's CPU time goes (ms) and what it did: building the triangles' GPU form, uploading them, +/// creating and recording BLASes and the TLAS; meshes added and rewritten in place, objects added or removed. Frame log +/// `edit_split`. +#[derive(Default, Clone, Copy, Debug)] +pub struct EditSplit { + pub tris: f64, + pub upload: f64, + pub blas: f64, + pub added: usize, + pub deformed: usize, + pub objects: usize, +} + +impl EditSplit { + pub(super) fn add(&mut self, o: &EditSplit) { + (self.tris, self.upload, self.blas) = (self.tris + o.tris, self.upload + o.upload, self.blas + o.blas); + (self.added, self.deformed, self.objects) = (self.added + o.added, self.deformed + o.deformed, self.objects + o.objects); + } +} + +/// The edit room in use (gpu.rs `EditRoom`): the next free triangle (GPU order), kernel record, object-table entry, +/// procedural word (counted from the region's start) and TLAS instance, and where they started. +#[derive(Default)] +pub(super) struct EditState { + /// Triangles (GPU order), kernel records (a record's TLAS instance is `inst_base` + it) and object-table entries + /// edits can place: holes left by what they no longer draw, then the room, which grows past its end (ROADMAP § 6 + /// step 5). + pub(super) tris: Holes, + pub(super) records: Holes, + pub(super) objects: Holes, + /// The next procedural word, and where the region (grown as needed) ends. + pub(super) word: usize, + pub(super) word_cap: usize, + /// The instanced mesh each object edits manage draws (`own_blas`: the build's and theirs), each mesh's objects, the + /// meshes edits may free (own-BLAS meshes: shared only by objects of one geometry and material), and the slots of + /// freed ones, reused by the next new mesh. + pub(super) mesh_of: std::collections::HashMap, + pub(super) users: std::collections::HashMap, + pub(super) managed: std::collections::HashSet, + pub(super) dead_meshes: Vec, + /// Times the room grew (frame log `grows`), and whether the scene bind group must be made again. + pub(super) grows: u32, + pub(super) rebind: bool, + /// The uniform tables (`gpu::Gpu::uniform_tables`): an edit placed entries past what their arrays hold, and it + /// was said (once a scene, as its upload says it of the scene's own). + pub(super) uniform_said: bool, + /// (triangles, records, objects, words, TLAS instances) the scene itself has: the room starts there. + pub(super) base: (usize, usize, usize, usize, usize), + /// The first pool triangle (instanced meshes' triangles are counted from it). + pub(super) pool_first: usize, + /// Procedural blobs in the region by hash: their first word (the build's and those edits wrote). + pub(super) blobs: std::collections::HashMap<[u8; 32], usize>, + /// Objects edits added: their (TLAS instance, record); instanced meshes edits added: their first record, capacity + /// and first object entry (contiguous, as `update_instanced` rewrites them). + pub(super) placed: std::collections::HashMap, + pub(super) instanced: std::collections::HashMap, + /// The build's model instances (records below are theirs, in `LoadedScene::instances` order). + pub(super) inst_at_load: usize, + /// What clicks pick among the objects edits added (`LiveCore::pick`; the build's are `instances` and `procs`). + pub(super) added: std::collections::BTreeMap, +} + +/// Ranges of one kind of the edit room: free holes (start -> length; first fit, neighbours merged) below `end`, the +/// part in use, which may reach `cap`, what the buffers hold (more by growing them). +#[derive(Default, Debug)] +pub(super) struct Holes { + pub(super) free: std::collections::BTreeMap, + pub(super) end: usize, + pub(super) cap: usize, + /// Where the room starts (the scene's own data before it). + pub(super) base: usize, +} + +impl Holes { + pub(super) fn new(end: usize, cap: usize) -> Holes { + Holes { free: Default::default(), end, cap, base: end } + } + + /// `n` contiguous: the first hole that fits, else at the end if within `cap`. + pub(super) fn take(&mut self, n: usize) -> Option { + if let Some((&at, &len)) = self.free.iter().find(|(_, &len)| len >= n) { + self.free.remove(&at); + if len > n { + self.free.insert(at + n, len - n); + } + return Some(at); + } + (self.end + n <= self.cap).then(|| { + self.end += n; + self.end - n + }) + } + + /// `n` from `at` free again: merged with the holes beside it; at the end, the end moves back. + pub(super) fn give(&mut self, at: usize, n: usize) { + if n == 0 { + return; + } + let (mut a, mut len) = (at, n); + if let Some((&pa, &pl)) = self.free.range(..at).next_back().filter(|(&pa, &pl)| pa + pl == at) { + self.free.remove(&pa); + (a, len) = (pa, len + pl); + } + if let Some(nl) = self.free.remove(&(at + n)) { + len += nl; + } + if a + len == self.end { + self.end = a; + } else { + self.free.insert(a, len); + } + } + + /// In use (below `end`, less the holes). + pub(super) fn used(&self) -> usize { + self.end - self.free.values().sum::() + } +} + +/// An object an edit placed, as `LiveCore::pick` tests it. +pub(super) enum Picked { + Mesh(scene::Instance), + Proc(scene::ProcObject), +} + +/// What an edit places at an object's slot: a procedural object's BLAS (hardware traversal), or instanced mesh `k`. +pub(super) enum Placed { + Proc(wgpu::Blas), + Mesh(u32), +} + +/// One object fewer draws instanced mesh `k`: a mesh edits manage that none draws any more is freed (its triangles a +/// hole the next new mesh can take, its slot reused, its BLAS dropped with it). +pub(super) fn drop_mesh_user(k: u32, edit: &mut EditState, tables: &mut tables::Tables, meshes: &[scene::InstMesh]) { + let n = edit.users.entry(k).or_default(); + *n = n.saturating_sub(1); + if *n == 0 && edit.managed.remove(&k) { + let im = meshes[k as usize]; + edit.tris.give(edit.pool_first + im.first as usize, im.count as usize); + tables.retained.own_mesh.retain(|_, v| *v != k); + edit.dead_meshes.push(k); + } +} + +/// The room's buffers past their end, doubled at least (ROADMAP § 6 step 5): new buffers, the old contents copied on +/// the GPU; the scene bind group is made again after the edit (`EditState::rebind`). Not with particles, whose pass +/// keeps a bind group of its own over these buffers. +pub(super) struct Grow<'a> { + pub(super) g: &'a gpu::Gpu, + pub(super) ok: bool, +} + +impl Grow<'_> { + /// The room twice as large (the scene's own data before it stays as it is), at least `extra` more at the end. + pub(super) fn cap(h: &Holes, extra: usize) -> usize { + h.base + ((h.cap - h.base) * 2).max(h.end + extra - h.base).max(1024) + } + + /// Whether buffers of these sizes (bytes) can be bound (the device's storage binding and buffer limits): past them + /// the edit rebuilds instead (Bugbot on #131). + pub(super) fn fits(&self, sizes: &[u64]) -> bool { + let l = self.g.device.limits(); + let max = (l.max_storage_buffer_binding_size as u64).min(l.max_buffer_size); + sizes.iter().all(|&s| s <= max) + } + + /// Triangles: vertices, attributes, the BLASes' vertices, vertex colours (after the previous vertices). + pub(super) fn tris(&self, h: &mut Holes, extra: usize, verts: &mut wgpu::Buffer, attrs: &mut wgpu::Buffer, prev: &mut wgpu::Buffer, vcolor_base: Option, accel: &mut gpu::Accel) -> bool { + if !self.ok { + return false; + } + let cap = Self::cap(h, extra); + let prev_bytes = vcolor_base.map_or(0, |base| (base + cap as u64 * 12).next_multiple_of(16)); + if !self.fits(&[(cap * 48) as u64, (cap * std::mem::size_of::()) as u64, (cap * 36) as u64, prev_bytes]) { + return false; + } + *verts = self.g.grown(verts, (cap * 48) as u64, "verts"); + *attrs = self.g.grown(attrs, (cap * std::mem::size_of::()) as u64, "attrs"); + self.g.grow_blas_verts(accel, (cap * 36) as u64); + if vcolor_base.is_some() { + *prev = self.g.grown(prev, prev_bytes, "prev verts + vertex colours"); + } + h.cap = cap; + true + } + + /// Kernel records and the TLAS (a record's instance is `inst_base` + it). + pub(super) fn records(&self, h: &mut Holes, extra: usize, instances: &mut wgpu::Buffer, accel: &mut gpu::Accel) -> bool { + if !self.ok { + return false; + } + let cap = Self::cap(h, extra); + if !self.fits(&[(cap * std::mem::size_of::()) as u64]) { + return false; + } + *instances = self.g.grown(instances, (cap * std::mem::size_of::()) as u64, "instances"); + self.g.grow_tlas(accel, accel.inst_base + cap); + h.cap = cap; + true + } + + /// Object-table entries (`stride` vec4s each). + pub(super) fn objects(&self, h: &mut Holes, extra: usize, objects: &mut wgpu::Buffer, stride: usize) -> bool { + if !self.ok { + return false; + } + let cap = Self::cap(h, extra); + if !self.fits(&[(cap * stride * 16) as u64]) { + return false; + } + *objects = self.g.grown(objects, (cap * stride * 16) as u64, "objects"); + h.cap = cap; + true + } + + /// Procedural words, after the environment's CDF (`cdf_words` of them). + pub(super) fn words(&self, cap: &mut usize, need: usize, cdf: &mut wgpu::Buffer, cdf_words: usize) -> bool { + if !self.ok { + return false; + } + let new = (*cap * 2).max(need); + if !self.fits(&[((cdf_words + new) * 4) as u64]) { + return false; + } + *cap = new; + *cdf = self.g.grown(cdf, ((cdf_words + *cap) * 4) as u64, "env_cdf + procedural"); + true + } +} + +/// An object removed or hidden in place: no longer picked until the next rebuild (`LiveCore::pick`). +pub(super) fn unpick(id: u32, edit: &mut EditState, tables: &tables::Tables, instances: &mut [scene::Instance], procs: &mut [scene::ProcObject], proc_of: &std::collections::HashMap) { + edit.added.remove(&id); + if let Some(i) = tables.retained.own_instance.get(&id).and_then(|&k| instances.get_mut(k as usize)) { + i.hidden = true; + } + if let Some(p) = proc_of.get(&id).and_then(|&j| procs.get_mut(j)) { + p.hidden = true; + } +} + +impl LiveCore { + /// Applies what scene code changed in the world since the last commit. Parameter changes (power, light / material + /// parameters, wetness / rain / fog, the camera) rewrite GPU table entries in place; anything structural rebuilds + /// the GPU scene from the world (history survives, validated per pixel by depth / normal). Returns true on a + /// rebuild. + pub fn commit(&mut self, world: &mut World) -> Result { + self.cpu_skin_ms = 0.0; + self.cpu_upload_ms = 0.0; + self.gpu_blas_ms = 0.0; + // The game's particle spawns, for the next frame's pass; the voxel occupancy grid's changes, uploaded. + for (id, v) in std::mem::take(&mut world.particle_spawns) { + self.particle_spawns.entry(id).or_default().extend(v); + } + if let Some(o) = &world.voxel_occupancy { + let mut o = o.write().unwrap(); + if !o.dirty.is_empty() { + self.g.upload_occupancy(&self.sc.up, &mut o); + } + } + // The density grids: the game's sources for the next frame's grid pass, and new data uploaded in place. + for (id, v) in std::mem::take(&mut world.grid_sources) { + self.grid_sources.entry(id).or_default().extend(v); + } + for id in std::mem::take(&mut world.grid_dirty) { + if let Some(d) = world.grids.get(&id).and_then(|g| g.data.as_ref()) { + self.g.upload_grid(&self.sc.up, id, d); + } + } + let mut changes = world.take_changes(); + if !changes.any() { + return Ok(false); + } + // Objects shown or hidden by their own `visible`: in place when the build has them (a rain curtain the weather + // turns off, a pool's idle fireball and its light cost nothing while off), else with the rebuild. + if !changes.structural { + for id in std::mem::take(&mut changes.shown) { + let hide = !world.shown(id); + let done = match world.objects.get(&id).map(|o| &o.kind) { + Some(crate::world::Kind::Particles { .. }) => self.g.set_particles_hidden(&self.sc.up, id, hide), + Some(crate::world::Kind::Volume { shape, .. }) => self.g.set_medium_hidden(&self.sc.up, id, *shape, hide), + // (Its table entry, black while hidden: `Tables::update_light`.) + Some(crate::world::Kind::Light(_)) if self.sc.tables.light_index.contains_key(&id) => { + changes.lights.insert(id); + true + } + _ => false, + }; + if !done { + changes.structural = true; + } + } + } + // New materials without textures: entries after the scene's, before the edits that place objects drawn with + // them (and the GPU's table with them, grown if it is full). + if !changes.new_materials.is_empty() && !changes.structural { + if self.sc.tables.add_materials(world, &changes.new_materials) { + self.upload_materials(); + // (A clear coat, an unlit material: the kernel variant with it, compiled once.) + let (e, (tex, func, groups)) = (&self.sc.tables, self.sc.fixed_features); + self.sc.up.kernel.features = gpu::features_of(&e.materials, e.fog.as_ref(), tex, func, groups, self.sc.instance_features) | if e.aerial[0] > 0.0 { gpu::F_AERIAL } else { 0 }; + } else { + self.fallback = Some("a new material that cannot be added in place (it emits, is glass or a cutout)".into()); + self.fallbacks += 1; + changes.structural = true; + } + } + // Objects added in place are not in the move tables (their records follow the scene's): a move rebuilds. Not a + // mesh a body drives: it is an instance, and its record moves as the build's do (`placed_movable`). + if !changes.structural && changes.moved.iter().any(|id| self.sc.edit.placed.contains_key(id) && self.placed_movable(world, *id).is_none()) { + self.fallback = Some("an object added in place moved".into()); + self.fallbacks += 1; + changes.structural = true; + if std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1") { + eprintln!("edit: rebuild (an object added in place moved)"); + } + } + // Instanced objects rewrite their instances in place (`update_instanced`); one that cannot rebuilds the scene. Before + // the geometry edits, whose TLAS build (always, or the rebuild they fall back to) then carries the instances too + // (a voxel scene's debris and batches: one build of a 20 k instance TLAS a frame, not two); else they build it. + // (The instances the last update moved, taken first: what this one moves, from here on, keeps its motion.) + let moved_before = std::mem::take(&mut self.inst_moving); + let mut instanced_early = false; + // (Not those added this commit: the geometry edits place them with their instances as they are.) + let early: Vec = std::mem::take(&mut changes.instanced).into_iter().filter(|id| !changes.added.contains(id)).collect(); + if !early.is_empty() && !changes.structural { + instanced_early = self.instanced_in_place(world, &early, !changes.edits(), &mut changes); + } + // Geometry edits in place when nothing else is structural; what they cannot apply rebuilds the scene. + if changes.edits() && !changes.structural { + let t = Instant::now(); + let r = self.edit_geometry(world, &changes); + if std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1") { + eprintln!("edit: {} geometries, {} added, {} removed: {}", changes.geometries.len(), changes.added.len(), changes.removed.len(), r.as_ref().map_or_else(|e| format!("rebuild ({e})"), |_| "in place".into())); + } + match r { + Ok(()) => { + self.edit_ms += t.elapsed().as_secs_f32() * 1e3; + // As after a rebuild: history validated per pixel. + self.lit_changed = self.frame.max(1); + } + Err(why) => { + self.fallback = Some(why); + self.fallbacks += 1; + changes.structural = true; + } + } + } + if !changes.promoted_geometry.is_empty() { + eprintln!("live: {}: geometry changed, now with its own BLAS (this once rebuilds; set `dynamicGeometry` or the attribute's DynamicDrawUsage to avoid it)", changes.promoted_geometry.join(", ")); + self.fallback = Some("promote".into()); + self.fallbacks += 1; + } + if !changes.promoted.is_empty() { + eprintln!("live: {} moved, now movable (this once rebuilds; set `movable = true` in the scene code to avoid it)", changes.promoted.join(", ")); + } + // Moved movable subtrees: their drawables are re-transformed with the posed models, their lights rewritten in + // the light table. + let mut dynamic = changes.poses.clone(); + let mut placed = vec![]; + let mut procs_moved = vec![]; + let mut instanced_moved = vec![]; + let children = if changes.moved.is_empty() { Default::default() } else { world.children() }; + for id in &changes.moved { + for k in world.subtree_with(*id, &children) { + match &world.objects[&k].kind { + crate::world::Kind::Light(crate::desc::Light::Rect { visible: true, .. }) | crate::world::Kind::Mesh { .. } | crate::world::Kind::Model { .. } => { + if world.shown(k) { + // Movable models placed as instances move their transform (with a shared pose too); the rest + // re-transform triangles. + let r = &self.sc.tables.retained; + match r.instance_of.get(&k).copied().or_else(|| self.placed_movable(world, k)) { + Some(i) if !changes.poses.contains(&k) || r.pose_member.contains_key(&k) => placed.push((k, i)), + _ => { + dynamic.insert(k); + } + } + } + } + crate::world::Kind::Light(_) => { + changes.lights.insert(k); + } + // Particle functions place their particles around the node (`i.pivot`). + crate::world::Kind::Particles { .. } => { + let p = world.world_matrix(k).transform_point3(glam::Vec3::ZERO); + self.g.set_particle_pivot(&self.sc.up, k, p.to_array()); + } + crate::world::Kind::GpuParticles { .. } => { + let p = world.world_matrix(k).transform_point3(glam::Vec3::ZERO); + self.g.set_gpu_particle_pivot(&self.sc.up, k, p.to_array()); + } + // Media: the box / sphere follows the node. + crate::world::Kind::Volume { .. } => { + self.g.set_medium(&self.sc.up, k, Some(scene::medium_rows(world.world_matrix(k))), None); + } + crate::world::Kind::Procedural { .. } if world.shown(k) => procs_moved.push(k), + crate::world::Kind::Instanced { .. } => instanced_moved.push(k), + _ => {} + } + } + } + // Instanced objects under a moved ancestor (or movable themselves) rewrite their instances too, unless the update + // above did (with the same world matrices; twice would take the new rows for the previous frame's: no motion). + // The TLAS is built by whatever moves after them, or there. + let mut instanced_done = instanced_early; + instanced_moved.retain(|k| !early.contains(k)); + if !instanced_moved.is_empty() && !changes.structural { + let others = !placed.is_empty() || !dynamic.is_empty() || !procs_moved.is_empty(); + instanced_done |= self.instanced_in_place(world, &instanced_moved, !others, &mut changes); + } + // Posed rigid models move their nodes' instances (`repose_rigid`), not triangles. + let rigid: Vec = dynamic.iter().copied().filter(|k| self.sc.tables.retained.rigid.contains_key(k)).collect(); + for k in &rigid { + dynamic.remove(k); + } + // Models in a shared pose re-skin their group's mesh (`repose_groups`, its BLAS only); their moves place instances. + let shared: Vec = dynamic.iter().copied().filter(|k| self.sc.tables.retained.pose_member.contains_key(k)).collect(); + for k in &shared { + dynamic.remove(k); + } + let shared_ok = shared.is_empty() || (!changes.structural && self.repose_groups(world, &shared)?); + // Procedural geometry moves its records and TLAS transforms; the TLAS is built by whatever moves after it, or here. + if !procs_moved.is_empty() && !changes.structural { + self.move_procs(world, &procs_moved, placed.is_empty() && dynamic.is_empty() && rigid.is_empty() && shared.is_empty()); + } + let placed_ok = shared_ok && (placed.is_empty() || (!changes.structural && self.move_instances(world, &placed, dynamic.is_empty() && rigid.is_empty()))); + let t_rigid = Instant::now(); + let rigid_ok = placed_ok && (rigid.is_empty() || (!changes.structural && self.repose_rigid(world, &rigid, dynamic.is_empty())?)); + self.cpu_skin_ms += t_rigid.elapsed().as_secs_f32() * 1000.0; + // Instances the last update moved and this one does not: their motion ends, the previous-frame rows catch up + // (written merged: an instanced object's thousands of records are contiguous). + if moved_before != self.inst_moving { + let size = std::mem::size_of::() as u64; + let now_moving: std::collections::HashSet = self.inst_moving.iter().copied().collect(); + let mut catch_up = Gather::default(); + for i in moved_before.into_iter().filter(|i| !now_moving.contains(i)) { + if let Some(r) = self.sc.gpu_instances.get_mut(i as usize) { + r.p = r.m; + catch_up.put(i as u64 * size, bytemuck::bytes_of(r)); + } + } + catch_up.flush(&self.g.queue, &self.sc.up.instances); + } + if rigid_ok && (dynamic.is_empty() || (!changes.structural && self.repose(world, &dynamic)?)) { + // A new shared pose and nothing else that rebuilds the TLAS (no move): rebuild it for the meshes' new bounds. + if !shared.is_empty() && placed.is_empty() && rigid.is_empty() && dynamic.is_empty() { + if let Some(accel) = self.sc.up.accel.as_ref() { + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_tlas(accel, &mut e); + self.g.queue.submit([e.finish()]); + } + } + // Moved emitters: the light tree's moving subtree around them, once (after every in-place update). + if std::mem::take(&mut self.emitters_moved) { + self.refit_moving_tree(); + } + changes.poses.clear(); + changes.moved.clear(); + // Moving objects move their shadows and reflections over static surfaces: let anti-lag look for it. + if !dynamic.is_empty() || !placed.is_empty() || !rigid.is_empty() || !shared.is_empty() || !procs_moved.is_empty() || instanced_done { + self.lit_changed = self.frame.max(1); + } + } + // Particle nodes' customData: their particle function's `i.custom` (the table has no object for the node). + for id in &changes.customs { + if let Some(o) = world.objects.get(id).filter(|o| matches!(o.kind, crate::world::Kind::Particles { .. })) { + self.g.set_particle_custom(&self.sc.up, *id, o.custom); + } + } + // Volume nodes' customData: their volume function's `i.custom` (the media table, not the objects table). + for id in &changes.customs { + if let Some(o) = world.objects.get(id).filter(|o| matches!(o.kind, crate::world::Kind::Volume { .. })) { + self.g.set_medium(&self.sc.up, *id, None, Some(o.custom)); + } + } + let now = shader_time(self.time); + let applied = self.sc.tables.apply(world, &changes, now).ok().flatten(); + let Some(d) = applied else { + let t = Instant::now(); + self.rebuild(world)?; + self.rebuild_ms += t.elapsed().as_secs_f32() * 1e3; + if changes.camera { + self.cam = self.sc.scene_camera; + } + return Ok(true); + }; + if d.materials { + self.upload_materials(); + } + let (q, up, e) = (&self.g.queue, &self.sc.up, &mut self.sc.tables); + if d.lights && !e.lights.is_empty() { + if d.lights_moved { + e.regroup_lights(); + } + q.write_buffer(&up.lights, 0, bytemuck::cast_slice(&crate::lights_with_groups(&e.lights))); + } + if d.objects { + q.write_buffer(&up.objects, 0, bytemuck::cast_slice(&e.objects)); + } + if d.materials || d.emitters { + self.add_material_emitters(); + self.write_emitters(); + } else if d.deferred { + self.send_powers(); + } + let (e, b) = (&mut self.sc.tables, &mut self.sc.base); + if d.environment { + b.time[1] = e.wetness; + b.time[2] = e.rain; + b.background = e.background.map_or([0.0; 4], |c| [c[0], c[1], c[2], 1.0]); + b.aerial = e.aerial; + [b.fog, b.fog_color, b.vol, b.vol2] = crate::fog_params(e.fog.as_ref()); + } + if d.materials || d.environment { + // e.g. fog appearing needs the fog kernel variant (compiled once, then cached) + let (tex, func, groups) = self.sc.fixed_features; + self.sc.up.kernel.features = gpu::features_of(&e.materials, e.fog.as_ref(), tex, func, groups, self.sc.instance_features) | if e.aerial[0] > 0.0 { gpu::F_AERIAL } else { 0 }; + } + if let Some(c) = e.camera.take() { + self.sc.scene_camera = Camera { pos: c.position, fwd: c.forward, up: c.up, right: c.right, tan_half_fov: c.tan_half_fov }; + self.sc.tan_half_fov = c.tan_half_fov; + self.sc.dof = c.dof.clone(); + self.cam = self.sc.scene_camera; + } + // Lighting changed: for 45 frames the histories hold HISTORY_LIVE frames at most (`live_lighting`), and where the + // change is strong the temporal pass cuts the history locally (anti-lag). + if d.materials || d.lights || d.objects || d.environment { + self.lit_changed = self.frame.max(1); + } + // (A new cloud cover re-bakes the sky and dims the sun as a sun move does.) + if changes.sun || self.sc.tables.cloud_cover != self.sky_cover { + self.sky_cover = self.sc.tables.cloud_cover; + self.move_sun(world); + } + if changes.exposure { + self.apply_exposure(world); + } + Ok(false) + } + + /// The atmosphere's sun moved (time of day): its light entry is rewritten now (direction, and its colour through + /// the atmosphere), and the sky's re-bake is asked of the worker thread (`DynamicSky`, started here on the first + /// move). Lockstep frames wait for the bake, so a lockstep run sees the sky that goes with each sun. + pub(super) fn move_sun(&mut self, world: &World) { + let Some((id, dir, lux)) = scene::atmosphere_sun(world) else { return }; + if self.sc.sky.is_none() { + let (size, alt, floor) = self.sc.sky_inputs; + self.sc.sky = DynamicSky::new(world, size, alt, floor); + } + let Some(sky) = self.sc.sky.as_ref() else { return }; + let cover = self.sc.tables.cloud_cover; + let colour = crate::sky::sun_illuminance(&sky.atm, dir, lux, cover); + let e = &mut self.sc.tables; + if let (Some(idx), Some(o)) = (e.light_index.get(&id), world.objects.get(&id)) { + if let crate::world::Kind::Light(l) = &o.kind { + if let Some(mut gl) = scene::gpu_light(l, world.world_matrix(id), o.power) { + // (The sun: hidden with a rebuild, `World::set_visible`.) + gl.color = [colour.x, colour.y, colour.z, 0.0]; + for &i in idx { + e.lights[i] = gl; + } + self.g.queue.write_buffer(&self.sc.up.lights, 0, bytemuck::cast_slice(&crate::lights_with_groups(&e.lights))); + } + } + } + let _ = sky.ask.send((dir, lux, cover)); + if self.fixed_dt.is_some() { + if let Ok(b) = sky.done.recv() { + self.put_sky(b); + } + } + self.lit_changed = self.frame.max(1); + } + + /// A sky the worker thread has baked since the last frame (the newest), into the environment map and its CDF. + pub(super) fn take_sky(&mut self) { + let Some(sky) = self.sc.sky.as_ref() else { return }; + let mut last = None; + while let Ok(b) = sky.done.try_recv() { + last = Some(b); + } + if let Some(b) = last { + self.put_sky(b); + } + } + + pub(super) fn put_sky(&mut self, (env, cdf, max): (gpu::EnvMap, Vec, f32)) { + let Some(sky) = self.sc.sky.as_ref() else { return }; + self.g.write_env(&self.sc.up, &env, &cdf); + // As the load decides it: the sky is light-sampled unless it is negligible next to the emitters (night), or a + // baked sky is not light-sampled at all (`env_nee_on`). + self.sc.base.counts2[1] = (max >= sky.env_nee_floor && crate::env_nee_on()) as u32; + self.lit_changed = self.frame.max(1); + } + + /// The camera's manual exposure changed value (an exposure that follows the time of day), in place: the post pass's + /// exposure and the kernel's firefly clamp, which is relative to it. + pub(super) fn apply_exposure(&mut self, world: &World) { + let Some(cid) = world.settings.camera else { return }; + let Some(crate::world::Kind::Camera(c)) = world.objects.get(&cid).map(|o| &o.kind) else { return }; + let crate::desc::Exposure::Manual { ev100 } = c.exposure else { return }; + let e = 2f32.powf(-ev100); + if self.sc.exposure > 0.0 { + self.sc.base.misc[0] *= self.sc.exposure / e; + } + self.sc.exposure = e; + self.sc.exposure_settings.ae2 = [e, 0.0, 0.0, 0.0]; + } + + /// Moves movable instances: their kernel records (the old transform kept as the previous frame's, for motion + /// vectors), their object entries' pivots and their TLAS transforms (hardware traversal; with `build_tlas` it + /// rebuilds the TLAS, otherwise the dynamic rebuild that follows does) or their units and the top tree over them + /// (software traversal, units.rs). No triangles change. False when it cannot (software traversal without units, + /// or an instance that is no unit; a top tree that cannot be made): the caller rebuilds. + pub(super) fn move_instances(&mut self, world: &World, placed: &[(u32, u32)], build_tlas: bool) -> bool { + // (Hardware traversal's TLAS instances, or software traversal's units; neither: the caller rebuilds.) + let Some(mut top) = gpu::Top::of(&mut self.sc.up.accel, &mut self.sc.up.units) else { return false }; + let size = std::mem::size_of::() as u64; + let q = &self.g.queue; + // (Many records, a rigid body world's thousands a frame, are written as the one range that holds them: the + // records between are as the GPU has them.) + let merged = placed.len() > 32; + let (mut lo, mut hi) = (usize::MAX, 0usize); + // (Instances the last update moved and this one does not catch up in `commit`.) + for &(node, i) in placed { + let w = world.world_matrix(node); + // (A mesh an edit added in place keeps its instance with the edit's, `placed_movable`; its record and TLAS + // instance are where the build's are.) + let inst = match self.sc.edit.added.get_mut(&node) { + Some(Picked::Mesh(inst)) => inst, + _ => &mut self.sc.instances[i as usize], + }; + // (Software traversal: an instance of a mesh that deforms is no unit; its triangles are among the still + // scene's, in the world, and a rebuild moves them.) + if !top.has_mesh(inst.mesh) { + return false; + } + let old = self.sc.gpu_instances[i as usize]; + inst.world = w; + let mut r = top.record(inst.gpu(old.info[0], old.info[3]), inst.mesh, inst.cast_shadow); + r.p = old.m; + self.sc.gpu_instances[i as usize] = r; + if merged { + (lo, hi) = (lo.min(i as usize), hi.max(i as usize)); + } else { + q.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(&r)); + } + let pivot = w.transform_point3(glam::Vec3::ZERO); + let st = self.sc.tables.stride; + let o = &mut self.sc.tables.objects[inst.object as usize * st]; + (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); + let slot = top.inst_base() + i as usize; + top.moved(slot, w, None); + self.inst_moving.push(i); + } + if merged { + q.write_buffer(&self.sc.up.instances, lo as u64 * size, bytemuck::cast_slice(&self.sc.gpu_instances[lo..=hi])); + } + q.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + self.inst_moved = true; + match top { + gpu::Top::Hw(accel) => { + if build_tlas { + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_tlas(accel, &mut e); + q.submit([e.finish()]); + } + true + } + // (The top tree is built here whoever else changes units this commit: it is CPU work and one write, and a + // tree that cannot be made must send the caller to a rebuild now.) + gpu::Top::Sw(units) => self.g.retop(units, &self.sc.up.bg_parts.nodes).map_err(|e| eprintln!("units: {e}: rebuilding the scene")).is_ok(), + } + } + + /// The material table to the GPU: written in place, or, with more materials than its buffer holds (materials added + /// while the game runs), into a buffer twice the size, the scene's bind group made over it. + pub(super) fn upload_materials(&mut self) { + let bytes: &[u8] = bytemuck::cast_slice(&self.sc.tables.materials); + if bytes.len() as u64 > self.sc.up.materials.size() { + let usage = self.sc.up.materials.usage(); + self.sc.up.materials = self.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("materials"), size: (bytes.len() as u64 * 2).next_multiple_of(16), usage, mapped_at_creation: false }); + self.g.rebind_scene(&mut self.sc.up); + } + self.g.queue.write_buffer(&self.sc.up.materials, 0, bytes); + } + + /// The record of a mesh an edit added in place that moves as an instance does (`move_instances`): one a rigid body + /// drives, placed by its node's transform (an emitter mesh's triangles are in world space: it cannot). + pub(super) fn placed_movable(&self, world: &World, id: u32) -> Option { + let &(_, record) = self.sc.edit.placed.get(&id)?; + let mesh = matches!(self.sc.edit.added.get(&id), Some(Picked::Mesh(_))); + (mesh && world.objects.get(&id).is_some_and(|o| o.driven) && !self.sc.tables.retained.emitter_mesh.contains_key(&id)).then_some(record as u32) + } + + /// `update_instanced` with its fallback (the reason in the frame log, the scene rebuilt) and its CPU time; whether it + /// changed anything. + pub(super) fn instanced_in_place(&mut self, world: &World, ids: &[u32], build_tlas: bool, changes: &mut crate::world::Changes) -> bool { + let t = Instant::now(); + let r = self.update_instanced(world, ids, build_tlas); + self.instanced_ms += t.elapsed().as_secs_f32() * 1e3; + let log = std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1"); + match r { + Ok(n) => { + if log { + eprintln!("edit: {n} instanced objects in place"); + } + n > 0 + } + Err(why) => { + if log { + eprintln!("edit: rebuild ({why})"); + } + self.fallback = Some(why); + self.fallbacks += 1; + changes.structural = true; + false + } + } + } + + /// Emitter entries for the materials that started emitting (`Tables::started_emitting`): their flattened static + /// triangles (`mat_runs`), positions from `tris`, in the power-share part of the CDF (path code 0, as the light + /// tree's moving emitters; ROADMAP § 6 step 3). `write_emitters` uploads them. + pub(super) fn add_material_emitters(&mut self) { + let sc = &mut self.sc; + for mat in std::mem::take(&mut sc.tables.started_emitting) { + for &[first, end, obj] in sc.mat_runs.get(&mat).map_or(&[][..], |v| v.as_slice()) { + for k in first..end { + let Some(&[a, b, c]) = sc.tri_order.get(k as usize).and_then(|&i| sc.tris.get(i as usize)) else { continue }; + let area = 0.5 * (b - a).cross(c - a).length(); + if area > 0.0 { + let centre = (a + b + c) / 3.0; + sc.tables.emitter_tris.push(crate::EmitterTri { tri: k, mat, obj, area, c: centre.to_array(), moves: false, nominal: false, in_tree: false }); + } + } + } + } + } + + /// The emitter CDF from `Tables::emitter_tris` (weights from materials and powers; entries added in place at its + /// end, removed ones at area 0), the light tree refit to the new weights and its nodes after the CDF, the CDF's + /// share of power, its count. With the tree's slot for emitters added in place (scenes that can edit geometry), their + /// subtree is grown again first and their entries go right after the static ones (ROADMAP § 6 step 4); without it + /// they stay at the end, by power share. The buffer grows (and the scene bind group is made again) when entries + /// were added past it: a scene that adds none keeps its layout. + pub(super) fn write_emitters(&mut self) { + self.land_powers_now(); + let g = &self.g; + let LoadedScene { tables: e, base: b, up, pool_tris, tris: flat, tri_order, edit, .. } = &mut self.sc; + // Emitters added in place, with the tree's slot for them (`ltree::build_slotted`): its subtree grown again over + // those alive, their entries right after the static ones in the CDF (in its leaf order: each leaf's entry is its + // position) and their path codes in their triangles' attrs (written over the code 0 an edit gave them). + let mut perm: Option> = None; + if let Some(t) = e.light_tree.as_mut() { + if let Some((_, ns)) = t.dynamic { + let ns = ns as usize; + let (mats, objs, st, pf) = (&e.materials, &e.objects, e.stride, edit.pool_first); + let verts = |k: usize| if k >= pf { pool_tris.get(k - pf).copied() } else { tri_order.get(k).and_then(|&i| flat.get(i as usize)).copied() }; + let mut items = vec![]; + for (i, x) in e.emitter_tris.iter().enumerate().skip(ns) { + let w = crate::emitter_power(x, mats, objs, st); + let Some([va, vb, vc]) = verts(x.tri as usize).filter(|_| !x.in_tree && !x.nominal && !x.moves && w > 0.0) else { continue }; + items.push(crate::ltree::DynItem { e: i as u32, lo: va.min(vb).min(vc).to_array(), hi: va.max(vb).max(vc).to_array(), c: x.c, w }); + } + t.set_dynamic(items); + let at = std::mem::offset_of!(scene::GpuTriAttr, x) + 12; + let mut codes = Gather::default(); + let mut placed = vec![false; e.emitter_tris.len()]; + let mut p: Vec = (0..ns).collect(); + for k in ns..t.order.len() { + let i = t.order[k] as usize; + codes.put((e.emitter_tris[i].tri as usize * std::mem::size_of::() + at) as u64, bytemuck::bytes_of(&t.code[k])); + placed[i] = true; + p.push(i); + } + codes.flush(&g.queue, &up.attrs); + p.extend((ns..e.emitter_tris.len()).filter(|&i| !placed[i])); + perm = Some(p); + } + } + let permuted: Vec; + let list = match &perm { + Some(p) => { + permuted = p.iter().map(|&i| e.emitter_tris[i]).collect(); + &permuted[..] + } + None => &e.emitter_tris[..], + }; + let (em, total, power) = crate::emitter_cdf(list, &e.materials, &e.objects, e.stride, b.light_lod, crate::EmitterCull::of(b)); + if let Some(t) = e.light_tree.as_mut() { + let (tris, mats, objs, st) = (&e.emitter_tris, &e.materials, &e.objects, e.stride); + let w: Vec = t.order.iter().map(|&i| crate::emitter_power(&tris[i as usize], mats, objs, st)).collect(); + t.refit(&|k| w[k]); + } + self.put_emitters(&em, total, power); + // Emitters added in place since the worker was made: another, over them. + if self.sc.emit.as_ref().is_some_and(|w| w.count != self.sc.tables.emitter_tris.len()) { + self.sc.emit = EmitterWorker::new(&self.sc.tables, &self.g.device); + self.sc.tables.defer_powers = self.sc.emit.is_some(); + } + } + + /// The emitter CDF `em` (its total weight and the emitters' power), then the light tree's nodes, into the emitters + /// buffer (grown, and the scene bind group made again, when they no longer fit), and the kernel's parameters. + pub(super) fn put_emitters(&mut self, em: &[[f32; 4]], total: f32, power: f32) { + let g = &self.g; + let LoadedScene { tables: e, base: b, up, .. } = &mut self.sc; + let nodes: &[[f32; 4]] = e.light_tree.as_ref().map_or(&[], |t| &t.nodes); + let need = ((em.len() + nodes.len()) * 16) as u64; + if need > up.emitters.size() { + // (The usage it was made with: the merged layout copies the tables each frame, `Gpu::sync_tables`.) + let usage = up.emitters.usage(); + up.emitters = g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("emitters"), size: need + need / 2, usage, mapped_at_creation: false }); + g.rebind_scene(up); + } + if !em.is_empty() { + g.queue.write_buffer(&up.emitters, 0, bytemuck::cast_slice(em)); + } + if let Some(t) = e.light_tree.as_ref() { + g.queue.write_buffer(&up.emitters, em.len() as u64 * 16, bytemuck::cast_slice(&t.nodes)); + } + let _ = b; + self.emitter_params(em.len(), total, power); + } + + /// The kernel's emitter parameters for a CDF of `entries` (its total weight; the emitters' power) and the tree. + pub(super) fn emitter_params(&mut self, entries: usize, total: f32, power: f32) { + let LoadedScene { tables: e, base: b, .. } = &mut self.sc; + b.emit_power[0] = power; + b.misc[1] = total; + if let Some(t) = e.light_tree.as_ref() { + b.ltree[0] = f32::from_bits(entries as u32); + // The tree's entries (with the slot: the static ones and those added in place), the rest by power share. + if t.dynamic.is_some() { + b.ltree[1] = f32::from_bits(t.order.len() as u32); + } + b.ltree[2] = if power > 0.0 { ((power - t.power) / power).max(0.0) } else { 0.0 }; + } + b.counts[1] = if total > 0.0 { e.emitter_tris.len() as u32 } else { 0 }; + } + + /// Power edits held back (`Tables::deferred`) to the emitter worker, unless it is on a job (they go with the next, + /// when that one lands). Lockstep frames wait for the result. + pub(super) fn send_powers(&mut self) { + let Some(w) = self.sc.emit.as_mut() else { return }; + if w.flight.is_some() || self.sc.tables.deferred.is_empty() { + return; + } + let e = &mut self.sc.tables; + let powers = std::mem::take(&mut e.deferred); + let mut objects = e.objects.clone(); + for &(row, power, _) in &powers { + objects[row][3] = power; + } + let b = &self.sc.base; + let job = EmitterJob { materials: e.materials.clone(), objects, lod: b.light_lod, cull: crate::EmitterCull::of(b) }; + if w.ask.send(job).is_err() { + // (The worker is gone: write them now.) + e.put_powers(&powers); + self.sc.emit = None; + e.defer_powers = false; + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + self.write_emitters(); + return; + } + w.flight = Some(powers); + if self.fixed_dt.is_some() { + if let Ok(r) = self.sc.emit.as_ref().unwrap().done.recv() { + self.land_emitters(r); + } + } + } + + /// The emitter worker's result, if it has one: its edits land with it, and the edits held back since go out. + pub(super) fn take_emitters(&mut self) { + let Some(r) = self.sc.emit.as_ref().and_then(|w| w.done.try_recv().ok()) else { return }; + self.land_emitters(r); + self.send_powers(); + } + + /// A job's edits and its emitter CDF and tree powers, together: the objects' powers, the CDF, the tree. + pub(super) fn land_emitters(&mut self, r: EmitterBuilt) { + let Some(powers) = self.sc.emit.as_mut().and_then(|w| w.flight.take()) else { return }; + let e = &mut self.sc.tables; + e.put_powers(&powers); + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&e.objects)); + if let Some(t) = e.light_tree.as_mut() { + for (n, &p) in t.nodes.iter_mut().zip(&r.node_powers) { + n[2] = p; + } + t.power = t.nodes.first().map_or(0.0, |n| n[2]); + } + // (Same entries and nodes as the CDF the scene was loaded with: the buffer holds them.) + let mut enc = self.g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("emitters") }); + enc.copy_buffer_to_buffer(&r.staging, 0, &self.sc.up.emitters, 0, r.bytes.min(self.sc.up.emitters.size())); + self.g.queue.submit([enc.finish()]); + self.emitter_params(r.entries, r.total, r.power); + self.lit_changed = self.frame.max(1); + } + + /// Before another emitter edit (`write_emitters`, a light-LOD rebuild): the job in flight waited for and its result + /// dropped, its edits and those held back written into the objects (and uploaded), so the rebuild sees them all. + pub(super) fn land_powers_now(&mut self) { + let Some(w) = self.sc.emit.as_mut() else { return }; + let mut powers = vec![]; + if let Some(f) = w.flight.take() { + let _ = w.done.recv(); + powers = f; + } + let e = &mut self.sc.tables; + powers.append(&mut e.deferred); + if !powers.is_empty() { + e.put_powers(&powers); + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&e.objects)); + // The lighting changed (as when a result lands): `live_lighting` for the next 45 frames. + self.lit_changed = self.frame.max(1); + } + } + + /// Instanced objects whose instances changed (`World::instanced`, the node's transform, power or customData): their + /// kernel records (the old transforms kept as the previous frame's, for motion vectors), object entries (pivot, + /// bounds, power, data, colour), TLAS transforms and masks (`scene::instance_world`: a hidden instance 0), each + /// object's records and entries written as one range; with `build_tlas` the TLAS is rebuilt here. No triangles or + /// BLASes change. Returns how many objects it updated; Err: why it cannot (software traversal, an object the build + /// flattened or did not place this way, an instance now mirrored), and the caller rebuilds. + /// `THREERT_EDIT_REBUILD=1` always rebuilds (A/B). + pub(super) fn update_instanced(&mut self, world: &World, ids: &[u32], build_tlas: bool) -> std::result::Result { + use crate::world::Kind; + if std::env::var("THREERT_EDIT_REBUILD").is_ok_and(|v| v == "1") { + return Err("THREERT_EDIT_REBUILD=1".into()); + } + let size = std::mem::size_of::(); + let now = shader_time(self.time); + let empty = crate::world::InstanceTable::default(); + let sc = &mut self.sc; + let Some(mut top) = gpu::Top::of(&mut sc.up.accel, &mut sc.up.units) else { return Err("software traversal".into()) }; + let mut done = 0; + for &id in ids { + // (Removed or hidden: nothing is drawn; a structural change rebuilds.) + let Some(n) = world.objects.get(&id).filter(|_| world.shown(id)) else { continue }; + let Kind::Instanced { capacity, procedural, .. } = n.kind else { continue }; + let Some(&place) = sc.tables.retained.instanced.get(&id) else { return Err("an instanced mesh the build flattened".into()) }; + if place.capacity != capacity || place.procedural != procedural { + return Err("an instanced mesh the build placed otherwise".into()); + } + let t = world.instanced.get(&id).unwrap_or(&empty); + let m = world.world_matrix(id); + // Past both the old and the new count every instance is hidden and stays so: left as it is. + let (first, cap) = (place.first as usize, place.drawn.max(t.count).min(capacity) as usize); + // (A procedural one's hidden instances are out of hardware traversal by their TLAS masks; software + // traversal walks every record there is: the build leaves them out.) + if procedural && top.sw() { + return Err("a procedural instanced mesh (software traversal)".into()); + } + let (rec0, slot0) = if procedural { (sc.proc_first + first, top.proc_base(sc.proc_first) + first) } else { (first, top.inst_base() + first) }; + for i in 0..cap { + let (wm, hidden) = scene::instance_world(m, t, i); + if !procedural && !hidden && wm.determinant() < 0.0 { + return Err("an instance is mirrored".into()); + } + let old = sc.gpu_instances[rec0 + i]; + let (mut rec, entry) = if procedural { + let p = &mut sc.procs[first + i]; + (p.world, p.hidden) = (wm, hidden); + (p.gpu(old.info[0]), p.entry(n.power, t.custom(i, n.custom), t.color(i))) + } else { + let inst = &mut sc.instances[first + i]; + if !top.has_mesh(inst.mesh) { + return Err("an instanced mesh that is no unit (software traversal)".into()); + } + (inst.world, inst.hidden) = (wm, hidden); + let (lo, hi) = sc.meshes[inst.mesh as usize].placed_bounds(wm); + (top.record(inst.gpu(old.info[0], old.info[3]), inst.mesh, n.cast_shadow), scene::object_row(wm.transform_point3(glam::Vec3::ZERO), n.power, hi - lo, t.custom(i, n.custom), t.color(i))) + }; + rec.p = old.m; + sc.gpu_instances[rec0 + i] = rec; + // The time of the last power edit: now for a new power. + let st = sc.tables.stride; + let at = (place.object as usize + i) * st; + let e = &mut sc.tables.objects[at..at + st]; + let edited = if e[0][3] != entry[0][3] { now } else { e[1][3] }; + e.copy_from_slice(&entry[..st]); + e[1][3] = edited; + top.moved(slot0 + i, wm, Some((n.cast_shadow, hidden))); + } + let q = &self.g.queue; + q.write_buffer(&sc.up.instances, (rec0 * size) as u64, bytemuck::cast_slice(&sc.gpu_instances[rec0..rec0 + cap])); + let st = sc.tables.stride; + let at = place.object as usize * st; + q.write_buffer(&sc.up.objects, (at * 16) as u64, bytemuck::cast_slice(&sc.tables.objects[at..at + cap * st])); + self.inst_moving.extend(rec0 as u32..(rec0 + cap) as u32); + sc.tables.retained.instanced.get_mut(&id).unwrap().drawn = t.count; + done += 1; + } + if done > 0 { + self.inst_moved = true; + match top { + gpu::Top::Hw(accel) => { + if build_tlas { + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_tlas(accel, &mut e); + self.g.queue.submit([e.finish()]); + } + } + gpu::Top::Sw(units) => self.g.retop(units, &sc.up.bg_parts.nodes)?, + } + } + Ok(done) + } + + /// Moves procedural objects: their kernel records (the old transform kept as the previous frame's, for motion + /// vectors), object pivots and, with hardware RT, TLAS transforms (`build_tlas`: the TLAS rebuilt here). Software + /// traversal reads the records alone, so neither path rebuilds the scene. + pub(super) fn move_procs(&mut self, world: &World, ids: &[u32], build_tlas: bool) { + let size = std::mem::size_of::() as u64; + for id in ids { + let Some(&j) = self.sc.proc_of.get(id) else { continue }; + let w = world.world_matrix(*id); + let p = &mut self.sc.procs[j]; + p.world = w; + let i = self.sc.proc_first + j; + let old = self.sc.gpu_instances[i]; + let mut r = p.gpu(old.info[0]); + r.p = old.m; + self.sc.gpu_instances[i] = r; + self.g.queue.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(&r)); + let pivot = w.transform_point3(glam::Vec3::ZERO); + let st = self.sc.tables.stride; + let o = &mut self.sc.tables.objects[p.object as usize * st]; + (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); + if let Some(accel) = self.sc.up.accel.as_mut() { + if let Some(t) = accel.tlas[accel.proc_base + j].as_mut() { + t.transform = gpu::tlas_rows(w); + } + } + self.inst_moving.push(i as u32); + } + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + if let (true, Some(accel)) = (build_tlas, self.sc.up.accel.as_ref()) { + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_tlas(accel, &mut e); + self.g.queue.submit([e.finish()]); + } + self.inst_moved = true; + } + + /// Posed rigid models (`scene::RigidModel`): each moving node's instance takes object x node transform from the clip + /// (previous rows kept for motion vectors) and their flattened triangles are re-transformed in place; then the dynamic + /// BLASes (if they have such triangles) and, when `build_tlas`, the TLAS are rebuilt (`repose` rebuilds both after + /// it otherwise). False when it cannot (no hardware RT, a model hidden or changed): the caller rebuilds. + pub(super) fn repose_rigid(&mut self, world: &World, ids: &[u32], build_tlas: bool) -> Result { + if self.sc.up.accel.is_none() { + return Ok(false); + } + let mut placements: Vec<(u32, glam::Mat4)> = vec![]; + let (mut lo, mut hi) = (usize::MAX, 0usize); + for id in ids { + let Some(o) = world.objects.get(id) else { return Ok(false) }; + let crate::world::Kind::Model { uri, animation, .. } = &o.kind else { return Ok(false) }; + if !world.shown(*id) { + return Ok(false); + } + let globals = scene::model_gltf(uri, &self.assets)?.node_globals(animation.as_ref())?; + let m = world.world_matrix(*id); + let r = &self.sc.tables.retained.rigid[id]; + placements.extend(r.instances.iter().map(|&(node, i)| (i, m * globals[node]))); + let d = &r.rest; + if d.finals.len() != d.mats.len() { + return Ok(false); + } + let (first, verts, attrs, blas) = &mut self.sc.dynamic; + for (j, (node, p, n, uv, tg)) in r.rest_local.iter().enumerate() { + let w = m * globals[*node]; + let nm = glam::Mat3::from_mat4(w).inverse().transpose(); + let pw = p.map(|v| w.transform_point3(v)); + let nw = n.map(|v| (nm * v).normalize_or_zero()); + let tw = tg.map(|(v, s)| (w.transform_vector3(v).normalize_or_zero(), s)); + let a = scene::tri_attr(pw, nw, *uv, tw, d.mats[j]); + let k = d.finals[j] as usize - *first; + let (e1, e2) = (pw[1] - pw[0], pw[2] - pw[0]); + verts[k * 3..k * 3 + 3].copy_from_slice(&[[pw[0].x, pw[0].y, pw[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); + attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; + blas[k * 3..k * 3 + 3].copy_from_slice(&pw.map(|v| v.to_array())); + self.sc.tris[(d.first + j as u32) as usize] = pw.map(glam::Vec3A::from); + (lo, hi) = (lo.min(k), hi.max(k + 1)); + } + let pivot = m.transform_point3(glam::Vec3::ZERO); + let o = &mut self.sc.tables.objects[d.object as usize * self.sc.tables.stride]; + (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); + } + // The instances: rows, previous rows (motion vectors), TLAS transforms. + let size = std::mem::size_of::() as u64; + let accel = self.sc.up.accel.as_mut().unwrap(); + let (mut ilo, mut ihi) = (usize::MAX, 0usize); + for &(i, w) in &placements { + let inst = &mut self.sc.instances[i as usize]; + let old = self.sc.gpu_instances[i as usize]; + inst.world = w; + let mut r = inst.gpu(old.info[0], old.info[3]); + r.p = old.m; + self.sc.gpu_instances[i as usize] = r; + (ilo, ihi) = (ilo.min(i as usize), ihi.max(i as usize + 1)); + let row = |k: usize| [w.x_axis[k], w.y_axis[k], w.z_axis[k], w.w_axis[k]]; + if let Some(t) = accel.tlas[accel.inst_base + i as usize].as_mut() { + t.transform = [row(0), row(1), row(2)].concat().try_into().unwrap(); + } + // (Rigid models' instances are theirs alone: no other update this commit placed them.) + self.inst_moving.push(i); + } + if lo < hi { + self.emitters_moved = true; + } + // A model's node instances are contiguous: one write. + if ilo < ihi { + self.g.queue.write_buffer(&self.sc.up.instances, ilo as u64 * size, bytemuck::cast_slice(&self.sc.gpu_instances[ilo..ihi])); + } + self.inst_moved = true; + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + if lo < hi { + // Motion vectors of the flattened triangles: the previous frame's vertices, as `repose` keeps them. + let both = self.prev_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi))); + self.keep_prev(both); + self.prev_span = Some((lo, hi)); + self.reposed = true; + let (q, up, (first, verts, attrs, blas)) = (&self.g.queue, &self.sc.up, &self.sc.dynamic); + let accel = up.accel.as_ref().unwrap(); + let k = (first + lo) as u64; + q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&verts[lo * 3..hi * 3])); + q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&attrs[lo..hi])); + q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&blas[lo * 3..hi * 3])); + } + if build_tlas { + let accel = self.sc.up.accel.as_ref().unwrap(); + let mut e = self.g.device.create_command_encoder(&Default::default()); + if lo < hi { + self.g.rebuild_dynamic(accel, &mut e); + } else { + self.g.rebuild_tlas(accel, &mut e); + } + self.g.queue.submit([e.finish()]); + } + Ok(true) + } + + /// Models in a shared pose (`scene::PoseGroup`): each group whose pose changed is skinned once, in model space (the + /// groups in parallel), into its mesh's pool triangles, and their BLASes are rebuilt; the TLAS is left to the moves + /// that follow (or `commit`). False when a group's members no longer share one pose, or one is hidden or changed: + /// the caller rebuilds, which groups them again. (Deforming meshes get no motion vectors of their own yet: an + /// instance's are its transform's.) + pub(super) fn repose_groups(&mut self, world: &World, members: &[u32]) -> Result { + use rayon::prelude::*; + if self.sc.up.accel.is_none() { + return Ok(false); + } + let r = &self.sc.tables.retained; + let mut touched: Vec = members.iter().map(|k| r.pose_member[k]).collect(); + touched.sort_unstable(); + touched.dedup(); + let mut jobs: Vec<(u32, crate::desc::Animation)> = vec![]; + for &g in &touched { + let pg = &r.pose_groups[g as usize]; + let mut pose: Option<&crate::desc::Animation> = None; + for (id, _) in &pg.members { + let Some(o) = world.objects.get(id) else { return Ok(false) }; + let crate::world::Kind::Model { uri, nodes, animation: Some(a), .. } = &o.kind else { return Ok(false) }; + if !world.shown(*id) || *uri != pg.uri || *nodes != pg.nodes || pose.is_some_and(|p| p != a) { + return Ok(false); + } + pose = Some(a); + } + if let Some(a) = pose.filter(|a| **a != pg.animation) { + jobs.push((g, a.clone())); + } + } + if jobs.is_empty() { + return Ok(true); + } + let t_skin = Instant::now(); + let assets = &self.assets; + type Skinned = (Vec<[f32; 4]>, Vec, Vec<[f32; 3]>, Vec<[glam::Vec3A; 3]>); + let skinned: Vec>> = jobs + .par_iter() + .map(|(g, a)| { + let pg = &r.pose_groups[*g as usize]; + let tris = scene::model_gltf(&pg.uri, assets)?.triangles(glam::Mat4::IDENTITY, pg.nodes.as_deref(), Some(a))?; + if tris.len() != pg.order.len() { + return Ok(None); + } + let n = pg.order.len(); + let (mut v, mut at, mut b, mut t) = (Vec::with_capacity(n * 3), Vec::with_capacity(n), Vec::with_capacity(n * 3), Vec::with_capacity(n)); + for &(k, mat) in &pg.order { + let q = &tris[k as usize]; + let (e1, e2) = (q.p[1] - q.p[0], q.p[2] - q.p[0]); + // The flag word keeps TRI_DYNAMIC: the kernel takes a deforming mesh's motion from `prev_verts`. + v.extend_from_slice(&[[q.p[0].x, q.p[0].y, q.p[0].z, f32::from_bits(scene::TRI_DYNAMIC)], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); + at.push(scene::tri_attr(q.p, q.n, q.uv, q.tangent, mat)); + b.extend(q.p.map(|p| p.to_array())); + t.push(q.p.map(glam::Vec3A::from)); + } + Ok(Some((v, at, b, t))) + }) + .collect(); + self.cpu_skin_ms += t_skin.elapsed().as_secs_f32() * 1000.0; + let t_up = Instant::now(); + // Motion vectors: the GPU still holds the previous pose of these meshes (and of the last re-skin's, if others): + // keep it in `prev_verts` before uploading the new one. + let df = self.sc.dynamic.0; + let (mut lo, mut hi) = (usize::MAX, 0usize); + for (g, _) in &jobs { + let pg = &self.sc.tables.retained.pose_groups[*g as usize]; + let k = self.sc.gpu_instances[pg.members[0].1 as usize].info[0] as usize - df; + (lo, hi) = (lo.min(k), hi.max(k + pg.order.len())); + } + self.keep_prev(self.pose_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi)))); + self.pose_span = Some((lo, hi)); + self.pose_reposed = true; + let (q, up) = (&self.g.queue, &self.sc.up); + let accel = up.accel.as_ref().unwrap(); + let mut meshes = Vec::with_capacity(jobs.len()); + for ((g, a), res) in jobs.into_iter().zip(skinned) { + let Some((v, at, b, t)) = res? else { return Ok(false) }; + let pg = &mut self.sc.tables.retained.pose_groups[g as usize]; + // The mesh's first triangle in the GPU order: any member's record has it. + let k = self.sc.gpu_instances[pg.members[0].1 as usize].info[0] as u64; + q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&v)); + q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&at)); + q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&b)); + let m = self.sc.meshes[pg.mesh as usize]; + self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].copy_from_slice(&t); + pg.animation = a; + meshes.push(pg.mesh); + } + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_meshes(accel, &meshes, &mut e); + let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); + if profile_blas { + let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); + } + let t_blas = Instant::now(); + q.submit([e.finish()]); + if profile_blas { + let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); + self.gpu_blas_ms += t_blas.elapsed().as_secs_f32() * 1000.0; + } + self.cpu_upload_ms += t_up.elapsed().as_secs_f32() * 1000.0; + Ok(true) + } + + /// Re-skins posed models and re-transforms movable objects in place (hardware RT): their triangles, shading + /// attributes and dynamic BLASes, then the TLAS. Their directly seen surfaces keep a short history for a moment, + /// like a power edit (no per-object motion vectors). False when it cannot (not dynamic at build, no hardware RT, a + /// different triangle count): the caller rebuilds. + pub(super) fn repose(&mut self, world: &World, ids: &std::collections::HashSet) -> Result { + if self.sc.up.accel.is_none() || ids.iter().any(|id| !self.sc.tables.retained.dynamic.contains_key(id)) { + return Ok(false); + } + let models = std::mem::take(&mut self.sc.tables.retained.dynamic); + let t_skin = Instant::now(); + let res = self.reskin(world, ids, &models); + self.cpu_skin_ms += t_skin.elapsed().as_secs_f32() * 1000.0; + self.sc.tables.retained.dynamic = models; + let Some((lo, hi)) = res? else { return Ok(false) }; + self.emitters_moved = true; + let t_up = Instant::now(); + // Motion vectors: the GPU still holds the previous frame's vertices for this span (and for last update's span, + // if different): keep them as `prev_verts` before uploading the new ones. + let both = self.prev_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi))); + self.keep_prev(both); + self.prev_span = Some((lo, hi)); + self.reposed = true; + // Upload the touched span of the dynamic tail, then rebuild the dynamic BLASes and the TLAS. + let (q, up, (first, verts, attrs, blas)) = (&self.g.queue, &self.sc.up, &self.sc.dynamic); + let accel = up.accel.as_ref().unwrap(); + let k = (first + lo) as u64; + q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&verts[lo * 3..hi * 3])); + q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&attrs[lo..hi])); + q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&blas[lo * 3..hi * 3])); + q.write_buffer(&up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_dynamic(accel, &mut e); + let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); + if profile_blas { + let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); + } + let t_blas = Instant::now(); + q.submit([e.finish()]); + if profile_blas { + let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); + self.gpu_blas_ms += t_blas.elapsed().as_secs_f32() * 1000.0; + } + self.cpu_upload_ms += t_up.elapsed().as_secs_f32() * 1000.0; + Ok(true) + } + + /// now (the CPU copy of the dynamic tail), its nodes and the root uploaded (`ltree::LightTree::refit_moving`). + pub(super) fn refit_moving_tree(&mut self) { + let first = self.sc.dynamic.0; + let (verts, tables) = (&self.sc.dynamic.1, &mut self.sc.tables); + let tris = &tables.emitter_tris; + let Some(t) = tables.light_tree.as_mut().filter(|t| t.moving_count > 0) else { return }; + t.refit_moving(&|k| { + let r = (tris[k].tri as usize - first) * 3; + crate::ltree::tri_vertices(&verts[r..r + 3]) + }); + let (q, up, off) = (&self.g.queue, &self.sc.up, self.sc.base.ltree[0].to_bits() as u64); + q.write_buffer(&up.emitters, off * 16, bytemuck::cast_slice(&t.nodes[..1])); + q.write_buffer(&up.emitters, (off + 2) * 16, bytemuck::cast_slice(&t.nodes[2..3])); + q.write_buffer(&up.emitters, (off + t.moving_nodes as u64) * 16, bytemuck::cast_slice(&t.nodes[t.moving_nodes..])); + } + + /// Copies the GPU's current vertices of a dynamic-tail span into `prev_verts` (submitted before any write queued + /// after it, so it reads what the last frame traced). + pub(super) fn keep_prev(&self, (lo, hi): (usize, usize)) { + let up = &self.sc.up; + let first = self.sc.dynamic.0; + let mut e = self.g.device.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(&up.verts, ((first + lo) * 48) as u64, &up.prev_verts, (lo * 48) as u64, ((hi - lo) * 48) as u64); + self.g.queue.submit([e.finish()]); + } + + /// Skins / transforms the objects into the CPU copy of the dynamic tail; the touched span (relative to the tail), or + /// None when one cannot be updated in place. + pub(super) fn reskin(&mut self, world: &World, ids: &std::collections::HashSet, models: &std::collections::HashMap) -> Result> { + use rayon::prelude::*; + if ids.iter().any(|id| !world.shown(*id)) { + return Ok(None); + } + // Objects whose triangles form one run of the dynamic tail (one traversal group) are skinned / re-transformed in + // parallel, one task per object, straight into their own spans of the tail and of the CPU triangles (one core + // took 17 ms for the crowd scene's 150 walkers, docs/ROADMAP.md § 0): ordered by span, the objects split the + // arrays into disjoint slices. The others (e.g. opaque and cutout materials in different groups) one by one. + let run = |d: &scene::DynamicModel| !d.finals.is_empty() && d.finals.iter().enumerate().all(|(j, &f)| f == d.finals[0] + j as u32); + let (mut runs, scattered): (Vec, Vec) = ids.iter().copied().partition(|id| run(&models[id])); + runs.sort_by_key(|id| models[id].finals[0]); + let assets = &self.assets; + let sc = &mut self.sc; + let (first, verts, attrs, blas) = &mut sc.dynamic; + let first = *first; + let (mut lo, mut hi) = (usize::MAX, 0); + // The CPU triangles in build order: each object's slice. + let mut by_build = runs.clone(); + by_build.sort_by_key(|id| models[id].first); + let mut tris_of: std::collections::HashMap = std::collections::HashMap::with_capacity(runs.len()); + let (mut tr, mut toff): (&mut [[glam::Vec3A; 3]], usize) = (&mut sc.tris[..], 0); + for id in &by_build { + let d = &models[id]; + let (t0, n) = (d.first as usize, d.finals.len()); + let (_, r) = std::mem::take(&mut tr).split_at_mut(t0 - toff); + let (t, r) = r.split_at_mut(n); + tr = r; + toff = t0 + n; + tris_of.insert(*id, t); + } + let (mut vr, mut ar, mut br): (&mut [[f32; 4]], &mut [scene::GpuTriAttr], &mut [[f32; 3]]) = (&mut verts[..], &mut attrs[..], &mut blas[..]); + let mut voff = 0usize; + let mut jobs = Vec::with_capacity(runs.len()); + for id in &runs { + let d = &models[id]; + let (k0, n) = (d.finals[0] as usize - first, d.finals.len()); + let (_, r) = std::mem::take(&mut vr).split_at_mut(k0 * 3 - voff * 3); + let (v, r) = r.split_at_mut(n * 3); + vr = r; + let (_, r) = std::mem::take(&mut ar).split_at_mut(k0 - voff); + let (a, r) = r.split_at_mut(n); + ar = r; + let (_, r) = std::mem::take(&mut br).split_at_mut(k0 * 3 - voff * 3); + let (b, r) = r.split_at_mut(n * 3); + br = r; + voff = k0 + n; + (lo, hi) = (lo.min(k0), hi.max(k0 + n)); + jobs.push((*id, v, a, b, tris_of.remove(id).unwrap())); + } + let ok: Vec> = jobs + .into_par_iter() + .map(|(id, v, a, b, t)| { + let d = &models[&id]; + let Some(tris) = scene::dynamic_tris(world, id, d, assets)? else { return Ok(false) }; + if tris.len() != d.finals.len() { + return Ok(false); + } + for (j, (p, at)) in tris.into_iter().enumerate() { + let (e1, e2) = (p[1] - p[0], p[2] - p[0]); + v[j * 3..j * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); + // (x.w: an emitter's light-tree code, which stays with its triangle.) + a[j] = scene::GpuTriAttr { x: [at.x[0], f32::from_bits(d.object), at.x[2], a[j].x[3]], ..at }; + b[j * 3..j * 3 + 3].copy_from_slice(&p.map(|q| q.to_array())); + t[j] = p.map(glam::Vec3A::from); + } + Ok(true) + }) + .collect(); + for r in ok { + if !r? { + return Ok(None); + } + } + for id in &scattered { + let d = &models[id]; + let Some(tris) = scene::dynamic_tris(world, *id, d, assets)? else { return Ok(None) }; + if tris.len() != d.finals.len() { + return Ok(None); + } + for (j, (p, a)) in tris.into_iter().enumerate() { + let k = d.finals[j] as usize - first; + let (e1, e2) = (p[1] - p[0], p[2] - p[0]); + verts[k * 3..k * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); + attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; + blas[k * 3..k * 3 + 3].copy_from_slice(&p.map(|v| v.to_array())); + sc.tris[(d.first + j as u32) as usize] = p.map(glam::Vec3A::from); + (lo, hi) = (lo.min(k), hi.max(k + 1)); + } + } + for id in ids { + let d = &models[id]; + // Pivot (material functions' object position). Motion vectors carry its history. + let pivot = world.world_matrix(*id).transform_point3(glam::Vec3::ZERO); + let st = sc.tables.stride; + let o = &mut sc.tables.objects[d.object as usize * st]; + (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); + } + Ok((lo < hi).then_some((lo, hi))) + } + + /// Rebuilds the GPU scene from the world. + /// Applies geometry edits in place (`Changes::geometries`, `added`, `removed`; objects with their own BLAS: + /// procedural meshes and `own_blas` meshes): each touched object's data is written into the edit room + /// (gpu.rs `EditRoom`) as the build would make it (`scene::proc_object`, `scene::own_mesh_tris`), new BLASes are + /// built for new geometry only, its TLAS instance is set (cleared for a removed or hidden object) and the TLAS + /// rebuilt, one submit. Nothing is moved: what an edit replaces stays behind until the next rebuild. Err: why it + /// cannot (software traversal, the room is full, a kernel feature the scene lacks, an emitting / glass / function + /// material, ...); the caller rebuilds the scene then. `THREERT_EDIT_REBUILD=1` always rebuilds (A/B). + pub(super) fn edit_geometry(&mut self, world: &World, ch: &crate::world::Changes) -> std::result::Result<(), String> { + use crate::world::Kind; + if std::env::var("THREERT_EDIT_REBUILD").is_ok_and(|v| v == "1") { + return Err("THREERT_EDIT_REBUILD=1".into()); + } + let LoadedScene { up, tables, edit, meshes, pool_tris, instances, gpu_instances, procs, proc_of, proc_first, .. } = &mut self.sc; + let (g, assets) = (&self.g, &self.assets); + let features = up.kernel.features; + let grow = Grow { g: &self.g, ok: up.particles.is_none() && up.gpu_particles.is_none() }; + // (Hardware traversal's TLAS and BLASes, or software traversal's units: `gpu::Top`.) + let Some(mut top) = gpu::Top::of(&mut up.accel, &mut up.units) else { + // Software traversal without units. With units on, that is a scene without an instance yet: the first + // one brings the unit walk into the kernel (F_UNITS), as hardware traversal's first brings F_INST. + let first = crate::units_on(true) && ch.added.iter().any(|id| matches!(world.objects.get(id).map(|o| &o.kind), Some(Kind::Mesh { own_blas: true, .. } | Kind::Instanced { procedural: false, .. }))); + return Err(if first { "the scene's first instanced mesh (kernel feature)" } else { "software traversal" }.into()); + }; + // The objects to place again: those added, and those whose geometry was replaced. + let mut todo: std::collections::BTreeSet = ch.added.clone(); + if !ch.geometries.is_empty() { + for (id, o) in &world.objects { + match &o.kind { + Kind::Procedural { geometry, .. } | Kind::Mesh { geometry, own_blas: true, .. } if ch.geometries.contains(geometry) => { + todo.insert(*id); + } + Kind::Mesh { geometry, .. } if ch.geometries.contains(geometry) => return Err("a flattened mesh's geometry changed".into()), + // (One added this commit is placed with its geometry as it is.) + Kind::Instanced { geometry, .. } if ch.geometries.contains(geometry) && !ch.added.contains(id) => return Err("an instanced mesh's geometry changed".into()), + _ => {} + } + } + } + let (inst_base, proc_base, proc_first) = (top.inst_base(), top.proc_base(*proc_first), *proc_first); + let slot_of = |id: u32, edit: &EditState, tables: &tables::Tables, proc_of: &std::collections::HashMap| -> Option<(usize, usize)> { + if let Some(&v) = edit.placed.get(&id) { + Some(v) + } else if let Some(&k) = tables.retained.own_instance.get(&id) { + Some((inst_base + k as usize, k as usize)) + } else { + proc_of.get(&id).map(|&j| (proc_base + j, proc_first + j)) + } + }; + let q = &g.queue; + let mut e = g.device.create_command_encoder(&Default::default()); + // An emitter mesh's entries go (area 0: their weight; the light tree keeps its shape) when it is removed, hidden + // or placed again. + let mut emitters_changed = false; + let release = |id: u32, tables: &mut tables::Tables, meshes: &[scene::InstMesh], pool_first: usize| -> bool { + let Some(k) = tables.retained.emitter_mesh.remove(&id) else { return false }; + let im = meshes[k as usize]; + let (lo, hi) = ((pool_first + im.first as usize) as u32, (pool_first + (im.first + im.count) as usize) as u32); + for e in tables.emitter_tris.iter_mut().filter(|e| (lo..hi).contains(&e.tri)) { + e.area = 0.0; + } + true + }; + for id in &ch.removed { + // An instanced mesh an edit added: its instances, records and entries. + if let Some((r0, cap, o0)) = edit.instanced.remove(id) { + for r in r0..r0 + cap { + top.clear(inst_base + r); + if let Some(i) = instances.get_mut(r) { + i.hidden = true; + } + } + edit.records.give(r0, cap); + edit.objects.give(o0, cap); + tables.retained.instanced.remove(id); + tables.retained.objects.remove(id); + if let Some(k) = edit.mesh_of.remove(id) { + drop_mesh_user(k, edit, tables, meshes); + } + continue; + } + // An instanced mesh the build placed: its instances out (records and entries stay until the next rebuild). + if let Some(place) = tables.retained.instanced.get(id).filter(|p| !p.procedural) { + for r in place.first as usize..(place.first + place.capacity) as usize { + top.clear(inst_base + r); + if let Some(i) = instances.get_mut(r) { + i.hidden = true; + } + } + tables.retained.instanced.remove(id); + tables.retained.objects.remove(id); + continue; + } + emitters_changed |= release(*id, tables, meshes, edit.pool_first); + let at = slot_of(*id, edit, tables, proc_of); + // Placed by the build otherwise (a model, a procedural instanced mesh): a rebuild takes it out. + if at.is_none() && tables.retained.objects.contains_key(id) { + return Err("an object the build placed otherwise (a model, a procedural instanced mesh) removed".into()); + } + if let Some((slot, _)) = at { + top.clear(slot); + } + unpick(*id, edit, tables, instances, procs, proc_of); + // What it held goes back to the room (the build's records and entries stay until the next rebuild). + if let Some((_, r)) = edit.placed.remove(id) { + edit.records.give(r, 1); + } + if let Some(k) = edit.mesh_of.remove(id) { + drop_mesh_user(k, edit, tables, meshes); + } + tables.retained.own_instance.remove(id); + tables.retained.instance_of.remove(id); + if let Some(k) = tables.retained.objects.remove(id).filter(|&k| k as usize >= edit.base.2) { + edit.objects.give(k as usize, 1); + } + proc_of.remove(id); + } + let mut new_meshes = vec![]; + let mut fresh_blases = vec![]; + let mut pending_procs = vec![]; + let mut split = EditSplit::default(); + let (mut w_cdf, mut w_verts, mut w_attrs, mut w_blas, mut w_records, mut w_objects) = (Gather::default(), Gather::default(), Gather::default(), Gather::default(), Gather::default(), Gather::default()); + // Vertex colours (F_VCOLOR), in `prev_verts` after the previous vertices. + let mut w_colors = Gather::default(); + let vcolor_base = up.vcolor_base; + // (geometry, material) -> the mesh built in this batch (objects sharing a replaced geometry share it again). + let mut fresh: std::collections::HashMap<(u32, u32), u32> = Default::default(); + let mask = |shadow: bool| if shadow { 1 } else { 2 }; + for id in todo { + let Some(o) = world.objects.get(&id) else { continue }; + let at = slot_of(id, edit, tables, proc_of); + emitters_changed |= release(id, tables, meshes, edit.pool_first); + if !world.shown(id) { + if let Some((slot, _)) = at { + top.clear(slot); + } + if edit.instanced.contains_key(&id) { + return Err("an instanced mesh added in place hidden".into()); + } + unpick(id, edit, tables, instances, procs, proc_of); + if let Some(k) = edit.mesh_of.remove(&id) { + drop_mesh_user(k, edit, tables, meshes); + } + continue; + } + // Drawn but not by a TLAS instance of its own: the build flattened it (movable, or a material instances skip). + if at.is_none() && tables.retained.objects.contains_key(&id) { + return Err("an object the build flattened".into()); + } + let m = world.world_matrix(id); + let object = match tables.retained.objects.get(&id) { + Some(&k) => k as usize, + None => match edit.objects.take(1) { + Some(k) => k, + None if grow.objects(&mut edit.objects, 1, &mut up.objects, tables.stride) => { + edit.grows += 1; + edit.rebind = true; + edit.objects.take(1).ok_or("edit room: objects")? + } + None => return Err("edit room: objects (particles, or the device's limits)".into()), + }, + }; + // Where its TLAS instance places it: the node, or identity for an emitter mesh (world-space triangles). + let mut placement = m; + let (record, gpu, blas, entry) = match &o.kind { + Kind::Procedural { .. } => { + // (Software traversal walks procedural objects by their count and their own trees in the region: + // the build lays those out.) + if top.sw() { + return Err("a procedural object (software traversal)".into()); + } + if features & gpu::F_PROC == 0 { + return Err("the scene's first procedural object (kernel feature)".into()); + } + let Some(p) = scene::proc_object(world, id, m, object as u32, &tables.retained.materials, assets).map_err(|e| format!("{e:#}"))? else { + if let Some((slot, _)) = at { + top.clear(slot); + } + unpick(id, edit, tables, instances, procs, proc_of); + continue; + }; + placement = m; + // Room for `used` words: the region grown if it lacks it. + let mut words_left = |used: usize, edit: &mut EditState| { + if used <= edit.word_cap { + return true; + } + let ok = grow.words(&mut edit.word_cap, used, &mut up.cdf, up.cdf_words); + if ok { + edit.grows += 1; + edit.rebind = true; + } + ok + }; + let boxes = match edit.blobs.get(&p.blob.hash) { + Some(&w) => w, + None => { + let w = edit.word; + if !words_left(w + p.blob.boxes.len() + p.blob.data.len(), edit) { + return Err("edit room: procedural words".into()); + } + let at = |word: usize| ((up.cdf_words + word) * 4) as u64; + w_cdf.put(at(w), bytemuck::cast_slice(&p.blob.boxes)); + w_cdf.put(at(w + p.blob.boxes.len()), bytemuck::cast_slice(&p.blob.data)); + edit.word += p.blob.boxes.len() + p.blob.data.len(); + edit.blobs.insert(p.blob.hash, w); + w + } + }; + let header = edit.word; + let words = p.header(header, boxes, (0, 0)); + if !words_left(header + words.len(), edit) { + return Err("edit room: procedural words".into()); + } + w_cdf.put(((up.cdf_words + header) * 4) as u64, bytemuck::cast_slice(&words)); + edit.word += words.len(); + let blas = g.proc_blas(&p.blob, &mut pending_procs, &mut fresh_blases); + let entry = p.object_entry(o); + let gpu = p.gpu(header as u32); + let record = at.map(|(_, r)| r); + match proc_of.get(&id) { + Some(&j) => procs[j] = p, + None => { + edit.added.insert(id, Picked::Proc(p)); + } + } + (record, gpu, Placed::Proc(blas), entry) + } + Kind::Mesh { materials, .. } if materials.len() > 1 => return Err("a mesh with several materials".into()), + Kind::Model { uri, nodes, animation, functions } => { + // A static model already in the scene: another instance of its mesh (one not in it brings materials + // and textures, which load with a rebuild). + if animation.is_some() { + return Err("an animated model".into()); + } + if !functions.is_empty() { + return Err("a model with material functions (its materials load with a rebuild)".into()); + } + if features & gpu::F_INST == 0 || m.determinant() <= 0.0 || std::env::var("THREERT_INSTANCING").is_ok_and(|v| v == "0") { + return Err("a model the build would flatten (mirrored, instancing off, or the scene's first instance)".into()); + } + let mesh = *tables.retained.model_mesh.get(&(uri.clone(), nodes.clone())).ok_or("a model not in the scene (its materials and textures load with a rebuild)")?; + let im = meshes[mesh as usize]; + if !top.has_mesh(mesh) { + return Err("a model whose mesh is no unit (it deforms; software traversal flattens it)".into()); + } + *edit.users.entry(mesh).or_default() += 1; + if let Some(k) = edit.mesh_of.insert(id, mesh) { + drop_mesh_user(k, edit, tables, meshes); + } + let inst = scene::Instance { mesh, world: m, object: object as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: None, hidden: false }; + let gpu = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); + let (lo, hi) = im.placed_bounds(m); + let entry = scene::object_row(m.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, o.custom, [1.0; 3]); + edit.added.insert(id, Picked::Mesh(inst)); + (at.map(|(_, r)| r), gpu, Placed::Mesh(mesh), entry) + } + Kind::Mesh { own_blas: true, .. } | Kind::Instanced { procedural: false, .. } => { + // An own-BLAS mesh, or (added in place) an instanced mesh: their mesh made alike. + let (geometry, material, capacity) = match &o.kind { + Kind::Mesh { geometry, material, .. } => (*geometry, *material, None), + Kind::Instanced { geometry, materials, capacity, .. } => (*geometry, *materials.first().ok_or("an instanced mesh without a material")?, Some(*capacity as usize)), + _ => unreachable!(), + }; + let (geometry, material) = (&geometry, &material); + if features & gpu::F_INST == 0 { + return Err("the scene's first instanced mesh (kernel feature)".into()); + } + // What the build flattens instead (scene.rs: mirrored, or instancing off). + if m.determinant() <= 0.0 || std::env::var("THREERT_INSTANCING").is_ok_and(|v| v == "0") { + return Err("a mirrored mesh, or instancing off (the build flattens it)".into()); + } + let mat = *tables.retained.materials.get(material).ok_or("unknown material")?; + let gm = tables.materials[mat as usize]; + // An emitting material (no glass, no function): an emitter mesh, its triangles in world space and its + // own (never shared), its emitter entries added (scene.rs; ROADMAP § 6 step 3). + let emits = scene::emits_only(&gm); + let class = if emits { u32::from(gm.uv2[3] > 0.0) } else { scene::material_class(&gm) }; + if class == 2 || (emits && capacity.is_some()) { + return Err("a glass or function material (an instanced mesh: emitting too)".into()); + } + // (An emitter mesh's triangles are in world space: one a body moves is flattened by the build.) + if emits && o.driven { + return Err("an emitting mesh a rigid body drives (the build flattens it)".into()); + } + if class == 1 && features & gpu::F_MASKED == 0 { + return Err("the scene's first cutout (kernel feature)".into()); + } + let colored = world.materials.get(material).is_some_and(|m| m.vertex_colors) && world.geometries.get(geometry).is_some_and(|g| !g.colors.is_empty()); + if colored && vcolor_base.is_none() { + return Err("the scene's first vertex colours (kernel feature)".into()); + } + let key = if emits { (u32::MAX - id, mat) } else { (*geometry, mat) }; + let kept = (!emits && !ch.geometries.contains(geometry)).then(|| tables.retained.own_mesh.get(&key).copied()).flatten(); + let mesh = match fresh.get(&key).copied().or(kept) { + Some(k) => k, + None => { + let geo = world.geometries.get(geometry).ok_or("unknown geometry")?; + let t0 = Instant::now(); + let (mut t, mut a) = if emits { scene::emitter_mesh_tris(geo, m, mat, object as u32) } else { scene::own_mesh_tris(geo, mat) }; + if t.is_empty() { + if let Some((slot, _)) = at { + top.clear(slot); + } + unpick(id, edit, tables, instances, procs, proc_of); + continue; + } + // Software traversal: the mesh's tree, and its triangles (their attributes, further down their + // colours) in the tree's order, as `prepare` lays a unit's mesh out. + let tree = match &top { + gpu::Top::Hw(_) => None, + gpu::Top::Sw(_) => { + let tree = crate::units::mesh_tree(&t)?; + t = tree.order.iter().map(|&i| t[i as usize]).collect(); + a = tree.order.iter().map(|&i| a[i as usize]).collect(); + Some(tree) + } + }; + // (A unit's cutout triangles carry their flag; hardware traversal has them in a geometry apart.) + let flags = f32::from_bits(if tree.is_some() && class == 1 { scene::TRI_MASKED } else { 0 }); + // As `prepare` lays the pool out: (a, flags), e1, e2; attributes; the BLAS's vertices. + let verts: Vec<[f32; 4]> = t.iter().flat_map(|&[a, b, c]| [[a.x, a.y, a.z, flags], [b.x - a.x, b.y - a.y, b.z - a.z, 0.0], [c.x - a.x, c.y - a.y, c.z - a.z, 0.0]]).collect(); + let blas_verts: Vec<[f32; 3]> = t.iter().flat_map(|&[a, b, c]| [[a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z]]).collect(); + split.tris += t0.elapsed().as_secs_f64() * 1e3; + // The same triangle count as the mesh it replaces: rewritten where it is, its BLAS rebuilt (no + // room used; a geometry that moves every frame, three.js's dynamic draw usage). Otherwise new + // triangles in the room and a new BLAS. + let same = tables.retained.own_mesh.get(&key).copied().filter(|&k| meshes[k as usize].count as usize == t.len() && meshes[k as usize].emits == emits); + let (k, first, im) = match same { + Some(k) => { + let im = meshes[k as usize]; + split.deformed += 1; + // (Its tree again: the records of the objects that draw it, all placed again by + // this edit, take the root it has now.) + if let (gpu::Top::Sw(units), Some(tree)) = (&mut top, &tree) { + if let Some(old) = units.drop_tree(k) { + g.fill_tree(&up.bg_parts.nodes, old); + } + let root = units.add_tree(k, tree).ok_or("edit room: tree nodes")?; + g.write_tree(&up.bg_parts.nodes, root, tree); + } + (k, edit.pool_first + im.first as usize, im) + } + None => { + // A hole that fits, else the room's end (grown if it lacks room). + let first = match edit.tris.take(t.len()) { + Some(f) => f, + // (Software traversal's room does not grow: the rebuild makes a larger one.) + None if match &mut top { gpu::Top::Hw(accel) => grow.tris(&mut edit.tris, t.len(), &mut up.verts, &mut up.attrs, &mut up.prev_verts, vcolor_base, accel), gpu::Top::Sw(_) => false } => { + edit.grows += 1; + edit.rebind = true; + edit.tris.take(t.len()).ok_or("edit room: triangles")? + } + None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: triangles (software traversal's does not grow)" } else { "edit room: triangles (particles, or the device's limits)" }.into()), + }; + let mut im = scene::own_inst_mesh(&t, (first - edit.pool_first) as u32, class); + im.emits = emits; + let t1 = Instant::now(); + // A freed mesh's slot, else a new one. + let dead = edit.dead_meshes.pop(); + let k = match &mut top { + gpu::Top::Hw(accel) => match dead { + Some(k) => { + g.set_mesh(accel, k, first, im.count, im.opaque); + k + } + None => g.add_mesh(accel, first, im.count, im.opaque), + }, + // Its tree into the mesh trees' room (a freed mesh's slot gives its tree back first). + gpu::Top::Sw(units) => { + let k = dead.unwrap_or(meshes.len() as u32); + let tree = tree.as_ref().expect("software traversal made the tree"); + if let Some(old) = units.drop_tree(k) { + g.fill_tree(&up.bg_parts.nodes, old); + } + let Some(root) = units.add_tree(k, tree) else { + edit.dead_meshes.extend(dead); + edit.tris.give(first, t.len()); + return Err("edit room: tree nodes".into()); + }; + g.write_tree(&up.bg_parts.nodes, root, tree); + k + } + }; + if k as usize == meshes.len() { + meshes.push(im); + } else { + meshes[k as usize] = im; + } + edit.managed.insert(k); + split.blas += t1.elapsed().as_secs_f64() * 1e3; + split.added += 1; + (k, first, im) + } + }; + let t2 = Instant::now(); + w_verts.put((first * 48) as u64, bytemuck::cast_slice(&verts)); + w_attrs.put((first * std::mem::size_of::()) as u64, bytemuck::cast_slice(&a)); + if !top.sw() { + w_blas.put((first * 36) as u64, bytemuck::cast_slice(&blas_verts)); + } + if let Some(base) = vcolor_base { + // White where it has none: the room's slots may hold an earlier mesh's. + let mut c = if colored { scene::tri_colors(geo, false, None) } else { vec![scene::WHITE; t.len()] }; + if let (Some(tree), true) = (&tree, colored) { + c = tree.order.iter().map(|&i| c[i as usize]).collect(); + } + w_colors.put(base + (first * 12) as u64, bytemuck::cast_slice(&c)); + } + split.upload += t2.elapsed().as_secs_f64() * 1e3; + let rel = first - edit.pool_first; + if pool_tris.len() < rel + t.len() { + pool_tris.resize(rel + t.len(), [glam::Vec3A::ZERO; 3]); + } + pool_tris[rel..rel + t.len()].copy_from_slice(&t); + if emits { + // Its emitter entries, in the power-share part of the CDF (path code 0). Rewritten where + // it was (a mesh that moves every frame: flying pieces), it takes back the entries it had + // there (`release` left them at area 0), so the CDF does not grow frame by frame; those + // in the light tree stay out (their triangles now carry code 0). + let range = first as u32..(first + t.len()) as u32; + let mut free: Vec = if same.is_some() { tables.emitter_tris.iter().enumerate().filter(|(_, e)| !e.in_tree && range.contains(&e.tri)).map(|(i, _)| i).rev().collect() } else { vec![] }; + for (j, tri) in t.iter().enumerate() { + let [ta, tb, tc] = *tri; + let area = 0.5 * (tb - ta).cross(tc - ta).length(); + if area > 0.0 { + let e = crate::EmitterTri { tri: (first + j) as u32, mat, obj: object as u32, area, c: ((ta + tb + tc) / 3.0).to_array(), moves: false, nominal: false, in_tree: false }; + match free.pop() { + Some(i) => tables.emitter_tris[i] = e, + None => tables.emitter_tris.push(e), + } + } + } + tables.retained.emitter_mesh.insert(id, k); + tables.emissive.insert(mat); + emitters_changed = true; + } + let _ = im; + new_meshes.push(k); + fresh.insert(key, k); + tables.retained.own_mesh.insert(key, k); + k + } + }; + let im = meshes[mesh as usize]; + // It draws `mesh` now (and no longer the one it drew, if another). + *edit.users.entry(mesh).or_default() += 1; + if let Some(k) = edit.mesh_of.insert(id, mesh) { + drop_mesh_user(k, edit, tables, meshes); + } + if let Some(cap) = capacity { + // An instanced mesh added in place: `cap` records (their TLAS instances `inst_base` + them) and + // object entries, contiguous, as the build places one (`update_instanced` then rewrites them). + let empty = crate::world::InstanceTable::default(); + let t = world.instanced.get(&id).unwrap_or(&empty); + if t.colors.is_some() && features & gpu::F_TINT == 0 { + return Err("the scene's first instance colours (kernel feature)".into()); + } + let placed: Vec<(glam::Mat4, bool)> = (0..cap).map(|i| scene::instance_world(m, t, i)).collect(); + if placed.iter().any(|(wm, hidden)| !hidden && wm.determinant() < 0.0) { + return Err("a mirrored instance (the build flattens them)".into()); + } + if !tables.retained.objects.contains_key(&id) { + edit.objects.give(object, 1); + } + let r0 = match edit.records.take(cap) { + Some(r) => r, + None if match &mut top { gpu::Top::Hw(accel) => grow.records(&mut edit.records, cap, &mut up.instances, accel), gpu::Top::Sw(_) => false } => { + edit.grows += 1; + edit.rebind = true; + edit.records.take(cap).ok_or("edit room: records")? + } + None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: records (software traversal's does not grow)" } else { "edit room: records (particles, or the device's limits)" }.into()), + }; + let o0 = match edit.objects.take(cap) { + Some(k) => k, + None if grow.objects(&mut edit.objects, cap, &mut up.objects, tables.stride) => { + edit.grows += 1; + edit.rebind = true; + edit.objects.take(cap).ok_or("edit room: objects")? + } + None => return Err("edit room: objects (particles, or the device's limits)".into()), + }; + let (st, size) = (tables.stride, std::mem::size_of::()); + if instances.len() < r0 + cap { + let filler = scene::Instance { mesh, world: glam::Mat4::IDENTITY, object: 0, cast_shadow: false, name: String::new(), sub: None, hidden: true }; + instances.resize(r0 + cap, filler); + } + if gpu_instances.len() < r0 + cap { + gpu_instances.resize(r0 + cap, scene::GpuInstance::default()); + } + if tables.objects.len() < (o0 + cap) * st { + tables.objects.resize((o0 + cap) * st, [0.0; 4]); + } + for (i, &(wm, hidden)) in placed.iter().enumerate() { + let inst = scene::Instance { mesh, world: wm, object: (o0 + i) as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: Some(i as u32), hidden }; + let rec = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); + w_records.put(((r0 + i) * size) as u64, bytemuck::bytes_of(&rec)); + gpu_instances[r0 + i] = rec; + top.place(inst_base + r0 + i, mesh, wm, gpu::INSTANCE_BIT | (r0 + i) as u32, o.cast_shadow, hidden); + let (lo, hi) = im.placed_bounds(wm); + let entry = scene::object_row(wm.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, t.custom(i, o.custom), t.color(i)); + w_objects.put(((o0 + i) * st * 16) as u64, bytemuck::cast_slice(&entry[..st])); + tables.objects[(o0 + i) * st..(o0 + i + 1) * st].copy_from_slice(&entry[..st]); + instances[r0 + i] = inst; + } + tables.retained.instanced.insert(id, scene::InstancedPlace { first: r0 as u32, capacity: cap as u32, object: o0 as u32, procedural: false, drawn: t.count }); + tables.retained.objects.insert(id, o0 as u32); + edit.instanced.insert(id, (r0, cap, o0)); + continue; + } + if emits { + placement = glam::Mat4::IDENTITY; + } + let inst = scene::Instance { mesh, world: placement, object: object as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: None, hidden: false }; + let gpu = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); + let (lo, hi) = im.placed_bounds(placement); + let entry = scene::object_row(m.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, o.custom, [1.0; 3]); + let record = at.map(|(_, r)| r); + match record.filter(|&r| r < edit.inst_at_load) { + Some(r) => instances[r] = inst, + None => { + edit.added.insert(id, Picked::Mesh(inst)); + } + } + (record, gpu, Placed::Mesh(mesh), entry) + } + _ => return Err("not an object with its own BLAS".into()), + }; + // Its record, TLAS instance and object entry: where it had them, or new ones from the room. + let (slot, record) = match (at, record) { + (Some((slot, _)), Some(r)) => (slot, r), + _ => { + let r = match edit.records.take(1) { + Some(r) => r, + None if match &mut top { gpu::Top::Hw(accel) => grow.records(&mut edit.records, 1, &mut up.instances, accel), gpu::Top::Sw(_) => false } => { + edit.grows += 1; + edit.rebind = true; + edit.records.take(1).ok_or("edit room: records")? + } + None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: records (software traversal's does not grow)" } else { "edit room: records (particles, or the device's limits)" }.into()), + }; + let v = (inst_base + r, r); + edit.placed.insert(id, v); + v + } + }; + w_records.put((record * std::mem::size_of::()) as u64, bytemuck::bytes_of(&gpu)); + if record >= gpu_instances.len() { + gpu_instances.resize(record + 1, scene::GpuInstance::default()); + } + gpu_instances[record] = gpu; + let custom = if matches!(o.kind, Kind::Procedural { .. }) { record as u32 } else { gpu::INSTANCE_BIT | record as u32 }; + match blas { + Placed::Proc(blas) => top.place_blas(slot, &blas, placement, custom, mask(o.cast_shadow)), + Placed::Mesh(k) => top.place(slot, k, placement, custom, o.cast_shadow, false), + } + let st = tables.stride; + w_objects.put((object * st * 16) as u64, bytemuck::cast_slice(&entry[..st])); + let at = object * st; + if at >= tables.objects.len() { + tables.objects.resize(at + st, [0.0; 4]); + } + tables.objects[at..at + st].copy_from_slice(&entry[..st]); + tables.retained.objects.insert(id, object as u32); + } + // The gathered writes, merged, before the builds that read them. + let tw = Instant::now(); + w_cdf.flush(q, &up.cdf); + w_verts.flush(q, &up.verts); + w_attrs.flush(q, &up.attrs); + if let gpu::Top::Hw(accel) = &top { + w_blas.flush(q, &accel.verts); + } + w_colors.flush(q, &up.prev_verts); + w_records.flush(q, &up.instances); + w_objects.flush(q, &up.objects); + split.upload += tw.elapsed().as_secs_f64() * 1e3; + let t3 = Instant::now(); + new_meshes.sort_unstable(); + new_meshes.dedup(); + // The new meshes' and procedural BLASes and the TLAS in one build (`Gpu::build_meshes`). + match &mut top { + gpu::Top::Hw(accel) => g.build_edit(accel, &new_meshes, &pending_procs, &mut e, true), + // The trees are written; the meshes this edit freed give theirs back; the top tree over the units as they + // are now (one that cannot be made: the scene is rebuilt). + gpu::Top::Sw(units) => { + for &k in &edit.dead_meshes { + if let Some(old) = units.drop_tree(k) { + g.fill_tree(&up.bg_parts.nodes, old); + } + } + g.retop(units, &up.bg_parts.nodes)?; + } + } + split.blas += t3.elapsed().as_secs_f64() * 1e3; + // THREERT_PROFILE_BLAS=1: the BLAS / TLAS builds' GPU time (the queue drained before and after; frame log + // `blas`), as for the dynamic rebuilds. + let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); + if profile_blas { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + } + let t = Instant::now(); + q.submit([e.finish()]); + g.keep_proc_blases(fresh_blases); + // Buffers that grew: the scene bind group over them. + if std::mem::take(&mut edit.rebind) { + g.rebind_scene(up); + } + if profile_blas { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + self.gpu_blas_ms += t.elapsed().as_secs_f32() * 1000.0; + } + // The uniform tables: entries the edits placed past what a uniform buffer holds read as its last one. + if g.uniform_tables && !edit.uniform_said { + let notes = gpu::uniform_overflow([0, (edit.objects.end * tables.stride) as u64, edit.records.end as u64, 0]); + for note in ¬es { + eprintln!("threert: {note}"); + } + edit.uniform_said = !notes.is_empty(); + } + split.objects = ch.added.len() + ch.removed.len(); + self.edit_split.add(&split); + if emitters_changed { + self.write_emitters(); + } + Ok(()) + } + +} diff --git a/renderer/src/live/frame.rs b/renderer/src/live/frame.rs new file mode 100644 index 0000000..6e5d191 --- /dev/null +++ b/renderer/src/live/frame.rs @@ -0,0 +1,670 @@ +//! One frame of the live view (`LiveCore::render`): the trace, the live passes, the radiance cache's and the media's +//! training, the raster visibility pass, and the GPU timing and profile. + +use super::*; + +/// GPU time per frame from timestamp queries (start of the first trace pass .. end of the post pass), read back a few +/// frames later without waiting, so measuring never drains the pipeline. +pub(super) struct FrameTimer { + pub(super) queries: wgpu::QuerySet, + pub(super) resolve: wgpu::Buffer, + /// Per slot: readback buffer and its state (0 free, 1 mapping, 2 mapped). + pub(super) reads: Vec<(wgpu::Buffer, Arc)>, + /// Per slot: the frame it timed, and that frame's samples per pixel (its traces; only the first is timestamped). + pub(super) frames: [u32; TIMER_SLOTS], + pub(super) spps: [u32; TIMER_SLOTS], + pub(super) slot: usize, +} + +pub(super) const TIMER_SLOTS: usize = 4; + +/// Gap (ms) past which a slot's post pass is not its trace's frame's (`frame_gpu_ms`). +pub(super) const TIMER_GAP_MS: f32 = 2.0; + +/// A frame's GPU time (ms) from its slot's timestamps (trace, post, raster, particle pass pairs; zero: not run): from +/// its first pass (particles, raster or trace) to the end of its post pass. The post pass follows the trace's command +/// buffer within ~0.2 ms, after the frame's further samples (`spp` - 1 more traces, untimed, each about as long as +/// the first). In 2–3 % of the crowd scene's frames (Metal) a slot read the post pair of the frame that used the slot +/// next, 4 frames on: the span then read 70–80 ms while frames came every 16.7 ms. A post pass that begins more than +/// TIMER_GAP_MS past where those traces would end (twice their length) is not this frame's: the frame is counted as +/// its passes, the further traces at the first one's length. +pub(super) fn frame_gpu_ms(t: &[u64; 8], spp: u32, period_ns: f32) -> Option { + let start = [t[6], t[4], t[0]].into_iter().filter(|&v| v > 0).min()?; + if t[3] <= start { + return None; + } + let ms = |ticks: u64| ticks as f32 * period_ns * 1e-6; + let (trace, more) = (ms(t[1].saturating_sub(t[0])), spp.max(1) - 1); + if t[2] >= t[1] && ms(t[2] - t[1]) > TIMER_GAP_MS + 2.0 * more as f32 * trace { + return Some(ms(t[1] - start) + more as f32 * trace + ms(t[3].saturating_sub(t[2]))); + } + Some(ms(t[3] - start)) +} + +impl FrameTimer { + pub(super) fn new(d: &wgpu::Device) -> FrameTimer { + let n = TIMER_SLOTS as u64; + FrameTimer { + // Per slot: trace pass begin / end, post pass begin / end, raster pass begin / end, particle pass begin / end + // (Metal writes a pass's timestamps in pairs). + queries: d.create_query_set(&wgpu::QuerySetDescriptor { label: Some("frame timer"), ty: wgpu::QueryType::Timestamp, count: (n * 8) as u32 }), + resolve: d.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 4 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), + reads: (0..n).map(|_| (d.create_buffer(&wgpu::BufferDescriptor { label: None, size: 64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), Arc::default())).collect(), + frames: [0; TIMER_SLOTS], + spps: [1; TIMER_SLOTS], + slot: 0, + } + } + + /// Latest finished measurement (frame ms, the frame it timed, its trace pass ms), and a free slot for this frame + /// (None: all readbacks still pending, skip). + pub(super) fn harvest(&mut self, period_ns: f32) -> (Option<(f32, u32, f32)>, Option) { + let mut latest: Option<(f32, u32, f32)> = None; + for (k, (buf, state)) in self.reads.iter().enumerate() { + if state.load(Ordering::Acquire) == 2 { + let t: [u64; 8] = bytemuck::pod_read_unaligned(&buf.slice(..).get_mapped_range().unwrap()[..64]); + buf.unmap(); + state.store(0, Ordering::Release); + let frame = self.frames[k]; + if let Some(ms) = frame_gpu_ms(&t, self.spps[k], period_ns).filter(|_| latest.is_none_or(|(_, f, _)| frame.wrapping_sub(f) as i32 > 0)) { + latest = Some((ms, frame, t[1].saturating_sub(t[0]) as f32 * period_ns * 1e-6)); + } + } + } + let slot = (0..TIMER_SLOTS).map(|k| (self.slot + k) % TIMER_SLOTS).find(|&k| self.reads[k].1.load(Ordering::Acquire) == 0); + if let Some(k) = slot { + self.slot = (k + 1) % TIMER_SLOTS; + } + (latest, slot) + } +} + +/// Primary visibility by rasterization (the decision experiment of docs/ROADMAP.md § 1): every triangle drawn into a +/// triangle-index target (+ depth) at the frame's sub-pixel jitter; the kernel starts each live path at that triangle +/// instead of tracing the camera ray. `live --raster` / THREERT_RASTER=1. +pub(super) struct Raster { + pub(super) pipeline: wgpu::RenderPipeline, + pub(super) layout: wgpu::BindGroupLayout, + pub(super) uniform: wgpu::Buffer, + pub(super) vis: wgpu::TextureView, + pub(super) depth: wgpu::TextureView, +} + +pub(super) const RASTER_WGSL: &str = r#" +struct R { pos: vec4, fwd: vec4, right: vec4, up: vec4, jit: vec4 }; // pos.w = tan(fov/2), fwd.w = aspect, jit = ndc offset xy, near +@group(0) @binding(0) var r: R; +@group(0) @binding(1) var tri_verts: array>; // per triangle: a, b - a, c - a (kernel layout) +struct V { @builtin(position) p: vec4, @location(0) @interpolate(flat) id: u32 }; +@vertex fn vs(@builtin(vertex_index) vi: u32) -> V { + let ti = vi / 3u; + let k = vi % 3u; + var p = tri_verts[ti * 3u].xyz; + if (k != 0u) { p += tri_verts[ti * 3u + k].xyz; } + let d = p - r.pos.xyz; + let z = dot(d, r.fwd.xyz); + // Reversed infinite depth: near / z. + return V(vec4(dot(d, r.right.xyz) / (r.pos.w * r.fwd.w) + r.jit.x * z, dot(d, r.up.xyz) / r.pos.w + r.jit.y * z, r.jit.z, z), ti + 1u); +} +@fragment fn fs(v: V) -> @location(0) u32 { return v.id; } +"#; + +impl Raster { + pub(super) fn new(g: &gpu::Gpu, w: u32, h: u32) -> Raster { + let d = &g.device; + let module = d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("raster"), source: wgpu::ShaderSource::Wgsl(RASTER_WGSL.into()) }); + let layout = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("raster"), + entries: &[ + wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None }, + wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None }, + ], + }); + let pl = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); + let pipeline = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("raster vis"), + layout: Some(&pl), + vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + fragment: Some(wgpu::FragmentState { module: &module, entry_point: Some("fs"), targets: &[Some(wgpu::TextureFormat::R32Uint.into())], compilation_options: Default::default() }), + primitive: wgpu::PrimitiveState { cull_mode: None, ..Default::default() }, + depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth32Float, depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::Greater), stencil: Default::default(), bias: Default::default() }), + multisample: Default::default(), + multiview_mask: None, + cache: g.cache(), + }); + g.save_pipeline_cache(); + let tex = |format, usage| { + d.create_texture(&wgpu::TextureDescriptor { label: Some("raster"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format, usage, view_formats: &[] }) + .create_view(&Default::default()) + }; + let vis = tex(wgpu::TextureFormat::R32Uint, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING); + let depth = tex(wgpu::TextureFormat::Depth32Float, wgpu::TextureUsages::RENDER_ATTACHMENT); + let uniform = d.create_buffer(&wgpu::BufferDescriptor { label: Some("raster"), size: 80, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + Raster { pipeline, layout, uniform, vis, depth } + } + + /// Records the visibility pass for `tris` triangles at internal size (iw, ih), sampling at jitter (jx, jy). + #[allow(clippy::too_many_arguments)] + pub(super) fn record(&self, g: &gpu::Gpu, e: &mut wgpu::CommandEncoder, verts: &wgpu::Buffer, tris: u32, cam: &Camera, aspect: f32, (iw, ih): (u32, u32), (jx, jy): (f32, f32), timestamps: Option) { + let u: [[f32; 4]; 5] = [ + [cam.pos.x, cam.pos.y, cam.pos.z, cam.tan_half_fov], + [cam.fwd.x, cam.fwd.y, cam.fwd.z, aspect], + [cam.right.x, cam.right.y, cam.right.z, 0.0], + [cam.up.x, cam.up.y, cam.up.z, 0.0], + // Pixel centres sample at +0.5; shift the image so they sample at the jittered position instead. + [2.0 * (0.5 - jx) / iw as f32, -2.0 * (0.5 - jy) / ih as f32, 0.01, 0.0], + ]; + g.queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&u)); + let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &self.layout, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: self.uniform.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: verts.as_entire_binding() }], + }); + let mut pass = e.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("raster vis"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.vis, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store } })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), store: wgpu::StoreOp::Discard }), stencil_ops: None }), + timestamp_writes: timestamps, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_viewport(0.0, 0.0, iw as f32, ih as f32, 0.0, 1.0); + pass.set_scissor_rect(0, 0, iw, ih); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.draw(0..tris * 3, 0..1); + } +} + +/// The live kernel's world-space radiance cache (docs/ROADMAP.md § 1, step 1): its buffers and per-frame resolve. +pub(super) struct RadianceCache { + pub(super) bufs: gpu::CacheBuffers, + pub(super) resolve: wgpu::ComputePipeline, +} + +/// Share of the pixels showing translucent particles whose nearest hit trains the volume voxels (up to +/// gpu::VOLUME_TRAIN_CAP points per frame). +pub(super) const VOLUME_TRAIN_SHARE: f32 = 1.0 / 8.0; +/// Media voxel training points per frame (pathtrace.wgsl MEDIA_TRAIN_POINTS). +pub(super) const MEDIA_TRAIN_POINTS: u32 = 4096; +pub(super) struct Profile { + pub(super) queries: wgpu::QuerySet, + pub(super) resolve: wgpu::Buffer, + pub(super) read: wgpu::Buffer, + pub(super) sums: Vec, + pub(super) frames: u32, + /// Timed apart from the slots above: the grid programs' two passes (`gridsim`, queries 0..4) and the media passes + /// (`media`, queries 4..6: the camera rays' march and, at one sample a frame, the volume voxels' training, which + /// are otherwise in the trace's pass). Their queries, the resolve and read buffers, the sums. + pub(super) grid: (wgpu::QuerySet, wgpu::Buffer, wgpu::Buffer), + pub(super) grid_sum: f64, + pub(super) media_sum: f64, +} + +impl LiveCore { + /// Profile slots after the cache resolve that this scene writes: particle pass + BLAS rebuild (2; stateless or GPU + /// particles), translucency + volume training (2 more). + pub(super) fn profile_extra(&self) -> u32 { + if self.sc.up.translucent.is_some() { + 4 + } else if self.sc.up.particles.is_some() || self.sc.up.gpu_particles.is_some() { + 2 + } else { + 0 + } + } + + /// Light LOD: the emitter CDF weighted by distance to the camera (`emitter_lod`) and the emitter cull with its view + /// cone (`EmitterCull`), rebuilt when the camera is 15 m from where it was last built or has turned LIGHT_LOD_TURN + /// from that direction (the kernel's emitter pdfs read the same reference from the parameters). + pub(super) fn update_light_lod(&mut self) { + let d0 = LIGHT_LOD_D0; + let (w, h) = (self.w, self.h); + let b = &mut self.sc.base; + // The light tree weighs emitters by their distance to each shading point itself: no light LOD, no cull. + if d0 <= 0.0 || self.sc.tables.emitter_tris.is_empty() || b.ltree[3] > 0.5 { + return; + } + let r = glam::Vec3::new(b.light_lod[0], b.light_lod[1], b.light_lod[2]); + let dir = glam::Vec3::new(b.lod_view[0], b.lod_view[1], b.lod_view[2]); + let turned = b.emit_power[2] > 0.0 && dir.dot(self.cam.fwd) < LIGHT_LOD_TURN.to_radians().cos(); + if b.light_lod[3] > 0.0 && r.distance(self.cam.pos) < 15.0 && !turned { + return; + } + b.light_lod = [self.cam.pos.x, self.cam.pos.y, self.cam.pos.z, d0]; + b.emit_power[1] = EMITTER_CULL; + b.emit_power[2] = EMITTER_CULL * EMITTER_CULL_VIEW; + // The frustum's half-diagonal: tan = tan(fov / 2) x sqrt(1 + aspect^2). + let half = (self.cam.tan_half_fov * (1.0 + (w as f32 / h as f32).powi(2)).sqrt()).atan() + EMITTER_CULL_MARGIN.to_radians(); + b.lod_view = [self.cam.fwd.x, self.cam.fwd.y, self.cam.fwd.z, half.min(std::f32::consts::PI).cos()]; + // Power edits held back for the emitter worker land first (its job in flight was built for the old reference: + // dropped), so the CDF built here has the powers the objects hold and the reference the kernel reads. + self.land_powers_now(); + let (e, b) = (&self.sc.tables, &mut self.sc.base); + let (em, total, power) = crate::emitter_cdf(&e.emitter_tris, &e.materials, &e.objects, e.stride, b.light_lod, crate::EmitterCull::of(b)); + self.g.queue.write_buffer(&self.sc.up.emitters, 0, bytemuck::cast_slice(&em)); + b.misc[1] = total; + b.emit_power[0] = power; + } + + pub fn render(&mut self, spp: u32, extra: impl FnOnce(&mut wgpu::CommandEncoder, &wgpu::Buffer)) { + // A dynamic sky's bake that finished since the last frame (time of day). + self.take_sky(); + self.take_emitters(); + let (w, h) = (self.w, self.h); + let (iw, ih) = self.internal_size(); + let now = Instant::now(); + // Lockstep (benchmarks) renders at a fixed frame time, whatever the wall clock says. + self.frame_dt = self.fixed_dt.unwrap_or_else(|| now.duration_since(self.last_frame).as_secs_f32()); + self.last_frame = now; + let moved = self.cam != self.prev_cam; + self.update_light_lod(); + // A camera cut (a jump no camera makes in one frame: 2 m or 30 degrees) is not motion: no blur (UE: bCameraCut). + let cut = (self.cam.pos - self.prev_cam.pos).length() > 2.0 || self.cam.fwd.dot(self.prev_cam.fwd) < 0.866; + self.cold_frames = if self.reset || cut { 0 } else { self.cold_frames.saturating_add(1) }; + // (Objects moved this frame: re-posed triangles or moved instances, e.g. walkers in a shared pose.) + let blur = !self.motion_blur_off && self.blur.0 > 0.0 && (moved || self.reposed || self.pose_reposed || self.inst_moved) && !cut; + // Camera motion is reprojected exactly (disocclusions are rejected per pixel), so history survives motion; + // the cap only bounds smearing of view-dependent shading (glossy reflections). + // Diffuse lighting reprojects exactly with the camera; view-dependent specular is handled per pixel (temporal pass). + // The most frames a pixel's history holds: HISTORY_LIVE while the scene's lighting changes, HISTORY_FRAMES once + // it has settled. Within it the length is each pixel's own (live.wgsl `dtemporal`: less where it moves, anti-lag + // where its lighting changes, restarted where its surface is new). No edit cuts it: until 2026-11-12 a + // lighting edit left every pixel 16 frames and a geometry edit 4, the whole frame for a change in one corner. + let cap = if self.reset { 1.0 } else if self.live_lighting() { HISTORY_LIVE } else { HISTORY_FRAMES }; + self.history_len = if self.reset { 1.0 } else { (self.history_len + 1.0).min(cap) }; + let cam = self.cam; + let g = &self.g; + // At most two frames in flight: beyond that the CPU waits, so latency stays bounded when GPU-bound. + // Wait for the oldest frame only (waiting for all work would leave the GPU idle while this frame records). + let t_wait = Instant::now(); + while self.inflight.len() >= 2 { + let oldest = self.inflight.pop_front().unwrap(); + let _ = g.device.poll(wgpu::PollType::Wait { submission_index: Some(oldest), timeout: None }); + } + let waited = t_wait.elapsed().as_secs_f32() * 1000.0; + // The loop guard's count, read back since an earlier frame. + let mut guard_free = false; + if let Some((read, state)) = &self.guard_read { + if state.load(Ordering::Acquire) == 2 { + let n = u32::from_le_bytes(read.slice(..).get_mapped_range().unwrap()[..4].try_into().expect("four bytes")); + read.unmap(); + state.store(0, Ordering::Release); + if self.guard_trips != Some(n) { + eprintln!("live: the loop guard has stopped {n} of the kernel's invocations: frames with wrong pixels (a traversal that does not end on this scene's data)"); + } + self.guard_trips = Some(n); + } + guard_free = state.load(Ordering::Acquire) == 0; + } + // GPU time drives adaptive resolution / samples per frame: timestamps when available (no stall), else twice a + // second a frame timed in isolation (idle GPU -> submit -> wait). + let period = g.queue.get_timestamp_period(); + let timed = match &mut self.timer { + Some(t) => { + let (latest, slot) = t.harvest(period); + if let Some((ms, frame, trace)) = latest { + (self.gpu_ms, self.gpu_frame, self.gpu_trace_ms) = (ms, frame, trace); + } + if let Some(k) = slot { + t.frames[k] = self.frame; + t.spps[k] = spp.max(1); + } + slot + } + None => None, + }; + let measure = self.timer.is_none() && self.frame % 30 == 0; + if measure { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + } + // Nothing moved since the last in-place update: its span's previous-frame vertices become the current ones. + if let (false, Some(span)) = (self.reposed, self.prev_span) { + self.keep_prev(span); + self.prev_span = None; + } + self.reposed = false; + if let (false, Some(span)) = (self.pose_reposed, self.pose_span) { + self.keep_prev(span); + self.pose_span = None; + } + self.pose_reposed = false; + // Likewise the instances the last update moved: their previous-frame rows become the current ones. + if !self.inst_moved && !self.inst_moving.is_empty() { + let size = std::mem::size_of::() as u64; + for i in std::mem::take(&mut self.inst_moving) { + let r = &mut self.sc.gpu_instances[i as usize]; + r.p = r.m; + self.g.queue.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(r)); + } + } + self.inst_moved = false; + let g = &self.g; + let t_submit = Instant::now(); + let pipeline = g.pipeline(&self.sc.up.kernel, true); + // (No media pass while every medium is hidden: a pool of idle fireballs costs the frame nothing.) + let media = self.sc.up.kernel.features & gpu::F_MEDIA != 0 && self.sc.up.media_hidden.borrow().iter().any(|h| !h); + let media_train = (media && self.cache.is_some()).then(|| g.media_pipeline(&self.sc.up.kernel, "media_train")); + let media_composite = media.then(|| g.media_pipeline(&self.sc.up.kernel, "media_composite")); + // This frame's sub-pixel jitter (Halton 2, 3), every camera ray's and the raster visibility pass's: 8 phases at + // full resolution, as many more as an output pixel has fewer traced ones (8 / scale^2: the upscaler needs each + // output pixel visited), in the 12 bits a side the samples carry it in. + let phases = ((8.0 * (w as f32 / iw as f32) * (h as f32 / ih as f32)).round() as u32).clamp(8, 512); + let quant = |v: f32| (v * 4095.0).round() / 4095.0; + let jitter = (quant(halton(self.frame % phases + 1, 2)), quant(halton(self.frame % phases + 1, 3))); + for k in 0..spp.max(1) { + let mut p = self.sc.base; + // Software traversal's units as edits left them: the top tree's root and how many it holds (trace_units.wgsl). + if let Some(u) = &self.sc.up.units { + (p.motion[2], p.motion[3]) = (f32::from_bits(u.layout.top_first), f32::from_bits(u.count)); + } + p.raster = [(self.raster.is_some() && !self.sc.instanced) as u32 as f32, jitter.0, jitter.1, 0.0]; + p.raster[3] = if self.rays { 0.0 } else { 1.0 }; + if !self.sc.dynamic.1.is_empty() || !self.sc.tables.retained.instance_of.is_empty() || !self.sc.tables.retained.rigid.is_empty() { + // The kernel finds the first dynamic triangle in Params::group_first (group 4), a u32. + p.motion[0] = 1.0; + } + if self.cache.is_some() { + // Light-sampled particles (sparks: Particles emitterArea) move their light every frame: the cache and + // the temporal pass keep the short history of a lighting edit while the scene has them. + let lit = self.live_lighting(); + let cold = if self.cold_frames >= COLD_FALLBACK_FRAMES { 1 } else { 0 }; + p.cache = [1.0, CACHE_SAMPLES, if lit { CACHE_FRAMES_LIT } else { CACHE_FRAMES }, (cache_flags() | cold) as f32]; + } + p.time[0] = shader_time(self.time); + p.cam_pos = [cam.pos.x, cam.pos.y, cam.pos.z, cam.tan_half_fov]; + p.cam_fwd = [cam.fwd.x, cam.fwd.y, cam.fwd.z, self.aspect]; + p.cam_right = [cam.right.x, cam.right.y, cam.right.z, 0.0]; + p.cam_up = [cam.up.x, cam.up.y, cam.up.z, 1.0]; + p.size = [iw, ih, self.frame.wrapping_mul(16).wrapping_add(k), crate::rng_seed()]; + p.counts2[2] = if ih < h { h } else { 0 }; + p.misc[3] = k as f32; + p.counts[2] = p.counts[2].min(self.bounces); + p.counts[3] = p.counts[3].min(2); + g.queue.write_buffer(&self.sc.up.params, 0, bytemuck::bytes_of(&p)); + let mut e = g.device.create_command_encoder(&Default::default()); + // Particles: placed for this frame's time and camera, then the dynamic BLASes rebuilt (before the raster and + // the trace). + // The grid programs (density grids changed on the GPU), before anything reads the grids. + if k == 0 && gpu::Gpu::has_grid_sim(&self.sc.up) { + let sources = std::mem::take(&mut self.grid_sources); + g.run_grids(&self.sc.up, shader_time(self.time), self.frame_dt, &sources, &mut e, self.profile.as_ref().map(|p| (&p.grid.0, 0))); + if let Some(p) = &self.profile { + e.resolve_query_set(&p.grid.0, 0..4, &p.grid.1, 0); + e.copy_buffer_to_buffer(&p.grid.1, 0, &p.grid.2, 0, 32); + } + } + // Stateful GPU particles first (their emissions, the game's spawns): with stateless ones the pass below + // rebuilds the dynamic BLASes for both, else this one does. + if let (Some(_), 0) = (&self.sc.up.gpu_particles, k) { + let n = self.passes.pipes.len() as u32 + 2; + let stateless = self.sc.up.particles.is_some(); + let (pass_ts, blas_ts) = match (&self.profile, &self.timer, timed, stateless) { + (Some(p), _, _, false) => (Some((&p.queries, n * 2)), Some((&p.queries, n * 2 + 2))), + (None, Some(t), Some(slot), false) => (Some((&t.queries, slot as u32 * 8 + 6)), None), + _ => (None, None), + }; + let spawns = std::mem::take(&mut self.particle_spawns); + g.run_gpu_particles(&self.sc.up, cam.pos.to_array(), cam.fwd.to_array(), shader_time(self.time), self.frame_dt, &spawns, !stateless, &mut e, pass_ts, blas_ts); + } + if let (Some(_), 0) = (&self.sc.up.particles, k) { + let b = &self.sc.base; + let n = self.passes.pipes.len() as u32 + 2; + let pass_ts = match (&self.profile, &self.timer, timed) { + (Some(p), _, _) => Some((&p.queries, n * 2)), + (None, Some(t), Some(slot)) => Some((&t.queries, slot as u32 * 8 + 6)), + _ => None, + }; + let blas_ts = self.profile.as_ref().map(|p| (&p.queries, n * 2 + 2)); + g.run_particles(&self.sc.up, cam.pos.to_array(), cam.fwd.to_array(), shader_time(self.time), self.frame_dt, b.time[2], b.time[1], &mut e, pass_ts, blas_ts); + } + // (The merged layout and the uniform tables: the tables as this frame's edits and the particle passes above + // left them, where the kernel reads them; merged.rs.) + if k == 0 { + g.sync_tables(&self.sc.up, &mut e); + } + if let (Some(r), 0, false) = (&self.raster, k, self.sc.instanced) { + let tw = self.timer.as_ref().zip(timed).map(|(t, slot)| wgpu::RenderPassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8 + 4), end_of_pass_write_index: Some(slot as u32 * 8 + 5) }); + r.record(g, &mut e, &self.sc.up.verts, self.sc.tris.len() as u32, &cam, self.aspect, (iw, ih), jitter, tw); + } + { + let tw = match (&self.profile, &self.timer, timed) { + (Some(p), _, _) if k == 0 => Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(0), end_of_pass_write_index: Some(1) }), + (None, Some(t), Some(slot)) if k == 0 => Some(wgpu::ComputePassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8), end_of_pass_write_index: Some(slot as u32 * 8 + 1) }), + _ => None, + }; + let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("live trace"), timestamp_writes: tw }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &self.sc.up.bg, &[]); + pass.set_bind_group(1, &self.kernel_bg, &[]); + pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); + // Participating media: their volume voxels trained after the trace (pathtrace.wgsl media_train), and + // camera rays through media once the frame's samples are traced (media_composite). In the trace's pass + // and timing; profiled, the frame's last sample's media passes are a pass of their own (`media`). + let last = k + 1 == spp.max(1); + if let (Some(p), true, true) = (&self.profile, last, media_composite.is_some()) { + drop(pass); + pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("media"), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.grid.0, beginning_of_pass_write_index: Some(4), end_of_pass_write_index: Some(5) }) }); + pass.set_bind_group(0, &self.sc.up.bg, &[]); + pass.set_bind_group(1, &self.kernel_bg, &[]); + } + if let (Some(mt), 0) = (&media_train, k) { + pass.set_pipeline(mt); + pass.dispatch_workgroups(MEDIA_TRAIN_POINTS.div_ceil(64), 1, 1); + } + if let (Some(mc), true) = (&media_composite, last) { + pass.set_pipeline(mc); + pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); + } + } + if let (Some(p), true, true) = (&self.profile, k + 1 == spp.max(1), media_composite.is_some()) { + e.resolve_query_set(&p.grid.0, 4..6, &p.grid.1, wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT); + e.copy_buffer_to_buffer(&p.grid.1, wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, &p.grid.2, 32, 16); + } + // Each pair is resolved in the command buffer that wrote it (Metal reads zeros otherwise). + if let (None, Some(t), Some(slot), 0) = (&self.profile, &self.timer, timed, k) { + let q = slot as u32 * 8; + let at = slot as u64 * 4 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; + e.resolve_query_set(&t.queries, q..q + 2, &t.resolve, at); + e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, 0, 16); + // Raster and particle pairs (pairs 2 and 3); zeros when the pass did not run. + for (pair, ran) in [(2u32, self.raster.is_some() && !self.sc.instanced), (3, self.sc.up.particles.is_some() || self.sc.up.gpu_particles.is_some())] { + let at = at + pair as u64 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; + if ran { + e.resolve_query_set(&t.queries, q + pair * 2..q + pair * 2 + 2, &t.resolve, at); + } else { + e.clear_buffer(&t.resolve, at, Some(16)); + } + e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, pair as u64 * 16, 16); + } + } + g.queue.submit([e.finish()]); + } + let (r, t) = self.bloom; + let lp = LiveParams { + cur: cam.uniform(self.aspect), + prev: self.prev_cam.uniform(self.aspect), + size: [w, h, self.frame, self.reset as u32], + misc: [self.sc.exposure, cap, self.vignette, (self.bloom.1.iter().sum::() > 0.0) as u32 as f32], + // misc2.z: motion blur, this frame's motion (pixels) -> blur length. UE (PostProcessMotionBlur.cpp, + // UpdateMotionBlurTimeScale) blurs over +- amount / 2 x the motion x (1 / target FPS) / frame time, the + // actual frame time without a target. 0 while nothing moves (the pass then costs nothing). + misc2: [1.0, SAMPLE_CEILING, if !blur { 0.0 } else if self.blur.1 > 0.0 { self.blur.0 / (self.blur.1 * self.frame_dt.clamp(1.0 / 240.0, 0.25)) } else { self.blur.0 }, spp.max(1) as f32], + mode: [self.view_mode, 0, self.live_lighting() as u32, 0], + post: self.post, + ae: self.sc.exposure_settings.ae, + ae2: self.sc.exposure_settings.ae2, + ae3: [self.sc.exposure_settings.speed[0], self.sc.exposure_settings.speed[1], self.frame_dt.clamp(1.0 / 240.0, 0.25), (self.reset || self.frame == 0 || exposure_snap()) as u32 as f32], + lx: self.sc.exposure_settings.lx, + isz: [iw, ih, self.prev_isz.0, self.prev_isz.1], + br: [r[0], r[1], r[2], r[3]], + br2: [r[4], r[5], 0.0, 0.0], + bt: [t[0], t[1], t[2], t[3]], + bt2: [t[4], t[5], 0.0, 0.0], + dof: dof_params(self.sc.dof.as_ref(), cam.tan_half_fov, w, h), + jit: [jitter.0, jitter.1, 0.0, 0.0], + state: [1 - self.frame % 2, 0, 0, 0], + }; + g.queue.write_buffer(&self.targets.params, 0, bytemuck::bytes_of(&lp)); + let bg = &self.sc.bg; + // Translucent particles: drawn over the upscaled image right after `upscale` (in sb when depth of field / motion + // blur follow, else fa: they have no motion vectors, so they are no part of the colour history; UE draws its + // translucency after the upscaler the same way), then their volume voxels trained. + // (Nothing to draw while every translucent particle system is hidden in place.) + let tl = self.sc.up.translucent.as_ref().filter(|_| g.translucent_particles_live(&self.sc.up)).map(|t| { + let pipes = g.translucent_pipelines(&self.sc.up.kernel, true); + let target = if lp.misc2[2] > 0.0 || lp.dof[0] > 0.0 { &self.targets.sb.buffer } else { &self.targets.fa.buffer }; + // Below full resolution the particles are drawn per traced pixel, then laid over the output (`translucent_up`). + let low = (iw, ih) != (w, h); + let tp = gpu::TranslucentParams { size: [w, h, t.first, 1], view: [iw, ih, gpu::VOLUME_TRAIN_CAP, low as u32], misc: [VOLUME_TRAIN_SHARE, translucent_hidden(self.frame) as u32 as f32, 0.0, 0.0] }; + g.queue.write_buffer(&t.params, 0, bytemuck::bytes_of(&tp)); + (pipes, g.translucent_bind_group(t, target), &t.count) + }); + // The pass (train = false) or the volume training (train = true). + let tl_dispatch = |pass: &mut wgpu::ComputePass, train: bool| { + if let Some((pipes, tbg, _)) = &tl { + pass.set_bind_group(0, &self.sc.up.bg, &[]); + pass.set_bind_group(1, &self.kernel_bg, &[]); + pass.set_bind_group(2, tbg, &[]); + if train { + pass.set_pipeline(&pipes.train); + pass.dispatch_workgroups(gpu::VOLUME_TRAIN_CAP / 64, 1, 1); + } else if (iw, ih) != (w, h) { + pass.set_pipeline(&pipes.draw); + pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); + pass.set_pipeline(&pipes.up); + pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1); + } else { + pass.set_pipeline(&pipes.draw); + pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1); + } + } + }; + let mut e = g.device.create_command_encoder(&Default::default()); + if let Some((_, _, count)) = &tl { + e.clear_buffer(count, 0, None); + } + if let Some(c) = &self.cache { + let n = self.passes.pipes.len() as u32 + 1; + let tw = self.profile.as_ref().map(|p| wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(n * 2), end_of_pass_write_index: Some(n * 2 + 1) }); + let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("cache resolve"), timestamp_writes: tw }); + pass.set_pipeline(&c.resolve); + pass.set_bind_group(0, &self.sc.up.bg, &[]); + pass.set_bind_group(1, &self.kernel_bg, &[]); + pass.dispatch_workgroups((gpu::CACHE_SIZE / 256) as u32, 1, 1); + } + if let Some(p) = &self.profile { + // One compute pass per stage, each timed. + for (k, (name, pipe)) in self.passes.pipes.iter().enumerate() { + let q = (k as u32 + 1) * 2; + let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(name), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(q), end_of_pass_write_index: Some(q + 1) }) }); + pass.set_pipeline(pipe); + pass.set_bind_group(0, bg, &[]); + let (gw, gh) = if *name == "exposure" { (8, 8) } else if *name == "dof_tiles" { (w.div_ceil(16), h.div_ceil(16)) } else if traced_pass(name) { (iw, ih) } else if name.starts_with("bloom") || name.starts_with("lum_") { (w.div_ceil(4), h.div_ceil(4)) } else { (w, h) }; + pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1); + drop(pass); + if *name == "upscale" && tl.is_some() { + for (j, train) in [(0u32, false), (1, true)] { + let q = (self.passes.pipes.len() as u32 + 4 + j) * 2; + let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(if train { "vtrain" } else { "translucent" }), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(q), end_of_pass_write_index: Some(q + 1) }) }); + tl_dispatch(&mut pass, train); + } + } + } + let n = self.passes.pipes.len() as u32 + 2 + self.profile_extra(); + e.resolve_query_set(&p.queries, 0..n * 2, &p.resolve, 0); + e.copy_buffer_to_buffer(&p.resolve, 0, &p.read, 0, p.read.size()); + } else { + let tw = self.timer.as_ref().zip(timed).map(|(t, slot)| wgpu::ComputePassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8 + 2), end_of_pass_write_index: Some(slot as u32 * 8 + 3) }); + let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("live post"), timestamp_writes: tw }); + for (name, pipe) in &self.passes.pipes { + pass.set_pipeline(pipe); + pass.set_bind_group(0, bg, &[]); + if *name == "exposure" { + pass.dispatch_workgroups(1, 1, 1); + continue; + } + let (gw, gh) = if *name == "dof_tiles" { (w.div_ceil(16), h.div_ceil(16)) } else if traced_pass(name) { (iw, ih) } else if name.starts_with("bloom") || name.starts_with("lum_") { (w.div_ceil(4), h.div_ceil(4)) } else { (w, h) }; + pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1); + if *name == "upscale" { + tl_dispatch(&mut pass, false); + tl_dispatch(&mut pass, true); + } + } + drop(pass); + if let (Some(t), Some(slot)) = (&self.timer, timed) { + let q = slot as u32 * 8 + 2; + let at = (slot as u64 * 4 + 1) * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; + e.resolve_query_set(&t.queries, q..q + 2, &t.resolve, at); + e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, 16, 16); + } + } + if let (Some((read, _)), Some(count), true) = (&self.guard_read, &self.targets.kernel.guard, guard_free) { + e.copy_buffer_to_buffer(&count.buffer, count.offset, read, 0, 4); + } + extra(&mut e, &self.targets.out); + let done = g.queue.submit([e.finish()]); + self.inflight.push_back(done); + self.cpu_wait_ms = waited; + if let (Some((read, state)), true) = (&self.guard_read, guard_free) { + let state = state.clone(); + state.store(1, Ordering::Release); + read.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { 2 } else { 0 }, Ordering::Release)); + } + if let (Some(t), Some(slot)) = (&self.timer, timed) { + let state = t.reads[slot].1.clone(); + state.store(1, Ordering::Release); + t.reads[slot].0.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { 2 } else { 0 }, Ordering::Release)); + } + // Without timestamps: GPU time ~ submit -> completion (the queue is idle at submit when the frame fits the budget). + if measure { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + (self.gpu_ms, self.gpu_frame) = (t_submit.elapsed().as_secs_f32() * 1000.0, self.frame); + } + let extra = self.profile_extra() as usize; + if let Some(p) = &mut self.profile { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + p.read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + let t: Vec = bytemuck::cast_slice::(&p.read.slice(..).get_mapped_range().unwrap()).to_vec(); + p.read.unmap(); + let period = g.queue.get_timestamp_period() as f64 * 1e-6; + // The particle, BLAS, translucency and volume training slots (the last four) are written only when the scene + // has particles / translucent ones. + let n = self.passes.pipes.len() + 2 + extra; + for (k, s) in p.sums.iter_mut().enumerate().take(n) { + *s += t[k * 2 + 1].saturating_sub(t[k * 2]) as f64 * period; + } + let (sim, media) = (gpu::Gpu::has_grid_sim(&self.sc.up), media_composite.is_some()); + if sim || media { + p.grid.2.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + let t: Vec = bytemuck::cast_slice::(&p.grid.2.slice(..).get_mapped_range().unwrap()).to_vec(); + p.grid.2.unmap(); + if sim { + p.grid_sum += (t[1].saturating_sub(t[0]) + t[3].saturating_sub(t[2])) as f64 * period; + } + if media { + p.media_sum += t[5].saturating_sub(t[4]) as f64 * period; + } + } + p.frames += 1; + if p.frames == 120 { + let names: Vec<&str> = std::iter::once("trace").chain(self.passes.pipes.iter().map(|(n, _)| *n)).chain(["cache", "particles", "blas", "translucent", "vtrain"]).collect(); + let mut rows: Vec = names.iter().zip(&p.sums).map(|(n, s)| format!("{n} {:.2}", s / p.frames as f64)).collect(); + rows.push(format!("gridsim {:.2}", p.grid_sum / p.frames as f64)); + rows.push(format!("media {:.2}", p.media_sum / p.frames as f64)); + rows.retain(|r| !r.ends_with(" 0.00")); + eprintln!("profile ms/frame: total {:.2} | {}", (p.sums.iter().sum::() + p.grid_sum + p.media_sum) / p.frames as f64, rows.join(", ")); + (p.grid_sum, p.media_sum) = (0.0, 0.0); + p.sums.iter_mut().for_each(|s| *s = 0.0); + p.frames = 0; + } + } + self.prev_cam = cam; + self.shown_spp = spp.max(1); + self.prev_isz = (iw, ih); + self.reset = false; + self.frame = self.frame.wrapping_add(1); + } + +} diff --git a/renderer/src/live/load.rs b/renderer/src/live/load.rs new file mode 100644 index 0000000..7f7d562 --- /dev/null +++ b/renderer/src/live/load.rs @@ -0,0 +1,304 @@ +//! A scene loaded for the live view (`load_scene`, `LoadedScene`), and the worker threads its loading and its +//! dynamic sky and emitters use (`DynamicSky`, `EmitterWorker`). + +use super::*; + +pub(super) struct LoadedScene { + /// Load phases (parse, bvh, sky, accel build, upload) in ms. + pub(super) timings: Vec<(String, f64)>, + /// World-space triangles + per-node ranges, kept on the CPU for picking and selection outlines; instanced meshes + /// (model space) and their placements likewise. + pub(super) tris: Vec<[glam::Vec3A; 3]>, + pub(super) tri_nodes: Vec<(String, u32, u32)>, + /// Flattened instances of instanced objects: (first, end, instance index), for picking. + pub(super) tri_instances: Vec<(u32, u32, u32)>, + pub(super) pool_tris: Vec<[glam::Vec3A; 3]>, + pub(super) meshes: Vec, + pub(super) instances: Vec, + /// The instances' kernel records as uploaded (movable ones are rewritten when they move), then the procedural + /// objects' (from `proc_first`). + pub(super) gpu_instances: Vec, + /// Procedural geometry (moves, picking), by node in `proc_of`; its records start at `proc_first`. + pub(super) procs: Vec, + pub(super) proc_of: std::collections::HashMap, + pub(super) proc_first: usize, + /// The scene has instanced meshes or procedural geometry (the raster experiment draws only flattened triangles: + /// primary visibility is traced). + pub(super) instanced: bool, + /// Kernel features of the instanced meshes (gpu::instance_features), which edits cannot change. + pub(super) instance_features: u32, + pub(super) name: String, + pub(super) triangles: usize, + pub(super) up: gpu::Uploaded, + pub(super) base: gpu::Params, + pub(super) bg: wgpu::BindGroup, + pub(super) exposure: f32, + pub(super) exposure_settings: ExposureSettings, + pub(super) tan_half_fov: f32, + pub(super) scene_camera: Camera, + /// The camera's depth of field (UE: off unless it sets a focus distance). + pub(super) dof: Option, + /// GPU table copies that parameter edits rewrite in place (`commit`). + pub(super) tables: tables::Tables, + /// CPU copy of the dynamic triangles (posed models: the tail of the GPU triangle order from `dynamic_first`): + /// vertices (kernel layout), shading attributes, BLAS vertices. + pub(super) dynamic: (usize, Vec<[f32; 4]>, Vec, Vec<[f32; 3]>), + /// Kernel feature inputs edits cannot change: textures, material functions, traversal groups. + pub(super) fixed_features: (bool, bool, [usize; crate::GROUPS]), + /// Where in-place geometry edits write next (`edit_geometry`); a rebuild starts it afresh. + pub(super) edit: EditState, + /// The atmosphere's sky once its sun has moved in place (`move_sun` starts it), and what it bakes with: the + /// environment map's size, the viewer's altitude and the environment's light-sampling floor, as the load had them. + pub(super) sky: Option, + /// Power edits' emitter rebuilds on a worker (`EmitterWorker`), for scenes with many emitters. + pub(super) emit: Option, + pub(super) sky_inputs: ((u32, u32), f32, f32), + /// For materials that start emitting (`add_material_emitters`): their flattened static triangles' runs (`prepare`) + /// and the flattened triangles' build indices in traversal order (positions in `tris`). + pub(super) mat_runs: std::collections::HashMap>, + pub(super) tri_order: Vec, +} + +/// Cores the sky's worker thread bakes with: the rest stay with the frame (scripts, uploads, the GPU's driver). +pub(super) const SKY_THREADS: usize = 3; + +/// An atmosphere whose sun moves while the scene runs (time of day). The sun's light is rewritten in place at every +/// move, its direction and its colour through the atmosphere (`sky::sun_illuminance`, no bake needed), which carries +/// the direct light and the shadows. The sky's radiance (the environment map and its sampling CDF) is baked again on a +/// worker thread with `SKY_THREADS` cores, the newest sun first, and lands with a later frame (`take_sky`): the sky +/// trails the sun by one bake (~10–40 ms at `sky::DYNAMIC_SKY`). Lockstep runs wait for it, so they repeat. +pub(super) struct DynamicSky { + pub(super) atm: Arc, + /// (sun direction, sun illuminance above the atmosphere, cloud cover) + pub(super) ask: mpsc::Sender<(glam::Vec3, glam::Vec3, f32)>, + pub(super) done: mpsc::Receiver<(gpu::EnvMap, Vec, f32)>, + /// The brightest sky texel from which the environment is light-sampled (`Prepared::env_nee_floor`). + pub(super) env_nee_floor: f32, +} + +impl DynamicSky { + pub(super) fn new(world: &World, (w, h): (u32, u32), altitude: f32, env_nee_floor: f32) -> Option { + let crate::desc::Sky::Atmosphere { rayleigh_scale, mie_scale, mie_anisotropy, ground_albedo, glow, horizon_glow, sun: Some(_), .. } = &world.environment.sky else { + return None; + }; + let atm = Arc::new(crate::sky::Atmosphere::earth(*rayleigh_scale, *mie_scale, *mie_anisotropy, glam::Vec3::from(*ground_albedo))); + let glow = crate::sky::glow_of(glow, horizon_glow); + let (ask, asked) = mpsc::channel::<(glam::Vec3, glam::Vec3, f32)>(); + let (tx, done) = mpsc::channel(); + let a = atm.clone(); + std::thread::Builder::new() + .name("sky bake".into()) + .spawn(move || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(SKY_THREADS).build().ok(); + let tables = match &pool { + Some(p) => p.install(|| crate::sky::SkyTables::new(&a)), + None => crate::sky::SkyTables::new(&a), + }; + let bake = |sun: (glam::Vec3, glam::Vec3, f32)| { + let b = crate::sky::bake_atmosphere_tables(&a, &tables, sun.0, sun.1, altitude, w, h, glow, sun.2); + let env = gpu::EnvMap { width: b.width, height: b.height, texels: b.texels }; + let cdf = crate::build_env_cdf(&env); + let max = env.texels.iter().map(|t| 0.2126 * t[0] + 0.7152 * t[1] + 0.0722 * t[2]).fold(0.0f32, f32::max); + (env, cdf, max) + }; + // The newest sun only: bakes asked for while one ran are stale. + while let Ok(mut sun) = asked.recv() { + while let Ok(s) = asked.try_recv() { + sun = s; + } + let out = match &pool { + Some(p) => p.install(|| bake(sun)), + None => bake(sun), + }; + if tx.send(out).is_err() { + break; + } + } + }) + .ok()?; + Some(DynamicSky { atm, ask, done, env_nee_floor }) + } +} + +/// Scenes with at least this many emitters rebuild their emitter CDF for power edits on a worker (`EmitterWorker`). +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(super) const ASYNC_EMITTERS: usize = 100_000; + +/// Power edits in a scene with many emitters (a city's lamps and windows switching with the clock): the emitter CDF and +/// the light tree's node powers are rebuilt on a worker thread (~13 ms over 1.4 M emitters, which held up the frame in +/// `write_emitters`), and the edits land with them, in a later frame (`take_emitters`): the objects' powers, the CDF +/// and the tree's powers together, so sampling and shading switch at once. The worker writes the CDF and the tree's +/// nodes into a mapped staging buffer; the frame only copies it on the GPU (a 22 MB `write_buffer` cost it ~5 ms). +/// Lockstep frames wait for it, so they repeat. Scenes whose emitters change in place (added in place: the tree's slot; +/// moving emitters, whose nodes the frame refits) keep `write_emitters`; so does any other emitter edit (materials), +/// which first lands the edits held back. +pub(super) struct EmitterWorker { + pub(super) ask: mpsc::Sender, + pub(super) done: mpsc::Receiver, + /// The emitters it was made for: a list that grew (added in place) makes another. + pub(super) count: usize, + /// The edits of the job it is on (they land with its result). + pub(super) flight: Option>, +} + +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(super) struct EmitterJob { + pub(super) materials: Vec, + pub(super) objects: Vec<[f32; 4]>, + pub(super) lod: [f32; 4], + pub(super) cull: crate::EmitterCull, +} + +pub(super) struct EmitterBuilt { + /// The CDF then the tree's nodes, as the emitters buffer holds them (`put_emitters`), and their bytes. + pub(super) staging: wgpu::Buffer, + pub(super) bytes: u64, + /// The CDF's entries (its guide table included). + pub(super) entries: usize, + pub(super) total: f32, + pub(super) power: f32, + /// The light tree's node powers (`nodes[i][2]`), for the frame's copy of the tree. + pub(super) node_powers: Vec, +} + +impl EmitterWorker { + /// wasm32: none, and power edits rebuild in the frame as before (the browser's WebGPU device and buffers stay on + /// the thread that made them). + #[cfg(target_arch = "wasm32")] + pub(super) fn new(_e: &tables::Tables, _device: &wgpu::Device) -> Option { + None + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn new(e: &tables::Tables, device: &wgpu::Device) -> Option { + let changing = e.light_tree.as_ref().is_some_and(|t| t.dynamic.is_some() || t.moving_count > 0); + if e.emitter_tris.len() < ASYNC_EMITTERS || changing { + return None; + } + let device = device.clone(); + let (tris, mut tree, stride) = (e.emitter_tris.clone(), e.light_tree.clone(), e.stride); + let (ask, asked) = mpsc::channel::(); + let (tx, done) = mpsc::channel(); + std::thread::Builder::new() + .name("emitters".into()) + .spawn(move || { + while let Ok(j) = asked.recv() { + let (em, total, power) = crate::emitter_cdf(&tris, &j.materials, &j.objects, stride, j.lod, j.cull); + let node_powers = tree.as_mut().map_or(vec![], |t| { + let w: Vec = t.order.iter().map(|&i| crate::emitter_power(&tris[i as usize], &j.materials, &j.objects, stride)).collect(); + t.refit(&|k| w[k]); + t.nodes.iter().map(|n| n[2]).collect() + }); + let nodes: &[[f32; 4]] = tree.as_ref().map_or(&[], |t| &t.nodes); + let bytes = ((em.len() + nodes.len()) * 16) as u64; + let staging = device.create_buffer(&wgpu::BufferDescriptor { label: Some("emitters staging"), size: bytes.max(16), usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: true }); + let at = em.len() as u64 * 16; + for (range, data) in [(0..at, bytemuck::cast_slice::<_, u8>(&em)), (at..bytes, bytemuck::cast_slice(nodes))] { + if !data.is_empty() { + let Ok(mut m) = staging.slice(range).get_mapped_range_mut() else { break }; + m.copy_from_slice(data); + } + } + staging.unmap(); + if tx.send(EmitterBuilt { staging, bytes, entries: em.len(), total, power, node_powers }).is_err() { + break; + } + } + }) + .ok()?; + Some(EmitterWorker { ask, done, count: e.emitter_tris.len(), flight: None }) + } +} + +pub(super) fn load_scene(g: &gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32, h: u32, targets: &Targets, passes: &Passes) -> Result<(LoadedScene, crate::desc::PostPreset)> { + let t0 = Instant::now(); + let mut sc = scene::build(world, assets, presets, None)?; + sc.width = w; + sc.height = h; + // Emitters its edits add get a light-tree subtree of their own (ROADMAP § 6 step 4). + // (Software traversal with units edits in place as hardware traversal does: `units_wanted`.) + sc.emitter_slot = gpu::EditRoom::editable(&sc, g.hwrt || crate::units_wanted(g.hwrt, &sc)); + let mut timings = vec![("build_ms".to_string(), t0.elapsed().as_secs_f64() * 1000.0)]; + let mut prep = crate::prepare_live(g, &mut sc, &mut timings); + let mut tables = tables::Tables::new(&sc, std::mem::take(&mut prep.emitter_tris), prep.light_tree.take()); + // (THREERT_EDIT_REBUILD=1: every edit rebuilds, a material that starts emitting too: the A/B.) + tables.emit_in_place = std::env::var("THREERT_EDIT_REBUILD").map_or(true, |v| v != "1"); + tables.fixed_mats = std::mem::take(&mut prep.fixed_mats); + let emit = EmitterWorker::new(&tables, &g.device); + tables.defer_powers = emit.is_some(); + let (mat_runs, tri_order) = (std::mem::take(&mut prep.mat_runs), std::mem::take(&mut prep.tri_order)); + let fixed_features = (!sc.textures.is_empty(), !sc.shaders.is_empty(), prep.groups); + let mut up = g.upload(&sc, w, h, &prep.tri_verts, prep.groups, bytemuck::cast_slice(&prep.nodes), &prep.emitters, &prep.env, &prep.env_cdf, &prep.energy, &mut timings)?; + // The merged layout: the kernel written for this view's group-1 buffers (merged.rs). + if let (Some(caps), Some((_, live))) = (&mut up.kernel.caps, &targets.kernel.merged) { + caps.live = *live; + } + let _ = g.pipeline(&up.kernel, true); // compile the scene's kernel variant now, not in the first frame + let sky_inputs = ((prep.env.width, prep.env.height), sc.camera.position.y.max(0.0), prep.env_nee_floor); + let mut base = prep.params; + // The live viewport resamples fewer light-tree candidates at camera hits than offline renders (ltree.rs). + if base.ltree[3] > 0.5 { + base.ltree[3] = crate::ltree::live_candidates() as f32; + } + base.cam_fwd[3] = w as f32 / h as f32; + let bg = targets.bind_group(&g.device, &passes.layout, &up.accum); + let exposure_settings = ExposureSettings::new(&sc.camera.exposure, &sc.preset.post); + // Manual: 2^-EV100. Auto: a first guess for the firefly clamp (the GPU histogram takes over from frame 1). + let exposure = match &sc.camera.exposure { + crate::desc::Exposure::Manual { ev100 } => 2f32.powf(-ev100), + crate::desc::Exposure::Auto { compensation, max_ev100, .. } => 2f32.powf(*compensation) / 2f32.powf(*max_ev100), + }; + let c = &sc.camera; + let scene_camera = Camera { pos: c.position, fwd: c.forward, up: c.up, right: c.right, tan_half_fov: c.tan_half_fov }; + let (tris, tri_nodes, tri_instances) = (std::mem::take(&mut sc.tris), std::mem::take(&mut sc.tri_nodes), std::mem::take(&mut sc.tri_instances)); + let instanced = !sc.gpu_instances.is_empty() || !sc.procs.is_empty(); + let instance_features = gpu::instance_features(&sc); + let (pool_tris, meshes, instances) = (std::mem::take(&mut sc.pool_tris), std::mem::take(&mut sc.meshes), std::mem::take(&mut sc.instances)); + let mut gpu_instances = std::mem::take(&mut sc.gpu_instances); + let proc_first = gpu_instances.len(); + gpu_instances.extend_from_slice(&sc.proc_records); + let procs = std::mem::take(&mut sc.procs); + let proc_of = procs.iter().enumerate().filter(|(_, p)| p.sub.is_none()).map(|(j, p)| (p.node, j)).collect(); + let triangles = sc.stats.get("triangles").map_or(tris.len(), |t| *t as usize); + let first = prep.groups[..4].iter().sum::(); + // The kernel's first dynamic triangle (motion vectors, prev_verts) is group 4's first. + debug_assert_eq!(prep.params.group_first[1][0] as usize, first); + // The dynamic tail: groups 4..8 (the instanced pool follows them). + let end = prep.groups.iter().sum::(); + let blas = prep.tri_verts[first * 3..end * 3].chunks(3).flat_map(|t| [0, 1, 2].map(|k| if k == 0 { [t[0][0], t[0][1], t[0][2]] } else { [t[0][0] + t[k][0], t[0][1] + t[k][1], t[0][2] + t[k][2]] })).collect(); + let dynamic = (first, prep.tri_verts[first * 3..end * 3].to_vec(), sc.attrs[first..end].to_vec(), blas); + let blobs = sc.proc_layout.as_ref().map_or_else(Default::default, |l| l.blobs.iter().map(|(b, off)| (b.hash, l.head.len() + off)).collect()); + let slot = up.accel.as_ref().map_or(0, |a| a.proc_base + procs.len()); + let room_base = (prep.tri_verts.len() / 3, gpu_instances.len(), tables.objects.len() / tables.stride, up.region_words, slot); + // A record's TLAS instance is `inst_base` + it (model instances, then procedural objects, then the room's). + debug_assert!(up.accel.as_ref().is_none_or(|a| room_base.4 == a.inst_base + room_base.1)); + let room = up.room; + let mut users: std::collections::HashMap = Default::default(); + for i in &instances { + *users.entry(i.mesh).or_default() += 1; + } + let mesh_of = tables.retained.own_instance.iter().filter_map(|(&id, &k)| instances.get(k as usize).map(|i| (id, i.mesh))).collect(); + let managed = tables.retained.own_mesh.values().chain(tables.retained.emitter_mesh.values()).copied().collect(); + let edit = EditState { + tris: Holes::new(room_base.0, room_base.0 + room.tris), + records: Holes::new(room_base.1, room_base.1 + room.records), + objects: Holes::new(room_base.2, room_base.2 + room.objects), + word: room_base.3, + word_cap: room_base.3 + room.proc_words, + mesh_of, + users, + managed, + dead_meshes: vec![], + grows: 0, + rebind: false, + uniform_said: false, + base: room_base, + pool_first: prep.groups.iter().sum(), + blobs, + placed: Default::default(), + instanced: Default::default(), + inst_at_load: instances.len(), + added: Default::default(), + }; + Ok((LoadedScene { timings, triangles, tris, tri_nodes, tri_instances, pool_tris, meshes, instances, gpu_instances, procs, proc_of, proc_first, instanced, instance_features, name: sc.name.clone(), up, base, bg, exposure, exposure_settings, tan_half_fov: c.tan_half_fov, scene_camera, dof: c.dof.clone(), tables, fixed_features, dynamic, edit, sky: None, emit, sky_inputs, mat_runs, tri_order }, sc.preset.post)) +} diff --git a/renderer/src/live/mod.rs b/renderer/src/live/mod.rs new file mode 100644 index 0000000..c82e39c --- /dev/null +++ b/renderer/src/live/mod.rs @@ -0,0 +1,1271 @@ +//! Real-time renderer (LiveCore), modelled on UE5's real-time path with hardware RT: every frame traces 1+ spp +//! (primary visibility, GI, shadows, reflections), reprojects and accumulates history (converging to the offline +//! reference while the camera holds still), denoises and applies the UE post chain. Drawn by the native window viewer +//! (`view`) and the playground client; `live` runs it headless at a fixed frame rate for benchmarks: +//! +//! threert-render live [--assets DIR] [--room id] [--width 1280 --height 720 --fps 60 --bounces 4 +//! --scale 1|0.6|auto --display-scale 1|2|3] [--frame-log frames.jsonl] +//! +//! `--scale` is the render scale (UE screen percentage): the frame is traced, denoised and composed at that fraction +//! of the output resolution and the temporal upscaler takes it to the output; `auto` = dynamic resolution. +//! `--display-scale`: the output is a display's physical pixels, that many a point (a Retina window: 2), as the +//! window host's is; dynamic resolution then starts from the points' resolution and goes down to half of it. +//! +//! stdin (JSON lines): {"cmd":"camera","pos":[x,y,z],"target":[x,y,z]} | {"cmd":"event","type":t,"payload":{..}} +//! | {"cmd":"click","u":u,"v":v} | {"cmd":"reset"} | {"cmd":"time","t":s} (set the scene time) +//! | {"cmd":"snapshot","path":p,"after":k (history length ≥ k),"frames":n (the n-th frame from now, default 1)} +//! (a path ending in .raw: the linear radiance, RGB f32 at output size; ending in .signals: +//! its diffuse / specular / emission apart, -{diffuse,specular,emission}.raw, as +//! `render --signals` writes them) +//! | {"cmd":"sync","id":s,"frames":n} (stderr "live: sync s" after the n-th frame from now) +//! | {"cmd":"lockstep","dt":s | "on":false} (render only the frames steps ask for, at frame time s) +//! | {"cmd":"camera",...,"frames":n,"sync":id | "snapshot":p} | {"cmd":"frames","n":n,"sync":id | "snapshot":p} +//! (a step: camera, frames, and the sync answer / snapshot at its last frame, in one command) +//! | {"cmd":"scale","scale":s (0 = auto)} | {"cmd":"quit"} +//! +//! The files: this one holds `LiveCore` itself (its settings, camera, resolution governor and readbacks) and what the +//! others share; `targets` the live passes' pipelines, screen-size buffers and bind group; `load` a scene loaded and +//! the workers of its sky and emitters; `edits` the world's edits applied in place; `frame` one frame (the trace, the +//! live passes, the caches' training, the GPU timing); `session` the headless runner (native only). + +use crate::gpu::Buf; +use crate::world::World; +use crate::{gpu, post, scene, tables}; +// (The headless runner, `session`, native only, is what uses all of it.) +#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] +use anyhow::{anyhow, Result}; +use glam::Vec3; +#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] +use std::io::BufRead; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc; +use std::sync::Arc; +#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] +use std::time::Duration; +use web_time::Instant; + +mod edits; +mod frame; +mod load; +#[cfg(not(target_arch = "wasm32"))] +mod session; +mod targets; +#[cfg(test)] +pub(crate) use targets::live_source; +pub(crate) use targets::Targets; +#[cfg(not(target_arch = "wasm32"))] +pub use session::{parse_cmd, parse_launch, parse_opts, run, save_bgra}; +use edits::*; +use frame::*; +use load::*; +use targets::*; + +pub struct LiveOpts { + pub width: u32, + pub height: u32, + pub fps: u32, + pub bounces: u32, + /// Render scale (traced / output resolution); None = dynamic resolution. + pub scale: Option, + /// Game bundle (game code + runtime) that builds and drives the scene in-process. + pub bundle: PathBuf, + /// Asset root its model / texture paths resolve against. + pub assets: PathBuf, + pub room: String, + /// JSON line per frame (wall interval, CPU work, GPU time, scale, history): hitch statistics (tools/bench-hitches.ts). + /// `interval` runs from the end of one frame's work to the next's, so it carries the variation of the work itself; + /// `start` runs from one frame's start to the next's, which the pacer sets: the frame rate a player sees. + pub frame_log: Option, + /// The output's pixels a point of its display (1: the output is in points). + pub display_scale: f32, + /// `--launch`: the `params` of the host channel's `launch` message (docs/RUNTIME.md "The host channel"). + pub launch: Option, +} + +/// Lowest dynamic-resolution scale (UE r.DynamicRes.MinScreenPercentage default) of a view whose pixels are the +/// display's points; a host whose view is in the display's physical pixels divides it by the display's scale factor +/// (`LiveCore::min_scale`), so the fewest rays a window traces do not depend on the display's density. +pub const MIN_SCALE: f32 = 0.5; +/// The lowest render scale any view takes (a view of 3 pixels a point at `MIN_SCALE`, rounded down). +pub const SCALE_FLOOR: f32 = 0.125; + +#[repr(C)] +#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] +struct CamU { + pos: [f32; 4], + fwd: [f32; 4], + right: [f32; 4], + up: [f32; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] +struct LiveParams { + cur: CamU, + prev: CamU, + size: [u32; 4], + misc: [f32; 4], + misc2: [f32; 4], + mode: [u32; 4], + post: [f32; 4], + /// Auto exposure (UE histogram): low / high fraction, histogram log2 min / max. + ae: [f32; 4], + /// Compensation (2^bias), min / max average luminance, mode (0 manual, 1 auto). + ae2: [f32; 4], + /// Speed up / down (EV/s), frame time (s), snap to target (1). + ae3: [f32; 4], + /// Local exposure: highlight contrast, shadow contrast, blurred-luminance blend, detail strength. + lx: [f32; 4], + /// Traced (internal) width, height. + isz: [u32; 4], + /// Bloom stage radii (texels of each stage's level) and tints, stages 0..3 then 4, 5. + br: [f32; 4], + br2: [f32; 4], + bt: [f32; 4], + bt2: [f32; 4], + /// Depth of field: CoC radius of infinity (output px; 0 = off), focus distance (m), max radius (px), -. + dof: [f32; 4], + /// This frame's sub-pixel jitter (every camera ray's: x, y in 0 .. 1 of a traced pixel), -, -. + jit: [f32; 4], + /// Which half of the lasting buffers is this frame's (live.wgsl `this_half`; the other the previous frame's), -. + state: [u32; 4], +} + +/// Auto / local exposure settings of the loaded scene (UE PostProcessVolume semantics). +#[derive(Clone, Copy, Default)] +struct ExposureSettings { + ae: [f32; 4], + ae2: [f32; 4], + speed: [f32; 2], + lx: [f32; 4], +} + +impl ExposureSettings { + fn new(exp: &crate::desc::Exposure, pp: &crate::desc::PostPreset) -> Self { + let a = &pp.auto_exposure; + let le = &pp.local_exposure; + let (ae2, comp) = match exp { + crate::desc::Exposure::Manual { ev100 } => ([2f32.powf(-ev100), 0.0, 0.0, 0.0], 2f32.powf(-ev100)), + crate::desc::Exposure::Auto { compensation, min_ev100, max_ev100 } => { + let c = 2f32.powf(*compensation); + ([c, 0.18 * 2f32.powf(*min_ev100), 0.18 * 2f32.powf(*max_ev100), 1.0], c) + } + }; + let _ = comp; + ExposureSettings { + ae: [a.low_percent.clamp(1.0, 99.0) / 100.0, a.high_percent.clamp(1.0, 99.0) / 100.0, a.histogram_log_min, a.histogram_log_max.max(a.histogram_log_min + 1.0)], + ae2, + speed: [a.speed_up.max(0.01), a.speed_down.max(0.01)], + lx: [le.highlight_contrast, le.shadow_contrast, le.blurred_luminance_blend, le.detail_strength], + } + } +} + +#[derive(Clone, Copy, PartialEq)] +pub struct Camera { + pub pos: Vec3, + pub fwd: Vec3, + pub up: Vec3, + pub right: Vec3, + pub tan_half_fov: f32, +} + +impl Camera { + pub fn look_at(pos: Vec3, target: Vec3, tan_half_fov: f32) -> Camera { + let fwd = (target - pos).normalize(); + let right = fwd.cross(Vec3::Y).normalize(); + Camera { pos, fwd, up: right.cross(fwd), right, tan_half_fov } + } + fn uniform(&self, aspect: f32) -> CamU { + CamU { + pos: [self.pos.x, self.pos.y, self.pos.z, self.tan_half_fov], + fwd: [self.fwd.x, self.fwd.y, self.fwd.z, aspect], + right: [self.right.x, self.right.y, self.right.z, 0.0], + up: [self.up.x, self.up.y, self.up.z, 0.0], + } + } +} + +pub enum Cmd { + Camera(Camera), + /// An event for the in-process game (type, payload). + Event(String, serde_json::Value), + /// A click at (u, v) for the in-process game (picks the node under it). + Click(f32, f32), + /// Messages of the host channel for the in-process game (`{"cmd":"host","messages":[{"t":...}, ...]}`: what an + /// app would send, its input among them; docs/RUNTIME.md "The host channel"). + Host(Vec), + /// Snapshot (path) of the first frame whose history length reaches `after` (None = any) and that is at least the + /// `frames`-th frame from now (None = the next). + Snapshot(PathBuf, Option, Option), + /// Answer `live: sync ` on stderr once the n-th frame from now is rendered (benchmarks step the camera frame by + /// frame, so what they measure does not depend on how many frames a busy machine renders per second). + Sync(String, u32), + /// Lockstep (benchmarks): with Some(frame time, s) the runner renders only the frames `Step`s ask for, at that frame + /// time (motion blur, exposure, frame script); None = real time again. + Lockstep(Option), + /// A step: an optional camera, `frames` frames to render (lockstep), and a sync answer / snapshot at the last of them, + /// registered with the camera in one command so neither can land a frame late. + Step { cam: Option, frames: u32, sync: Option, snapshot: Option }, + /// Drop all accumulated history (benchmarks measure convergence from here). + Reset, + /// Set the scene time, s (benchmarks: with lockstep, frame k from here renders at t + k x its frame time; frame + /// time 0 holds it there). + Time(f32), + /// View mode (see LiveCore::view_mode). + Mode(u32), + Bounces(u32), + /// Max samples per frame (adaptive below it). + SppCap(u32), + /// Viewer status text (e.g. the game score), shown in the window title. + Status(String), + /// Render scale (0 = dynamic resolution). + Scale(f32), + Quit, +} + +/// The real-time renderer: scene + screen targets + camera/history state. Records one frame at a time; the output +/// (sRGB BGRA8 per pixel) lands in `targets.out`. Used by the headless runner, the native window viewer and the playground. +pub struct LiveCore { + /// The game's particle spawns since the last frame (`World::particle_spawns`), for the GPU particle pass. + particle_spawns: std::collections::BTreeMap>, + /// The sources the game gave its density grids since the last frame (`World::grid_sources`), for the grid pass. + grid_sources: std::collections::BTreeMap>, + pub g: gpu::Gpu, + passes: Passes, + pub targets: Targets, + sc: LoadedScene, + presets: PathBuf, + /// Asset root (models, file textures). + assets: PathBuf, + pub w: u32, + pub h: u32, + /// Committed state sequence number the image shows (set by the game session). + pub seq: i64, + aspect: f32, + pub cam: Camera, + prev_cam: Camera, + pub frame: u32, + pub history_len: f32, + reset: bool, + /// Bloom stage radii / tints (`bloom_stages`). + bloom: ([f32; 6], [f32; 6]), + vignette: f32, + /// Chromatic aberration, film grain, bloom threshold, longest motion blur (output pixels). + post: [f32; 4], + /// Motion blur (UE MotionBlurAmount, MotionBlurTargetFPS; amount 0 = off). + blur: (f32, f32), + /// Last submission of each frame still on the GPU (at most two frames in flight). + inflight: std::collections::VecDeque, + pub gpu_ms: f32, + /// The frame `gpu_ms` timed (it lags by the frames in flight). + gpu_frame: u32, + /// That frame's trace pass alone (the path-tracing kernel: intersection and shading), ms; 0 without timestamps. + /// Frame log `trace`: the per-frame cost a geometry representation changes, without THREERT_PROFILE's stalls. + pub gpu_trace_ms: f32, + /// This frame's geometry edits applied in place (`edit_geometry`: CPU ms, its BLAS / TLAS builds submitted), and + /// scene rebuilds (ms) and, when edits fell back to one, why: summed over the frame's commits (a game event's and the + /// frame script's), reset when the frame is logged. Fallbacks since the viewport opened. + /// Frame log `edit`, `rebuild`, `fallback`, `fallbacks`. + pub edit_ms: f32, + /// CPU time of this frame's in-place instance updates (`update_instanced`), ms. + pub instanced_ms: f32, + pub edit_split: EditSplit, + pub rebuild_ms: f32, + pub fallback: Option, + pub fallbacks: u32, + /// Path-tracing bounces for real-time frames (the offline preset uses more). + pub bounces: u32, + /// Ray tracing (true). Off (`set_rays`), the kernel traces the camera ray of each pixel and no other: no shadows, + /// no bounce light, reflections of the sky only; analytic lights and emissive surfaces light unoccluded. The + /// fallback for GPUs that trace too slowly (no hardware ray queries), and the "what tracing adds" comparison on + /// those that do: the same scene, materials, exposure and post either way. + rays: bool, + /// 0 lit, 1 base color, 2 world normal, 3 lit without denoiser, 4 raw sample, 5 history length, 6 diffuse, + /// 7 specular, 8 deterministic direct light (THREERT_VIEW_MODE at start: e.g. bench-temporal per signal). + pub view_mode: u32, + /// Lockstep frame time (s; benchmarks): motion blur and exposure adaptation use it instead of the wall clock. + pub fixed_dt: Option, + /// A/B switch (playground key N): motion blur off whatever the scene's post settings say. + pub motion_blur_off: bool, + pub triangles: usize, + pub scene_name: String, + /// Scene time, s: what `advance_time` added up, the dt each frame script gets, so it is the game's clock (`f.time`) + /// and a burst fired at a game time starts then; lockstep runs repeat exactly. Animated shading, the particle pass + /// and power edits' time stamps use it (`shader_time`). + time: f32, + /// Time between rendered frames (auto exposure adaptation), s. + frame_dt: f32, + last_frame: Instant, + /// The post preset the LUT / bloom / grain settings were built from (scenes may override it). + post_key: String, + /// Traced / output resolution (UE screen percentage); the temporal pass upsamples below 1. + pub render_scale: f32, + /// Adjust `render_scale` to the frame budget (`govern_resolution`). + pub dynamic_res: bool, + /// Dynamic resolution's lowest scale: `MIN_SCALE` over the display's scale factor. + pub min_scale: f32, + /// The view's pixels a point of its display (`set_display_scale`): dynamic resolution's steps are a point's. + display_scale: f32, + /// Dynamic resolution: consecutive measured frames well over the frame time, and the first frame at the current + /// scale. + over_budget: (u32, u32), + /// When this view was made, and when its render scale was last checked (`govern_resolution` by the clock). + born: Instant, + governed: Instant, + /// THREERT_PROFILE=1: GPU time per pass (timestamp queries), printed every 2 s. + profile: Option, + /// GPU time per frame without stalls (timestamp queries; None: measured by draining the queue twice a second). + timer: Option, + /// Rasterized primary visibility (None: the kernel traces camera rays). + raster: Option, + /// World-space radiance cache (pathtrace.wgsl; None: THREERT_CACHE=0, paths trace every bounce). + cache: Option, + /// Kernel group 1: primary visibility + radiance cache + what the live passes read of a trace. + kernel_bg: wgpu::BindGroup, + /// Span (relative to the dynamic tail) re-transformed by the last in-place update whose previous-frame vertices + /// (`Uploaded::prev_verts`) still hold the frame before it: synced to the current ones next frame. + prev_span: Option<(usize, usize)>, + /// An in-place update happened this frame (it keeps `prev_verts` itself). + reposed: bool, + /// Dynamic triangles moved in this commit: the light tree's moving subtree is refit once at its end. + emitters_moved: bool, + /// Frames since the last reset or camera cut (`COLD_FALLBACK_FRAMES`). + cold_frames: u32, + /// Likewise for the deforming meshes of shared poses (`repose_groups`): the span they took (relative to the dynamic + /// tail, past its end) and whether this frame re-skinned any. + pose_span: Option<(usize, usize)>, + pose_reposed: bool, + /// Movable instances moved by the last update (their records' previous-frame rows differ), and whether it was this + /// frame's. + inst_moving: Vec, + inst_moved: bool, + /// The previous frame's traced size (its traced-grid diffuse history; 0 before the first frame). + prev_isz: (u32, u32), + /// Frame of the last lighting edit (power, light / material parameters, environment): the temporal pass looks + /// for changed lighting (anti-lag) only shortly after one. + lit_changed: u32, + /// The scene has light-sampled particles (emitter table entries with a nominal area). + spark_lights: bool, + /// Samples per pixel of the last frame rendered (the kernel's sample buffer holds their sums). + shown_spp: u32, + /// CPU time of the last commit (ms): skinning and re-transforming moving objects (`reskin`, rigid instance poses), and + /// uploading them with the dynamic BLAS / TLAS rebuild submitted (frame log `skin`, `upload`). + pub cpu_skin_ms: f32, + pub cpu_upload_ms: f32, + /// THREERT_PROFILE_BLAS=1: GPU time of the last dynamic BLAS / TLAS rebuild (ms; frame log `blas`), measured by draining + /// the queue before it and waiting for it after (it stalls the frame: measurement runs only). The pass profile has + /// no timestamps for it (Metal cannot write them between acceleration-structure builds). + pub gpu_blas_ms: f32, + /// CPU time the last `render` spent waiting for the oldest frame in flight (ms; frame log `wait`): GPU-bound frames. + pub cpu_wait_ms: f32, + /// The cloud cover the sky and the sun light were made with (`move_sun`). + sky_cover: f32, + /// The loop guard (merged.rs `guard`), if a kernel of this session may have it: the invocations it has stopped at + /// its limit since the view was made, as last read back (a frame or two behind, without waiting). Above zero: + /// frames with wrong pixels, a traversal that did not end on the scene's data. None without the guard. + pub guard_trips: Option, + /// Its readback: the buffer and its state (0 free, 1 mapping, 2 mapped), as the frame timer's. + guard_read: Option<(wgpu::Buffer, Arc)>, +} + +/// THREERT_CACHE_FLAGS: experiment switches of the radiance cache (pathtrace.wgsl `cfl`), 0 = defaults: 1 = no cold +/// fallback (cold voxels read as they are), 2 = no hit lighting (reflection hits use the cache's total), 4 = no +/// training tiles, 8 = the exact light loop at reflection hits. THREERT_CACHE=0 turns the cache off. +/// Frames after a reset or a camera cut during which pixels whose radiance-cache voxel is cold trace a fresh estimate +/// (pathtrace.wgsl, cache flag 1 off). Later the cold voxels left are mostly where bounce rays land off screen, +/// scattered, sparsely trained: their traces cost each SIMD group nearly a whole extra trace (Bistro's path 0.7-1.2 +/// ms, the crowd's square 2.3 ms) for ~1 % of the image (bench-crowd ΔE 13.1 -> 13.3). +const COLD_FALLBACK_FRAMES: u32 = 30; + +/// Ceiling for one live lighting sample, in display units at the current exposure (live.wgsl `sample_clamp`): fireflies +/// far above white. 64 until 2026-11-09, when it cut the light of small strong lights that the denoisers now keep: the +/// Bistro crowd in motion (lockstep at 10 s) diffuse 0.87 -> 0.90 of our offline render, specular 0.83 -> 0.89, the +/// pavement under the headlights 0.71 -> 0.77. 1024 adds nothing more. +const SAMPLE_CEILING: f32 = 256.0; + +/// THREERT_GOVERN_CLOCK=1|0 (default 1 on Android): dynamic resolution also checks by the clock, so a view whose +/// frames are far slower than 60 a second comes down to its frame rate in seconds (`LiveCore::govern_resolution`). +fn govern_by_clock() -> bool { + static F: std::sync::OnceLock = std::sync::OnceLock::new(); + *F.get_or_init(|| std::env::var("THREERT_GOVERN_CLOCK").map(|v| v == "1").unwrap_or(cfg!(target_os = "android"))) +} + +fn cache_flags() -> u32 { + static F: std::sync::OnceLock = std::sync::OnceLock::new(); + *F.get_or_init(|| std::env::var("THREERT_CACHE_FLAGS").ok().and_then(|v| v.parse().ok()).unwrap_or(0)) +} + +/// THREERT_FREEZE_TIME=1: animation holds still (shader time 0, the frame script gets dt 0: signs, screens, flicker groups), +/// so frames taken at different times compare (tools/bench-ghosting.ts). +fn freeze_time() -> bool { + static F: std::sync::OnceLock = std::sync::OnceLock::new(); + *F.get_or_init(|| std::env::var("THREERT_FREEZE_TIME").is_ok_and(|v| v == "1")) +} + +/// Seconds of animated shading (sign flicker, screens, ripples) and particles: the scene time; 0 under THREERT_FREEZE_TIME. +fn shader_time(time: f32) -> f32 { + if freeze_time() { 0.0 } else { time } +} + +/// THREERT_EXPOSURE_SNAP=1: auto exposure jumps to its target every frame (no adaptation lag), so a frame taken while the +/// view moves has the exposure a settled view of that pose would have (tools/bench-ghosting.ts). +fn exposure_snap() -> bool { + static F: std::sync::OnceLock = std::sync::OnceLock::new(); + *F.get_or_init(|| std::env::var("THREERT_EXPOSURE_SNAP").is_ok_and(|v| v == "1")) +} + +/// UE's physical CoC model (DiaphragmDOFUtils.cpp, FPhysicalCocModel::Compile): focal length from the sensor height +/// (sensor width / aspect) and the vertical FOV, the CoC of infinity on the sensor f^2 / (N (focus - f)), as a radius in +/// output pixels; max radius r.DOF.Kernel.Max{Fore,Back}groundRadius = 2.5 % of the width. Off without a focus distance. +fn dof_params(dof: Option<&crate::desc::Dof>, tan_half_fov: f32, w: u32, h: u32) -> [f32; 4] { + let Some(d) = dof.filter(|d| d.focus_distance > 0.0 && d.f_stop > 0.0 && d.sensor_width > 0.0) else { return [0.0; 4] }; + let aspect = w as f32 / h as f32; + let sensor_h = d.sensor_width / aspect; + let f = 0.5 * sensor_h / tan_half_fov; + let diameter = f * f / (d.f_stop * (d.focus_distance * 1000.0 - f).max(1e-3)); + [diameter * 0.5 / sensor_h / aspect * w as f32, d.focus_distance, 0.025 * w as f32, 0.0] +} + +/// Live passes that run on the traced grid (the rest on the output grid). +fn traced_pass(name: &str) -> bool { + name == "refl" || name == "dtemporal" || name.starts_with("atrous") || name == "stemporal" || name.starts_with("sfilt") || name == "compose" || name == "judge" || name == "reject" +} + + +/// Light LOD (`emitter_lod`): emitters within this distance of the camera keep their sampling weight, farther ones +/// lose it as (d0 / d)^2. +const LIGHT_LOD_D0: f32 = 10.0; + +/// Emitters farther than this from the light LOD's reference are not proposed (`emitter_lod`); BSDF rays bring their +/// light. +const EMITTER_CULL: f32 = 60.0; + +/// The cull's reach inside the view cone (`EmitterCull`), in cull radii: emitters ahead of the camera are kept out to +/// this. +const EMITTER_CULL_VIEW: f32 = 2.0; +/// The view cone: the frustum's half-diagonal plus this margin (degrees); the CDF is rebuilt when the camera turns by +/// LIGHT_LOD_TURN, so the frustum stays inside the cone between rebuilds. +const EMITTER_CULL_MARGIN: f32 = 20.0; +const LIGHT_LOD_TURN: f32 = 15.0; + + + +/// Halton(base) of index i (sub-pixel jitter sequence). +fn halton(mut i: u32, base: u32) -> f32 { + let (mut f, mut r) = (1.0f32, 0.0f32); + while i > 0 { + f /= base as f32; + r += f * (i % base) as f32; + i /= base; + } + r +} + +/// History caps of the cache: samples (noise), frames (lag; shorter for a while after a lighting edit). +const CACHE_SAMPLES: f32 = 256.0; +const CACHE_FRAMES: f32 = 32.0; +const CACHE_FRAMES_LIT: f32 = 4.0; +/// The most frames a pixel's history holds while the scene's lighting changes: short enough to follow the change by +/// itself (a real-time denoiser's bound: NRD's default is 30), so no edit has to cut it. Against main's rules (24 +/// frames for good in a scene with spark lights, every pixel cut to 16 at a lighting edit, an on / off anti-lag), dE +/// against the offline render or the converged frame: the Bistro slice half a second after its explosion 6.04 for +/// 6.58, at rest 2.82 for 2.79; Kabukichō 2 s and 4 s after its signs close 6.70 and 2.92 for 6.56 and 2.82. At 64 +/// frames the explosion read 5.72 and the signs 7.82 and 3.37. +const HISTORY_LIVE: f32 = 32.0; +/// The most once the scene has settled: holding still, the frame converges on the offline render. +const HISTORY_FRAMES: f32 = 4096.0; + +/// THREERT_TL_HIDE (diagnostics): the translucency pass trains the volume voxels but draws nothing, so frames show what is +/// behind translucent particles; `odd` hides them on odd frames only (rain on / off pairs from one run). +fn translucent_hidden(frame: u32) -> bool { + static F: std::sync::OnceLock> = std::sync::OnceLock::new(); + match F.get_or_init(|| std::env::var("THREERT_TL_HIDE").ok().map(|v| v == "odd")) { + None => false, + Some(false) => true, + Some(true) => frame % 2 == 1, + } +} + +impl LiveCore { + /// Advances the scene time by a frame's dt (the frame script's: at most 0.1 s). + pub fn advance_time(&mut self, dt: f32) { + self.time += dt.clamp(0.0, 0.1); + } + + /// Sets the scene time: a game session's frame script clock (`GameSession::frame`). + pub fn set_time(&mut self, t: f32) { + self.time = t; + } + + /// The scene time animation sees (0 under THREERT_FREEZE_TIME). + pub fn scene_time(&self) -> f32 { + shader_time(self.time) + } + + /// A live view of the engine's world; asset files resolve against `assets`. + pub fn new(g: gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32, h: u32) -> Result { + let passes = Passes::new(&g); + // The scene's post preset replaces this LUT below (apply_post). + let cache_on = !std::env::var("THREERT_CACHE").is_ok_and(|v| v == "0"); + // (Without the cache the kernel binds one of one entry.) + let targets = Targets::new(&g, w, h, &vec![0u16; (LUT * LUT * LUT * 4) as usize], if cache_on { gpu::CACHE_SIZE } else { 1 }); + let (sc, pp) = load_scene(&g, world, assets, presets, w, h, &targets, &passes)?; + let cam = sc.scene_camera; + let (triangles, scene_name) = (sc.triangles, sc.name.clone()); + let profile = (g.timestamps && std::env::var("THREERT_PROFILE").is_ok()).then(|| { + // trace, the post passes, cache resolve, then the particle pass, the BLAS rebuild, the translucency pass and + // the volume training. + let n = (passes.pipes.len() as u64 + 6) * 2; + Profile { + queries: g.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("profile"), ty: wgpu::QueryType::Timestamp, count: n as u32 }), + resolve: g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 8, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }), + read: g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 8, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), + sums: vec![0.0; passes.pipes.len() + 6], + frames: 0, + grid: ( + g.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("profile grid"), ty: wgpu::QueryType::Timestamp, count: 6 }), + g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 2 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }), + g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 48, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), + ), + grid_sum: 0.0, + media_sum: 0.0, + } + }); + let timer = (g.timestamps && profile.is_none()).then(|| FrameTimer::new(&g.device)); + let raster = std::env::var("THREERT_RASTER").is_ok_and(|v| v == "1").then(|| Raster::new(&g, w, h)); + let cache = cache_on.then(|| RadianceCache { bufs: targets.kernel.cache.clone(), resolve: g.cache_resolve_pipeline(&targets.kernel) }); + let kernel_bg = gpu::vis_bind_group(&g.device, &g.vis_layout, raster.as_ref().map_or(&g.no_vis_view, |r| &r.vis), &targets.kernel); + // (Read whenever a kernel of this session may have the guard: under software traversal a scene's first unit + // brings it, `gpu::loop_guard_of`.) + let guard_read = (gpu::loop_guard_of(!g.hwrt).is_some() && targets.kernel.guard.is_some()) + .then(|| (g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("loop guard read"), size: 4, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), Arc::new(AtomicU32::new(0)))); + let mut core = LiveCore { particle_spawns: Default::default(), grid_sources: Default::default(), g, passes, targets, sc, presets: presets.to_path_buf(), assets: assets.to_path_buf(), w, h, seq: 0, aspect: w as f32 / h as f32, cam, prev_cam: cam, frame: 0, history_len: 0.0, reset: true, bloom: ([0.0; 6], [0.0; 6]), vignette: 0.0, post: [0.0; 4], blur: (0.0, 0.0), inflight: Default::default(), gpu_ms: 0.0, gpu_frame: 0, gpu_trace_ms: 0.0, edit_ms: 0.0, instanced_ms: 0.0, edit_split: EditSplit::default(), rebuild_ms: 0.0, fallback: None, fallbacks: 0, bounces: 4, rays: true, view_mode: std::env::var("THREERT_VIEW_MODE").ok().and_then(|v| v.parse().ok()).unwrap_or(0), fixed_dt: None, motion_blur_off: false, triangles, scene_name, time: 0.0, frame_dt: 1.0 / 60.0, last_frame: Instant::now(), post_key: String::new(), render_scale: 1.0, dynamic_res: false, min_scale: MIN_SCALE, display_scale: 1.0, over_budget: (0, 0), born: Instant::now(), governed: Instant::now(), profile, timer, raster, cache, kernel_bg, prev_span: None, reposed: false, emitters_moved: false, cold_frames: 0, pose_span: None, pose_reposed: false, inst_moving: vec![], inst_moved: false, prev_isz: (0, 0), lit_changed: 0, spark_lights: false, shown_spp: 1, cpu_skin_ms: 0.0, cpu_upload_ms: 0.0, gpu_blas_ms: 0.0, cpu_wait_ms: 0.0, sky_cover: 0.0, guard_trips: guard_read.as_ref().map(|_| 0), guard_read }; + core.spark_lights = core.sc.tables.emitter_tris.iter().any(|e| e.nominal); + core.sky_cover = core.sc.tables.cloud_cover; + core.apply_post(&pp); + Ok(core) + } + + pub fn scene_camera(&self) -> Camera { + self.sc.scene_camera + } + + /// `guard_trips` for the frame log: None while the scene's kernel has no guard. Natively with nothing set that is + /// a scene without units (`gpu::loop_guard_of`): under software traversal the first unit brings the guard. + pub fn guard_count(&self) -> Option { + self.guard_trips.filter(|_| gpu::loop_guard_of(self.sc.up.kernel.features & gpu::F_UNITS != 0).is_some()) + } + + /// The edit room as the frame log reports it (`room`): triangles, records and object entries in use and held (GPU + /// order counts, holes not in use), its growths, and the GPU bytes of the buffers edits write and of the triangle + /// BLASes the live meshes need (estimated from the build's bytes per triangle), MB. + pub fn room_stats(&self) -> serde_json::Value { + let (e, up) = (&self.sc.edit, &self.sc.up); + // Software traversal's (it does not grow): the same counts, and of the node buffer the mesh trees' range (in + // use, held) and the units the top tree has against the most its range holds. + if let Some(u) = up.units.as_ref() { + return serde_json::json!({ + "tris": [e.tris.used(), e.tris.cap], "records": [e.records.used(), e.records.cap], "objects": [e.objects.used(), e.objects.cap], + "holes": e.tris.free.len(), "grows": e.grows, "nodes": [u.layout.mesh_cap as usize - u.layout.free_nodes(), u.layout.mesh_cap], "units": [u.count, u.layout.max_units()], + }); + } + let Some(a) = up.accel.as_ref() else { return serde_json::Value::Null }; + let buffers = [&up.verts, &up.attrs, &up.prev_verts, &up.instances, &up.objects, &up.cdf, &up.emitters, &a.verts].iter().map(|b| b.size()).sum::(); + let live: u64 = self.sc.meshes.iter().enumerate().filter(|(k, _)| e.users.get(&(*k as u32)).copied().unwrap_or(0) > 0).map(|(_, m)| m.count as u64).sum::() + e.pool_first as u64; + let mb = |b: f64| (b / (1 << 20) as f64 * 10.0).round() / 10.0; + serde_json::json!({ + "tris": [e.tris.used(), e.tris.cap], "records": [e.records.used(), e.records.cap], "objects": [e.objects.used(), e.objects.cap], + "holes": e.tris.free.len(), "grows": e.grows, "buffers_mb": mb(buffers as f64), "blas_mb": mb(live as f64 * a.blas_per_tri), + }) + } + + /// Software traversal's top tree so far (the frame log's `top`): how many times it was built, kept with leaves + /// taken out and put in, and kept with its boxes brought up to date, and the ms all of that took. Null without + /// units. + pub fn top_stats(&self) -> serde_json::Value { + self.sc.up.units.as_ref().map_or(serde_json::Value::Null, |u| serde_json::json!([u.kept.builds, u.kept.changes, u.kept.refits, (u.kept.ms * 1e3).round() / 1e3])) + } + + pub fn rebuild(&mut self, world: &World) -> Result<()> { + let (s, pp) = load_scene(&self.g, world, &self.assets, &self.presets, self.w, self.h, &self.targets, &self.passes)?; + self.apply_post(&pp); + self.triangles = s.triangles; + self.scene_name = s.name.clone(); + // The scene's camera changed (a new lens rebuilds, and a move in the same commit comes with it): the view takes it. + // Otherwise the view stays where it is. + if s.scene_camera != self.sc.scene_camera { + self.cam = s.scene_camera; + } + // What the grid programs had simulated goes with the grids that are still the same. + self.g.carry_grids(&self.sc.up, &s.up); + self.sc = s; + self.spark_lights = self.sc.tables.emitter_tris.iter().any(|e| e.nominal); + self.inst_moving.clear(); + self.prev_span = None; + self.pose_span = None; + self.emitters_moved = false; + self.lit_changed = self.frame.max(1); + Ok(()) + } + + /// Rebuilds the tonemap LUT and bloom / vignette / fringe / grain settings when the scene's post preset changed. + fn apply_post(&mut self, pp: &crate::desc::PostPreset) { + let key = format!("{pp:?}"); + if key == self.post_key { + return; + } + let lut = tonemap_lut(pp); + self.g.queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &self.targets.lut_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(&lut), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT * 8), rows_per_image: Some(LUT) }, + wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, + ); + self.bloom = bloom_stages(pp, self.w, self.h); + self.vignette = pp.vignette; + // THREERT_NO_GRAIN=1: no film grain (a fresh random pattern every frame; comparisons between frames see it as error). + let grain = if std::env::var("THREERT_NO_GRAIN").is_ok_and(|v| v == "1") { 0.0 } else { pp.film_grain }; + // THREERT_MOTION_BLUR=1: UE's default motion blur whatever the preset says; =amount,max,targetFps: those. + let mb = match std::env::var("THREERT_MOTION_BLUR").ok().as_deref() { + Some("1") => crate::desc::MotionBlur::default(), + Some(v) => match v.split(',').map(|x| x.trim().parse::()).collect::, _>>().as_deref() { + Ok([amount, max, target_fps]) => crate::desc::MotionBlur { amount: *amount, max: *max, target_fps: *target_fps }, + _ => pp.motion_blur.clone(), + }, + None => pp.motion_blur.clone(), + }; + self.post = [pp.chromatic_aberration, grain, pp.bloom.threshold, mb.max / 100.0 * self.w as f32]; + self.blur = (if mb.max > 0.0 { mb.amount.max(0.0) } else { 0.0 }, mb.target_fps); + self.post_key = key; + } + + /// Switches to a different world: camera from it, history cleared. + pub fn open(&mut self, world: &World, assets: &Path) -> Result<()> { + self.assets = assets.to_path_buf(); + self.rebuild(world)?; + if let Some(c) = &self.cache { + let mut e = self.g.device.create_command_encoder(&Default::default()); + c.bufs.clear(&mut e); + self.g.queue.submit([e.finish()]); + } + self.cam = self.sc.scene_camera; + self.prev_cam = self.cam; + self.reset = true; + Ok(()) + } + + /// The view is a display's physical pixels, `k` a point: dynamic resolution, from the points' resolution (the rays + /// a view in points starts with) down to `MIN_SCALE` of it. A fixed scale is set after it (`set_scale`). + pub fn set_display_scale(&mut self, k: f32) { + let k = k.max(1.0); + self.display_scale = k; + self.min_scale = (MIN_SCALE / k).max(SCALE_FLOOR); + self.render_scale = 1.0 / k; + self.dynamic_res = true; + } + + /// The scene's lighting is changing: something moved or was edited in the last 45 frames, or the scene has spark + /// lights (light-sampled particles: they move every frame, and no edit says so). + fn live_lighting(&self) -> bool { + self.spark_lights || (self.lit_changed > 0 && self.frame.wrapping_sub(self.lit_changed) < 45) + } + + /// Fixed render scale (`s` in SCALE_FLOOR ..= 1), or dynamic resolution (`s` = 0). + pub fn set_scale(&mut self, s: f32) { + self.dynamic_res = s <= 0.0; + if s > 0.0 { + self.render_scale = s.clamp(SCALE_FLOOR, 1.0); + } + } + + pub fn reset_history(&mut self) { + self.reset = true; + } + + /// Ray tracing on or off (see `rays`). The history starts over: the two modes light a pixel differently. + pub fn set_rays(&mut self, on: bool) { + if self.rays != on { + self.rays = on; + self.reset = true; + } + } + + pub fn rays(&self) -> bool { + self.rays + } + + pub fn set_camera(&mut self, mut c: Camera) { + if c.tan_half_fov <= 0.0 { + c.tan_half_fov = self.sc.tan_half_fov; + } + self.cam = c; + } + + /// Renders one frame with `spp` samples per pixel; returns the command buffers to submit after the samples. + /// Path-trace dispatches are submitted inside (each needs its own uniform contents). + /// Traced resolution at the current render scale. + pub fn internal_size(&self) -> (u32, u32) { + let s = self.render_scale.clamp(SCALE_FLOOR, 1.0); + (((self.w as f32 * s).round() as u32).clamp(8, self.w), ((self.h as f32 * s).round() as u32).clamp(8, self.h)) + } + + /// Dynamic resolution (UE r.DynamicRes): twice a second, move the render scale toward the frame budget (traced + /// pixels ~ GPU time), within [`min_scale`, 1], in damped steps with hysteresis so it does not oscillate. + /// Two frames in a row well over the frame time (x 1.25) act at once (UE + /// r.DynamicRes.MaxConsecutiveOverbudgetGPUFrameCount = 2): turning from a light view at a raised scale toward a + /// heavy one otherwise cost up to 30 frames at 35-48 ms (the 3 x 3 district's drive, its far end). Just over the + /// frame time waits for the regular check, as before. After a change, only frames rendered at the new scale count: + /// GPU time lags the scale by the frames in flight, and acting on the old scale's time overshot to the floor. + /// + /// "Twice a second" and "the first 2 s" are counted in frames (every 30th, the first 120), which is the same thing + /// at 60 frames a second. Where frames are much slower (`govern_by_clock`: a phone's GPU, on which a scene opens + /// at its full scale at 6 frames a second) they are also read off the clock: counted in frames alone the first + /// check that may act at once came after 20 s and the regular one every 5 s, and a game opened as a slide show + /// for half a minute (Adreno 740, OpenStrikeRT: 25 s to its frame rate; 3 s with the clock). + pub fn govern_resolution(&mut self, fps: u32) { + if !self.dynamic_res || self.gpu_ms <= 0.0 { + return; + } + if (self.gpu_frame.wrapping_sub(self.over_budget.1) as i32) < 0 { + return; + } + let clock = govern_by_clock(); + // Not in the first 2 s: those frames run while caches fill (Kabukichō's first frames at 0.7 take ~47 ms), and + // the regular check settles the start as before. + let warm = self.frame >= 120 || (clock && self.born.elapsed().as_secs_f32() >= 2.0); + self.over_budget.0 = if warm && self.gpu_ms > 1250.0 / fps as f32 && self.render_scale > self.min_scale { self.over_budget.0 + 1 } else { 0 }; + let urgent = self.over_budget.0 >= 2; + // (By the clock only when 30 frames take longer than 0.75 s: at 40 frames a second and above the frame count + // comes first, as it always did.) + let due = self.frame % 30 == 1 || (clock && self.governed.elapsed().as_secs_f32() >= 0.75); + if !due && !urgent { + return; + } + self.governed = Instant::now(); + self.over_budget.0 = 0; + let before = self.render_scale; + let budget = 1000.0 / fps as f32 * 0.9; + let target = (self.render_scale * (budget / self.gpu_ms).sqrt()).clamp(self.min_scale, 1.0); + // The hysteresis (0.03) does not hold the scale just short of either end: 0.525 with the floor as the target + // kept the district's return drive at 21-22 ms. + let to_end = (target == 1.0 && self.render_scale < 1.0 && self.gpu_ms < budget * 0.8) || (target == self.min_scale && self.render_scale > self.min_scale); + // (The hysteresis, the steps and the grid of scales are a view in points'; on a display of k pixels a point the + // same share of the rays is 1 / k of the scale. Left absolute, a Retina view held 0.325 at 17.3 ms: its target, + // 0.30, was inside the hysteresis.) + let k = self.display_scale; + if (target - self.render_scale).abs() > 0.03 / k || to_end { + // Down fast when far over budget (a heavy scene just opened), up slowly; straight to the target when urgent. + let step = if urgent { target - self.render_scale } else { (target - self.render_scale).clamp(if self.gpu_ms > budget * 1.5 { -0.3 } else { -0.15 } / k, 0.1 / k) }; + self.render_scale = ((self.render_scale + step) * 40.0 * k).round() / (40.0 * k); + } + if self.render_scale != before { + // `frame` is already the next frame's index (render advanced it): the first one at the new scale. + self.over_budget.1 = self.frame; + } + } + + #[cfg(not(target_arch = "wasm32"))] + /// The last frame's linear HDR radiance (nits, before exposure and post), RGB per output pixel. Waits for the GPU. + pub fn read_hdr(&self) -> Result> { + let d = &self.g.device; + let size = self.w as u64 * self.h as u64 * 16; + let read = d.create_buffer(&wgpu::BufferDescriptor { label: Some("hdr read"), size, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let mut e = d.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(&self.targets.fa.buffer, self.targets.fa.offset, &read, 0, size); + self.g.queue.submit([e.finish()]); + read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + d.poll(wgpu::PollType::wait_indefinitely())?; + let px = bytemuck::cast_slice::(&read.slice(..).get_mapped_range().unwrap()).iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + Ok(px) + } + + #[cfg(not(target_arch = "wasm32"))] + /// The last frame's signals apart, linear RGB f32 per output pixel: diffuse (the composed colour less the rest, so + /// with the sun's direct light, which view mode 6 leaves out), specular (the specular denoiser's output: mode 7) and + /// emission (what the camera ray saw directly). They are the render resolution's (the frame as `compose` hands it to + /// the upscaler), each output pixel taking the traced sample under it; not with depth of field and motion blur both + /// on (the composed colour's buffer is theirs then). Waits for the GPU. + pub fn read_signals(&self) -> Result<[Vec; 3]> { + let d = &self.g.device; + let (w, h) = (self.w as usize, self.h as usize); + let (iw, ih) = (self.prev_isz.0 as usize, self.prev_isz.1 as usize); + let (n, tn) = ((w * h) as u64, (iw * ih) as u64); + let t = &self.targets; + let read = |buffer: &wgpu::Buffer, offset: u64, size: u64| -> Result> { + let r = d.create_buffer(&wgpu::BufferDescriptor { label: Some("signals read"), size, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let mut e = d.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(buffer, offset, &r, 0, size); + self.g.queue.submit([e.finish()]); + r.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + d.poll(wgpu::PollType::wait_indefinitely())?; + let v = r.slice(..).get_mapped_range()?.to_vec(); + Ok(v) + }; + let half = |b: u32, hi: bool| half::f16::from_bits(if hi { (b >> 16) as u16 } else { b as u16 }).to_f32(); + let composed = read(&t.fb.buffer, t.fb.offset, tn * 16)?; + let spec = read(&t.spec_pp.buffer, t.spec_pp.offset + n * 8, tn * 8)?; // spec_pp slot 1: (rgb, history) as two pairs of f16 + let samples = read(&self.sc.up.accum, 0, tn * 80)?; // the kernel's samples (live.wgsl `Pixel`): emit at 48 + let (composed, spec, samples): (&[[f32; 4]], &[[u32; 2]], &[[f32; 20]]) = (bytemuck::cast_slice(&composed), bytemuck::cast_slice(&spec), bytemuck::cast_slice(&samples)); + let (mut dif, mut spe, mut emi) = (Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3)); + let finite = |v: f32| if v.is_finite() { v } else { 0.0 }; + for y in 0..h { + for x in 0..w { + // live.wgsl `sample_under`. + let q = (((y as f32 + 0.5) * ih as f32 / h as f32) as usize).min(ih - 1) * iw + (((x as f32 + 0.5) * iw as f32 / w as f32) as usize).min(iw - 1); + // The media before the surface (live.wgsl `medium_tr`): the specular as the frame shows it. + let tr = (samples[q][11].fract() * 2.0).clamp(0.0, 1.0); + let s = [half(spec[q][0], false), half(spec[q][0], true), half(spec[q][1], false)].map(|v| v * tr); + for c in 0..3 { + let e = finite(samples[q][12 + c] / self.shown_spp as f32); + spe.push(s[c]); + emi.push(e); + dif.push((composed[q][c] - s[c] - e).max(0.0)); + } + } + } + Ok([dif, spe, emi]) + } + + #[cfg(not(target_arch = "wasm32"))] + /// The exposure the last frame was shown at, EV100 (auto exposure: its adapted value). + pub fn read_ev100(&self) -> Result { + if self.sc.exposure_settings.ae2[3] <= 0.5 { + return Ok(-self.sc.exposure.log2()); + } + let d = &self.g.device; + let read = d.create_buffer(&wgpu::BufferDescriptor { label: Some("ae read"), size: 16, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let mut e = d.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(&self.targets.ae_state.buffer, self.targets.ae_state.offset, &read, 0, 16); + self.g.queue.submit([e.finish()]); + read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + d.poll(wgpu::PollType::wait_indefinitely())?; + let s = bytemuck::cast_slice::(&read.slice(..).get_mapped_range().unwrap())[0]; + Ok(-s.max(1e-30).log2()) + } + + /// Phases of the last scene (re)load, ms. + pub fn load_timings(&self) -> &[(String, f64)] { + &self.sc.timings + } + + pub fn aspect(&self) -> f32 { + self.aspect + } + + /// Primary ray through (u, v) of the image (0..1, v down), as the path tracer generates it. + pub fn ray(&self, u: f32, v: f32) -> (Vec3, Vec3) { + let c = &self.cam; + let d = c.fwd + c.right * ((u * 2.0 - 1.0) * c.tan_half_fov * self.aspect) + c.up * ((1.0 - v * 2.0) * c.tan_half_fov); + (c.pos, d.normalize()) + } + + /// Closest surface under (u, v): (node id, world-space hit point). CPU ray cast over the scene triangles, and over + /// each instance's model-space mesh with the ray in its space (same ray parameter t). + /// The object under view point (u, v): its name, the hit point, and for an instanced object the instance's index. + pub fn pick(&self, u: f32, v: f32) -> Option<(String, Vec3, Option)> { + let (o, d) = self.ray(u, v); + let (o, d) = (glam::Vec3A::from(o), glam::Vec3A::from(d)); + // Moller-Trumbore, two-sided: the ray parameter of the closest hit in `tris` beyond `near`. + let closest = |tris: &[[glam::Vec3A; 3]], o: glam::Vec3A, d: glam::Vec3A| { + let mut best = (f32::INFINITY, usize::MAX); + for (i, [a, b, c]) in tris.iter().enumerate() { + let (e1, e2) = (*b - *a, *c - *a); + let p = d.cross(e2); + let det = e1.dot(p); + if det.abs() < 1e-12 { + continue; + } + let inv = 1.0 / det; + let s = o - *a; + let bu = s.dot(p) * inv; + if !(0.0..=1.0).contains(&bu) { + continue; + } + let q = s.cross(e1); + let bv = d.dot(q) * inv; + if bv < 0.0 || bu + bv > 1.0 { + continue; + } + let t = e2.dot(q) * inv; + if t > 1e-4 && t < best.0 { + best = (t, i); + } + } + best + }; + let (mut t, i) = closest(&self.sc.tris, o, d); + let mut node = self.sc.tri_nodes.iter().find(|(_, a, b)| (*a as usize..*b as usize).contains(&i)).map(|n| n.0.as_str()); + let mut sub = self.sc.tri_instances.iter().find(|(a, b, _)| (*a as usize..*b as usize).contains(&i)).map(|r| r.2); + let added = |want_mesh: bool| self.sc.edit.added.values().filter_map(move |p| match p { + Picked::Mesh(i) if want_mesh => Some(Ok(i)), + Picked::Proc(q) if !want_mesh => Some(Err(q)), + _ => None, + }); + for inst in self.sc.instances.iter().filter(|i| !i.hidden).chain(added(true).filter_map(|x| x.ok())) { + let m = self.sc.meshes[inst.mesh as usize]; + let inv = inst.world.inverse(); + let (lo, ld) = (inv.transform_point3a(o), inv.transform_vector3a(d)); + // Slab test against the mesh bounds before its triangles. + let (t0, t1) = ((m.lo - lo) / ld, (m.hi - lo) / ld); + let (near, far) = (t0.min(t1).max_element(), t0.max(t1).min_element()); + if far < near.max(0.0) || near > t { + continue; + } + let (ti, _) = closest(&self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize], lo, ld); + if ti < t { + t = ti; + node = Some(inst.name.as_str()); + sub = inst.sub; + } + } + // Procedural geometry: its boxes (the geometry function runs on the GPU only). + for p in self.sc.procs.iter().filter(|p| !p.hidden).chain(added(false).filter_map(|x| x.err())) { + let inv = p.world.inverse(); + let (lo, ld) = (inv.transform_point3a(o), inv.transform_vector3a(d)); + let slab = |a: glam::Vec3A, b: glam::Vec3A| { + let (t0, t1) = ((a - lo) / ld, (b - lo) / ld); + let (near, far) = (t0.min(t1).max_element().max(1e-4), t0.max(t1).min_element()); + (near <= far).then_some(near) + }; + if slab(p.lo.into(), p.hi.into()).is_none_or(|near| near > t) { + continue; + } + for b in p.blob.boxes.chunks(6) { + if let Some(near) = slab(glam::Vec3A::new(b[0], b[1], b[2]), glam::Vec3A::new(b[3], b[4], b[5])).filter(|&near| near < t) { + t = near; + node = Some(p.name.as_str()); + sub = p.sub; + } + } + } + Some((node?.to_string(), Vec3::from(o + d * t), sub)) + } + + /// World-space triangles of a node (empty for lights without geometry, cameras, hidden nodes). + pub fn node_tris(&self, id: &str) -> Vec<[glam::Vec3A; 3]> { + // World-space triangles (an instance's glass / emissive part among them), then its instanced mesh. + let mut out: Vec<[glam::Vec3A; 3]> = scene::node_ranges(&self.sc.tri_nodes, id).flat_map(|r| self.sc.tris[r].iter().copied()).collect(); + // A model's first instance; every drawn instance of an instanced object. + let mut of_node = self.sc.instances.iter().filter(|i| i.name == id && !i.hidden); + let first = of_node.next(); + for inst in first.into_iter().chain(of_node.filter(|i| i.sub.is_some())) { + let m = self.sc.meshes[inst.mesh as usize]; + out.extend(self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].iter().map(|t| inst.tri(t))); + } + // Objects added in place (Bugbot on #131: `pick` saw them, this did not). + for p in self.sc.edit.added.values() { + if let Picked::Mesh(inst) = p { + if inst.name != id { + continue; + } + let m = self.sc.meshes[inst.mesh as usize]; + out.extend(self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].iter().map(|t| inst.tri(t))); + } + } + out + } + + /// Frame metadata (camera pose for picking, convergence, timings). + pub fn meta(&self) -> serde_json::Value { + let c = &self.cam; + serde_json::json!({ + "seq": self.seq, "frame": self.frame, "history": self.history_len, "gpu_ms": self.gpu_ms, + "scene": self.scene_name, "triangles": self.triangles, "bounces": self.bounces, "mode": self.view_mode, "width": self.w, "height": self.h, "scale": self.render_scale, + "cam": { "pos": c.pos.to_array(), "fwd": c.fwd.to_array(), "up": c.up.to_array(), "right": c.right.to_array(), "tan": c.tan_half_fov, "aspect": self.aspect }, + }) + } +} + +/// Adaptive samples per frame: fill ~75% of the frame budget with path tracing. +pub fn next_spp(spp: u32, gpu_ms: f32, fps: u32) -> u32 { + if gpu_ms <= 0.0 { + return spp; + } + let budget = 750.0 / fps as f32; + let per_sample = gpu_ms / spp as f32; + let target = (budget / per_sample).floor() as u32; + target.clamp(1, 16).min(spp + 1).max(spp.saturating_sub(2).max(1)) +} + +#[cfg(not(target_arch = "wasm32"))] +/// `--scale`: a fraction (0.25 .. 1) or `auto` (dynamic resolution). +pub fn parse_scale(v: Option<&str>, default: Option) -> Result> { + match v { + None => Ok(default), + Some("auto") => Ok(None), + Some(v) => v.parse::().map(|s| Some(s.clamp(SCALE_FLOOR, 1.0))).map_err(|_| anyhow!("--scale must be a number or auto")), + } +} + +#[cfg(test)] +mod timer_tests { + use super::*; + + #[test] + fn a_view_in_physical_pixels_starts_with_dynamic_resolution() { + let opts = |extra: &[&str]| parse_opts(&["threert-render", "live", "game.js"].iter().chain(extra).map(|a| a.to_string()).collect::>()).unwrap(); + assert_eq!(opts(&[]).scale, Some(1.0)); + assert_eq!(opts(&["--display-scale", "2"]).scale, None); + assert_eq!(opts(&["--display-scale", "2", "--scale", "0.2"]).scale, Some(0.2)); + assert_eq!(opts(&["--scale", "auto"]).scale, None); + } + + #[test] + fn a_post_pass_from_another_frame_counts_by_its_own_length() { + // Two frames of the crowd scene (ns, period 1): frame 453 as read, and frame 454, whose slot read frame 458's + // post pair (74.5 ms after its trace began). + let t453 = [35929577531333, 35929585495833, 35929585700583, 35929591610875, 0, 0, 0, 0]; + let t454 = [35929593765500, 35929601614666, 35929668312875, 35929673973041, 0, 0, 0, 0]; + let ms = |t: [u64; 8]| frame_gpu_ms(&t, 1, 1.0).unwrap(); + assert_eq!(ms(t453), (t453[3] - t453[0]) as f32 * 1e-6); + assert!((ms(t454) - (7.849166 + 5.660166)).abs() < 1e-3, "{}", ms(t454)); + // The first pass is the frame's start; nothing timed, no measurement. + assert_eq!(frame_gpu_ms(&[0, 0, 0, 0, 0, 0, 0, 0], 1, 1.0), None); + let particles_first = [2_000_000, 9_000_000, 9_200_000, 14_000_000, 0, 0, 1_000_000, 1_900_000]; + assert_eq!(frame_gpu_ms(&particles_first, 1, 1.0), Some(13.0)); + // 3 samples: two more traces (untimed) between the first and the post pass, a 16.2 ms gap: the span. The post + // pair of frame 458 under 3 samples: still out of place (trace 7.85 ms, gap 66.7 ms), counted as the passes. + let three = [1_000_000, 9_000_000, 25_200_000, 31_000_000, 0, 0, 0, 0]; + assert_eq!(frame_gpu_ms(&three, 3, 1.0), Some(30.0)); + assert!((frame_gpu_ms(&t454, 3, 1.0).unwrap() - (3.0 * 7.849166 + 5.660166)).abs() < 1e-3); + } +} + +#[cfg(test)] +mod instanced_tests { + use super::*; + use crate::world::{Geometry, Kind}; + use glam::Mat4; + + /// Clicks pick what is drawn (ROADMAP § 6 step 5): an object added in place is picked, one removed in place no + /// longer is, and a rebuild agrees. (Skipped without a GPU with hardware RT.) + #[test] + fn picks_follow_in_place_edits() { + let g = match gpu::Gpu::new() { + Ok(g) if g.hwrt => g, + other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), + }; + let mut w = World::default(); + w.set_material(1, serde_json::from_value(serde_json::json!({ "name": "m", "color": [0.5, 0.5, 0.5], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": true })).unwrap()); + let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; + w.set_geometry(2, quad(1.0)).unwrap(); + w.set_geometry(3, quad(0.5)).unwrap(); + let mesh = |geometry| Kind::Mesh { geometry, material: 1, own_blas: true, materials: vec![] }; + w.set_object(10, "a".into(), None, mesh(2), true, false).unwrap(); + let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); + w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); + w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); + let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); + w.take_changes(); + let picked = |core: &LiveCore| core.pick(0.5, 0.5).map(|p| p.0); + assert_eq!(picked(&core).as_deref(), Some("a")); + // Added in place, in front of it: picked. + w.set_object(11, "b".into(), None, mesh(3), true, false).unwrap(); + w.set_transform(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + assert!(!core.commit(&mut w).unwrap(), "added in place"); + assert_eq!(picked(&core).as_deref(), Some("b")); + assert_eq!(core.node_tris("b").len(), 2, "its triangles under its name"); + // Removed in place: the one behind it is picked again; both removed: nothing. + w.remove(11); + assert!(!core.commit(&mut w).unwrap(), "removed in place"); + assert_eq!(picked(&core).as_deref(), Some("a")); + assert!(core.node_tris("b").is_empty(), "removed: none"); + w.remove(10); + assert!(!core.commit(&mut w).unwrap(), "removed in place"); + assert_eq!(picked(&core), None); + core.rebuild(&w).unwrap(); + assert_eq!(picked(&core), None, "a rebuild agrees"); + } + + /// A mesh a rigid body drives, added while the game runs with a material the scene did not have (ROADMAP § 6 step + /// 7): placed in place, and its body's moves are its instance's transform, with no rebuild; a rebuild places it the + /// same and it goes on moving in place. (Skipped without a GPU with hardware RT.) + #[test] + fn driven_meshes_added_in_place_move_as_instances() { + let g = match gpu::Gpu::new() { + Ok(g) if g.hwrt => g, + other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), + }; + let mut w = World::default(); + let mat = |r: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [r, r, r], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": true })).unwrap(); + w.set_material(1, mat(0.5)); + let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; + w.set_geometry(2, quad(1.0)).unwrap(); + w.set_geometry(3, quad(0.5)).unwrap(); + let mesh = |geometry, material| Kind::Mesh { geometry, material, own_blas: true, materials: vec![] }; + w.set_object(10, "a".into(), None, mesh(2, 1), true, false).unwrap(); + let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); + w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); + w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); + let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); + w.take_changes(); + let picked = |core: &LiveCore, u: f32| core.pick(u, 0.5).map(|p| p.0); + // The box and its material arrive together, as scene code adds them; its body is told of it right after. + let materials = core.sc.tables.materials.len(); + w.set_material(5, mat(0.9)); + w.set_object(11, "b".into(), None, mesh(3, 5), true, false).unwrap(); + w.set_driven(11).unwrap(); + w.set_transform(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + assert!(!core.commit(&mut w).unwrap(), "added in place, its material too"); + assert_eq!(core.sc.tables.materials.len(), materials + 1); + assert_eq!(picked(&core, 0.5).as_deref(), Some("b")); + // Its body moves it 3 m to the side (u 0.95 of the image, 4 m from the camera): no rebuild, its record moved + // with the rows it had the frame before. + let record = core.sc.edit.placed[&11].1; + assert!(w.place_driven(11, Vec3::new(3.0, 0.0, 1.0), glam::Quat::IDENTITY)); + assert!(!core.commit(&mut w).unwrap(), "moved as an instance"); + assert_eq!(core.fallbacks, 0); + assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95).as_deref()), (Some("a"), Some("b"))); + let r = core.sc.gpu_instances[record]; + assert_eq!((r.m[0][3], r.p[0][3]), (3.0, 0.0), "moved: last frame's rows kept"); + // A rebuild places it as an instance too, and it moves on in place. + core.rebuild(&w).unwrap(); + assert!(core.sc.tables.retained.instance_of.contains_key(&11) && core.sc.edit.placed.is_empty()); + assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95).as_deref()), (Some("a"), Some("b"))); + assert!(w.place_driven(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY)); + assert!(!core.commit(&mut w).unwrap(), "moved as an instance"); + assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95)), (Some("b"), None)); + // Removed in place. + w.remove(11); + assert!(!core.commit(&mut w).unwrap(), "removed in place"); + assert_eq!(picked(&core, 0.5).as_deref(), Some("a")); + } + + /// An instanced mesh's moved instances keep their previous-frame rows (motion vectors) through the commit that moves + /// them, and catch up at the next commit that does not move them. (Skipped without a GPU with hardware RT.) + #[test] + fn moved_instances_keep_their_motion() { + let g = match gpu::Gpu::new() { + Ok(g) if g.hwrt => g, + other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), + }; + let mut w = World::default(); + let mat = |r: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [r, r, r], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": false })).unwrap(); + w.set_material(1, mat(0.5)); + w.set_geometry(2, Geometry { positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(3), uvs: vec![0.0; 6], indices: vec![0, 1, 2], groups: vec![], colors: vec![] }).unwrap(); + w.set_object(3, "copies".into(), None, Kind::Instanced { geometry: 2, procedural: false, materials: vec![1], capacity: 2 }, true, false).unwrap(); + let at = |x: f32| [Mat4::IDENTITY.to_cols_array(), Mat4::from_translation(Vec3::new(x, 0.0, 0.0)).to_cols_array()].concat(); + w.set_instances(3, 2, Some(at(1.0)), None, None).unwrap(); + let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); + w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); + w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); + let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); + w.take_changes(); + let first = core.sc.tables.retained.instanced[&3].first as usize; + w.set_instances(3, 2, Some(at(2.0)), None, None).unwrap(); + assert!(!core.commit(&mut w).unwrap(), "in place"); + let r = core.sc.gpu_instances[first + 1]; + assert_eq!((r.m[0][3], r.p[0][3]), (2.0, 1.0), "moved: last frame's rows kept"); + w.set_material(1, mat(0.6)); + core.commit(&mut w).unwrap(); + let r = core.sc.gpu_instances[first + 1]; + assert_eq!(r.p, r.m, "not moved since: caught up"); + // Movable, moved and given new instances in one commit: updated once (twice would lose the motion). + w.set_object(3, "copies".into(), None, Kind::Instanced { geometry: 2, procedural: false, materials: vec![1], capacity: 2 }, true, true).unwrap(); + assert!(core.commit(&mut w).unwrap(), "made movable: rebuilt"); + let first = core.sc.tables.retained.instanced[&3].first as usize; + w.set_transform(3, Vec3::new(0.0, 1.0, 0.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_instances(3, 2, Some(at(3.0)), None, None).unwrap(); + let c = w.changes.clone(); + assert!(c.moved.contains(&3) && c.instanced.contains(&3), "{c:?}"); + assert!(!core.commit(&mut w).unwrap(), "in place"); + let r = core.sc.gpu_instances[first + 1]; + assert_eq!((r.m[0][3], r.m[1][3], r.p[0][3], r.p[1][3]), (3.0, 1.0, 2.0, 0.0), "moved: last frame's rows kept"); + } + + /// The uniform tables (`gpu::GpuOptions::uniform_tables`: what a device is opened with on Android), on this + /// machine's GPU: a lit scene with an instanced mesh renders offline to the default device's image, bit for bit; + /// live, the four uniform buffers hold what their tables hold when a frame is traced, and after an edit in place + /// what the tables hold then. (Skipped without a GPU.) + #[test] + fn the_uniform_tables_render_what_the_tables_do() { + let open = |uniform: bool| gpu::Gpu::new_with(gpu::GpuOptions { uniform_tables: Some(uniform), ..Default::default() }); + let (plain, uniform) = match (open(false), open(true)) { + (Ok(a), Ok(b)) => (a, b), + (Err(e), _) | (_, Err(e)) => return eprintln!("skipped: {e}"), + }; + if uniform.merged { + return eprintln!("skipped: the merged layout has no uniform tables"); + } + assert!(uniform.uniform_tables && !plain.uniform_tables); + let mut w = World::default(); + let mat = |color: f32, emissive: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [color, color * 0.5, color], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [1, 1, 1], "emissiveIntensity": emissive, "doubleSided": true })).unwrap(); + w.set_material(1, mat(0.8, 0.0)); + w.set_material(5, mat(0.0, 40.0)); + let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: [0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; + w.set_geometry(2, quad(1.0)).unwrap(); + w.set_geometry(3, quad(0.25)).unwrap(); + let mesh = |geometry, material, own_blas| Kind::Mesh { geometry, material, own_blas, materials: vec![] }; + // A wall, a small movable panel in front of it, and an emissive quad that lights both from the side. + w.set_object(10, "wall".into(), None, mesh(2, 1, false), true, false).unwrap(); + w.set_object(11, "panel".into(), None, mesh(3, 1, true), true, true).unwrap(); + w.set_transform(11, Vec3::new(-0.4, 0.0, 0.5), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_object(12, "lamp".into(), None, mesh(3, 5, false), true, false).unwrap(); + w.set_transform(12, Vec3::new(0.6, 0.3, 1.5), glam::Quat::from_rotation_y(2.6), Vec3::ONE).unwrap(); + let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); + w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); + w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); + let presets = crate::default_presets_dir(); + + // Offline: the same bytes. + let offline = |g: &gpu::Gpu| { + let mut sc = crate::scene::build(&w, Path::new("."), &presets, None).unwrap(); + (sc.width, sc.height) = (64, 36); + let prep = crate::prepare(g, &mut sc, &mut vec![]); + let frame = g.render(&sc, &prep.tri_verts, prep.groups, bytemuck::cast_slice(&prep.nodes), &prep.emitters, &prep.env, &prep.env_cdf, &prep.energy, prep.params, 16, &mut vec![]).unwrap(); + frame.color.iter().flatten().map(|v| v.to_bits()).collect::>() + }; + let (a, b) = (offline(&plain), offline(&uniform)); + let lit = a.iter().filter(|v| f32::from_bits(**v) > 0.0).count(); + assert!(lit > a.len() / 16, "the scene is lit ({lit} of {} numbers)", a.len()); + let differ = a.iter().zip(&b).filter(|(x, y)| x != y).count(); + assert_eq!(differ, 0, "{differ} of {} numbers differ between the storage tables and the uniform ones", a.len()); + drop(plain); + + // Live: the uniform buffers follow the tables. + let mut core = LiveCore::new(uniform, &w, Path::new("."), &presets, 64, 36).unwrap(); + w.take_changes(); + let read = |core: &LiveCore, buf: &wgpu::Buffer, bytes: u64| -> Vec { + let d = &core.g.device; + let to = d.create_buffer(&wgpu::BufferDescriptor { label: None, size: bytes, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let mut e = d.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(buf, 0, &to, 0, bytes); + core.g.queue.submit([e.finish()]); + to.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + d.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let bytes = to.slice(..).get_mapped_range().unwrap().to_vec(); + bytes + }; + // Each table's bytes and its uniform copy's, over what both hold. + let pairs = |core: &LiveCore| -> Vec<(Vec, Vec)> { + let up = &core.sc.up; + let copies = up.uniform.as_ref().expect("the uniform tables' buffers"); + [&up.bg_parts.texinfo, &up.objects, &up.instances, &up.media].into_iter().zip(copies).map(|(t, c)| { + let n = t.size().min(c.size()) & !3; + (read(core, t, n), read(core, c, n)) + }).collect() + }; + core.render(1, |_, _| {}); + let first = pairs(&core); + for (k, (table, copy)) in first.iter().enumerate() { + assert!(!table.is_empty() && table == copy, "table {k} and its uniform buffer differ after a frame"); + } + assert!(first[1].0.iter().any(|b| *b != 0), "the objects' table has the scene's objects"); + // The panel moves in place: its object's entry and its instance's record change, and the next frame's + // uniform buffers have them. + w.set_transform(11, Vec3::new(0.3, 0.2, 0.75), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + assert!(!core.commit(&mut w).unwrap(), "moved in place"); + core.render(1, |_, _| {}); + let moved = pairs(&core); + for (k, (table, copy)) in moved.iter().enumerate() { + assert!(table == copy, "table {k} and its uniform buffer differ after an edit"); + } + assert!(moved[1].1 != first[1].1 || moved[2].1 != first[2].1, "the move reached the uniform buffers"); + } +} diff --git a/renderer/src/live/session.rs b/renderer/src/live/session.rs new file mode 100644 index 0000000..54447ff --- /dev/null +++ b/renderer/src/live/session.rs @@ -0,0 +1,305 @@ +//! The headless runner (`threert-render live`): the frame loop at a fixed rate or in lockstep, the commands on stdin +//! and their parsing. + +use super::*; + +/// Headless runner: renders at a fixed frame rate and saves snapshots on request (tools/bench-live.ts). +pub fn run(presets: &Path, opts: LiveOpts) -> Result<()> { + let (w, h) = (opts.width, opts.height); + let (cmd_tx, cmd_rx) = mpsc::channel::(); + spawn_stdin_reader(cmd_tx); + let (mut game, mut core) = crate::script::GameSession::start(&opts.bundle, &opts.room, &opts.assets, gpu::Gpu::new()?, presets, w, h)?; + // The host channel: this runner is the app. How the game was opened, once after setup; what the game posts goes + // out on stdout (`emit_posts`), and whoever drives the runner answers with `{"cmd":"host"}`. + if let Err(e) = game.host_messages(&mut core, &[crate::host::launch_message(opts.launch.as_ref())]) { + eprintln!("live: launch: {e:#}"); + } + emit_posts(&mut game); + // Frame-script cost (script + commit), reported every 2 s. + let (mut fs_sum, mut fs_max, mut fs_n) = (0.0f32, 0.0f32, 0u32); + core.bounces = opts.bounces; + core.set_display_scale(opts.display_scale); + if let Some(s) = opts.scale { + core.set_scale(s); + } + eprintln!("live: load {}", serde_json::Value::Object(core.load_timings().iter().map(|(k, v)| (k.clone(), (*v).into())).collect())); + eprintln!("live: {}x{} @{} fps on {} ({})", w, h, opts.fps, core.g.adapter_info.name, if core.g.hwrt { "HW RT" } else { "BVH" }); + let staging = core.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("snapshot"), size: (w * h * 4) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let frame_dt = Duration::from_secs_f64(1.0 / opts.fps as f64); + let mut next = Instant::now(); + let mut snapshots: Vec<(PathBuf, u32, u32)> = vec![]; // path, min history, first eligible frame + let mut syncs: Vec<(String, u32)> = vec![]; // id, frame after which to answer + let mut spp = 1u32; + let mut last = Instant::now(); + let start = Instant::now(); + let mut log = opts.frame_log.as_ref().map(|p| std::fs::File::create(p).map(std::io::BufWriter::new)).transpose()?; + let mut last_frame = Instant::now(); + let mut prev_start = Instant::now(); + let mut lockstep: Option = None; + let mut budget = 0u32; // lockstep: frames the commands so far asked for and not yet rendered + loop { + let t_frame = Instant::now(); + let mut cmds: Vec = vec![]; + if lockstep.is_some() && budget == 0 { + match cmd_rx.recv() { + Ok(c) => cmds.push(c), + Err(_) => return Ok(()), + } + } + cmds.extend(std::iter::from_fn(|| cmd_rx.try_recv().ok())); + for c in cmds { + match c { + Cmd::Camera(c) => core.set_camera(c), + Cmd::Lockstep(dt) => { + lockstep = dt; + core.fixed_dt = dt; + budget = 0; + } + Cmd::Step { cam, frames, sync, snapshot } => { + if let Some(c) = cam { + core.set_camera(c); + } + let n = frames.max(1); + if lockstep.is_some() { + budget += n; + } + let last = core.frame + n - 1; + if let Some(p) = snapshot { + snapshots.push((p, 0, last)); + } + if let Some(id) = sync { + syncs.push((id, last)); + } + } + Cmd::Snapshot(path, after, frames) => snapshots.push((path, after.unwrap_or(0), core.frame + frames.unwrap_or(1).max(1) - 1)), + Cmd::Sync(id, frames) => syncs.push((id, core.frame + frames.max(1) - 1)), + Cmd::Reset => core.reset_history(), + Cmd::Time(t) => core.set_time(t), + Cmd::Mode(m) => core.view_mode = m, + Cmd::Bounces(b) => core.bounces = b, + Cmd::Scale(s) => core.set_scale(s), + Cmd::Event(kind, payload) => { + if let Err(e) = game.event(&mut core, &kind, payload) { + eprintln!("live: event {kind} failed: {e:#}"); + } + } + Cmd::Click(u, v) => { + if let Err(e) = game.click(&mut core, u, v) { + eprintln!("live: click failed: {e:#}"); + } + } + Cmd::Host(messages) => { + if let Err(e) = game.host_messages(&mut core, &messages) { + eprintln!("live: host messages failed: {e:#}"); + } + } + Cmd::SppCap(_) | Cmd::Status(_) => {} + Cmd::Quit => return Ok(()), + } + } + // The frame script runs on wall-clock time (frames slower than the target rate still animate at real speed). + if lockstep.is_some() && budget == 0 { + emit_posts(&mut game); + continue; // commands without frames (events, switches): wait for a step + } + let dt = if freeze_time() { 0.0 } else { lockstep.unwrap_or_else(|| last.elapsed().as_secs_f32().min(0.1)) }; + last = Instant::now(); + core.advance_time(dt); + if let Err(e) = game.frame(&mut core, dt) { + eprintln!("live: frame script: {e:#}"); + } + emit_posts(&mut game); + if game.host.has_frame() { + (fs_sum, fs_max, fs_n) = (fs_sum + game.frame_ms, fs_max.max(game.frame_ms), fs_n + 1); + if fs_n == opts.fps * 2 { + eprintln!("live: frame script {:.3} ms avg, {:.3} ms max (script + commit)", fs_sum / fs_n as f32, fs_max); + (fs_sum, fs_max, fs_n) = (0.0, 0.0, 0); + } + } + // Snapshot metadata is this frame's (history / spp at render time), so copy the output whenever one is pending. + let pending = !snapshots.is_empty(); + core.render(spp, |e, out| { + if pending { + e.copy_buffer_to_buffer(out, 0, &staging, 0, (w * h * 4) as u64); + } + }); + game.click_shown(); + let (hist, fno) = (core.history_len as u32, core.frame); + syncs.retain(|(id, from)| { + if fno > *from { + eprintln!("live: sync {id}"); + } + fno <= *from + }); + if snapshots.iter().any(|(_, after, from)| fno > *from && hist >= *after) { + staging.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + core.g.device.poll(wgpu::PollType::wait_indefinitely())?; + let px = staging.slice(..).get_mapped_range()?.to_vec(); + staging.unmap(); + let mut hdr: Option> = None; + snapshots.retain(|(path, after, from)| { + if fno > *from && hist >= *after { + if path.extension().is_some_and(|e| e == "signals") { + // The signals apart (`read_signals`), as `render --signals` writes them. + if let Ok([d, s, e]) = core.read_signals() { + let stem = path.with_extension(""); + for (name, v) in [("diffuse", d), ("specular", s), ("emission", e)] { + let _ = std::fs::write(format!("{}-{name}.raw", stem.display()), bytemuck::cast_slice(&v)); + } + } + } else if path.extension().is_some_and(|e| e == "raw") { + // Linear radiance (RGB f32, output size), as `serve` live jobs write it. + if hdr.is_none() { + hdr = core.read_hdr().ok(); + } + let _ = std::fs::write(path, bytemuck::cast_slice(hdr.as_deref().unwrap_or(&[]))); + } else { + save_bgra(path, &px, w, h); + } + eprintln!("live: snapshot {} at history {hist} ({spp} spp/frame, GPU {:.2} ms, scale {:.2})", path.display(), core.gpu_ms, core.render_scale); + false + } else { + true + } + }); + } + if let Some(l) = &mut log { + use std::io::Write; + let line = serde_json::json!({ + "t": start.elapsed().as_secs_f64() * 1e3, "interval": last_frame.elapsed().as_secs_f64() * 1e3, "start": t_frame.duration_since(prev_start).as_secs_f64() * 1e3, + "cpu": t_frame.elapsed().as_secs_f64() * 1e3, "script": game.frame_ms, "js": game.js_ms, "commit": game.commit_ms, "skin": core.cpu_skin_ms, "upload": core.cpu_upload_ms, "blas": core.gpu_blas_ms, "wait": core.cpu_wait_ms, "gpu": core.gpu_ms, "trace": core.gpu_trace_ms, "edit": core.edit_ms, "instanced": core.instanced_ms, "edit_split": [core.edit_split.tris, core.edit_split.upload, core.edit_split.blas, core.edit_split.added, core.edit_split.deformed, core.edit_split.objects], "rebuild": core.rebuild_ms, "fallback": core.fallback, "fallbacks": core.fallbacks, "room": core.room_stats(), "top": core.top_stats(), "voxel": std::mem::take(&mut game.host.voxels.borrow_mut().ticks), "physics": game.host.physics.borrow_mut().as_mut().map(|p| { let s = p.take_stats(); serde_json::json!({ "ticks": s.ticks, "step": s.step_ms, "sync": s.sync_ms, "bodies": s.bodies, "moved": s.moved, "kicks": s.kicks }) }), "scale": core.render_scale, "history": core.history_len, "guard": core.guard_count(), + }); + writeln!(l, "{line}")?; + } + // (Summed over the frame's commits, a game event's and the frame script's: reset once logged.) + (core.edit_ms, core.instanced_ms, core.edit_split, core.rebuild_ms, core.fallback) = (0.0, 0.0, EditSplit::default(), 0.0, None); + prev_start = t_frame; + last_frame = Instant::now(); + core.govern_resolution(opts.fps); + spp = if core.render_scale < 1.0 || lockstep.is_some() { 1 } else { next_spp(spp, core.gpu_ms, opts.fps) }; + let _ = core.g.device.poll(wgpu::PollType::Poll); + if lockstep.is_some() { + budget -= 1; + continue; // no pacing: frames as fast as the GPU renders them + } + // Pace to the frame rate; when the GPU is the bottleneck the two-frames-in-flight limit paces instead (sleeping + // after a frame that did not wait would alternate long and short frames). + next += frame_dt; + let now = Instant::now(); + if next > now && core.gpu_ms < frame_dt.as_secs_f32() * 1000.0 * 0.95 { + sleep_until(next); + } else { + next = now; + } + } +} + +/// What the game posted on the host channel since the last call, as one line on stdout: `{"host":[{"t":...}, ...]}` +/// (nothing when it posted nothing). +fn emit_posts(game: &mut crate::script::GameSession) { + use std::io::Write; + let posts = game.take_posts(); + if !posts.is_empty() { + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{}", serde_json::json!({ "host": posts })); + let _ = out.flush(); + } +} + +/// Sleeps until `t` without trusting the timer's precision: macOS gives a process that is not a foreground app timer +/// leeway of half the interval (a 16 ms sleep returned after 24, measured from a terminal-launched viewport and from +/// plain Python alike). Pacing sleeps of the headless viewport overslept by ~5 ms once the GPU fit the frame: Bistro's +/// camera path read frame interval p95 21.9 ms at GPU p95 16.0. Sleep 60 % of what is left, again, and yield through +/// the last 1.5 ms. +fn sleep_until(t: Instant) { + loop { + let now = Instant::now(); + if now >= t { + return; + } + let rem = t - now; + if rem > Duration::from_micros(1500) { + std::thread::sleep(rem.mul_f64(0.6)); + } else { + std::thread::yield_now(); + } + } +} + +pub fn save_bgra(path: &Path, px: &[u8], w: u32, h: u32) { + let rgba: Vec = px.chunks(4).flat_map(|p| [p[2], p[1], p[0], 255]).collect(); + if let Err(e) = image::save_buffer(path, &rgba, w, h, image::ColorType::Rgba8) { + eprintln!("live: snapshot failed: {e}"); + } +} + +fn spawn_stdin_reader(cmd_tx: mpsc::Sender) { + std::thread::spawn(move || { + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + let Some(cmd) = parse_cmd(&line) else { continue }; + if cmd_tx.send(cmd).is_err() { + break; + } + } + let _ = cmd_tx.send(Cmd::Quit); + }); +} + +pub fn parse_cmd(line: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(line).ok()?; + let vec = |k: &str| v[k].as_array().map(|a| Vec3::new(a[0].as_f64().unwrap_or(0.0) as f32, a[1].as_f64().unwrap_or(0.0) as f32, a[2].as_f64().unwrap_or(0.0) as f32)); + Some(match v["cmd"].as_str()? { + "camera" if v.get("sync").is_some() || v.get("snapshot").is_some() || v.get("frames").is_some() => Cmd::Step { + cam: Some(Camera::look_at(vec("pos")?, vec("target")?, v["tan"].as_f64().unwrap_or(0.0) as f32)), + frames: v["frames"].as_u64().unwrap_or(1) as u32, + sync: v.get("sync").map(|s| s.as_str().map(str::to_string).unwrap_or_else(|| s.to_string())), + snapshot: v["snapshot"].as_str().map(PathBuf::from), + }, + "camera" => Cmd::Camera(Camera::look_at(vec("pos")?, vec("target")?, v["tan"].as_f64().unwrap_or(0.0) as f32)), + "frames" => Cmd::Step { + cam: None, + frames: v["n"].as_u64().unwrap_or(1) as u32, + sync: v.get("sync").map(|s| s.as_str().map(str::to_string).unwrap_or_else(|| s.to_string())), + snapshot: v["snapshot"].as_str().map(PathBuf::from), + }, + "lockstep" => Cmd::Lockstep(if v["on"].as_bool().unwrap_or(true) { Some(v["dt"].as_f64().unwrap_or(1.0 / 60.0) as f32) } else { None }), + "event" => Cmd::Event(v["type"].as_str()?.to_string(), if v["payload"].is_null() { serde_json::json!({}) } else { v["payload"].clone() }), + "click" => Cmd::Click(v["u"].as_f64()? as f32, v["v"].as_f64()? as f32), + "host" => Cmd::Host(v["messages"].as_array()?.clone()), + "snapshot" => Cmd::Snapshot(PathBuf::from(v["path"].as_str().unwrap_or("snapshot.png")), v["after"].as_u64().map(|a| a as u32), v["frames"].as_u64().map(|a| a as u32)), + "reset" => Cmd::Reset, + "time" => Cmd::Time(v["t"].as_f64()? as f32), + "mode" => Cmd::Mode(v["mode"].as_u64()? as u32), + "bounces" => Cmd::Bounces(v["bounces"].as_u64()?.clamp(1, 16) as u32), + "spp" => Cmd::SppCap(v["max"].as_u64()?.clamp(1, 16) as u32), + "scale" => Cmd::Scale(v["scale"].as_f64()? as f32), + "sync" => Cmd::Sync(v["id"].as_str().map(str::to_string).unwrap_or_else(|| v["id"].to_string()), v["frames"].as_u64().unwrap_or(1) as u32), + "quit" => Cmd::Quit, + _ => return None, + }) +} + +pub fn parse_opts(args: &[String]) -> Result { + let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); + let num = |name: &str, d: u32| -> Result { flag(name).map(|v| v.parse().map_err(|_| anyhow!("{name} must be a number"))).transpose().map(|v| v.unwrap_or(d)) }; + Ok(LiveOpts { + width: num("--width", 1280)?, + height: num("--height", 720)?, + fps: num("--fps", 60)?, + bounces: num("--bounces", 4)?, + // (Without `--scale`: full resolution, or dynamic resolution on a display's physical pixels.) + scale: parse_scale(flag("--scale").as_deref(), if flag("--display-scale").is_some() { None } else { Some(1.0) })?, + bundle: PathBuf::from(args.get(2).filter(|a| !a.starts_with("--")).ok_or_else(|| anyhow!("game bundle required"))?), + assets: PathBuf::from(flag("--assets").unwrap_or_else(|| ".".into())), + room: flag("--room").unwrap_or_else(|| "local".into()), + frame_log: flag("--frame-log").map(PathBuf::from), + display_scale: flag("--display-scale").map(|v| v.parse::()).transpose()?.unwrap_or(1.0), + launch: parse_launch(flag("--launch").as_deref())?, + }) +} + +/// `--launch '{"room":"K7Q4MZ","name":"Ada"}'`: the `params` of the host channel's `launch` message. +pub fn parse_launch(v: Option<&str>) -> Result> { + v.map(|t| serde_json::from_str::(t).ok().filter(|p| p.is_object()).ok_or_else(|| anyhow!("--launch must be a JSON object, as in --launch '{{\"room\":\"K7Q4MZ\"}}'"))).transpose() +} diff --git a/renderer/src/live/targets.rs b/renderer/src/live/targets.rs new file mode 100644 index 0000000..3bed44c --- /dev/null +++ b/renderer/src/live/targets.rs @@ -0,0 +1,321 @@ +//! The live passes' GPU side: the shader module and its pipelines (`Passes`), the screen-size buffers and the bind +//! group (`Targets`), and the tonemap LUT and bloom pyramid they are sized for. + +use super::*; + +/// Bloom pyramid level j (0..4: 1/4 .. 1/64 resolution) size; stage k (0..5) runs on level max(k - 1, 0). +pub(super) fn bloom_dim(w: u32, h: u32, j: u32) -> (u32, u32) { + (w.div_ceil(4).div_ceil(1 << j), h.div_ceil(4).div_ceil(1 << j)) +} + +pub(super) fn bloom_stage_level(k: u32) -> u32 { + k.max(1) - 1 +} + +/// UE Standard bloom per stage: Gaussian radius in texels of its level (BloomNSize % of the stage width * size scale +/// / 2, as `post::bloom`) and tint (BloomNTint * intensity / 6). +pub(super) fn bloom_stages(pp: &crate::desc::PostPreset, w: u32, h: u32) -> ([f32; 6], [f32; 6]) { + let (mut r, mut t) = ([0f32; 6], [0f32; 6]); + for k in 0..6 { + let (lw, _) = bloom_dim(w, h, bloom_stage_level(k as u32)); + r[k] = lw as f32 * pp.bloom.sizes.get(k).copied().unwrap_or(0.0) * pp.bloom.size_scale * 0.01 * 0.5; + t[k] = pp.bloom.tints.get(k).copied().unwrap_or(0.0) * pp.bloom.intensity / 6.0; + } + (r, t) +} + +pub(super) const LOG_OFFSET: f32 = 0.0026883; // must match live.wgsl lin_to_log input offset +pub(super) const LUT: u32 = 32; + +/// UE-style 32^3 LUT: log-encoded linear input -> display sRGB, built from the exact CPU port of the filmic tonemapper. +pub(super) fn tonemap_lut(p: &crate::desc::PostPreset) -> Vec { + let tm = post::Tonemapper::new(p); + let mut out = Vec::with_capacity((LUT * LUT * LUT * 4) as usize); + let log_to_lin = |v: f32| (2f32.powf((v - 444.0 / 1023.0) * 14.0) * 0.18 - LOG_OFFSET).max(0.0); + for b in 0..LUT { + for g in 0..LUT { + for r in 0..LUT { + let enc = |k: u32| log_to_lin(k as f32 / (LUT - 1) as f32); + let d = tm.apply(Vec3::new(enc(r), enc(g), enc(b))); + for c in [d.x, d.y, d.z, 1.0] { + out.push(half::f16::from_f32(post::linear_to_srgb(c.clamp(0.0, 1.0))).to_bits()); + } + } + } + } + out +} + +/// The live passes' source: live.wgsl as it is, or in the merged layout its buffers merged into slabs (merged.rs). +pub(crate) fn live_source(merged: bool) -> std::borrow::Cow<'static, str> { + let src = include_str!("../../shaders/live.wgsl"); + if merged { + crate::merged::live(src).into() + } else { + src.into() + } +} + +pub(super) struct Passes { + pub(super) layout: wgpu::BindGroupLayout, + pub(super) pipes: Vec<(&'static str, wgpu::ComputePipeline)>, +} + +impl Passes { + pub(super) fn new(g: &gpu::Gpu) -> Passes { + let d = &g.device; + let module = d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("live"), source: wgpu::ShaderSource::Wgsl(live_source(g.merged).into()) }); + let buf = |binding: u32, read_only: bool| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only }, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }; + let mut entries = vec![wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }]; + entries.extend([buf(1, true), buf(2, false), buf(3, false), buf(4, false), buf(5, false), buf(6, false), buf(7, false), buf(8, false), buf(9, false), buf(10, false)]); + entries.push(wgpu::BindGroupLayoutEntry { + binding: 11, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D3, multisampled: false }, + count: None, + }); + entries.push(wgpu::BindGroupLayoutEntry { binding: 12, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None }); + entries.extend([buf(13, false), buf(14, false), buf(15, false), buf(16, false), buf(17, false), buf(18, false)]); + entries.extend([buf(19, false), buf(20, false), buf(21, false), buf(22, false), buf(23, true), buf(24, false), buf(25, true), buf(26, true)]); + // The merged layout: the buffers that stay, the slabs, and the offsets' uniform (merged.rs). + if g.merged { + use crate::merged::{LIVE_KEPT, OFFSETS_BINDING, SLABS}; + entries.retain(|e| !matches!(e.ty, wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { .. }, .. }) || LIVE_KEPT.iter().any(|k| k.0 == e.binding)); + entries.extend(SLABS.iter().map(|s| buf(s.binding, s.read_only))); + entries.push(wgpu::BindGroupLayoutEntry { binding: OFFSETS_BINDING, ..entries[0] }); + } + let layout = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("live"), entries: &entries }); + let pl = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); + let pipes = ["refl", "dtemporal", "atrous0", "atrous1", "atrous2", "atrous3", "atrous4", "stemporal", "sfilt0", "sfilt1", "sfilt2", "compose", "judge", "reject", "upscale", "dof_tiles", "dof", "mblur", "lum_down", "exposure", "lum_blur", "bloom_down", "bloom_dn1", "bloom_dn2", "bloom_dn3", "bloom_dn4", + "bloom_h5", "bloom_v5", "bloom_h4", "bloom_v4", "bloom_h3", "bloom_v3", "bloom_h2", "bloom_v2", "bloom_h1", "bloom_v1", "bloom_h0", "bloom_v0", "post"] + .into_iter() + .map(|name| { + (name, d.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(name), layout: Some(&pl), module: &module, entry_point: Some(name), compilation_options: Default::default(), cache: g.cache() })) + }) + .collect(); + g.save_pipeline_cache(); + Passes { layout, pipes } + } +} + +/// Screen-size targets shared across scene reloads. +pub struct Targets { + pub(super) params: wgpu::Buffer, + /// What lasts from one frame to the next (live.wgsl, "The live passes' buffers"), two halves each: this frame's + /// and the previous frame's (`LiveParams::state`). Per traced sample: the diffuse irradiance's history, its + /// luminance moments and its fast mean in colour, the specular history, the g-buffer, the upscaler's state; per + /// output pixel: the colour history. + pub(super) diffuse_hist: Buf, + pub(super) diffuse_mom: Buf, + pub(super) diffuse_fast: Buf, + pub(super) spec_hist: Buf, + pub(super) gbuffer: Buf, + pub(super) up_state: Buf, + pub(super) color_hist: Buf, + /// What lives within a frame (live.wgsl says who uses each when): per traced sample, screen velocity, view depth + /// and animated shading; the specular filter's two slots; the scratch the diffuse filter and the upscaled image + /// share; the irradiance filter's ping-pong, the composed colour and the image; bloom's pyramid and scratch. + pub(super) sample_vel: Buf, + pub(super) spec_pp: Buf, + pub(super) sb: Buf, + pub(super) fa: Buf, + pub(super) fb: Buf, + pub(super) bloom: [Buf; 2], + /// Reflection resolve: the resolved specular reflection per traced pixel (live.wgsl `refl`), from the trace + /// kernel's specular-lobe rays (2 vec4 per traced pixel, pathtrace.wgsl `refl`: `kernel.refl`). + pub(super) refl_res: Buf, + pub out: wgpu::Buffer, + /// Auto exposure: 64 histogram buckets (fixed point), state (exposure, target, mean log2 luminance, valid). + pub(super) ae_bins: wgpu::Buffer, + pub(super) ae_state: Buf, + /// Quarter-resolution log2 luminance and its edge-aware blur (local exposure base). + pub(super) lumq: Buf, + pub(super) lumb: Buf, + /// The kernel's group 1 (`gpu::KernelBufs`): the radiance cache, and what the passes here read of + /// a trace: the specular-lobe rays (`refl`), motion vectors per traced pixel (pathtrace.wgsl `motion_out`) and the + /// deterministic direct light per traced pixel (`direct_out`). + pub kernel: gpu::KernelBufs, + /// The merged layout (merged.rs): the slabs the buffers above are ranges of (`SLABS`' first four). + pub(super) slabs: Option<[wgpu::Buffer; 4]>, + pub(super) lut_tex: wgpu::Texture, + pub(super) lut_view: wgpu::TextureView, + pub(super) sampler: wgpu::Sampler, +} + +impl Targets { + /// For a view of `w` x `h` and a radiance cache of `cache` entries. + pub(super) fn new(g: &gpu::Gpu, w: u32, h: u32, lut: &[u16], cache: u64) -> Targets { + use crate::merged::{SLABS, SLAB_F1, SLAB_U2, SLAB_U4, SLAB_V4}; + let d = &g.device; + let n = (w * h) as u64; + let sb = |label: &str, bytes: u64| d.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size: bytes, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }); + let bn = (w.div_ceil(4) * h.div_ceil(4)) as u64 * 16; + let texels = |j: u32| bloom_dim(w, h, j).0 as u64 * bloom_dim(w, h, j).1 as u64; + let bloom_texels = (0..5).map(texels).sum::() + (0..6).map(|k| texels(bloom_stage_level(k))).sum::(); + let lut_tex = d.create_texture(&wgpu::TextureDescriptor { + label: Some("tonemap lut"), + size: wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D3, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + g.queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &lut_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(lut), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT * 8), rows_per_image: Some(LUT) }, + wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, + ); + // The buffers, in the order of the fields below: a buffer each, or in the merged layout (merged.rs) a range of + // the slab of its element type (each range starts at a multiple of 16 bytes: of every slab's element). + // (The lasting ones are two halves of `n` elements: this frame's and the previous frame's.) + let specs: [(&str, u64, usize); 18] = [ + ("diffuse_hist", 2 * n * 16, SLAB_V4), + ("diffuse_mom", 2 * n * 16, SLAB_V4), + ("diffuse_fast", 2 * n * 8, SLAB_U2), + ("spec_hist", 2 * n * 16, SLAB_V4), + ("gbuffer", 2 * n * 8, SLAB_U2), + ("up_state", 2 * n * 16, SLAB_U4), + ("color_hist", 2 * n * 16, SLAB_V4), + ("sample_vel", n * 16, SLAB_V4), + ("spec_pp", n * 16, SLAB_U2), // two slots of `n` (vec2) + ("sb", n * 16, SLAB_V4), + ("fa", n * 16, SLAB_V4), + ("fb", n * 16, SLAB_V4), + ("bloom_a", bloom_texels * 16, SLAB_V4), + ("bloom_b", bn, SLAB_V4), + ("refl_res", n * 16, SLAB_V4), + ("ae_state", 32, SLAB_V4), + ("lumq", bn / 4, SLAB_F1), + ("lumb", bn / 4, SLAB_F1), + ]; + let slabs: Option<[wgpu::Buffer; 4]> = g.merged.then(|| std::array::from_fn(|k| sb(SLABS[k].name, specs.iter().filter(|s| s.2 == k).map(|s| s.1.next_multiple_of(16)).sum()))); + let mut at = [0u64; 4]; + let bufs: Vec = specs + .iter() + .map(|&(label, bytes, slab)| match &slabs { + None => Buf::whole(sb(label, bytes)), + Some(s) => { + let b = Buf { buffer: s[slab].clone(), offset: at[slab], size: bytes }; + at[slab] += bytes.next_multiple_of(16); + b + } + }) + .collect(); + let mut bufs = bufs.into_iter(); + let mut b = || bufs.next().expect("a buffer of `specs`"); + Targets { + params: d.create_buffer(&wgpu::BufferDescriptor { label: Some("live params"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), + diffuse_hist: b(), + diffuse_mom: b(), + diffuse_fast: b(), + spec_hist: b(), + gbuffer: b(), + up_state: b(), + color_hist: b(), + sample_vel: b(), + spec_pp: b(), + sb: b(), + fa: b(), + fb: b(), + bloom: [b(), b()], + refl_res: b(), + out: sb("out", n * 4), + ae_bins: sb("ae_bins", 64 * 4), + ae_state: b(), + lumq: b(), + lumb: b(), + kernel: gpu::KernelBufs::new(d, g.merged, g.guard_room(), cache, n * 32, n * 16, n * 16), + slabs, + lut_view: lut_tex.create_view(&Default::default()), + lut_tex, + sampler: d.create_sampler(&wgpu::SamplerDescriptor { mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, ..Default::default() }), + } + } + + /// What live.wgsl's buffer bindings hold (besides the uniforms at 0, the trace's samples at 1, `out` at 10 and + /// the histogram at 13). + pub(super) fn buffers(&self) -> [(u32, &Buf); 21] { + [ + (2, &self.diffuse_hist), + (3, &self.color_hist), + (4, &self.gbuffer), + (5, &self.spec_hist), + (6, &self.diffuse_mom), + (7, &self.diffuse_fast), + (8, &self.up_state), + (9, &self.sample_vel), + (14, &self.ae_state), + (15, &self.lumq), + (16, &self.lumb), + (17, &self.fa), + (18, &self.fb), + (19, &self.bloom[0]), + (20, &self.bloom[1]), + (21, &self.spec_pp), + (22, &self.sb), + (23, &self.kernel.refl), + (24, &self.refl_res), + (25, &self.kernel.motion), + (26, &self.kernel.direct), + ] + } + + /// The live passes' bind group. (The lasting buffers' halves alternate by `LiveParams::state`, not by binding.) In + /// the merged layout (merged.rs) it binds the slabs, and the offsets the passes read (`LO`). + pub(super) fn bind_group(&self, d: &wgpu::Device, layout: &wgpu::BindGroupLayout, sample: &wgpu::Buffer) -> wgpu::BindGroup { + let mk = || { + fn e(binding: u32, buf: &wgpu::Buffer) -> wgpu::BindGroupEntry<'_> { + wgpu::BindGroupEntry { binding, resource: buf.as_entire_binding() } + } + let mut entries = vec![ + e(0, &self.params), + e(1, sample), + e(10, &self.out), + wgpu::BindGroupEntry { binding: 11, resource: wgpu::BindingResource::TextureView(&self.lut_view) }, + wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + e(13, &self.ae_bins), + ]; + let buffers = self.buffers(); + let offsets; + match (&self.slabs, &self.kernel.merged) { + (Some(slabs), Some((live, _))) => { + use crate::merged::{LIVE_BUFS, OFFSETS_BINDING, SLABS, SLAB_K}; + // Each merged buffer's (first element, length) in its slab, in `MergedOffsets`' order. + let lo: Vec<[u32; 2]> = LIVE_BUFS + .iter() + .map(|m| { + let buf = buffers.iter().find(|b| b.0 == m.binding).expect("a merged buffer's binding").1; + let stride = SLABS[m.slab].stride; + debug_assert!(buf.offset % stride == 0 && buf.size % stride == 0); + [(buf.offset / stride) as u32, (buf.size / stride) as u32] + }) + .collect(); + // (And the struct's unused last pair: `merged::OFFSETS_BYTES`.) + let mut lo = lo; + lo.push([0, 0]); + debug_assert_eq!(lo.len() * 8, crate::merged::OFFSETS_BYTES); + offsets = wgpu::util::DeviceExt::create_buffer_init(d, &wgpu::util::BufferInitDescriptor { label: Some("live offsets"), contents: bytemuck::cast_slice(&lo), usage: wgpu::BufferUsages::UNIFORM }); + entries.extend(slabs.iter().zip(&SLABS).map(|(s, m)| e(m.binding, s))); + entries.push(e(SLABS[SLAB_K].binding, live)); + entries.push(e(OFFSETS_BINDING, &offsets)); + } + _ => entries.extend(buffers.iter().map(|(binding, buf)| wgpu::BindGroupEntry { binding: *binding, resource: buf.binding() })), + } + d.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("live"), layout, entries: &entries }) + }; + mk() + } +} diff --git a/renderer/src/main.rs b/renderer/src/main.rs index dfcfce1..676b701 100644 --- a/renderer/src/main.rs +++ b/renderer/src/main.rs @@ -8,7 +8,7 @@ //! [--hdr out.raw] [--signals PREFIX] (linear RGB f32: the image; its diffuse / specular / //! emission apart, PREFIX-{diffuse,specular,emission}.raw) //! threert-render serve [--presets DIR] # JSON-lines jobs on stdin, keeps the GPU device and game runtimes warm -//! threert-render play | live # native window / headless real-time (view.rs, live.rs) +//! threert-render play | live # native window / headless real-time (view.rs, live/) //! threert-render setup # setup cost in the embedded runtime //! threert-render edit-check --events '[{"type":..,"payload":..}]' //! threert-render info # print adapter info diff --git a/renderer/src/merged.rs b/renderer/src/merged.rs index 80c9c8d..2d564f4 100644 --- a/renderer/src/merged.rs +++ b/renderer/src/merged.rs @@ -1,6 +1,6 @@ //! The merged binding layout: the engine's shaders with at most eight storage buffers a stage, WebGPU's default limit -//! (`maxStorageBuffersPerShaderStage`). The kernel binds 19 as it is written and the live passes 28: more than a -//! browser gives a page. There is one source: pathtrace.wgsl and live.wgsl, and the bindings gpu.rs and live.rs make for +//! (`maxStorageBuffersPerShaderStage`). The kernel binds 19 as it is written and the live passes 24: more than a +//! browser gives a page. There is one source: pathtrace.wgsl and live.wgsl, and the bindings gpu.rs and live/targets.rs make for //! them, are what the native renderer runs, unchanged. This module writes the browser's from them, as text (as //! `gpu::kernel_source` patches the kernel for a scene's features), and gives the Rust side the numbers that go with //! it. `THREERT_MERGED=1` runs the same layout natively (software traversal), so the tests that check the renderer @@ -15,8 +15,9 @@ //! - The kernel's six group-1 buffers (`KERNEL_LIVE`: the radiance cache, what the live passes read //! of a trace) become the arrays of `ML: MergedLive`, one buffer that they are ranges of (`gpu::KernelBufs`). //! - The live passes' buffers of one element type are ranges of one slab each (`SLABS`), indexed from an offset -//! the pass reads from a uniform (`LO`): the source does not change with the size of the view, and the two -//! bind groups of the history's ping-pong are two sets of offsets. `MK` is the kernel's `ML` buffer read as vec4s. +//! the pass reads from a uniform (`LO`): the source does not change with the size of the view. (The lasting +//! buffers' two halves alternate by a uniform, live.wgsl `this_half`.) `MK` is the kernel's `ML` buffer read as +//! vec4s. //! //! A use of a merged name the generator does not know (anything but `name[...]`, `&name[...]` and //! `arrayLength(&name)`), a declaration that is not found exactly once, a name the generator adds that the source @@ -146,32 +147,28 @@ pub struct LiveBuf { } /// In `MergedOffsets`' order (group 0, binding `OFFSETS_BINDING`: an (offset, length) pair of u32 each, in elements). -pub const LIVE_BUFS: [LiveBuf; 25] = [ - LiveBuf { name: "hist_in", binding: 2, slab: SLAB_V4, decl: "@group(0) @binding(2) var hist_in: array>;" }, - LiveBuf { name: "hist_out", binding: 3, slab: SLAB_V4, decl: "@group(0) @binding(3) var hist_out: array>;" }, - LiveBuf { name: "gb_in", binding: 4, slab: SLAB_U2, decl: "@group(0) @binding(4) var gb_in: array>;" }, - LiveBuf { name: "gb_out", binding: 5, slab: SLAB_U2, decl: "@group(0) @binding(5) var gb_out: array>;" }, - LiveBuf { name: "fa", binding: 6, slab: SLAB_V4, decl: "@group(0) @binding(6) var fa: array>;" }, - LiveBuf { name: "fb", binding: 7, slab: SLAB_V4, decl: "@group(0) @binding(7) var fb: array>;" }, - LiveBuf { name: "bloom_a", binding: 8, slab: SLAB_V4, decl: "@group(0) @binding(8) var bloom_a: array>;" }, - LiveBuf { name: "bloom_b", binding: 9, slab: SLAB_V4, decl: "@group(0) @binding(9) var bloom_b: array>;" }, +pub const LIVE_BUFS: [LiveBuf; 21] = [ + LiveBuf { name: "diffuse_hist", binding: 2, slab: SLAB_V4, decl: "@group(0) @binding(2) var diffuse_hist: array>;" }, + LiveBuf { name: "color_hist", binding: 3, slab: SLAB_V4, decl: "@group(0) @binding(3) var color_hist: array>;" }, + LiveBuf { name: "gbuffer", binding: 4, slab: SLAB_U2, decl: "@group(0) @binding(4) var gbuffer: array>;" }, + LiveBuf { name: "spec_hist", binding: 5, slab: SLAB_V4, decl: "@group(0) @binding(5) var spec_hist: array>;" }, + LiveBuf { name: "diffuse_mom", binding: 6, slab: SLAB_V4, decl: "@group(0) @binding(6) var diffuse_mom: array>;" }, + LiveBuf { name: "diffuse_fast", binding: 7, slab: SLAB_U2, decl: "@group(0) @binding(7) var diffuse_fast: array>;" }, + LiveBuf { name: "up_state", binding: 8, slab: SLAB_U4, decl: "@group(0) @binding(8) var up_state: array>;" }, + LiveBuf { name: "sample_vel", binding: 9, slab: SLAB_V4, decl: "@group(0) @binding(9) var sample_vel: array>;" }, LiveBuf { name: "ae_state", binding: 14, slab: SLAB_V4, decl: "@group(0) @binding(14) var ae_state: array, 2>;" }, LiveBuf { name: "lumq", binding: 15, slab: SLAB_F1, decl: "@group(0) @binding(15) var lumq: array;" }, LiveBuf { name: "lumb", binding: 16, slab: SLAB_F1, decl: "@group(0) @binding(16) var lumb: array;" }, - LiveBuf { name: "ae_in", binding: 17, slab: SLAB_U4, decl: "@group(0) @binding(17) var ae_in: array>;" }, - LiveBuf { name: "ae_out", binding: 18, slab: SLAB_U4, decl: "@group(0) @binding(18) var ae_out: array>;" }, - LiveBuf { name: "shist_in", binding: 19, slab: SLAB_V4, decl: "@group(0) @binding(19) var shist_in: array>;" }, - LiveBuf { name: "shist_out", binding: 20, slab: SLAB_V4, decl: "@group(0) @binding(20) var shist_out: array>;" }, - LiveBuf { name: "mom_in", binding: 21, slab: SLAB_V4, decl: "@group(0) @binding(21) var mom_in: array>;" }, - LiveBuf { name: "mom_out", binding: 22, slab: SLAB_V4, decl: "@group(0) @binding(22) var mom_out: array>;" }, - LiveBuf { name: "spec_pp", binding: 23, slab: SLAB_U2, decl: "@group(0) @binding(23) var spec_pp: array>;" }, - LiveBuf { name: "sb", binding: 24, slab: SLAB_V4, decl: "@group(0) @binding(24) var sb: array>;" }, - LiveBuf { name: "refl_in", binding: 25, slab: SLAB_K, decl: "@group(0) @binding(25) var refl_in: array>;" }, - LiveBuf { name: "refl_res", binding: 26, slab: SLAB_V4, decl: "@group(0) @binding(26) var refl_res: array>;" }, - LiveBuf { name: "motion_in", binding: 27, slab: SLAB_K, decl: "@group(0) @binding(27) var motion_in: array>;" }, - LiveBuf { name: "direct_in", binding: 28, slab: SLAB_K, decl: "@group(0) @binding(28) var direct_in: array>;" }, - LiveBuf { name: "xhist_in", binding: 29, slab: SLAB_V4, decl: "@group(0) @binding(29) var xhist_in: array>;" }, - LiveBuf { name: "xhist_out", binding: 30, slab: SLAB_V4, decl: "@group(0) @binding(30) var xhist_out: array>;" }, + LiveBuf { name: "fa", binding: 17, slab: SLAB_V4, decl: "@group(0) @binding(17) var fa: array>;" }, + LiveBuf { name: "fb", binding: 18, slab: SLAB_V4, decl: "@group(0) @binding(18) var fb: array>;" }, + LiveBuf { name: "bloom_a", binding: 19, slab: SLAB_V4, decl: "@group(0) @binding(19) var bloom_a: array>;" }, + LiveBuf { name: "bloom_b", binding: 20, slab: SLAB_V4, decl: "@group(0) @binding(20) var bloom_b: array>;" }, + LiveBuf { name: "spec_pp", binding: 21, slab: SLAB_U2, decl: "@group(0) @binding(21) var spec_pp: array>;" }, + LiveBuf { name: "sb", binding: 22, slab: SLAB_V4, decl: "@group(0) @binding(22) var sb: array>;" }, + LiveBuf { name: "refl_in", binding: 23, slab: SLAB_K, decl: "@group(0) @binding(23) var refl_in: array>;" }, + LiveBuf { name: "refl_res", binding: 24, slab: SLAB_V4, decl: "@group(0) @binding(24) var refl_res: array>;" }, + LiveBuf { name: "motion_in", binding: 25, slab: SLAB_K, decl: "@group(0) @binding(25) var motion_in: array>;" }, + LiveBuf { name: "direct_in", binding: 26, slab: SLAB_K, decl: "@group(0) @binding(26) var direct_in: array>;" }, ]; pub const OFFSETS_BINDING: u32 = 31; /// `MergedOffsets`' size: a pair for each of `LIVE_BUFS`, and one unused that makes it a multiple of 16 bytes. @@ -661,7 +658,7 @@ mod tests { #[test] fn the_merged_live_passes_fit_eight_storage_buffers() { let (m, info) = parse(LIVE); - assert_eq!(storage_buffers(&m, &info).1, 28, "the live passes as written"); + assert_eq!(storage_buffers(&m, &info).1, 24, "the live passes as written"); let passes = m.entry_points.len(); let (m, info) = parse(&live(LIVE)); let (used, declared) = storage_buffers(&m, &info); @@ -669,9 +666,9 @@ mod tests { assert!(used.iter().all(|u| u.1 as u32 <= STORAGE_BUFFERS), "{used:?}"); assert_eq!(used.len(), passes, "every pass is still there"); // The offsets' struct: a pair of u32 each, in order (what `live::Targets` writes). - assert_eq!(layout(&m, "MergedOffsets"), ((0..26).map(|k| k * 8).collect(), OFFSETS_BYTES as u64)); + assert_eq!(layout(&m, "MergedOffsets"), ((0..22).map(|k| k * 8).collect(), OFFSETS_BYTES as u64)); assert_eq!(OFFSETS_BYTES % 16, 0); - assert_eq!(LIVE_BUFS.len(), 25); + assert_eq!(LIVE_BUFS.len(), 21); } /// The Rust side's records are the size the merged structs give their elements. diff --git a/renderer/src/physics.rs b/renderer/src/physics.rs index 952aebf..4b5582b 100644 --- a/renderer/src/physics.rs +++ b/renderer/src/physics.rs @@ -3,7 +3,7 @@ //! objects that bodies drive; it has no GPU in it and builds for wasm32); here is the scene it drives, the renderer's //! `World`, and the frame's timing. Scene code reaches it through `__engine.physics(op, args)` and reads the driven //! objects' poses through `__engine.physicsPoses()`; it never steps it. After a frame's ticks the bodies that moved -//! place their objects: a driven mesh or model is an instance, so a move is its TLAS transform, no triangle (live.rs +//! place their objects: a driven mesh or model is an instance, so a move is its TLAS transform, no triangle (live/edits.rs //! `move_instances`), and nothing of it passes through the script. A session that never makes a physics call has no //! world and pays nothing. diff --git a/renderer/src/scene.rs b/renderer/src/scene.rs index 5227909..47a7f7d 100644 --- a/renderer/src/scene.rs +++ b/renderer/src/scene.rs @@ -160,7 +160,7 @@ pub const PROC_HEADER: usize = 16; /// Procedural object `id` (a `Kind::Procedural`) as the build places it at world matrix `m` with object-table index /// `object`, or None when its geometry has no boxes (nothing drawn). The build and the live view's in-place edits -/// (live.rs `edit_geometry`) both make it here. Its geometry function's index is its place among the world's geometry +/// (live/edits.rs `edit_geometry`) both make it here. Its geometry function's index is its place among the world's geometry /// functions (the kernel's dispatch order). pub fn proc_object(w: &World, id: u32, m: Mat4, object: u32, materials: &HashMap, assets: &Path) -> Result> { let n = &w.objects[&id]; @@ -543,15 +543,15 @@ pub struct Retained { pub pose_groups: Vec, pub pose_member: HashMap, /// `own_blas` meshes placed as instances (hardware RT): object -> its instance; (geometry, GPU material) -> the - /// shared mesh (objects with the same geometry and material share its BLAS). live.rs `edit_geometry` keeps both. + /// shared mesh (objects with the same geometry and material share its BLAS). live/edits.rs `edit_geometry` keeps both. pub own_instance: HashMap, pub own_mesh: HashMap<(u32, u32), u32>, /// Emitting `own_blas` meshes (`InstMesh::emits`): object -> its mesh (its own, in world space). pub emitter_mesh: HashMap, - /// Instanced objects (`Kind::Instanced`) placed as instances -> where (live.rs `update_instanced`). + /// Instanced objects (`Kind::Instanced`) placed as instances -> where (live/edits.rs `update_instanced`). pub instanced: HashMap, /// Static models' instanced meshes by (uri, nodes), those with nothing flattened per placement: a model added in - /// place is another instance of one (live.rs `edit_geometry`). + /// place is another instance of one (live/edits.rs `edit_geometry`). pub model_mesh: HashMap<(String, Option>), u32>, } @@ -1141,7 +1141,7 @@ pub fn build(w: &World, assets: &Path, presets_dir: &Path, overrides: Option<&se if emits_only(&materials[mat as usize]) && !n.driven { // An emitting material: its own BLAS too, but its triangles in world space (placed at identity, // never shared), so they are light-sampled as flattened triangles are and can be edited in place - // (live.rs `edit_geometry`; ROADMAP § 6 step 3). + // (live/edits.rs `edit_geometry`; ROADMAP § 6 step 3). let oi = (objects.len() / stride) as u32; let (t, a) = emitter_mesh_tris(g, m, mat, oi); if !t.is_empty() { @@ -1171,7 +1171,7 @@ pub fn build(w: &World, assets: &Path, presets_dir: &Path, overrides: Option<&se } Kind::Instanced { geometry, procedural, materials: mats, capacity } if shown => { // Copies of one geometry: one shared mesh (one BLAS), an instance and an object entry per copy, the - // copies' records and entries contiguous (live.rs `update_instanced` rewrites them in place). + // copies' records and entries contiguous (live/edits.rs `update_instanced` rewrites them in place). // Glass, emissive and function materials and mirrored copies are flattened instead, each copy its // own triangles, as `own_blas` meshes are; changing their instances rebuilds. let empty = world::InstanceTable::default(); @@ -2053,7 +2053,7 @@ pub fn put_colors(dst: &mut Vec<[u32; 3]>, at: usize, c: Option>) } /// An `own_blas` mesh's triangles in model space and their attributes, as the pool holds them. The build and the live -/// view's in-place edits (live.rs `edit_geometry`) both make them here, so either path gives the same data. +/// view's in-place edits (live/edits.rs `edit_geometry`) both make them here, so either path gives the same data. pub fn own_mesh_tris(g: &world::Geometry, mat: u32) -> (Vec<[Vec3A; 3]>, Vec) { let (mut tris, mut flags, mut attrs) = (vec![], vec![], vec![]); append_arrays(g, Mat4::IDENTITY, mat, 0, None, &mut tris, &mut flags, &mut attrs); diff --git a/renderer/src/tables.rs b/renderer/src/tables.rs index 858c8a4..abf62aa 100644 --- a/renderer/src/tables.rs +++ b/renderer/src/tables.rs @@ -45,13 +45,13 @@ pub struct Tables { pub(crate) emissive: HashSet, pub wetness: f32, pub rain: f32, - /// Cloud cover (0..1): a change re-bakes the sky and dims the sun (live.rs `move_sun`). + /// Cloud cover (0..1): a change re-bakes the sky and dims the sun (live/edits.rs `move_sun`). pub cloud_cover: f32, pub fog: Option, /// three.js `scene.background` (`Params::background`). pub background: Option<[f32; 3]>, /// A material that starts emitting gets emitter entries in place (the live view sets these up, `prepare`'s - /// `mat_runs` / `fixed_mats`): it is queued in `started_emitting` for live.rs `add_material_emitters`, unless its + /// `mat_runs` / `fixed_mats`): it is queued in `started_emitting` for live/edits.rs `add_material_emitters`, unless its /// triangles include pool or dynamic ones (fixed: a rebuild flattens them). Off: such a material rebuilds. pub emit_in_place: bool, pub fixed_mats: HashSet, @@ -64,7 +64,7 @@ pub struct Tables { /// vec4s an object in `objects` (`scene::object_stride`). pub stride: usize, /// Power edits are held back in `deferred` instead of written to `objects` (the live view's emitter worker: a - /// scene with many emitters, live.rs `EmitterWorker`). + /// scene with many emitters, live/load.rs `EmitterWorker`). pub defer_powers: bool, /// Held-back power edits: (the object's first row in `objects`, its power, the time of the edit). pub deferred: Vec<(usize, f32, f32)>, diff --git a/renderer/src/units.rs b/renderer/src/units.rs index e4a89ad..cca2af8 100644 --- a/renderer/src/units.rs +++ b/renderer/src/units.rs @@ -1312,7 +1312,7 @@ mod tests { } /// A scene as the renderer keeps one under software traversal with units, small: the buffers the kernel reads and - /// the bookkeeping that writes them. Its operations are what an edit does (live.rs): a mesh's triangles and tree + /// the bookkeeping that writes them. Its operations are what an edit does (live/edits.rs): a mesh's triangles and tree /// into room, a record, the top tree again. struct Scene { nodes: Vec, @@ -1736,7 +1736,7 @@ mod tests { assert_eq!((none.nodes(), none.max_units()), (1, 1)); } - /// `Units` (what live.rs's edits keep): a unit names the tree its mesh has when its record is written; a top tree + /// `Units` (what live/edits.rs's edits keep): a unit names the tree its mesh has when its record is written; a top tree /// is refused, and nothing changed, while a shown unit names a tree that was freed or placed again elsewhere, or /// sits past the kernel's records; writing the record again (`moved`), hiding it or clearing it mends that. #[test] @@ -1807,7 +1807,7 @@ mod tests { assert_eq!((u.top().unwrap().len(), u.count), (0, 0)); } - /// A mesh written again where it was (a geometry that moves every frame: live.rs writes a mesh of the same + /// A mesh written again where it was (a geometry that moves every frame: live/edits.rs writes a mesh of the same /// triangle count over the one it replaces) has a new tree, and its unit's box in the top tree is the new tree's: /// the walk must reach triangles that left the old bounds. (Seen on the GPU, voxel/cut-falls-live: a part that /// fell 0.2 m was cut off at the box it had before.) diff --git a/renderer/src/voxel.rs b/renderer/src/voxel.rs index a9bef42..beafc8f 100644 --- a/renderer/src/voxel.rs +++ b/renderer/src/voxel.rs @@ -6,7 +6,7 @@ //! Two routes, one switch for the whole level: //! - meshes: each object's exposed faces greedy-meshed (`voxel_core::greedy_mesh`), one `own_blas` mesh per palette //! material; an edit meshes the touched objects again (and the terrain tiles beside them) and replaces their -//! geometry in place (live.rs `edit_geometry`); +//! geometry in place (live/edits.rs `edit_geometry`); //! - boxes: each object one procedural geometry with one box (`VOXEL_BRICKS`); an edit lays the touched objects out //! again and replaces their data in place. //! diff --git a/renderer/src/world.rs b/renderer/src/world.rs index 5697d23..8af46ad 100644 --- a/renderer/src/world.rs +++ b/renderer/src/world.rs @@ -123,7 +123,7 @@ pub struct Texture { pub enum Kind { Group, /// `own_blas` (scene code's `dynamicGeometry`): instanced with its own BLAS, so replacing its geometry, creating or - /// removing it is applied in place (live.rs `edit_geometry`) instead of rebuilding the scene; otherwise its + /// removing it is applied in place (live/edits.rs `edit_geometry`) instead of rebuilding the scene; otherwise its /// triangles are flattened into the shared groups. /// `materials`: one per geometry group (three.js's material array; empty: `material` for every triangle). Mesh { geometry: u32, material: u32, own_blas: bool, materials: Vec }, @@ -153,7 +153,7 @@ pub enum Kind { /// Copies of one geometry (scene code's `InstancedMesh`): triangle geometry `geometry` drawn with `materials[0]`, /// or procedural geometry (`procedural`) with `materials` as a `Procedural` has them. `capacity` instances, whose /// transforms (relative to the node), colours and per-instance data are its `World::instanced` table; one BLAS for - /// all, one TLAS instance each, so the table changes in place (live.rs `update_instanced`), never a rebuild. + /// all, one TLAS instance each, so the table changes in place (live/edits.rs `update_instanced`), never a rebuild. Instanced { geometry: u32, procedural: bool, materials: Vec, capacity: u32 }, } @@ -276,7 +276,7 @@ impl Default for Settings { #[derive(Default, Debug, Clone)] pub struct Changes { pub structural: bool, - /// Objects shown or hidden by their own `visible` whose build can take it in place (live.rs `commit`): particle + /// Objects shown or hidden by their own `visible` whose build can take it in place (live/edits.rs `commit`): particle /// systems that are not light-sampled (gpu.rs `set_particles_hidden`), volumes (their medium: `set_medium_hidden`) /// and lights without a card (their table entry goes black). A rebuild where the build left the object out. pub shown: HashSet, @@ -305,7 +305,7 @@ pub struct Changes { /// Objects created (or re-created) in this batch: the rebuild builds them where they are, so setting their /// transform is not a move. pub created: HashSet, - /// Geometry edits the live view can apply in place (live.rs `edit_geometry`), when nothing else is structural: + /// Geometry edits the live view can apply in place (live/edits.rs `edit_geometry`), when nothing else is structural: /// geometries replaced, objects created (or re-created with other geometry or materials) and objects removed, /// whose objects carry their own BLAS (procedural meshes, `own_blas` meshes). The live view falls back to a /// rebuild for what it cannot apply. @@ -313,12 +313,12 @@ pub struct Changes { pub added: BTreeSet, pub removed: BTreeSet, /// Instanced objects whose instances changed (table, node transform, power, customData): rewritten in place - /// (live.rs `update_instanced`). + /// (live/edits.rs `update_instanced`). pub instanced: HashSet, /// Density grids created in this batch (their volume objects may not exist yet: `World::set_grid` keeps them). pub grids_new: BTreeSet, /// Materials created in this batch that bring no texture and no material function: the live view adds them in - /// place (live.rs `add_materials`; a rebuild for one it cannot take), so an object added while the game runs may + /// place (live/edits.rs `add_materials`; a rebuild for one it cannot take), so an object added while the game runs may /// come with a material of its own. pub new_materials: BTreeSet, } @@ -538,7 +538,7 @@ impl World { // rebuilds. // (Movable as the build sees it: the object or any ancestor, `movable`.) let static_parent = parent.is_none_or(|p| !self.objects.contains_key(&p) || !self.movable(p)); - // A new instanced mesh or static model is placed in place too (ROADMAP § 6 step 5; live.rs `edit_geometry` + // A new instanced mesh or static model is placed in place too (ROADMAP § 6 step 5; live/edits.rs `edit_geometry` // says when it cannot); changing one still rebuilds. let placed_new = !self.objects.contains_key(&id) && matches!(kind, Kind::Instanced { procedural: false, .. } | Kind::Model { animation: None, .. }); let fresh = (own_blas(&kind) || placed_new) @@ -583,7 +583,7 @@ impl World { } /// A rigid body drives object `id` from now on: it is movable, built as an instance where it can be (a rebuild, - /// once; none for an object this batch adds in place, which is placed as one: live.rs `edit_geometry`). + /// once; none for an object this batch adds in place, which is placed as one: live/edits.rs `edit_geometry`). pub fn set_driven(&mut self, id: u32) -> Result<()> { let o = self.objects.get_mut(&id).ok_or_else(|| anyhow!("unknown object {id}"))?; if !o.driven { @@ -654,7 +654,7 @@ impl World { /// Records that an object's transform changed: in place where it can be, otherwise a rebuild. fn note_move(&mut self, id: u32) { - // An object added in place in this batch is placed where it is then (live.rs `edit_geometry`). + // An object added in place in this batch is placed where it is then (live/edits.rs `edit_geometry`). if self.changes.added.contains(&id) { return; } @@ -683,7 +683,7 @@ impl World { } else if light && !sun && self.leaf(id) { self.changes.lights.insert(id); } else if sun && self.leaf(id) { - // The atmosphere's sun (time of day): its light rewritten and the sky re-baked in place (live.rs `move_sun`). + // The atmosphere's sun (time of day): its light rewritten and the sky re-baked in place (live/edits.rs `move_sun`). self.changes.sun = true; } else { // Fixed geometry moved: from now on it is movable. This move rebuilds once, to set its triangles apart. @@ -818,7 +818,7 @@ impl World { /// An instanced object's instances: `count` drawn, `matrices` (16 numbers each), optionally `colors` (3 each) and /// per-instance data (4 each), all for its whole capacity; None keeps what it had. Game code is untrusted: sizes /// are checked, colours and data made finite (colours >= 0); a matrix that is not finite or not invertible hides - /// its instance (live.rs, scene.rs). Colours appearing or going rebuild (a kernel feature); the rest is in place. + /// its instance (live/edits.rs, scene.rs). Colours appearing or going rebuild (a kernel feature); the rest is in place. pub fn set_instances(&mut self, id: u32, count: u32, matrices: Option>, colors: Option>, custom: Option>) -> Result<()> { let o = self.objects.get(&id).ok_or_else(|| anyhow!("unknown object {id}"))?; let Kind::Instanced { capacity, .. } = o.kind else { return Err(anyhow!("object {}: not an instanced mesh", o.name)) }; @@ -929,7 +929,7 @@ impl World { k += 1; } // A lone object with its own BLAS, an instanced mesh or a static model is removed in place (or never added, if it - // came in this batch; live.rs `edit_geometry` rebuilds for what it cannot take out). + // came in this batch; live/edits.rs `edit_geometry` rebuilds for what it cannot take out). if let [only] = doomed[..] { if self.objects.get(&only).is_some_and(|o| own_blas(&o.kind) || matches!(o.kind, Kind::Instanced { procedural: false, .. } | Kind::Model { animation: None, .. })) { self.instanced.remove(&only); diff --git a/tests/render/geometry-added-live.ts b/tests/render/geometry-added-live.ts index 6c36a8f..74dfd52 100644 --- a/tests/render/geometry-added-live.ts +++ b/tests/render/geometry-added-live.ts @@ -1,4 +1,4 @@ -// An object added in place is drawn (live.rs `edit_geometry`; ROADMAP § 6 step 5): a scene of one `dynamicGeometry` +// An object added in place is drawn (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): a scene of one `dynamicGeometry` // speck far away (its TLAS one instance) gets a `dynamicGeometry` plane across the centre at frame 3: Lambert 0.5 facing // a uniform 1-nit sky, so 0.5. Before, the plane's new BLAS and the TLAS were built apart in one encoder, and Metal, // which the TLAS reaches its BLASes by resource id through, could build the TLAS first: the plane was missing in most diff --git a/tests/render/geometry-edit-live.ts b/tests/render/geometry-edit-live.ts index 7c58d98..a5e4458 100644 --- a/tests/render/geometry-edit-live.ts +++ b/tests/render/geometry-edit-live.ts @@ -1,4 +1,4 @@ -// Geometry edits applied in place (live.rs `edit_geometry`; ROADMAP § 6 step 2c-4): after a few frames the frame script +// Geometry edits applied in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 2c-4): after a few frames the frame script // removes a procedural box that hid the centre, swaps a `dynamicGeometry` mesh's tiny plane for a 4 m one across the // centre, and adds a procedural box and a `dynamicGeometry` mesh behind it (with the materials already in the scene: a // new material rebuilds the scene). The centre then shows the swapped plane: diff --git a/tests/render/geometry-edit-move-live.ts b/tests/render/geometry-edit-move-live.ts index 6393f7d..8cd3855 100644 --- a/tests/render/geometry-edit-move-live.ts +++ b/tests/render/geometry-edit-move-live.ts @@ -1,4 +1,4 @@ -// An object added in place, then moved (live.rs `commit`: objects added in place are not in the move tables, so the +// An object added in place, then moved (live/edits.rs `commit`: objects added in place are not in the move tables, so the // move rebuilds the scene). After a few frames the frame script adds a procedural box beside the view, a few frames // later it moves the box across the centre: Lambert 0.5 facing the camera under a uniform sky of 1 nit, nothing else in // its hemisphere, so 0.5. Left where it was added, the centre would show the sky (1). diff --git a/tests/render/geometry-emitter-added-live.ts b/tests/render/geometry-emitter-added-live.ts index 13499de..5f0c948 100644 --- a/tests/render/geometry-emitter-added-live.ts +++ b/tests/render/geometry-emitter-added-live.ts @@ -1,4 +1,4 @@ -// Emitting geometry added in place (live.rs `edit_geometry`; ROADMAP § 6 step 3): after a few frames the frame script +// Emitting geometry added in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 3): after a few frames the frame script // adds a `dynamicGeometry` panel of an emissive material (1 nit, black) 1 m above a Lambert 0.5 floor, so large it // fills the floor's hemisphere. Its emitter entries come in place (no rebuild), and the floor reads 0.5 x 1 = 0.5. // The material is in the scene from the start (a speck far away): a new material rebuilds. diff --git a/tests/render/geometry-emitter-removed-live.ts b/tests/render/geometry-emitter-removed-live.ts index 747ece5..b23f755 100644 --- a/tests/render/geometry-emitter-removed-live.ts +++ b/tests/render/geometry-emitter-removed-live.ts @@ -1,4 +1,4 @@ -// Emitting geometry removed in place (live.rs `edit_geometry`; ROADMAP § 6 step 3): a `dynamicGeometry` panel of an +// Emitting geometry removed in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 3): a `dynamicGeometry` panel of an // emissive material lights a white Lambert floor; after a few frames the frame script removes it. Its emitter entries // go in place (no rebuild): the floor turns black, against an unlit patch of colour 1 beside it (MeshBasicMaterial, // lighting nothing): floor / patch = 0. diff --git a/tests/render/geometry-grow-live.ts b/tests/render/geometry-grow-live.ts index 10a8f96..1a3ff36 100644 --- a/tests/render/geometry-grow-live.ts +++ b/tests/render/geometry-grow-live.ts @@ -1,4 +1,4 @@ -// In-place edits past the room reserved at upload (live.rs `edit_geometry`; ROADMAP § 6 step 5): at frame 3 the frame +// In-place edits past the room reserved at upload (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): at frame 3 the frame // script adds 1,100 `dynamicGeometry` specks far away (more records and object entries than the room's 1,024) and a // 72,200-triangle plane across the centre (more triangles than its 65,536). The room grows (buffers and the TLAS // doubled, copied on the GPU), nothing rebuilds, and the plane reads 0.5: Lambert 0.5 under a uniform 1-nit sky. diff --git a/tests/render/geometry-instanced-added-live.ts b/tests/render/geometry-instanced-added-live.ts index a9b6b52..717fe57 100644 --- a/tests/render/geometry-instanced-added-live.ts +++ b/tests/render/geometry-instanced-added-live.ts @@ -1,4 +1,4 @@ -// An InstancedMesh added in place (live.rs `edit_geometry`; ROADMAP § 6 step 5): a scene of one `dynamicGeometry` +// An InstancedMesh added in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): a scene of one `dynamicGeometry` // speck gets an InstancedMesh of two planes at frame 3 (its records, TLAS instances and object entries from the room, // contiguous, as the build places one), one across the centre: Lambert 0.5 under a uniform 1-nit sky, so 0.5. A // later move of an instance (setMatrixAt) applies in place too. diff --git a/tests/render/geometry-instanced-live.ts b/tests/render/geometry-instanced-live.ts index c5958bb..237d6a8 100644 --- a/tests/render/geometry-instanced-live.ts +++ b/tests/render/geometry-instanced-live.ts @@ -1,8 +1,8 @@ -// InstancedMesh instances changed in place (live.rs `update_instanced`; ROADMAP § 6 step 2e): two copies of a dark +// InstancedMesh instances changed in place (live/edits.rs `update_instanced`; ROADMAP § 6 step 2e): two copies of a dark // cover hide the centre of a plane, a third waits behind it. After a few frames the frame script moves the first // cover behind the plane (`setMatrixAt`, `needsUpdate`), recolours it, and lowers `count` to hide the last one; the // second is hidden by a matrix scaled to 0. In the same frame a `dynamicGeometry` mesh off to the side gets a new -// geometry: the instances' TLAS changes then ride on that edit's TLAS build (live.rs `commit`). The centre then shows +// geometry: the instances' TLAS changes then ride on that edit's TLAS build (live/edits.rs `commit`). The centre then shows // the plane: Lambert, albedo 0.5, facing a uniform sky of 1 nit with nothing in its hemisphere, so 0.5. The same frames // with THREERT_EDIT_REBUILD=1 (every change rebuilds the scene) are byte-identical (the PR's A/B). import { Color, InstancedMesh, Matrix4, Mesh, PlaneGeometry } from "@threert/scene"; diff --git a/tests/render/geometry-instanced-removed-live.ts b/tests/render/geometry-instanced-removed-live.ts index db76842..75bd99f 100644 --- a/tests/render/geometry-instanced-removed-live.ts +++ b/tests/render/geometry-instanced-removed-live.ts @@ -1,4 +1,4 @@ -// An InstancedMesh removed in place (live.rs `edit_geometry`; ROADMAP § 6 step 5): its two planes (Lambert 0.2) hide a +// An InstancedMesh removed in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): its two planes (Lambert 0.2) hide a // `dynamicGeometry` plane (Lambert 0.5) behind them; at frame 3 the InstancedMesh is removed, its TLAS instances taken // out without a rebuild, and the centre shows the plane behind: 0.5 under a uniform 1-nit sky. import { InstancedMesh, Matrix4, Mesh, PlaneGeometry } from "@threert/scene"; diff --git a/tests/render/geometry-model-added-live.ts b/tests/render/geometry-model-added-live.ts index 0222ff5..8ed5a55 100644 --- a/tests/render/geometry-model-added-live.ts +++ b/tests/render/geometry-model-added-live.ts @@ -1,4 +1,4 @@ -// A glTF model added in place (live.rs `edit_geometry`; ROADMAP § 6 step 5): a static model already in the scene (a +// A glTF model added in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): a static model already in the scene (a // grey quad, tests/render/data/quad.glb, on the left) gets a second copy on the right at frame 3, another instance of // its mesh, without a rebuild. Both under a uniform 1-nit sky: the right reads as the left, ratio 1. import { GLTFModel } from "@threert/scene"; diff --git a/tests/render/geometry-vertex-colors-live.ts b/tests/render/geometry-vertex-colors-live.ts index 65993f2..c146afd 100644 --- a/tests/render/geometry-vertex-colors-live.ts +++ b/tests/render/geometry-vertex-colors-live.ts @@ -1,4 +1,4 @@ -// Vertex colours on geometry added in place (live.rs `edit_geometry`; F_VCOLOR): a scene with vertex colours from the +// Vertex colours on geometry added in place (live/edits.rs `edit_geometry`; F_VCOLOR): a scene with vertex colours from the // start (a coloured speck far away, `dynamicGeometry`) gets material/vertex-colors' quad at frame 3, in place; its // colours are written into the edit room with its triangles, and its centre reads 0.5 as when built. (A plain mesh far // below too: in a scene whose TLAS held one instance, an object added in place is not drawn; ROADMAP Known issues.) diff --git a/tests/render/material-emitter-started-live.ts b/tests/render/material-emitter-started-live.ts index d220f27..19ac99a 100644 --- a/tests/render/material-emitter-started-live.ts +++ b/tests/render/material-emitter-started-live.ts @@ -1,4 +1,4 @@ -// A material that starts emitting in place (tables.rs `can_emit`, live.rs `add_material_emitters`; ROADMAP § 6 +// A material that starts emitting in place (tables.rs `can_emit`, live/edits.rs `add_material_emitters`; ROADMAP § 6 // step 3): a panel 1 m above a Lambert 0.5 floor, filling its hemisphere, of a black material that emits nothing at // first; after a few frames the frame script sets its emission to 1 nit. Its triangles get emitter entries in place // (no rebuild), and the floor reads 0.5. diff --git a/tools/bench-voxel.ts b/tools/bench-voxel.ts index 70bc004..6ec8a6c 100644 --- a/tools/bench-voxel.ts +++ b/tools/bench-voxel.ts @@ -112,7 +112,7 @@ async function measure(configs: Config[], what: string) { } if (args.includes("--edits")) { - // One object's geometry replaced every half second (tools/perf/voxel-level*-edits.game.ts), in place (live.rs + // One object's geometry replaced every half second (tools/perf/voxel-level*-edits.game.ts), in place (live/edits.rs // `edit_geometry`) against a scene rebuild (THREERT_EDIT_REBUILD=1), alternated; camera still at the path's start. // Per edit frame from the frame log: the edit's CPU ms (in place) or the rebuild's, and that frame's interval. const b = branchBuild(rendererBin); diff --git a/tools/render-identity.ts b/tools/render-identity.ts index 17a3486..b27fa2c 100644 --- a/tools/render-identity.ts +++ b/tools/render-identity.ts @@ -5,10 +5,14 @@ // twice the larger of the two builds' own run-to-run differences (the largest relative difference of a value, under a // floor of 1e-3). `--tolerance r` lets a difference up to r pass as rounding (a shader compiled from other source can // round differently: 2e-6 is what removing unused kernel code did, 2026-11-13); it is reported apart. Exit 1 when a test -// differs beyond these or its output is missing on one side. +// differs beyond these or its output is missing on one side. Two runs a side under-read the spread of a few tests +// (2026-11-13: a skinning test that repeats itself twice and then differs by 2 %), so what is flagged is run again, +// alone, `--confirm` times a side (default 4), and judged on those runs. A confirming run that leaves no output (a +// test that fails before its picture: geometry/grow-live's frame script sometimes runs past its time, 2026-11-13) is +// left out and counted; the test is judged on the runs that left one, and is changed if a side has none. // -// node tools/render-identity.ts --a --b [--runs 2] [--tolerance 0] [--env K=V,K=V] -// [--filter id] [--out out/render-identity/] +// node tools/render-identity.ts --a --b [--runs 2] [--confirm 4] [--tolerance 0] +// [--env K=V,K=V] [--filter id] [--out out/render-identity/] // // `--env` goes to both builds (THREERT_MERGED=1: the browser's binding layout, natively). Each run is test-render with // an output directory of its own (it holds the GPU lock as it always does); the runs of one build follow each other. @@ -29,22 +33,26 @@ if (!isFile(flag("--a")) || !isFile(flag("--b"))) { const a = resolve(flag("--a")), b = resolve(flag("--b")); const runs = Number(flag("--runs", "2")); const tolerance = Number(flag("--tolerance", "0")); +const confirmRuns = Number(flag("--confirm", "4")); const extra = Object.fromEntries(flag("--env").split(",").filter(Boolean).map((kv) => kv.split("=") as [string, string])); const filter = flag("--filter"); const out = resolve(flag("--out", join(repoRoot, "out/render-identity", new Date().toISOString().replace(/[:.]/g, "-")))); -/** One test-render run on `bin`, its outputs in `dir` (its own: nothing else writes there). */ -function run(bin: string, dir: string) { +/** One test-render run on `bin` (the tests `only` names, else all / `--filter`), its outputs in `dir` (its own: + * nothing else writes there). Whether it left an output; a run of the gate's own (not confirming) that leaves none + * is an error. */ +function run(bin: string, dir: string, only = filter, confirming = false): boolean { rmSync(dir, { recursive: true, force: true }); try { - execFileSync("node", [join(repoRoot, "tools/test-render.ts"), ...(filter ? [filter] : []), "--out", dir], { env: { ...process.env, ...extra, THREERT_BIN: bin }, stdio: ["ignore", "ignore", "inherit"] }); + execFileSync("node", [join(repoRoot, "tools/test-render.ts"), ...(only ? [only] : []), "--out", dir], { env: { ...process.env, ...extra, THREERT_BIN: bin }, stdio: ["ignore", "ignore", "inherit"] }); } catch { // (Failing tests still leave their outputs; what is compared here is the pictures, not the verdicts.) } const n = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".raw")).length : 0; - if (n === 0) { + if (n === 0 && !confirming) { console.error(`render-identity: test-render left no outputs in ${dir} (${bin})`); process.exit(2); } + return n > 0; } const dirs = { a: [] as string[], b: [] as string[] }; @@ -77,32 +85,46 @@ const spread = (ds: string[], f: string) => { return m; }; +/** One output across the runs: byte-identical, within the runs' spread, rounding (under --tolerance), or changed. */ +function judge(f: string, a: string[], b: string[]): { verdict: "same" | "within" | "rounding" | "changed"; why: string } { + const all = [...a, ...b]; + if (!all.every((d) => existsSync(join(d, f)))) return { verdict: "changed", why: `missing in ${all.filter((d) => !existsSync(join(d, f))).map((d) => d.split("/").pop()).join(", ")}` }; + const between = Math.max(...a.flatMap((x) => b.map((y) => diff(join(x, f), join(y, f))))); + if (between === 0) return { verdict: "same", why: "" }; + const noise = Math.max(spread(a, f), spread(b, f)); + if (noise > 0 && between <= 2 * noise) return { verdict: "within", why: `run to run ${noise.toExponential(2)}, between ${between.toExponential(2)}` }; + if (between <= tolerance) return { verdict: "rounding", why: `${between.toExponential(2)}, under --tolerance` }; + return { verdict: "changed", why: `${noise === 0 ? "repeatable, and" : `run to run ${noise.toExponential(2)},`} between the builds ${between.toExponential(2)}` }; +} + const names = new Set([...dirs.a, ...dirs.b].flatMap((d) => readdirSync(d).filter((f) => f.endsWith(".raw")))); -let same = 0, within = 0, rounding = 0; +const count = { same: 0, within: 0, rounding: 0, changed: 0 }; const changed: string[] = []; for (const f of [...names].sort()) { - const all = [...dirs.a, ...dirs.b]; - if (!all.every((d) => existsSync(join(d, f)))) { - changed.push(`${f}: missing in ${all.filter((d) => !existsSync(join(d, f))).map((d) => d.split("/").pop()).join(", ")}`); - continue; - } - const between = Math.max(...dirs.a.flatMap((x) => dirs.b.map((y) => diff(join(x, f), join(y, f))))); - if (between === 0) { - same++; - continue; - } - const noise = Math.max(spread(dirs.a, f), spread(dirs.b, f)); - if (noise > 0 && between <= 2 * noise) { - within++; - continue; - } - if (between <= tolerance) { - rounding++; - console.log(` ${f}: ${between.toExponential(2)} (rounding, under --tolerance)`); - continue; + let j = judge(f, dirs.a, dirs.b); + if (j.verdict === "changed" && confirmRuns > 0) { + // Run that test alone, `confirmRuns` times a side, and judge on those runs. + const test = f.replace(/\.raw$/, ""); + console.log(`render-identity: ${f} flagged (${j.why}); ${confirmRuns} more runs a side of it alone`); + const again = { a: [] as string[], b: [] as string[] }; + let lost = 0; + for (const side of ["a", "b"] as const) { + for (let k = 0; k < confirmRuns; k++) { + const dir = join(out, "confirm", test, `${side}${k + 1}`); + if (run(side === "a" ? a : b, dir, test, true)) again[side].push(dir); + else lost++; + } + } + const left = lost ? ` (${lost} of ${2 * confirmRuns} runs left no output)` : ""; + if (!again.a.length || !again.b.length) j = { verdict: "changed", why: `no output on side ${again.a.length ? "b" : "a"} in ${confirmRuns} runs` }; + else j = judge(f, again.a, again.b); + if (j.verdict !== "changed") j.why = `confirmed on ${again.a.length} / ${again.b.length} runs${left}: ${j.why || "byte-identical"}`; + else j.why += left; } - changed.push(`${f}: ${noise === 0 ? "repeatable, and" : `run to run ${noise.toExponential(2)},`} between the builds ${between.toExponential(2)}`); + count[j.verdict]++; + if (j.verdict === "rounding" || (j.verdict === "within" && j.why.startsWith("confirmed"))) console.log(` ${f}: ${j.why}`); + if (j.verdict === "changed") changed.push(`${f}: ${j.why}`); } -console.log(`render-identity: ${names.size} outputs, ${same} byte-identical, ${within} within their run-to-run spread, ${rounding} within --tolerance, ${changed.length} changed (${out})`); +console.log(`render-identity: ${names.size} outputs, ${count.same} byte-identical, ${count.within} within their run-to-run spread, ${count.rounding} within --tolerance, ${count.changed} changed (${out})`); for (const c of changed) console.log(` ${c}`); process.exit(changed.length ? 1 : 0);