Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7a81145
[feat] 33 L1 P0: scene-load trace probe (long tasks, frame gaps, prof…
AlexZ005 Oct 1, 2026
6333b43
[feat] 33 L1 P1: progressive scene loads - time-sliced build, kit pie…
AlexZ005 Oct 1, 2026
a7d4cee
[feat] 33 L1 P2: the scene-load bar - "Loading <scene> - n / N object…
AlexZ005 Oct 1, 2026
942e3eb
[feat] 33 L1 P3: Restore without freezing - kit pieces autosave as st…
AlexZ005 Oct 1, 2026
24fe36b
[feat] 33 lod groups: pack lods, a LOD Group in object settings, VR f…
AlexZ005 Oct 1, 2026
78d42bc
[feat] 33 P2: animated, functional pack items (door/toggle/oneshot/lo…
AlexZ005 Oct 1, 2026
a7c9991
[docs] 33 P3: PACKS.md behavior field + example item, MODULES.md api.…
AlexZ005 Oct 1, 2026
1fe7c08
[fix] 33 L1: the long tasks a load still had - object list rows, comp…
AlexZ005 Oct 1, 2026
477624f
[fix] 33 L1: warmTemplate keeps its JSDoc (svelte-check back to 332)
AlexZ005 Oct 1, 2026
81d4ccf
[test] 33 lod groups: the battery is green; real pack LOD files throu…
AlexZ005 Oct 1, 2026
c6a6a25
[fix] 33 L1: programs link off-frame - sliced warmPrograms + a bounde…
AlexZ005 Oct 1, 2026
a3c93a7
[fix] 33 L1: a load applies the scene's look first and lets two near-…
AlexZ005 Oct 1, 2026
5ea1d26
[fix] 33 L1: the look in two steps, the governor sits out a load, the…
AlexZ005 Oct 1, 2026
ea7d312
[fix] 33 P2: a functional door is never the sim's fallback body; firs…
AlexZ005 Oct 1, 2026
959b8e3
[fix] 33 L1: the look's re-keyed programs link off-frame; no geometry…
AlexZ005 Oct 1, 2026
25452f7
[fix] 33 L1: a load waits for a program warm-up at most 1.2 s (sceneL…
AlexZ005 Oct 2, 2026
0a91461
[fix] 33 L1: the composer's pass shaders compile off-frame; a heavier…
AlexZ005 Oct 2, 2026
7103d01
[fix] 33 L1: one slice budget across pump runs; the program warm-up c…
AlexZ005 Oct 2, 2026
55a1f8a
[test] 33 L1: section 7 parses real vertex data per object (1000 icos…
AlexZ005 Oct 2, 2026
9fb06a2
[docs] CLAUDE.md: the sceneLoader entry as shipped (33 L1)
AlexZ005 Oct 2, 2026
7c0de89
scratch: merge lod-editor (resolved copies)
AlexZ005 Oct 2, 2026
e7a965f
scratch: merge scene-load (union)
AlexZ005 Oct 2, 2026
62f65b0
[feat] 33 scenes P1: kit instancing - every pristine copy of a pack p…
AlexZ005 Oct 2, 2026
849cc6b
[feat] 33 scenes P2a: animated pack pieces save as references; the au…
AlexZ005 Oct 2, 2026
249b6d0
[feat] 33 scenes P1b: kit instancing culls per member; 128 m columns
AlexZ005 Oct 2, 2026
f257f82
[fix] 33 scenes: the walker passes sensors; a garden gate opens to th…
AlexZ005 Oct 2, 2026
056667d
[feat] 33 scenes K5: the Tavern gets working doors, furniture and a s…
AlexZ005 Oct 2, 2026
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
Empty file added .lane-ready
Empty file.
41 changes: 41 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,47 @@ loadable play content. Everything a user does must be visible to connected peers
not-on-this-device cards) routed through `travelToPeerScene` (the guarded Go-to path). The
invite link carries NO scene hint — the link IS the session, where the host stands is presence.
Suite `session-scenes` (36, three peers).
- `src/lib/sceneLoader.js` (33 L1, a LEAF: svelte/store only) — A SCENE LOAD MAY TAKE TIME; IT
MAY NOT TAKE THE WINDOW. Measured on a CPU x6 phone (scripts/scene-load-trace.cjs): Restore of
Castle Courtyard was ONE 89 s task (`applyRestore` re-serialized every child with `toJSON()` for
the wire — textures as PNG data URLs — solo or not) on a 51 MB full-geometry snapshot (Forest's
was over the 50 MB cap: no crash recovery at all), and a template open froze 2 s (525 ms tasks).
Now every task of those loads is under 200 ms, asserted by suite `scene-load`.
· `slice(job)` = a cooperative yield for a LOOP (10 ms, throws `LoadCancelled` between two
objects); `schedule(fn)` = the same budget for queued sync work. **ONE SHARED CLOCK**: a loop that
awaits one scheduled item at a time starts a fresh pump per item through microtasks, and with a
per-run budget the whole loop was one task (an 881 ms warm-up). `sceneLoad` = the ONE job
`{name, verb, total, done, phase: preparing|reading|objects|models, cancellable, interrupted}`,
drawn by `menu/SceneLoadBar.svelte` (toast stack's first slot, after 250 ms, CSS entrance — a
svelte `fly` reads getComputedStyle = a whole-document layout mid-load; `#scene-load-bar`
data-done/total/phase, `#scene-load-cancel`). A new load SUPERSEDES the running one (no hooks;
every await in `applySession`/`applyRestore` re-checks `isLive(job)`); Cancel (also during
`models`) runs the load's `onCancel` (sessions: replicated clear + a toast naming the Backup;
restore: clear + RE-OFFER). A load interrupting one mid-BUILD writes no "Backup before".
· THE ORDER OF A LOAD (sessions + autosave): clear -> environment -> `holdFrames()` (Outline
skips its render; bounded 2 s) + `warmPrograms(scene)` (the fog/light change re-keys every
existing program) -> post stack + `warmComposer()` (Outline registers it: every pass's
fullscreen scene compiled against the target it draws into) -> release + `nextFrames(2)` ->
hold -> the sliced build -> `warmPrograms(objectsGroup)` -> release. Warm-ups are awaited at most
`WARM_WAIT_MS` (`within`): software GL links in hundreds of ms and a load must never wait on the
driver. "Session loaded" waits for the kit models.
· `packRefs.warmPrograms`: compile in BATCHES of throwaway twins sharing each mesh's geometry +
material (`compile` walks the whole scene for lights per call — per mesh was quadratic), canvas
AND render-target variants (three keys a program by tone mapping + output colour space), then
each program's FIRST USE (`getUniforms`) per slice: without KHR_parallel_shader_compile that read
WAITS for the link, and it was the cost left in the first frames. `warmTemplate` = initTexture
per slice + warmPrograms. Refill attach through `schedule`; refills poke at most every 200 ms;
`packRef.box` (root-frame bounds, additive) drawn as ONE scene-root InstancedMesh of grey
`kit-placeholders` while a piece is on its way.
· **The autosave writes pristine kit pieces as STUBS** (`parkPackPieces`, reverses 30c's
"autosave stays full"): hollowed for the GLTF export and put back on the exporter's `afterParse`
hook (once the tree is READ, before the async encode lets a frame draw a hollow castle;
`parkedRoots` keeps a scan from refilling one mid-export). The fingerprint is NOT cached — a
version-keyed cache read an in-place vertex move as pristine (pack-refs caught it: an edit
lost on reload). The restore sends a wire copy only with an OPEN peer, stubs for pristine pieces.
· Elsewhere: the object list's plain tree mounts in chunks (Controls.svelte, 40 + 16/frame);
`qualityGovernor` ignores frames while a load runs (its setPixelRatio->setSize was a 432 ms
task); the debounced autosave waits out a load (`saveNow` does not).
- `src/lib/flowLayout.js` + `src/lib/coalesce.js` (R29 S1/S2, both LEAVES, vitest-covered):
`freeRegion({w,h,graphId})` is the ONE placement rule for anything that authors nodes on the
user's behalf (`hudActions.addBinding` calls it, side 'right', byte-identical); the SDK
Expand Down
35 changes: 35 additions & 0 deletions MODULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,10 +502,21 @@ if (api.quality) {
if (api.lod) {
const lod = api.lod(enemyFigure, { ratios: [0.5, 0.2], distances: [6, 18] });
lod.ready.then((n) => console.log(n, "meshes have levels")); // lod.remove() undoes it
lod.force?.(2); // 1.19: draw level 2 on THIS screen whatever the distance (null = auto)
lod.levels?.(); // 1.19: the level each mesh drew last (0 = full)
}
mesh.userData.lod = false; // keep one mesh out of auto LOD
```

**1.19 — LOD groups on scene objects.** A replicated object may carry a LOD GROUP
(`userData.lod`, edited in its Properties ▸ LOD section, or written by a pack item's `lods`):
`{mode: 'auto'|'forced', forced?, bias?, cull?, levels: [{source: 'self'|'pack'|'generated'|
'explorer'|'object', ref?, ratio?, screenSize, offset?, material?}]}` with thresholds by SCREEN
SIZE (the share of the viewport height the object covers). It is scene data (saved, replicated,
undone) and drawn at render time like `api.lod` — the tree never holds a level. A module that
builds scene objects can write one through the same path the panel uses
(`objectParameters {parameter: 'lod'}` is the wire shape); module-only geometry keeps `api.lod`.

Both are LOCAL (a fact about this machine) — never let them change replicated state, or two
peers on different hardware disagree about the game. Skinned meshes and morph targets get no
levels (the simplifier cannot carry weights): pre-decimate those offline.
Expand Down Expand Up @@ -536,6 +547,30 @@ The things 1.18's round found, in the order they cost:
- **Lights and shadow casters are draw calls**: each shadow-casting light draws every caster
again; transmission (glass) renders the scene an extra time per camera, per eye.

### Functional pack items: doors, lids, levers — `api.behavior` (1.19, roadmap 33)

A pack item can be FUNCTIONAL: a door that opens on a click, a chest lid, a lever, a fan
(the `behavior` field, see PACKS.md). Core runs them. Nothing plays in Edit; a click, a
knock or walking up triggers them in Interact/Play; the state replicates; a door's
collider follows its leaf. A module can drive them too:

```js
if (api.behavior) {
for (const item of api.behavior.list()) { // [{uuid, type, trigger, open}]
if (item.type === 'door' && !item.open) api.behavior.trigger(item.uuid, true); // open it
}
api.behavior.state(uuid); // {on, at, n} or null (never triggered)
api.behavior.trigger(uuid); // toggle, like a player's click
}
```

`trigger` is REPLICATED (one `behavior` message; every peer poses the door from the same
session-clock stamp), so call it on ONE peer: the authority, or the peer that saw the
cause. It returns false when nothing changed (opening an open door). The state is
runtime: it is never saved, and a peer in Edit shows the rest pose whatever it says. The
item sounds are core game sounds: `door`, `gate`, `slide`, `lever`, `lid` (plus `click`),
which `api.playSound` can play too.

### Game feel: sound, music, haptics, effects, banners (1.17, roadmap 30b)

Everything here is LOCAL to the device it runs on — broadcast your own op
Expand Down
79 changes: 79 additions & 0 deletions PACKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,85 @@ CC0, OpenGameArt CC0). Keep each file under the 5 MB share cap so it round-trips
peers. Installed audio items appear in the Explorer library and can be assigned to
a **Sound** node (spatial) or the **Scene music** channel (global).

### Levels of detail: the `lods` field (contract P1, 1.19)

A model item in an item list (the `default.json` row beside `variants`) MAY list offline
LOD files:

```jsonc
{ "name": "WallStone", "label": "Wall — sandstone (2 × 3 m)",
"variants": { "glTF-Binary": "wall-stone.glb" },
"lods": [ { "file": "wall-stone.lod1.glb", "ratio": 0.5 },
{ "file": "wall-stone.lod2.glb", "ratio": 0.2 } ] }
```

- `file` is relative to the item's `glTF-Binary/` folder (no `..`, no absolute URLs);
`ratio` is the level's triangle count as a share of LOD0 (informational — the app sorts
levels finest first by it and shows it in the LOD panel).
- **Keep the node names and hierarchy of LOD0.** The app matches each level mesh to the
placed object's mesh BY NODE NAME (falling back to traverse order when both have the same
mesh count) and draws the level's GEOMETRY with the object's OWN material — so a level
file's materials and textures are not used at all (ship them shared or tiny), and an
animated item keeps animating, because the mixer still drives the same nodes.
- Bake the same node transforms as LOD0 where you can; a level mesh placed differently is
re-expressed in LOD0's frame on load, which costs a geometry copy per placement.
- Placing an item writes its group into the object (`userData.lod`, refs as
PACKS_BASE-relative paths), so it saves, replicates and undoes with the object. A piece
placed BEFORE its pack gained `lods` picks them up from the pack row at runtime.
- Levels are fetched LAZILY — only when an object is small enough on screen to need one —
and parsed once per file for every placement. Items without `lods` keep the automatic
runtime LOD (meshoptimizer, for meshes of 3000+ triangles).

### Animated, functional items — `behavior` (roadmap 33, P2)

An item row in a default pack's item list (`default.json`) MAY carry a `behavior`: a
door you click open, a chest lid, a lever, a fan. The GLB holds the clips; the row says
how they are used:

```jsonc
{
"name": "WoodDoor", "label": "Wooden door", "screenshot": "screenshot/screenshot.webp",
"variants": { "glTF-Binary": "WoodDoor.glb" },
"behavior": {
"type": "door", // door | toggle | oneshot | loop
"clip": "open", // the clip a trigger plays (its time 0 is the REST pose)
"closeClip": "close", // optional (door/toggle): played to close; absent = `clip` backwards
"trigger": "click", // click | proximity | knock
"autoplay": false, // honoured ONLY for type "loop" (ambient: a fan, a flag, a flame)
"sound": "door", // optional: a game-sound name (door gate slide lever lid click …)
"collider": "follow" // optional; the default for a door
}
}
```

**The rule.** Nothing plays on placement, on load, or in Edit, and nothing loops unless
it is a `loop` with `autoplay: true`. A peer in Edit always sees the rest pose. A trigger
works in Interact or Play: `door`/`toggle` alternate open ⇄ closed, `oneshot` plays once
per trigger, and a `loop` without autoplay starts and stops. The Animation panel previews
a clip in Edit, locally: nothing is sent or saved, and the item goes back to rest.

- **Triggers.** `click` is the desktop click, the Play tap, and the VR laser, trigger and
poke. `proximity` opens when the player comes within 1.5 m and closes when they leave
(a click still works). `knock` is a VR hand moving into the part at ≥ 0.6 m/s (on a
desktop, a click).
- **Shared state.** One `behavior` message per trigger, stamped with the session clock.
Every peer derives the pose from the stamp, so a door swings in step everywhere, and a
late joiner receives the state with the door. The state is runtime only: a scene saved
with a door open reopens shut.
- **Frame and moving parts.** A door ALWAYS ships with its frame. The nodes the
behavior's clips animate are the MOVING parts (put the hinge pivot on the node's origin);
everything else is the static frame. With `collider: "follow"` the frame becomes slab
colliders with the opening cut out, and each moving part gets a box that follows it, so
you can walk through an open door and are stopped by a shut one (while a simulation
runs; that is when the walker collides at all).
- **Authoring the GLB.** Use LINEAR TRS tracks on the moving nodes, with `clip` at time 0
being the closed pose. A static `idle` clip FIRST is harmless and keeps older app
builds (which autoplayed the first clip) still. `lods` files must keep the same node
names, hierarchy and clips. The same `behavior` object may also sit in the GLB's scene
extras (`scene.extras.behavior`); the pack row wins over it.
- The Explorer marks such items with a small ▶ "animated" badge that says what they do
and what sets them off.

## Repo / .zip structure

```
Expand Down
49 changes: 48 additions & 1 deletion scripts/author-templates.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@
// ([x,y,z] or a number) — a PACK PIECE as a reference (30c packRefs.js): written
// as a stub the app refills from PACKS_BASE, so a level of 100 pieces stays small.
// Kept TOP-LEVEL (physics only reads top-level objects); `physics` sets its
// collider (custom compound boxes/wedges for doorways, stairs, trees)
// collider (custom compound boxes/wedges for doorways, stairs, trees).
// 33-scenes: an item whose pack row carries a `behavior` (a door, a chest, a
// lever — contract P2) is placed through importFile like an Explorer drop: an
// animated import with the row's behavior + LOD group, saved as an `animRef`
// MATERIAL (every mesh type): color, roughness (0.85), metalness (0), emissive +
// emissiveIntensity (1), opacity (< 1 → transparent), flatShading, side ('double' | 'back'),
// toon (MeshToonMaterial), physical (MeshPhysicalMaterial — also implied by any of:
Expand Down Expand Up @@ -2184,6 +2187,10 @@ const DEFS = [
// own item list (default.json) on PACKS_BASE, once per pack, before anything builds
/** @type {Record<string, Record<string, string>>} */
const kitFiles = {};
// 33-scenes: the item rows too — a row with a `behavior` (a door, a chest, a lever:
// contract P2) is an ANIMATED piece, placed through the Explorer's own import path
/** @type {Record<string, Record<string, any>>} */
const kitRows = {};
/** @param {any[]} list @param {Set<string>} sink */
const kitPacks = (list, sink) => {
for (const o of list ?? []) {
Expand All @@ -2197,9 +2204,11 @@ const DEFS = [
const res = await fetch(base + '/' + pack + '/default.json');
if (!res.ok) throw new Error('kit: pack "' + pack + '" is not served at ' + base + ' (HTTP ' + res.status + ')');
kitFiles[pack] = {};
kitRows[pack] = {};
for (const row of await res.json()) {
const file = row?.variants?.['glTF-Binary'];
if (row?.name && file) kitFiles[pack][row.name] = pack + '/' + row.name + '/glTF-Binary/' + file;
if (row?.name) kitRows[pack][row.name] = row;
}
}
/** @type {any} */
Expand Down Expand Up @@ -2495,6 +2504,8 @@ const DEFS = [
object.updateMatrix();
return object;
};
/** @type {any[]} */
const animatedKits = [];
for (const o of d.objects) {
if (o.type === 'mirror') {
const src = d.objects.find((/** @type {any} */ x) => x.name === o.of);
Expand All @@ -2508,8 +2519,44 @@ const DEFS = [
group.add(ghost);
continue;
}
// 33-scenes: a FUNCTIONAL kit piece (its row carries a `behavior`) is not a stub:
// an animated import is never a kit reference (fileHandler ignores packRef for it),
// so it is placed after the static build through importFile — the Explorer drop's
// own call, with the row's behavior and LOD group — and its bytes ride the file
if (o.type === 'kit' && kitRows[o.pack]?.[o.item]?.behavior) {
animatedKits.push(o);
continue;
}
group.add(build(o));
}
if (animatedKits.length) {
const base = String(s.packs.PACKS_BASE).replace(/\/+$/, '');
const { placementGroupFor } = await import('/src/lib/lodGroup.js');
for (const o of animatedKits) {
const row = kitRows[o.pack][o.item];
const url = base + '/' + kitFiles[o.pack][o.item];
const res = await fetch(url);
if (!res.ok) throw new Error('kit "' + o.name + '": ' + url + ' HTTP ' + res.status);
const uuid = await s.fileHandler.importFile(new File([await res.blob()], o.item + '.glb'), o.name, undefined, o.pos, undefined, {
// the reference makes the save NAME the pack file (animatedImports animRef)
// instead of carrying its bytes
packRef: { pack: o.pack, item: o.item, path: kitFiles[o.pack][o.item] },
lod: placementGroupFor(url, row.lods),
behavior: row.behavior
});
const root = uuid ? group.getObjectByProperty('uuid', uuid) : null;
if (!root) throw new Error('kit "' + o.name + '": the animated import did not land');
root.name = o.name;
if (o.rot) root.rotation.set(o.rot[0], o.rot[1], o.rot[2]);
if (o.scale != null) {
const k = Array.isArray(o.scale) ? o.scale : [o.scale, o.scale, o.scale];
root.scale.set(k[0], k[1], k[2]);
}
root.updateMatrix();
}
s.objectActions.deselectObject?.();
s.selectedObjects.set([]);
}
// 30c: refill every kit stub from its pack BEFORE anything measures the scene (the
// shadow fit below, the card) — and refuse to write a level whose pack is unreachable
if (kitPacks(d.objects, new Set()).size) {
Expand Down
Loading
Loading