Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>`
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
Expand Down
8 changes: 4 additions & 4 deletions docs/RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`. |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/VOXEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion games/tokyo/tokyo-tod.game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
}
Expand Down
2 changes: 1 addition & 1 deletion games/voxel-level/level.game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading