diff --git a/.lane-ready b/.lane-ready new file mode 100644 index 00000000..e69de29b diff --git a/CLAUDE.md b/CLAUDE.md index 1b68eaa3..d46d4a3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/MODULES.md b/MODULES.md index 3c527c4d..9d0ebf9d 100644 --- a/MODULES.md +++ b/MODULES.md @@ -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. @@ -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 diff --git a/PACKS.md b/PACKS.md index 7152b904..1427e514 100644 --- a/PACKS.md +++ b/PACKS.md @@ -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 ``` diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index bba26b2b..1500a901 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -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: @@ -2184,6 +2187,10 @@ const DEFS = [ // own item list (default.json) on PACKS_BASE, once per pack, before anything builds /** @type {Record>} */ 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>} */ + const kitRows = {}; /** @param {any[]} list @param {Set} sink */ const kitPacks = (list, sink) => { for (const o of list ?? []) { @@ -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} */ @@ -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); @@ -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) { diff --git a/scripts/level-templates.cjs b/scripts/level-templates.cjs index 036bd415..1215865a 100644 --- a/scripts/level-templates.cjs +++ b/scripts/level-templates.cjs @@ -21,6 +21,14 @@ const A = 'architecture-kit'; const N = 'nature-kit'; const P = 'props-kit'; +// 33-scenes: the round-33 kits — furniture and trims (Interiors), the street (Town & Market), +// pieces that OPEN (Interactive: doors with their frames, chests, a lever, shutters — placed +// through the Explorer's own import path, see author-templates' kit branch) and the Wizard's +// Tower hero props (Arcane Study) +const I = 'interior-kit'; +const TK = 'town-kit'; +const X = 'interactive-kit'; +const AR = 'arcane-kit'; const PI = Math.PI; const H = PI / 2; @@ -455,13 +463,82 @@ const FOREST_DEF = { thumb: {} }; -// ---- 3. Tavern Interior ---------------------------------------------------------------- +// ---- shared: a 4 × 4 m cottage ------------------------------------------------------------- // -// A two-storey room 12 × 10 m: the HALL (x -6..2) rises the full 6 m with a BALCONY along its -// north wall, the KITCHEN (x 2..6) sits behind a partition with a doorway and has a LOFT above -// it that opens onto the balcony. An oak staircase climbs the west wall to the balcony; a bar -// runs under it; tables, benches, barrels and a hearth fill the rooms, and lanterns and wall -// torches light it warm. The front door is shut: you are inside. +// kit.md's cottage (architecture kit), centred on its own origin so it can be dropped and turned +// anywhere: walls on the lines x = ±2 and z = ±2, the doorway and a window on its FRONT (+Z, +// local), a window on its +X side, a gable roof with a chimney. `at` [x, z] and `yaw` (a multiple +// of 90°) place it; `door` is 'open' (the Interactive Kit's DoorWood — it opens), 'shut' (the +// architecture kit's static Door) or 'none'; `inside(add)` furnishes it in LOCAL coordinates. +/** + * @param {any[]} o the object list @param {(s: string) => string} n the namer + * @param {{at: number[], yaw?: number, name: string, plaster?: boolean, door?: 'open'|'shut'|'none', + * shutters?: boolean, inside?: (add: (pack: string, item: string, name: string, p: number[], yaw?: number, extra?: any) => void) => void}} c + */ +function cottage(o, n, c) { + const yaw = c.yaw ?? 0; + const cos = Math.cos(yaw); + const sin = Math.sin(yaw); + const [ox, oz] = c.at; + /** local -> world (three's rotation.y: x' = x cos + z sin, z' = -x sin + z cos) */ + const add = (/** @type {string} */ pack, /** @type {string} */ item, /** @type {string} */ name, /** @type {number[]} */ p, ry = 0, extra = {}) => + o.push(piece(pack, item, name, [ox + p[0] * cos + p[2] * sin, p[1], oz - p[0] * sin + p[2] * cos], ry + yaw, extra)); + const wall = c.plaster === false ? 'WallStone' : 'WallPlaster'; + const tag = c.name; + for (const [x, z] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) add(A, 'FloorWood', n(tag + ' floor'), [x, 0, z]); + // the front (+Z): the doorway at x -1, a window at x 1 + add(A, wall + 'Door', tag + ' doorway', [-1, 0, 2], 0, { physics: COLLIDERS.doorway }); + if (c.door === 'open') add(X, 'DoorWood', tag + ' door', [-1, 0, 2]); + else if (c.door !== 'none') add(A, 'Door', tag + ' door', [-1, 0, 2]); + add(A, wall + 'Window', n(tag + ' window wall'), [1, 0, 2]); + if (c.shutters) add(X, 'Shutters', n(tag + ' shutters'), [1, 0, 2]); + else add(A, 'Window', n(tag + ' window'), [1, 0, 2]); + // the back and the sides; a window on the +X side + for (const x of [-1, 1]) add(A, wall, n(tag + ' wall'), [x, 0, -2], PI); + add(A, wall, n(tag + ' wall'), [-2, 0, -1], -H); + add(A, wall, n(tag + ' wall'), [-2, 0, 1], -H); + add(A, wall, n(tag + ' wall'), [2, 0, 1], H); + add(A, wall + 'Window', n(tag + ' window wall'), [2, 0, -1], H); + add(A, 'Window', n(tag + ' window'), [2, 0, -1], H); + for (const [x, z] of [[-2, -2], [2, -2], [-2, 2], [2, 2]]) add(A, 'CornerPostStone', n(tag + ' corner'), [x, 0, z]); + for (const x of [-1, 1]) { + add(A, 'RoofSlope', n(tag + ' roof'), [x, 3, 1]); + add(A, 'RoofSlope', n(tag + ' roof'), [x, 3, -1], PI); + } + for (const x of [-2, 2]) { + add(A, wall + 'Gable', n(tag + ' gable'), [x, 3, 1], H); + add(A, wall + 'Gable', n(tag + ' gable'), [x, 3, -1], -H); + } + add(A, 'Chimney', tag + ' chimney', [1, 3.7, -1]); + c.inside?.(add); +} + +/** a street along X between `x0` and `x1` (tile centres every 2 m), its near sidewalk's inner + * edge on `z0`: sidewalk / curb / road / curb / sidewalk (town-kit kit.md's layout), with an + * optional pedestrian crossing at `crossAt` @param {any[]} o @param {(s: string) => string} n + * @param {{x0: number, x1: number, z0: number, crossAt?: number}} s */ +function street(o, n, s) { + for (let x = s.x0; x <= s.x1; x += 2) { + o.push(piece(TK, 'Sidewalk', n('Sidewalk'), [x, 0, s.z0 + 1])); + o.push(piece(TK, 'RoadCurb', n('Curb'), [x, 0, s.z0 + 3], PI)); + o.push(piece(TK, x === s.crossAt ? 'RoadCrossing' : 'Road', n('Road'), [x, 0, s.z0 + 5])); + o.push(piece(TK, 'RoadCurb', n('Curb'), [x, 0, s.z0 + 7])); + o.push(piece(TK, 'Sidewalk', n('Sidewalk'), [x, 0, s.z0 + 9])); + } +} + +// ---- 3. Tavern Interior (the architecture shell) --------------------------------------- +// +// A two-storey tavern 12 × 10 m on a market street: the HALL (x -6..2) rises the full 6 m with +// a BALCONY along its north wall, the KITCHEN (x 2..6) sits behind a partition and has a LOFT +// above it that opens onto the balcony. An oak staircase climbs the west wall to the balcony. +// 33-scenes: the shell's doors WORK — the front door (DoorWood) and the kitchen door +// (DoorStudded) open with a click in Interact or Play, the street window's shutters close, the +// loft chest and drawers open — and it is furnished from the Interiors kit (a bar counter and +// back-bar under the balcony, a stone fireplace, a tavern table set and a dining set, a +// chandelier, a kitchen run, a double bed and a wardrobe in the loft, teal wainscot). Outside, +// a cobbled street from the Town & Market kit (sidewalks, curbs, a crossing, lamp posts, a +// cart) with two cottages across it. You arrive on the sidewalk, facing the front door. function tavernObjects() { const n = namer(); /** @type {any[]} */ @@ -470,12 +547,13 @@ function tavernObjects() { const ZS = [-5, -3, -1, 1, 3]; // ground floor, ceiling, balcony and loft ------------------------------------------ for (const x of XS) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Floor'), [x, 0, z])); - for (const x of XS) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Ceiling'), [x, 6, z])); + // the hall is open to the roof (its slopes and beams are the ceiling); the loft keeps one + for (const x of [3, 5]) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Ceiling'), [x, 6, z])); for (const x of [-5, -3, -1, 1]) o.push(piece(A, 'FloorWood', n('Balcony'), [x, 3, -5])); for (const x of [3, 5]) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Loft'), [x, 3, z])); // outer walls, two storeys: sandstone below, plaster above -------------------------- - /** @param {number} y @param {string} solid @param {string} win @param {string} winWall */ - const shell = (y, solid, win, winWall) => { + /** @param {number} y @param {string} solid @param {string} winWall */ + const shell = (y, solid, winWall) => { // a window's shutters are its FRONT (+Z): every wall of the shell is turned so its // front faces OUT (kit.md: the Window shares its wall's position and rotation) for (const x of XS) { @@ -486,9 +564,15 @@ function tavernObjects() { } else o.push(piece(A, solid, n('Wall'), [x, y, -6], PI)); // south wall z = 4 (the front: the door, and windows) if (y === 0 && x === -3) { - o.push(piece(A, 'WallStoneDoor', 'Front doorway', [x, y, 4])); - o.push(piece(A, 'Door', 'Front door', [x, y, 4])); - } else if ((y === 0 && (x === 1 || x === 5)) || (y === 3 && x === -3)) { + // the doorway takes its jambs-and-lintel collider (the walker comes in from the + // street now), and the DOOR opens (the Interactive Kit's door + frame) + o.push(piece(A, 'WallStoneDoor', 'Front doorway', [x, y, 4], 0, { physics: COLLIDERS.doorway })); + o.push(piece(X, 'DoorWood', 'Front door', [x, y, 4])); + } else if (y === 0 && x === 1) { + // the street window: shutters that close (placed open, flat against the wall) + o.push(piece(A, winWall, n('Window wall'), [x, y, 4])); + o.push(piece(X, 'Shutters', 'Street window shutters', [x, y, 4])); + } else if ((y === 0 && x === 5) || (y === 3 && x === -3)) { o.push(piece(A, winWall, n('Window wall'), [x, y, 4])); o.push(piece(A, 'Window', n('Window'), [x, y, 4])); } else o.push(piece(A, solid, n('Wall'), [x, y, 4])); @@ -505,12 +589,33 @@ function tavernObjects() { } for (const [x, z] of [[-6, -6], [6, -6], [-6, 4], [6, 4]]) o.push(piece(A, 'CornerPostStone', n('Corner post'), [x, y, z])); }; - shell(0, 'WallStone', 'Window', 'WallStoneWindow'); - shell(3, 'WallPlaster', 'Window', 'WallPlasterWindow'); - // the partition between hall and kitchen (x = 2), a doorway at z = 1 -------------------- + shell(0, 'WallStone', 'WallStoneWindow'); + shell(3, 'WallPlaster', 'WallPlasterWindow'); + // the slate roof (33-scenes: the street sees the building now): a gable roof with its ridge + // along X over the 10 m depth — two rows of slopes a side (2 m run, 1.5 m rise each), a 2 m + // flat ridge of slate slabs at y 9, and the end walls closed with half walls + gables + for (const x of XS) { + o.push(piece(A, 'RoofSlope', n('Roof'), [x, 6, -5], PI)); + o.push(piece(A, 'RoofSlope', n('Roof'), [x, 7.5, -3], PI)); + o.push(piece(A, 'RoofSlope', n('Roof'), [x, 6, 3])); + o.push(piece(A, 'RoofSlope', n('Roof'), [x, 7.5, 1])); + o.push(piece(A, 'FloorSlate', n('Ridge'), [x, 8.9, -1])); + } + for (const x of [-6, 6]) { + for (const z of [-3, -1, 1]) o.push(piece(A, 'WallPlasterHalf', n('End wall'), [x, 6, z], H)); + o.push(piece(A, 'WallPlasterHalf', n('End wall'), [x, 7.5, -1], H)); + o.push(piece(A, 'WallPlasterGable', n('Gable'), [x, 6, 3], H)); + o.push(piece(A, 'WallPlasterGable', n('Gable'), [x, 6, -5], -H)); + o.push(piece(A, 'WallPlasterGable', n('Gable'), [x, 7.5, 1], H)); + o.push(piece(A, 'WallPlasterGable', n('Gable'), [x, 7.5, -3], -H)); + } + o.push(piece(A, 'Chimney', 'Chimney', [-1, 7.6, 1.1])); + // the partition between hall and kitchen (x = 2): a doorway at z = 1 with a door that opens for (const z of ZS) { - if (z === 1) o.push(piece(A, 'WallPlasterDoor', 'Kitchen doorway', [2, 0, z], H, { physics: COLLIDERS.doorway })); - else o.push(piece(A, 'WallPlaster', n('Partition'), [2, 0, z], H)); + if (z === 1) { + o.push(piece(A, 'WallPlasterDoor', 'Kitchen doorway', [2, 0, z], H, { physics: COLLIDERS.doorway })); + o.push(piece(X, 'DoorStudded', 'Kitchen door', [2, 0, z], H)); + } else o.push(piece(A, 'WallPlaster', n('Partition'), [2, 0, z], H)); } // the staircase up the west wall to the balcony, rails along the open edges ---------- o.push(piece(A, 'StairsWood', 'Balcony stairs', [-5, 0.1, -2], 0, { physics: COLLIDERS.stairs })); @@ -520,48 +625,72 @@ function tavernObjects() { o.push(piece(A, 'Column', n('Column'), [-2, 0.1, -4])); for (const x of [-3, -1, 1]) o.push(piece(A, 'Beam', n('Balcony beam'), [x, 2.7, -4])); for (const x of [-5, -3, -1, 1]) o.push(piece(A, 'Beam', n('Ceiling beam'), [x, 5.7, -0.5])); - // the bar, under the balcony: half walls (scaled to counter height) with a trim top -- + // the bar under the balcony (Interiors kit: two counter sections, the back-bar on the north + // wall's room face — a wall-line piece takes the wall's line and faces into the room) ---- for (const x of [-2.2, -0.2]) { - o.push(piece(A, 'WallStoneHalf', n('Bar'), [x, 0.1, -2.6], 0, { scale: [1, 0.72, 1] })); - o.push(piece(A, 'Trim', n('Bar top'), [x, 1.18, -2.6])); - } - o.push(piece(P, 'Bookcase', n('Shelf'), [-1.2, 0.1, -5.5])); - o.push(piece(P, 'Bookcase', n('Shelf'), [0.4, 0.1, -5.5])); - for (const [x, z] of [[-3.4, -5.2], [-3.6, -4.4], [1.4, -5.2]]) o.push(piece(P, 'Barrel', n('Barrel'), [x, 0.1, z], x, { physics: DYNAMIC(30) })); - o.push(piece(P, 'Candles', n('Candles'), [-1.6, 1.38, -2.6], 0, { physics: COLLIDERS.sensor })); - o.push(piece(P, 'Lantern', n('Lantern'), [-0.2, 1.38, -2.6], 0, { physics: COLLIDERS.sensor })); - for (const x of [-2.4, -1.2, 0]) o.push(piece(P, 'Chair', n('Bar stool'), [x, 0.1, -1.7], PI)); - // the hall: two long tables with benches, a rug, a plant -------------------------- - for (const [x, z] of [[-2.6, 1.2], [-0.2, 0.6]]) { - o.push(piece(P, 'Table', n('Table'), [x, 0.1, z], H)); - o.push(piece(P, 'Bench', n('Bench'), [x - 0.75, 0.1, z], H)); - o.push(piece(P, 'Bench', n('Bench'), [x + 0.75, 0.1, z], H)); - o.push(piece(P, 'Candles', n('Candles'), [x, 0.88, z], 0, { physics: COLLIDERS.sensor })); + o.push(piece(I, 'BarCounter', n('Bar counter'), [x, 0.1, -3.4])); + o.push(piece(I, 'BackBar', n('Back-bar'), [x, 0.1, -6])); } - o.push(piece(P, 'Rug', 'Hall rug', [-1.2, 0.1, 1.0], H, { scale: 1.2, physics: COLLIDERS.sensor })); - o.push(piece(P, 'PottedPlant', n('Plant'), [1.4, 0.1, 3.3])); + for (const x of [-2.8, -1.7, -0.6, 0.5]) o.push(piece(I, 'BarStool', n('Bar stool'), [x, 0.1, -2.75], x)); + for (const [x, z] of [[-3.75, -5.3], [-3.8, -4.55], [1.4, -5.2]]) o.push(piece(P, 'Barrel', n('Barrel'), [x, 0.1, z], x, { physics: DYNAMIC(30) })); + o.push(piece(P, 'Candles', n('Candles'), [-1.6, 1.2, -3.4], 0, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Lantern', n('Lantern'), [-0.2, 1.2, -3.4], 0, { physics: COLLIDERS.sensor })); + for (const x of [-3.6, 1.2]) o.push(piece(I, 'WallSconce', n('Sconce'), [x, 1.75, -6])); + // the hall: a tavern table set and a dining set, a fireplace on the front wall, a chandelier + o.push(piece(I, 'TavernTableSet', 'Tavern table', [-2.1, 0.1, 0.9])); + o.push(piece(I, 'DiningSet', 'Dining table', [0.35, 0.1, -0.7], H)); + o.push(piece(P, 'Candles', n('Candles'), [0.35, 0.88, -0.7], 0, { physics: COLLIDERS.sensor })); + o.push(piece(I, 'Fireplace', 'Fireplace', [-1, 0.1, 4], PI)); + o.push(piece(I, 'Chandelier', 'Chandelier', [-2, 5.75, -0.5], 0, { physics: COLLIDERS.sensor })); + o.push(piece(I, 'Plant', n('Plant'), [1.55, 0.1, 3.45])); o.push(piece(P, 'PottedPlant', n('Plant'), [-5.3, 0.1, 3.3])); o.push(piece(P, 'CrateTeal', n('Crate'), [-4.9, 0.1, 1.9], 0.2, { physics: DYNAMIC(12) })); - // the kitchen: a hearth, a cauldron, a workbench, stores ----------------------------- - o.push(piece(A, 'Chimney', 'Hearth', [5.4, 0.1, -5.4])); - o.push(piece(P, 'Cauldron', 'Cauldron', [4.4, 0.1, -5], 0.4)); - o.push(piece(P, 'Workbench', 'Workbench', [5.45, 0.1, -1.5], -H)); - o.push(piece(P, 'Candles', n('Candles'), [5.45, 1.02, -1.2], 0, { physics: COLLIDERS.sensor })); + // teal wainscot on the hall's free walls (same line + rotation as the wall's room face). + // Pass-through: a trim's default collider is its bounding box, which for the DOORWAY trim + // spans the gap it leaves for the door — the walker stopped at the threshold of an open + // front door (measured, z 4.22); the wall behind a trim already holds the walker + const TRIM = { physics: COLLIDERS.sensor }; + for (const x of [-5, 1]) o.push(piece(I, 'Wainscot', n('Wainscot'), [x, 0.1, 4], PI, TRIM)); + o.push(piece(I, 'WainscotDoorway', 'Front wainscot', [-3, 0.1, 4], PI, TRIM)); + for (const z of [1, 3]) o.push(piece(I, 'Wainscot', n('Wainscot'), [-6, 0.1, z], H, TRIM)); + // the kitchen: a counter / range / counter run on the north wall, a long table, stores ----- + o.push(piece(I, 'KitchenCounter', n('Kitchen counter'), [3.0, 0.1, -6])); + o.push(piece(I, 'Stove', 'Range', [3.95, 0.1, -6])); + o.push(piece(I, 'KitchenCounter', n('Kitchen counter'), [4.9, 0.1, -6])); + o.push(piece(I, 'LongTable', 'Kitchen table', [4.1, 0.1, -2.4], H)); + for (const z of [-3.1, -1.7]) o.push(piece(I, 'Stool', n('Kitchen stool'), [3.35, 0.1, z])); + o.push(piece(I, 'CrateGoods', n('Fruit'), [5.5, 0.1, -4.3], 0.3)); o.push(piece(P, 'CrateStack', 'Kitchen crates', [4.6, 0.1, 3.2])); o.push(piece(P, 'Sacks', 'Kitchen sacks', [5.2, 0.1, 1.4], 1.9)); - o.push(piece(P, 'BarrelSmall', n('Barrel'), [3.1, 0.1, -5.3], 0, { physics: DYNAMIC(10) })); - // the loft: a bed, a chest, a rug, a tapestry, a plant -------------------------------- - o.push(piece(P, 'Bed', 'Loft bed', [5, 3.1, -4.4])); - o.push(piece(P, 'Chest', 'Loft chest', [5.2, 3.1, 2.8], -H)); + o.push(piece(P, 'BarrelSmall', n('Barrel'), [2.6, 0.1, -4.6], 0, { physics: DYNAMIC(10) })); + // the loft: a double bed and a wardrobe on its walls, a chest and drawers that open ----- + o.push(piece(I, 'DoubleBed', 'Loft bed', [6, 3.1, -4], -H)); + o.push(piece(I, 'Wardrobe', 'Loft wardrobe', [3.2, 3.1, -6])); + o.push(piece(X, 'Chest', 'Loft chest', [5.3, 3.1, 2.9], -H)); + o.push(piece(X, 'Drawers', 'Loft drawers', [5.625, 3.1, -1.4], -H)); o.push(piece(P, 'Rug', 'Loft rug', [4.1, 3.1, -0.5], H, { physics: COLLIDERS.sensor })); - o.push(piece(P, 'Tapestry', n('Tapestry'), [5.875, 3.9, 0.5], -H, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Tapestry', n('Tapestry'), [5.875, 3.9, 0.9], -H, { physics: COLLIDERS.sensor })); o.push(piece(P, 'Tapestry', n('Tapestry'), [-3, 3.9, -5.875], 0, { physics: COLLIDERS.sensor })); o.push(piece(P, 'PottedPlant', n('Plant'), [1.3, 3.1, -5.3])); - // wall torches and lights ---------------------------------------------------------- - for (const [x, z, yaw] of [[-5.875, 0.7, H], [-5.875, 2.8, H], [1.875, -1.8, -H], [-0.8, 3.875, PI], [5.875, 2, -H]]) - o.push(piece(P, 'WallTorch', n('Wall torch'), [x, 1.55, z], yaw, { physics: COLLIDERS.sensor })); - for (const [x, y, z, i] of [[-3, 2.5, 1.2, 9], [0.2, 2.5, 1, 9], [-1, 2.4, -3.2, 7], [4, 2.4, -2.5, 8], [4, 5.2, -1, 8], [-2, 5.2, -5, 6], [-4.2, 2.3, -1, 5]]) - o.push({ type: 'light', name: n('Lamp'), pos: [x, y, z], color: 0xffb060, intensity: i, distance: 11, decay: 2 }); + // the street outside the front (its sidewalk starts at the wall's outer face) ---------- + street(o, n, { x0: -10, x1: 10, z0: 4.125, crossAt: -3 }); + for (const [x, z] of [[-6.5, 5.6], [3.5, 5.6], [-1, 12.7], [8, 12.7]]) o.push(piece(TK, 'LampPost', n('Lamp post'), [x, 0.35, z])); + for (const x of [-4.1, -1.9]) o.push(piece(P, 'WallTorch', n('Door torch'), [x, 1.6, 4.125], 0, { physics: COLLIDERS.sensor })); + o.push(piece(TK, 'BarrelCluster', 'Street barrels', [0.6, 0.35, 4.75])); + o.push(piece(TK, 'FlowerBox', n('Flower box'), [5, 1.0, 4.29])); + o.push(piece(TK, 'ParkBench', 'Street bench', [2.8, 0.35, 5.3], PI)); + o.push(piece(TK, 'LoadedCart', 'Cart', [6.5, 0.2, 9.4], 0.08)); + // across the street: two cottages, their fronts to the street (shut — scenery) + // (on the kit's grid: a cottage's centre on even metres, so its walls stand on grid lines) + cottage(o, n, { at: [-6, 18], yaw: PI, name: 'Cottage A', door: 'shut' }); + cottage(o, n, { at: [4, 18], yaw: PI, name: 'Cottage B', door: 'shut', plaster: false }); + for (const x of [-6, 4]) o.push(piece(TK, 'FlowerBox', n('Flower box'), [x - 1, 1.0, 15.715], PI)); + // trees behind them and at the street's ends, so its ends fade into green, not the void + for (const [item, x, z, s] of [['Oak', -12, 23, 1], ['Birch', -1, 23, 1], ['Oak', 9, 23, 1.1], ['Pine', -14, 9, 1.1], ['Pine', 14, 8, 1.05], ['Birch', -13, -2, 1], ['Oak', 12, -4, 1]]) + o.push(piece(N, /** @type {string} */ (item), n(/** @type {string} */ (item)), [/** @type {number} */ (x), 0, /** @type {number} */ (z)], /** @type {number} */ (x) * 0.4, { scale: s, physics: TRUNKS[/** @type {string} */ (item)] })); + // light: the hall and the kitchen (the chandelier and the fire glow carry the rest) + o.push({ type: 'light', name: 'Hall lamp', pos: [-1.4, 3.6, 0.4], color: 0xffb060, intensity: 16, distance: 16, decay: 2 }); + o.push({ type: 'light', name: 'Kitchen lamp', pos: [4, 2.4, -2.5], color: 0xffb060, intensity: 8, distance: 11, decay: 2 }); return o; } @@ -571,28 +700,300 @@ const TAVERN_DEF = { slug: 'tavern-interior', title: 'Tavern Interior', description: - 'A two-storey tavern: a hall with a bar under the balcony, a kitchen with a hearth, and a loft up the oak stairs, lit warm by lanterns and torches. Press Play to walk it.', + 'A two-storey tavern on a market street: the doors open, the shutters close, the loft chest lifts its lid. A bar and back-bar, a fireplace, a kitchen and a loft, furnished from the Interiors kit. Press Play and walk in.', author: 'theprototype', license: 'CC0-1.0', - tags: ['level design', 'kits', 'interior', 'walkable'], + tags: ['level design', 'kits', 'interior', 'walkable', 'doors'], objects: tavernObjects(), - // an early evening outside the windows; inside, the warm lamps do the work and a warm - // hemisphere keeps the corners from going black + // an early evening: a low warm sun down the street, a warm hemisphere so the corners inside + // never go black, a haze that swallows the ends of the street env: { preset: 'sunset', exposure: 1.1, background: { top: '#3f4a78', bottom: '#f0a766' }, - fog: null, + fog: { color: '#e3a87c', near: 22, far: 60 }, ground: { color: '#5b5040', roughness: 1 }, - sun: { color: '#ffb06a', intensity: 1.6, dir: [0.7, 0.35, 0.6] }, - hemi: { sky: '#ffd9ae', ground: '#6b5236', intensity: 1.2 } + sun: { color: '#ffb06a', intensity: 1.8, dir: [0.7, 0.35, 0.6] }, + hemi: { sky: '#ffd9ae', ground: '#6b5236', intensity: 1.35 } }, - // by the front door, the bar and the stairs ahead - physics: playPhysics([-1.4, 0.3, 3.3], -0.2), + // on the road, the tavern's front and its door ahead + physics: playPhysics([-3, 0.3, 10.4], 0), post: lookPost(0.7), graphs: { scene: walkGraph() }, - view: { pos: [0.5, 2.1, 3.3], target: [-1.5, 1.6, -3] }, + // the card: inside, from the kitchen doorway's corner, down the hall to the bar + view: { pos: [1.3, 2.3, 3.2], target: [-2.4, 1.2, -2.8] }, + thumb: {} +}; + +// ---- 4. Wizard's Tower ------------------------------------------------------------------ +// +// 33-scenes: a three-storey stone tower, 8 × 8 m, on a dusk hillside. Every storey is one room +// and a staircase climbs to the next (they alternate sides, so each floor has a hole on one side +// and a stair on the other): the ALCHEMY LAB on the ground (an alchemist's table, a shelf of +// potions, a cauldron, a crystal ball, a trapdoor to the cellar, a chest, a lever), the STUDY +// above (a rune rug, a lectern with an open spellbook, a desk, an armillary sphere, a chest of +// drawers that opens, shutters that close), the BEDCHAMBER on top (a bed and a wardrobe that +// opens), and a ROOF TERRACE behind battlements with a telescope. A path runs to the door +// (it opens) through a garden gate (it opens). The hero props are the Arcane Study Kit's. +function wizardObjects() { + const n = namer(); + /** @type {any[]} */ + const o = []; + const P4 = [-3, -1, 1, 3]; + // floors: stone on the ground, oak above, stone on the roof; each skips its stairwell + /** @param {number} y @param {string} item @param {string} name @param {number[][]} holes */ + const floor = (y, item, name, holes) => { + for (const x of P4) for (const z of P4) if (!holes.some(([hx, hz]) => hx === x && hz === z)) o.push(piece(A, item, n(name), [x, y, z])); + }; + floor(0, 'FloorStone', 'Lab floor', []); + floor(3, 'FloorWood', 'Study floor', [[-3, -1], [-3, 1]]); + floor(6, 'FloorWood', 'Bedchamber floor', [[3, -1], [3, 1]]); + floor(9, 'FloorStone', 'Roof', [[-3, -1], [-3, 1]]); + // three storeys of sandstone walls (fronts out), windows on every side, the door south + /** @param {number} y @param {Record} openings key 'side:pos' -> 'window' | 'shutters' | 'door' */ + const storey = (y, openings) => { + /** @param {string} key @param {number[]} p @param {number} rot */ + const panel = (key, p, rot) => { + const kind = openings[key]; + if (kind === 'door') { + o.push(piece(A, 'WallStoneDoor', 'Tower doorway', p, rot, { physics: COLLIDERS.doorway })); + o.push(piece(X, 'DoorStudded', 'Tower door', p, rot)); + } else if (kind === 'window' || kind === 'shutters') { + o.push(piece(A, 'WallStoneWindow', n('Window wall'), p, rot)); + o.push(kind === 'shutters' ? piece(X, 'Shutters', n('Shutters'), p, rot) : piece(A, 'Window', n('Window'), p, rot)); + } else o.push(piece(A, 'WallStone', n('Tower wall'), p, rot)); + }; + for (const x of P4) { + panel('n:' + x, [x, y, -4], PI); + panel('s:' + x, [x, y, 4], 0); + } + for (const z of P4) { + panel('w:' + z, [-4, y, z], -H); + panel('e:' + z, [4, y, z], H); + } + for (const [x, z] of [[-4, -4], [4, -4], [-4, 4], [4, 4]]) o.push(piece(A, 'CornerPostStone', n('Tower corner'), [x, y, z])); + }; + storey(0, { 's:1': 'door', 'e:-1': 'window', 'w:-3': 'window', 'n:1': 'window' }); + storey(3, { 'n:-1': 'shutters', 'e:1': 'window', 's:-1': 'window', 'w:-3': 'window' }); + storey(6, { 's:-1': 'window', 'w:1': 'window', 'e:-3': 'window', 'n:1': 'window' }); + // battlements round the roof terrace + for (const x of P4) { + o.push(piece(A, 'Battlement', n('Battlement'), [x, 9, -4], PI)); + o.push(piece(A, 'Battlement', n('Battlement'), [x, 9, 4])); + } + for (const z of P4) { + o.push(piece(A, 'Battlement', n('Battlement'), [-4, 9, z], -H)); + o.push(piece(A, 'Battlement', n('Battlement'), [4, 9, z], H)); + } + // the stairs: up the west side to the study, the east side to the bedchamber, the west + // again to the roof (kit.md: pivot at the centre of the 2 × 4 slot, rising towards -Z) + o.push(piece(A, 'StairsWood', 'Stairs to the study', [-3, 0.1, 0], 0, { physics: COLLIDERS.stairs })); + o.push(piece(A, 'StairsWood', 'Stairs to the bedchamber', [3, 3.1, 0], PI, { physics: COLLIDERS.stairs })); + o.push(piece(A, 'StairsWood', 'Stairs to the roof', [-3, 6.1, 0], 0, { physics: COLLIDERS.stairs })); + // rails along each stairwell's open side + o.push(piece(A, 'Railing', n('Stairwell rail'), [-1.92, 3.1, -1], H)); + o.push(piece(A, 'Railing', n('Stairwell rail'), [-1.92, 3.1, 1], H)); + o.push(piece(A, 'Railing', n('Stairwell rail'), [1.92, 6.1, -1], H)); + o.push(piece(A, 'Railing', n('Stairwell rail'), [1.92, 6.1, 1], H)); + o.push(piece(A, 'Railing', n('Stairwell rail'), [-1.92, 9.1, -1], H)); + o.push(piece(A, 'Railing', n('Stairwell rail'), [-1.92, 9.1, 1], H)); + // the ALCHEMY LAB -------------------------------------------------------------------- + o.push(piece(AR, 'AlchemyTable', 'Alchemist’s table', [0.6, 0.1, -3.43])); + o.push(piece(AR, 'PotionShelf', n('Potion shelf'), [3.655, 0.1, -2.6], -H)); + o.push(piece(P, 'Cauldron', 'Cauldron', [-0.6, 0.1, -0.6], 0.6)); + o.push(piece(I, 'RoundTable', 'Scrying table', [2.5, 0.1, 0.4])); + o.push(piece(AR, 'CrystalBall', 'Crystal ball', [2.5, 0.86, 0.4])); + o.push(piece(X, 'Trapdoor', 'Cellar trapdoor', [-0.4, 0.1, 1.9])); + o.push(piece(X, 'Chest', 'Lab chest', [3.4, 0.1, 2.9], -H)); + o.push(piece(X, 'Lever', 'Lever', [-1.6, 0.1, -3.3])); + o.push(piece(P, 'Barrel', n('Barrel'), [3.4, 0.1, -0.7], 0.4, { physics: DYNAMIC(30) })); + for (const [x, z, yaw] of [[-1.6, 3.875, PI], [3.875, 1.6, -H]]) o.push(piece(P, 'WallTorch', n('Wall torch'), [x, 1.55, z], yaw, { physics: COLLIDERS.sensor })); + // the STUDY ---------------------------------------------------------------------------- + o.push(piece(AR, 'RuneRug', 'Rune rug', [0.3, 3.1, 0.6], 0, { physics: COLLIDERS.sensor })); + o.push(piece(AR, 'Lectern', 'Lectern', [0.3, 3.1, 0.6], PI * 0.85)); + o.push(piece(I, 'Desk', 'Study desk', [0.2, 3.1, -3.3])); + o.push(piece(I, 'Stool', n('Stool'), [0.2, 3.1, -2.5])); + o.push(piece(P, 'Candles', n('Candles'), [0.6, 3.86, -3.3], 0, { physics: COLLIDERS.sensor })); + o.push(piece(I, 'WallShelfBooks', 'Book shelf', [-1.6, 4.2, -4])); + o.push(piece(AR, 'Armillary', 'Armillary sphere', [1.6, 3.1, -2.3], 0.4)); + o.push(piece(X, 'Drawers', 'Study drawers', [-0.2, 3.1, 3.625], PI)); + o.push(piece(I, 'Armchair', 'Study armchair', [1.4, 3.1, 2.6], PI * 0.8)); + o.push(piece(P, 'Bookcase', n('Bookcase'), [-1.6, 3.1, 3.55], PI)); + // the BEDCHAMBER ----------------------------------------------------------------------- + o.push(piece(I, 'DoubleBed', 'Wizard’s bed', [-0.4, 6.1, -4])); + o.push(piece(X, 'Cabinet', 'Wardrobe', [-1.4, 6.1, 3.625], PI)); + o.push(piece(AR, 'PotionShelf', n('Potion shelf'), [0.6, 6.1, 3.655], PI)); + o.push(piece(P, 'Rug', 'Bedchamber rug', [-0.2, 6.1, 0.4], H, { physics: COLLIDERS.sensor })); + o.push(piece(I, 'Plant', n('Plant'), [1.4, 6.1, -3.4])); + // the ROOF TERRACE --------------------------------------------------------------------- + o.push(piece(AR, 'Telescope', 'Telescope', [1.4, 9.1, 1.6], -0.6)); + o.push(piece(P, 'Crate', n('Crate'), [2.9, 9.1, 2.9], 0.3, { physics: DYNAMIC(15) })); + // outside: torches either side of the door, a banner stirring above it (the Interactive + // Kit's one AMBIENT piece here: it loops on its own, in Interact and Play only) + for (const x of [0.1, 1.9]) o.push(piece(P, 'WallTorch', n('Door torch'), [x, 1.6, 4.125], 0, { physics: COLLIDERS.sensor })); + o.push(piece(X, 'Banner', 'Tower banner', [1, 3.4, 4.125])); + // a path to the door through a garden gate, a hillside of trees and stones ------------- + for (const z of [5.5, 7.5, 9.5, 11.5]) o.push(piece(N, 'PathTile', n('Path'), [1, 0, z])); + for (const x of [-5, -3, -1, 3, 5]) o.push(piece(TK, 'Fence', n('Garden fence'), [x, 0, 10.5])); + o.push(piece(TK, 'FenceGate', 'Garden gate', [1, 0, 10.5])); + for (const [item, x, z, s] of [['Pine', -8, -5, 1.1], ['Pine', -10, 3, 1], ['DeadTree', 8, -6, 1], ['Oak', 9, 5, 1], ['Pine', 6, -12, 1.15], ['Pine', -6, -12, 1.05], ['Birch', -9, 9, 1], ['Pine', 12, 12, 1.1], ['Pine', -13, -1, 1.1]]) + o.push(piece(N, /** @type {string} */ (item), n(/** @type {string} */ (item)), [/** @type {number} */ (x), 0, /** @type {number} */ (z)], /** @type {number} */ (x) * 0.5, { scale: s, physics: TRUNKS[/** @type {string} */ (item)] })); + for (const [item, x, z, yaw] of [['RockLarge', -6.5, 6.5, 0.4], ['RockMedium', 6.2, 7.5, 2], ['BoulderCluster', -7, -8, 1.1], ['Stump', 5, 2.5, 0.3]]) o.push(piece(N, /** @type {string} */ (item), n(/** @type {string} */ (item)), [/** @type {number} */ (x), 0, /** @type {number} */ (z)], /** @type {number} */ (yaw))); + for (const [x, z] of [[-2.8, 6.8], [4.3, 8.4], [-4.2, 8.9], [3.2, 5.6]]) o.push(piece(N, x < 0 ? 'Mushrooms' : 'FlowerPatch', n('Garden'), [x, 0, z], x, { physics: COLLIDERS.sensor })); + for (const [x, z] of [[-5.5, 5], [6, 4.8], [-2, 12.5], [5, 12]]) o.push(piece(N, 'Bush', n('Bush'), [x, 0, z], z)); + // light: the lab and the study (the crystal ball, the torches and the moon do the rest) + o.push({ type: 'light', name: 'Lab lamp', pos: [0.8, 2.3, 0], color: 0xffa868, intensity: 9, distance: 10, decay: 2 }); + o.push({ type: 'light', name: 'Study lamp', pos: [0.4, 5.3, 0], color: 0xc9a2ff, intensity: 9, distance: 11, decay: 2 }); + return o; +} + +const WIZARD_DEF = { + kind: 'template', + seed: false, + slug: 'wizards-tower', + title: 'Wizard’s Tower', + description: + 'A three-storey stone tower at dusk: an alchemy lab with a trapdoor and a chest, a study with a lectern and drawers, a bedchamber, and a roof terrace with a telescope. Doors, lids and shutters open. Press Play and climb it.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['level design', 'kits', 'interior', 'walkable', 'doors', 'fantasy'], + objects: wizardObjects(), + // dusk: a violet sky over a warm horizon, a low cool moon, a soft haze past the trees + env: { + preset: 'sunset', + exposure: 1.1, + background: { top: '#2b2752', bottom: '#d09272' }, + fog: { color: '#a98a96', near: 20, far: 60 }, + ground: { color: '#4f6436', roughness: 1 }, + sun: { color: '#c8c4ff', intensity: 1.5, dir: [-0.5, 0.55, 0.65] }, + hemi: { sky: '#cbb8ff', ground: '#4f6436', intensity: 1.3 } + }, + // on the path, the garden gate and the tower door ahead + physics: playPhysics([1, 0.3, 13], 0), + post: lookPost(0.8), + graphs: { scene: walkGraph() }, + view: { pos: [11, 8, 17], target: [0, 4.5, 0] }, + thumb: {} +}; + +// ---- 5. Market Town Square --------------------------------------------------------------- +// +// 33-scenes: a paved square in a market town (Town & Market kit + the architecture kit + nature): +// a fountain in the middle, three market stalls with their goods, a well, benches, lamp posts, +// a notice board, banner poles, a clock tower on the corner, a road along the south side with a +// cart, cottages round the edges and trees beyond. The BAKERY on the north side is the one you +// walk into: its door and shutters open, and inside a kitchen run, a table, a chest of drawers. +function marketObjects() { + const n = namer(); + /** @type {any[]} */ + const o = []; + // the square: flagstones 16 × 16 m on ODD centres, so the fountain in the middle stands on + // four tiles (town-kit kit.md's plaza) and the square's edges fall on the architecture kit's + // grid lines (x/z ±8), where the buildings round it stand + const S8 = [-7, -5, -3, -1, 1, 3, 5, 7]; + for (const x of S8) for (const z of S8) o.push(piece(TK, 'Sidewalk', n('Paving'), [x, 0, z])); + o.push(piece(TK, 'Fountain', 'Fountain', [0, 0.35, 0])); + // the road along the south side (curb, cobbles, curb) and a cart on it + for (let x = -13; x <= 13; x += 2) { + o.push(piece(TK, 'RoadCurb', n('Curb'), [x, 0, 9], PI)); + o.push(piece(TK, 'Road', n('Road'), [x, 0, 11])); + o.push(piece(TK, 'RoadCurb', n('Curb'), [x, 0, 13])); + } + o.push(piece(TK, 'LoadedCart', 'Cart', [5.5, 0.2, 11.2], 0.05)); + // the market: three stalls on the east side facing the fountain, goods beside them + for (const z of [-4, -0.6, 2.8]) o.push(piece(TK, 'MarketStall', n('Market stall'), [5.2, 0.35, z], -H)); + o.push(piece(I, 'CrateGoods', n('Fruit'), [6.6, 0.35, -2.3], 0.4)); + o.push(piece(TK, 'SacksBarrel', 'Sacks', [6.5, 0.35, 1.1], -H)); + o.push(piece(TK, 'BarrelCluster', 'Barrels', [6.4, 0.35, 4.9])); + o.push(piece(TK, 'HayStack', 'Hay', [3.4, 0.35, 6.2], 0.6)); + // loose goods you can pick up and throw (the square's dynamic bodies: a level with none + // would start no simulation on Play, and the walker only collides while one runs) + for (const [item, x, z, mass] of [['Crate', 6.9, -5.7, 15], ['CrateTeal', 4.2, -2.2, 12], ['Barrel', 4.1, 4.4, 30], ['BarrelSmall', -3.6, 5.2, 12]]) + o.push(piece(P, /** @type {string} */ (item), n('Goods'), [/** @type {number} */ (x), 0.35, /** @type {number} */ (z)], /** @type {number} */ (x), { physics: DYNAMIC(/** @type {number} */ (mass)) })); + // the west side: the well, the notice board; benches round the fountain; lamps on the corners + o.push(piece(TK, 'Well', 'Well', [-4.6, 0.35, 3.6])); + o.push(piece(TK, 'NoticeBoard', 'Notice board', [-6.2, 0.35, -2.6], H)); + o.push(piece(TK, 'ParkBench', n('Bench'), [0, 0.35, 3.1], PI)); + o.push(piece(TK, 'ParkBench', n('Bench'), [-3.1, 0.35, 0], -H)); + for (const [x, z] of [[-7.4, -7.4], [7.4, -7.4], [-7.4, 7.4], [7.4, 7.4]]) o.push(piece(TK, 'LampPost', n('Lamp post'), [x, 0.35, z])); + for (const x of [-2.6, 2.6]) o.push(piece(TK, 'BannerPole', n('Banner pole'), [x, 0.35, 7.4])); + o.push(piece(TK, 'FlowerBoxLong', n('Flowers'), [0.4, 0.35, -7.4])); + // the BAKERY: a plaster cottage on the north side, its front to the square — the door and the + // shutters open; inside a kitchen run on the back wall, a table, drawers, a plant + cottage(o, n, { + at: [-4, -10], + yaw: 0, + name: 'Bakery', + door: 'open', + shutters: true, + inside: (add) => { + add(I, 'KitchenCounter', n('Bakery counter'), [-1.3, 0.1, -2]); + add(I, 'Stove', 'Bakery oven', [-0.35, 0.1, -2]); + add(I, 'KitchenCounter', n('Bakery counter'), [0.6, 0.1, -2]); + add(I, 'RoundTable', 'Bakery table', [0.6, 0.1, 0.5]); + add(I, 'Stool', n('Bakery stool'), [1.2, 0.1, 0.9]); + add(I, 'Stool', n('Bakery stool'), [0.0, 0.1, 0.9]); + add(X, 'Drawers', 'Bakery drawers', [-1.625, 0.1, 0.4], H); + add(I, 'CrateGoods', n('Fruit'), [1.5, 0.1, -0.7]); + add(I, 'Plant', n('Plant'), [-1.55, 0.1, 1.55]); + add(I, 'Picture', 'Bakery picture', [-2, 1.4, -0.6], H); + } + }); + o.push(piece(TK, 'FlowerBox', n('Flower box'), [-3, 1.0, -7.715])); + // the clock tower on the north-east corner: two storeys of sandstone, the clock top on them + { + const cx = 6; + const cz = -10; + for (const y of [0, 3]) { + for (const d of [-1, 1]) { + o.push(piece(A, y === 0 && d === -1 ? 'WallStoneDoor' : 'WallStone', n('Tower wall'), [cx + d, y, cz + 2])); + o.push(piece(A, 'WallStone', n('Tower wall'), [cx + d, y, cz - 2], PI)); + o.push(piece(A, 'WallStone', n('Tower wall'), [cx - 2, y, cz + d], -H)); + o.push(piece(A, 'WallStone', n('Tower wall'), [cx + 2, y, cz + d], H)); + } + for (const [dx, dz] of [[-2, -2], [2, -2], [-2, 2], [2, 2]]) o.push(piece(A, 'CornerPostStone', n('Tower corner'), [cx + dx, y, cz + dz])); + } + o.push(piece(A, 'Door', 'Tower door', [cx - 1, 0, cz + 2])); + o.push(piece(TK, 'ClockTowerTop', 'Clock', [cx, 6, cz])); + } + // cottages round the square (shut — the scenery that makes it a town) + cottage(o, n, { at: [-10, -2], yaw: H, name: 'West cottage', door: 'shut', plaster: false }); + cottage(o, n, { at: [-10, 4], yaw: H, name: 'Corner cottage', door: 'shut' }); + cottage(o, n, { at: [10, 2], yaw: -H, name: 'East cottage', door: 'shut' }); + // trees beyond, so the edges of the town fade into green + for (const [item, x, z, s] of [['Oak', -15, -9, 1.1], ['Pine', -16, 6, 1.1], ['Birch', 15, -6, 1], ['Oak', 16, 8, 1.05], ['Pine', -4, -17, 1.15], ['Pine', 10, -17, 1.1], ['Oak', -12, 16, 1], ['Birch', 12, 16, 1]]) + o.push(piece(N, /** @type {string} */ (item), n(/** @type {string} */ (item)), [/** @type {number} */ (x), 0, /** @type {number} */ (z)], /** @type {number} */ (x) * 0.3, { scale: s, physics: TRUNKS[/** @type {string} */ (item)] })); + o.push({ type: 'light', name: 'Bakery lamp', pos: [-4, 2.4, -10.2], color: 0xffc080, intensity: 6, distance: 7, decay: 2 }); + return o; +} + +const MARKET_DEF = { + kind: 'template', + seed: false, + slug: 'market-square', + title: 'Market Town Square', + description: + 'A market square built from the Town & Market kit: a fountain, stalls, a well, a clock tower and cottages round it — and a bakery whose door and shutters open. Press Play to walk it.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['level design', 'kits', 'town', 'walkable', 'doors'], + objects: marketObjects(), + // a bright morning: a high sun from the south-east, a pale sky, a haze past the town + env: { + preset: 'daylight', + exposure: 1.05, + background: { top: '#5a8fd0', bottom: '#e3ebef' }, + fog: { color: '#e3ebef', near: 26, far: 80 }, + ground: { color: '#6d7f45', roughness: 1 }, + sun: { color: '#fff1d6', intensity: 2.5, dir: [0.5, 0.7, 0.45] }, + hemi: { sky: '#d3e6ff', ground: '#6d7f45', intensity: 1.25 } + }, + // on the south side of the square, the fountain and the bakery ahead + physics: playPhysics([0, 0.45, 6.3], 0), + post: lookPost(0.5), + graphs: { scene: walkGraph() }, + view: { pos: [16, 11.5, 19], target: [0, 2.6, -3] }, thumb: {} }; -module.exports = { LEVEL_DEFS: [CASTLE_DEF, FOREST_DEF, TAVERN_DEF], COLLIDERS, TRUNKS, BRIDGE_DECK, HILL_RINGS }; +module.exports = { LEVEL_DEFS: [CASTLE_DEF, FOREST_DEF, TAVERN_DEF, WIZARD_DEF, MARKET_DEF], COLLIDERS, TRUNKS, BRIDGE_DECK, HILL_RINGS }; diff --git a/scripts/level-views.cjs b/scripts/level-views.cjs new file mode 100644 index 00000000..70c6d3f7 --- /dev/null +++ b/scripts/level-views.cjs @@ -0,0 +1,36 @@ +// 33-scenes — named viewpoints per General-tab level, shared by scripts/perf-levels.cjs (the +// draw-call probe) and the level suites: [label, feet [x, y, z], yaw]. yaw 0 looks -Z, +// +PI/2 looks -X, PI looks +Z, -PI/2 looks +X (the spawn convention). Each level lists its +// spawn first and then the views a player would stand in that see the MOST of it (the worst +// case for draw calls), so a budget claim is about the heaviest frame and not an easy one. +const PI = Math.PI; +module.exports = { + 'castle-courtyard': [ + ['spawn', [1, 0.3, 4.6], 0], + ['rampart looking south', [0, 3, -12.8], PI], + ['the well looking north', [0, 0, -2], 0] + ], + 'tavern-interior': [ + ['spawn (the road)', [-3, 0.2, 10.4], 0], + ['the front door', [-3, 0.35, 6.4], 0], + ['across the street', [6, 0.35, 13.5], 2.2], + ['inside the door', [-1.4, 0.1, 3.3], -0.2], + ['back corner over the hall', [1.2, 0.1, -3.2], 2.6], + ['balcony over the hall', [-2, 3.1, -4.6], PI * 0.8], + ['kitchen', [3.2, 0.1, 2.5], 0.3] + ], + 'wizards-tower': [ + ['spawn (the path)', [1, 0.3, 13], 0], + ['the lab from the door', [1, 0.1, 3.2], 0.35], + ['the study', [-1, 3.1, 3.2], -0.4], + ['the bedchamber', [1, 6.1, 2.9], 0.45], + ['the roof terrace', [-2.5, 9.1, 3.2], -0.9] + ], + 'market-square': [ + ['spawn (the square)', [0, 0.35, 6.3], 0], + ['the market from the west', [-5, 0.35, 2], -1.7], + ['the square from the bakery', [-4.5, 0.1, -9.2], PI], + ['inside the bakery', [-2.7, 0.1, -8.8], 0.6], + ['the road', [-8, 0.2, 11], -1.9] + ] +}; diff --git a/scripts/perf-levels.cjs b/scripts/perf-levels.cjs new file mode 100644 index 00000000..1e5a0857 --- /dev/null +++ b/scripts/perf-levels.cjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +// 33-scenes — THE LEVEL PROBE: draw calls per display frame for the General-tab LEVELS +// (scripts/level-templates.cjs), from named viewpoints, so "≤ 150 calls on a Quest" is a +// measured claim per scene and not a feeling. The Performance protocol's numbers (31 rules), +// read the way scripts/perf-games.cjs reads them: every render() pass in a display frame is +// summed (the shadow pass included), averaged over a short window per viewpoint. +// +// node scripts/perf-levels.cjs --dir /scene.tpscene> +// [--only tavern-interior,market-square] [--seconds 2] [--out ] [--label run] +// +// Per level, on a FRESH page: load the .tpscene through the Templates path +// (`importSessionZip` + `requestLoadSession`), wait for every kit piece to refill +// (packRefsSettled) and every animated item to register, press Play, then for each +// viewpoint put the rig there and record. Two render modes per viewpoint: +// desktop — the scene's own look (post stack + AO), what a desktop player sees; +// vr — the headset analogue: post off, Shaded, direct render (a fake XR session +// cannot present, so this is the nearest desktop reading of the XR frame). +// The Quest budget applies to `vr`. +// +// Viewpoints come from VIEWS below (per slug), else the spawn alone. +const path = require('path'); +const fs = require('fs'); +const h = require('../tests/e2e/helpers.cjs'); + +const argv = process.argv.slice(2); +const arg = (name, fallback) => { + const i = argv.indexOf('--' + name); + return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback; +}; +const DIR = arg('dir', null); +const ONLY = (arg('only', '') || '').split(',').filter(Boolean); +const SECONDS = Number(arg('seconds', '2')); +const OUT = arg('out', '/home/deck/.code/lanes-30/after-33/33-scenes'); +const LABEL = arg('label', 'run'); +// the counterfactual: the same views with kit instancing OFF (kitInstancing.js) +const NO_INSTANCING = argv.includes('--no-instancing'); +const CELL = Number(arg('cell', '0')) || 0; +const BUDGET = 150; +const PI = Math.PI; + +/** [label, feet [x, y, z], yaw] — yaw 0 looks -Z (the spawn convention) */ +const VIEWS = require('./level-views.cjs'); + +function startRecorder() { + const s = window.__stores; + let r; + s.globalRenderer.subscribe((v) => (r = v))(); + const rec = { calls: 0, triangles: 0, renders: 0, frames: 0, last: 0, on: true, max: 0, cur: 0 }; + const inner = r.render; + r.render = function (...a) { + const out = inner.apply(this, a); + const i = this.info?.render; + if (rec.on && i) { + rec.calls += i.calls; + rec.cur += i.calls; + rec.triangles += i.triangles; + rec.renders++; + } + return out; + }; + const tick = () => { + if (!rec.on) return; + rec.frames++; + rec.max = Math.max(rec.max, rec.cur); + rec.cur = 0; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + rec.stop = () => { + rec.on = false; + r.render = inner; + const n = rec.frames || 1; + return { calls: Math.round(rec.calls / n), maxCalls: rec.max, triangles: Math.round(rec.triangles / n), renders: Math.round((rec.renders / n) * 10) / 10 }; + }; + window.__levelRec = rec; +} + +async function probe(browser, slug, bytes) { + const peer = await h.setupPage(browser, slug, { context: { viewport: { width: 1280, height: 720 } } }); + const page = peer.page; + const out = { slug, views: [] }; + try { + await page.evaluate(async (arr) => { + const s = window.__stores; + const payload = await s.sessions.importSessionZip(new Uint8Array(arr).buffer); + if (payload) await s.sessions.requestLoadSession(payload.id); + }, Array.from(bytes)); + if (NO_INSTANCING) await page.evaluate(() => window.__stores.kitInstancing?.kitInstancingEnabled.set(false)); + if (CELL) await page.evaluate((c) => window.__stores.kitInstancing?.setKitCellMetres(c), CELL); + await page.waitForTimeout(1500); + await page.evaluate(() => window.__stores.packRefs.packRefsSettled()); + await page.waitForTimeout(2500); + out.objects = await page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + let meshes = 0; + g.traverse((n) => n.isMesh && meshes++); + return { top: g.children.length, meshes, behaviors: window.__stores.animatedImports.behaviorUuids?.().length ?? 0 }; + }); + await page.locator('#play-button').click({ timeout: 10000 }).catch(() => page.evaluate(() => window.__stores.isLocked.set(true))); + await page.waitForTimeout(2500); + const views = VIEWS[slug] ?? []; + for (const mode of ['desktop', 'vr']) { + await page.evaluate((mode) => { + const s = window.__stores; + s.viewportOverrides.setRenderLayer('post', mode === 'desktop'); + // a headset starts with shadows OFF (the governor's XR entry floor, qualityGovernorCore + // "step 1 = shadows off" — the protocol's "shadows off in Interact") + if (mode === 'vr') { + s.viewMode.set('shaded'); + s.lightParams.shadowQuality.set('off'); + } + }, mode); + for (const [label, feet, yaw] of views) { + await page.evaluate( + ({ feet, yaw }) => { + const s = window.__stores; + let cam; + s.playerCam.subscribe((c) => (cam = c))(); + const v = new s.THREE.Vector3(feet[0], feet[1] + 1.7, feet[2]); + cam.parent?.worldToLocal(v); + cam.position.copy(v); + cam.quaternion.setFromEuler(new s.THREE.Euler(0, yaw, 0, 'YXZ')); + cam.updateMatrixWorld(true); + }, + { feet, yaw } + ); + await page.waitForTimeout(600); + await page.evaluate(startRecorder); + await page.waitForTimeout(SECONDS * 1000); + const m = await page.evaluate(() => ({ ...window.__levelRec.stop(), batches: window.__stores.kitInstancing?.kitInstancingStats().batches ?? null, batched: window.__stores.kitInstancing?.kitInstancingStats().members ?? null })); + out.views.push({ mode, label, ...m }); + if (process.env.SHOTS) await page.screenshot({ path: path.join(OUT, `view-${LABEL}-${slug}-${mode}-${label.replace(/\W+/g, '-')}.png`) }); + } + } + out.pageErrors = h.pageErrors ? h.pageErrors(peer).length : 0; + } catch (e) { + out.error = String(e?.message ?? e).split('\n')[0]; + } finally { + await page.context().close().catch(() => {}); + } + return out; +} + +(async () => { + if (!DIR) throw new Error('--dir is required'); + const index = JSON.parse(fs.readFileSync(path.join(DIR, 'index.json'), 'utf8')); + const rows = (index.templates ?? []).filter((r) => (ONLY.length ? ONLY.includes(r.slug) : VIEWS[r.slug])); + fs.mkdirSync(OUT, { recursive: true }); + const browser = await h.launch({ args: h.GPU_ARGS }); + const results = []; + for (const row of rows) { + const file = path.join(DIR, 'templates', row.slug, 'scene.tpscene'); + if (!fs.existsSync(file)) continue; + const r = await probe(browser, row.slug, fs.readFileSync(file)); + results.push(r); + console.log(row.slug, r.error ?? '', JSON.stringify(r.objects ?? {})); + for (const v of r.views) console.log(` ${v.mode.padEnd(7)} ${v.label.padEnd(28)} calls ${String(v.calls).padStart(4)} (max ${v.maxCalls})${v.mode === 'vr' && v.calls > BUDGET ? ' OVER' : ''} tris ${v.triangles} renders/frame ${v.renders} batches ${v.batches} (${v.batched} meshes)`); + } + await browser.close(); + let md = `# perf-levels ${LABEL}\n\nCalls per display frame (every render pass summed), mean over ${SECONDS} s per view. Quest budget ${BUDGET} applies to the vr column.\n\n| level | view | desktop calls | vr calls (max) | vr tris |\n|---|---|---:|---:|---:|\n`; + for (const r of results) { + const labels = [...new Set(r.views.map((v) => v.label))]; + for (const l of labels) { + const d = r.views.find((v) => v.mode === 'desktop' && v.label === l); + const v = r.views.find((x) => x.mode === 'vr' && x.label === l); + md += `| ${r.slug} | ${l} | ${d?.calls ?? '-'} | ${v?.calls ?? '-'} (${v?.maxCalls ?? '-'})${v && v.calls > BUDGET ? ' ⚠' : ''} | ${v?.triangles ?? '-'} |\n`; + } + } + fs.writeFileSync(path.join(OUT, `perf-levels-${LABEL}.md`), md); + fs.writeFileSync(path.join(OUT, `perf-levels-${LABEL}.json`), JSON.stringify(results, null, 1)); + console.log(md); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/scene-load-trace.cjs b/scripts/scene-load-trace.cjs new file mode 100644 index 00000000..7495ab6b --- /dev/null +++ b/scripts/scene-load-trace.cjs @@ -0,0 +1,298 @@ +// 33 L1 P0 — WHERE A SCENE LOAD SPENDS ITS MAIN THREAD, on a throttled "phone". +// +// For each scene: a fresh context with a mobile viewport (isMobile + touch, 412x915 @2.6), +// CPU throttled (CPU=6 by default), then two loads measured the same way: +// open the Templates path (fetch .tpscene -> importSessionZip -> requestLoadSession) +// restore autosave -> reload -> the Restore button's call (autosave.restoreSnapshot) +// Each one reports: wall time until every kit piece refilled, the long tasks (> 50 ms, +// PerformanceObserver 'longtask'), the LONGEST gap between two animation frames (what a user +// calls "the window froze"), and the CPU profile's top self-time functions INSIDE long tasks. +// +// APP_URL=https://theprototype.app:5275/ node scripts/scene-load-trace.cjs +// SCENES_DIR= PACKS_DIR= SLUGS=castle-courtyard,... CPU=6 +// OUT= PHASES=open,restore PACK_DELAY=1 (slow pack downloads) +const fs = require('fs'); +const path = require('path'); +const h = require('../tests/e2e/helpers.cjs'); + +const SCENES_DIR = process.env.SCENES_DIR || '/home/deck/.code/theprototype-app/scenes'; +const PACKS_DIR = process.env.PACKS_DIR || '/home/deck/.code/lanes-30/levels-packs'; +const SLUGS = (process.env.SLUGS || 'castle-courtyard,forest-clearing,tavern-interior').split(',').filter(Boolean); +const CPU = Number(process.env.CPU || 6); +const OUT = process.env.OUT || ''; +const PHASES = (process.env.PHASES || 'open,restore').split(','); + +const MOBILE = { viewport: { width: 412, height: 915 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2.6 }; + +/** the in-page probe: long tasks + rAF gaps, reset per phase */ +const PROBE = () => { + const w = /** @type {any} */ (window); + if (w.__loadProbe) return; + const p = { longtasks: /** @type {any[]} */ ([]), maxGap: 0, frames: 0, last: 0, on: false }; + w.__loadProbe = p; + try { + new PerformanceObserver((list) => { + if (!p.on) return; + for (const e of list.getEntries()) p.longtasks.push({ start: Math.round(e.startTime), dur: Math.round(e.duration) }); + }).observe({ type: 'longtask', buffered: false }); + } catch {} + const tick = (/** @type {number} */ t) => { + if (p.on) { + if (p.last) p.maxGap = Math.max(p.maxGap, t - p.last); + p.frames++; + } + p.last = t; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +}; + +const settled = (page, marker) => + page.evaluate((marker) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + /** @type {any} */ let pending; + s.packRefs.packRefsPending.subscribe((v) => (pending = v))(); + let hollow = 0; + let meshes = 0; + g?.traverse((/** @type {any} */ n) => { + if (n.userData?.packStub) hollow++; + if (n.isMesh) meshes++; + }); + return { ok: !!g && g.children.length > 0 && hollow === 0 && pending === 0 && (!marker || !!g.getObjectByName(marker) || !!g.getObjectByName(marker.replace(/ /g, '_'))), top: g?.children.length ?? 0, meshes, hollow, pending }; + }, marker); + +async function measure(page, cdp, label, start, marker) { + await page.evaluate(() => { + const p = /** @type {any} */ (window).__loadProbe; + p.longtasks = []; + p.maxGap = 0; + p.frames = 0; + p.last = 0; + p.on = true; + }); + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.setSamplingInterval', { interval: 500 }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: CPU }); + const perfAtStart = await page.evaluate(() => performance.now()); + await cdp.send('Profiler.start'); + const t0 = Date.now(); + await start(); + let state = null; + const deadline = Date.now() + 240000; + while (Date.now() < deadline) { + state = await settled(page, marker).catch(() => null); + if (state?.ok) break; + await page.waitForTimeout(250); + } + const wall = Date.now() - t0; + // a beat for trailing work (scan, fingerprints, shader compiles on the next frames) + await page.waitForTimeout(2500); + const { profile } = await cdp.send('Profiler.stop'); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }); + const probe = await page.evaluate(() => { + const p = /** @type {any} */ (window).__loadProbe; + p.on = false; + return { longtasks: p.longtasks, maxGap: Math.round(p.maxGap), frames: p.frames, origin: performance.timeOrigin }; + }); + // attribute profile samples to functions (self time over the whole load) + const nodes = new Map(profile.nodes.map((n) => [n.id, n])); + const self = new Map(); + for (let i = 0; i < profile.samples.length; i++) { + const cf = nodes.get(profile.samples[i]).callFrame; + if (cf.functionName === '(idle)' || cf.functionName === '(program)') continue; + const key = (cf.functionName || '(anon)') + ' ' + (cf.url.split('/').slice(-1)[0].split('?')[0] || '') + ':' + (cf.lineNumber + 1); + self.set(key, (self.get(key) || 0) + profile.timeDeltas[i] / 1000); + } + const top = [...self.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25).map(([k, ms]) => ({ fn: k, ms: Math.round(ms) })); + // the caller chain of the heaviest frames: walk parents for the top 8 + const parentOf = new Map(); + for (const n of profile.nodes) for (const c of n.children ?? []) parentOf.set(c, n.id); + const chains = new Map(); + for (let i = 0; i < profile.samples.length; i++) { + const id = profile.samples[i]; + const node = nodes.get(id); + const cf = node.callFrame; + if (cf.functionName === '(idle)' || cf.functionName === '(program)' || cf.functionName === '(garbage collector)') continue; + const frames = []; + let cur = id; + while (cur && frames.length < 40) { + const n = nodes.get(cur); + const f = n.callFrame; + const file = f.url.split('/').slice(-1)[0].split('?')[0]; + if (/src\/(lib|components|stores)|\.svelte/.test(f.url)) frames.push((f.functionName || '(anon)') + '@' + file + ':' + (f.lineNumber + 1)); + cur = parentOf.get(cur); + } + const key = frames.slice(0, 3).join(' < ') || '(no app frame)'; + chains.set(key, (chains.get(key) || 0) + profile.timeDeltas[i] / 1000); + } + // the three longest tasks, each broken down by app call chain (profile clock -> performance.now + // via the moment the profiler started; a few ms of slack either side) + const longest = probe.longtasks.slice().sort((a, b) => b.dur - a.dur).slice(0, 3); + const perTask = longest.map((task) => { + const by = new Map(); + let at = 0; + for (let i = 0; i < profile.samples.length; i++) { + at += profile.timeDeltas[i]; + const when = perfAtStart + at / 1000; + if (when < task.start - 5 || when > task.start + task.dur + 5) continue; + const id = profile.samples[i]; + const frames = []; + let cur = id; + while (cur && frames.length < 60) { + const f = nodes.get(cur).callFrame; + const file = f.url.split('/').slice(-1)[0].split('?')[0]; + if (f.functionName || file) frames.push((f.functionName || '(anon)') + '@' + file + ':' + (f.lineNumber + 1)); + cur = parentOf.get(cur); + } + const key = frames.slice(0, 6).join(' < '); + by.set(key, (by.get(key) || 0) + profile.timeDeltas[i] / 1000); + } + return { dur: task.dur, start: task.start, top: [...by.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, ms]) => ({ ms: Math.round(ms), chain: k })) }; + }); + const appChains = [...chains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15).map(([k, ms]) => ({ chain: k, ms: Math.round(ms) })); + const long = probe.longtasks.slice().sort((a, b) => b.dur - a.dur); + const total = probe.longtasks.reduce((s, e) => s + e.dur, 0); + const result = { + label, + cpu: CPU, + wallMs: wall, + settled: state, + longtasks: probe.longtasks.length, + longtaskTotalMs: total, + maxLongtaskMs: long[0]?.dur ?? 0, + top5Longtasks: long.slice(0, 5).map((e) => e.dur), + maxFrameGapMs: probe.maxGap, + frames: probe.frames, + topSelf: top, + appChains, + perTask + }; + console.log(JSON.stringify({ label, wall, longtasks: result.longtasks, max: result.maxLongtaskMs, total, maxGap: probe.maxGap, frames: probe.frames, settled: state })); + return result; +} + +const MARKERS = { 'castle-courtyard': 'Castle gate', 'forest-clearing': 'Footbridge', 'tavern-interior': 'Balcony stairs' }; + +h.run(async () => { + const index = JSON.parse(fs.readFileSync(path.join(SCENES_DIR, 'index.json'), 'utf8')); + const browser = await h.launch({ args: h.GPU_ARGS }); + const results = []; + for (const slug of SLUGS) { + // MANY=1200: a synthetic scene of that many ordinary meshes (no kit references), opened + // through applySession — the case where the build loop itself is the cost + if (slug === 'many-boxes') { + const n = Number(process.env.MANY || 1200); + const A = await h.setupPage(browser, slug, { context: MOBILE }); + const page = A.page; + await page.evaluate(PROBE); + await page.evaluate((n) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + for (let i = 0; i < n; i++) { + const m = new s.THREE.Mesh(new s.THREE.BoxGeometry(0.4, 0.4 + (i % 7) * 0.1, 0.4), new s.THREE.MeshStandardMaterial({ color: (i * 2654435761) & 0xffffff })); + m.name = 'box-' + i; + m.position.set((i % 40) - 20, 0.2, Math.floor(i / 40) - 15); + g.add(m); + } + s.pokeScene(); + /** @type {any} */ (window).__many = s.sessions.buildSessionPayload('Many boxes'); + s.sceneLoader.cancelLoad(); + }, n); + await page.waitForTimeout(2000); + const cdp = await page.context().newCDPSession(page); + results.push({ slug, ...(await measure(page, cdp, slug + ' open', () => page.evaluate(() => void window.__stores.sessions.applySession(/** @type {any} */ (window).__many, { backup: false })), 'box-0')) }); + await A.ctx.close(); + continue; + } + const entry = [...(index.templates ?? []), ...(index.examples ?? []), ...(index.games ?? [])].find((t) => t.slug === slug); + if (!entry) { + console.log('no entry for ' + slug); + continue; + } + const A = await h.setupPage(browser, slug, { context: MOBILE }); + const page = A.page; + await page.route('**/cdn.jsdelivr.net/**', async (route) => { + const url = route.request().url(); + const scenes = url.match(/\/theprototype-app\/scenes@[^/]+\/(.*)$/); + if (scenes) { + const file = path.join(SCENES_DIR, decodeURIComponent(scenes[1])); + if (scenes[1] === 'index.json') return route.fulfill({ json: index }); + if (fs.existsSync(file)) return route.fulfill({ body: fs.readFileSync(file) }); + return route.fulfill({ status: 404 }); + } + const packs = url.match(/\/theprototype-app\/packs@[^/]+\/(.*)$/); + if (packs) { + const file = path.join(PACKS_DIR, decodeURIComponent(packs[1])); + if (fs.existsSync(file)) { + // PACK_DELAY=1: a phone on a real link — each pack file lands 0.6-2.7 s later (the + // scene-load suite's staggered delay), so pieces arrive one file at a time + if (process.env.PACK_DELAY && /\.glb$/i.test(file)) { + let x = 0; + for (const ch of url) x = (x * 31 + ch.charCodeAt(0)) >>> 0; + await new Promise((r) => setTimeout(r, 600 + (x % 8) * 300)); + } + return route.fulfill({ body: fs.readFileSync(file) }).catch(() => {}); + } + } + return route.continue(); + }); + await page.evaluate(PROBE); + const cdp = await page.context().newCDPSession(page); + const sceneUrl = await page.evaluate((p) => window.__stores.sceneTemplates.resolveUrl(p, window.__stores.sceneTemplates.SCENES_BASE), entry.scene); + const marker = MARKERS[slug]; + if (PHASES.includes('open')) + results.push({ + slug, + ...(await measure(page, cdp, slug + ' open', () => + page.evaluate((url) => { + // not awaited: the probe measures the page while it works + void window.__stores.sceneTemplates.loadRemoteScene({ slug: 'x', title: 'x', sceneUrl: url }); + }, sceneUrl), marker)) + }); + if (PHASES.includes('restore')) { + if (!PHASES.includes('open')) { + await page.evaluate((url) => window.__stores.sceneTemplates.loadRemoteScene({ slug: 'x', title: 'x', sceneUrl: url }), sceneUrl); + await h.eventually(() => settled(page, marker), (r) => r?.ok, slug + ' loaded for the restore', 120000); + } + await page.waitForTimeout(1500); + await page.evaluate(() => window.__stores.autosave.saveNow()); + const snap = await page.evaluate(async () => { + const s = await window.__stores.idb.idbGet('latest'); + return { bytes: s?.scene?.byteLength ?? (s?.scene ? JSON.stringify(s.scene).length : 0), objects: s?.objects }; + }); + console.log('autosave snapshot', JSON.stringify(snap)); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__stores && !!window.__stores.moduleSDK, { timeout: 60000 }); + await page.evaluate(PROBE); + await h.eventually( + () => page.evaluate(() => { let v; window.__stores.autosave.restoreAvailable.subscribe((x) => (v = x))(); return !!v; }), + (v) => v, + slug + ' the Restore offer is up after the reload', + 30000 + ); + const cdp2 = await page.context().newCDPSession(page); + results.push({ + slug, + snapshot: snap, + ...(await measure(page, cdp2, slug + ' restore', () => page.evaluate(() => void window.__stores.autosave.restoreSnapshot()), marker)) + }); + } + await A.ctx.close(); + } + if (OUT) { + fs.mkdirSync(OUT, { recursive: true }); + const stamp = process.env.TAG || 'run'; + fs.writeFileSync(path.join(OUT, 'scene-load-' + stamp + '.json'), JSON.stringify(results, null, 1)); + const md = [ + '| load | wall ms | long tasks | total long ms | max long ms | max frame gap ms | top 5 |', + '|---|---:|---:|---:|---:|---:|---|', + ...results.map((r) => `| ${r.label} | ${r.wallMs} | ${r.longtasks} | ${r.longtaskTotalMs} | ${r.maxLongtaskMs} | ${r.maxFrameGapMs} | ${r.top5Longtasks.join(', ')} |`) + ]; + fs.writeFileSync(path.join(OUT, 'scene-load-' + stamp + '.md'), md.join('\n') + '\n'); + console.log(md.join('\n')); + } + await browser.close(); +}); diff --git a/src/App.svelte b/src/App.svelte index 4d640087..fc5b611a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -34,6 +34,8 @@ import StatsOverlay from './components/menu/StatsOverlay.svelte' import { startSceneMetrics, budgetSummary } from './lib/sceneBudget' import { startLod } from './lib/lod' + import { startLodGroups } from './lib/lodGroup' + import { startKitInstancing } from './lib/kitInstancing' import PlayReticle from './components/play/PlayReticle.svelte' import TouchPlayControls from './components/play/TouchPlayControls.svelte' import DrawToolbar from './components/menu/DrawToolbar.svelte' @@ -141,6 +143,10 @@ import { startMusicToolbox } from './lib/musicToolbox' startSceneMetrics() // 31-perf P2: automatic levels of detail (a render-time swap; see lod.js) startLod() + // 33: per-object LOD GROUPS (userData.lod / a pack item's `lods`) ride the same render pass + startLodGroups() + // 33-scenes: pristine copies of one kit piece draw as ONE instanced call (kitInstancing.js) + startKitInstancing() // …and the numbers ride the bundle, so a report carries them (audit H4 + roadmap // 26 section 3's last row). registerDiagnosticsSection('scene-budget', () => budgetSummary()) @@ -494,9 +500,15 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/moduleWorld'), import('./lib/playSpawn'), import('./lib/packRefs'), - import('./lib/lod') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib } + import('./lib/lod'), + import('./lib/lodGroup'), + import('./lib/lodGroupActions'), + import('./lib/lodLevelEdit'), + import('./lib/packBehavior'), + import('./lib/sceneLoader'), + import('./lib/kitInstancing') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib, lodGroupLib, lodGroupActionsLib, lodLevelEditLib, packBehaviorLib, sceneLoaderLib, kitInstancingLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib, lodGroup: lodGroupLib, lodGroupActions: lodGroupActionsLib, lodLevelEdit: lodLevelEditLib, packBehavior: packBehaviorLib, sceneLoader: sceneLoaderLib, kitInstancing: kitInstancingLib } }) } }) diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index ccbf5950..b0e218db 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -37,6 +37,9 @@ import { viewPrefs } from '$lib/viewPrefs'; import { shadowQuality } from '$lib/lightParams'; import { useTask, useThrelte } from '@threlte/core'; + import { framesHeld, registerComposerWarm, schedule } from '$lib/sceneLoader'; + import { WebGLRenderTarget } from 'three'; + import { tick } from 'svelte'; import { BlendFunction, EffectComposer, @@ -387,6 +390,43 @@ // frozen world strapped to their face with no overlay (DOM is invisible in VR). let renderIsPaused = false; const stopPauseWatch = renderPaused.subscribe((value) => (renderIsPaused = !!value)); + // 33 L1: compile the composer's pass shaders OFF-FRAME. A scene load restores its look (the + // post stack) and the composer would otherwise build every pass's program — the effect pass, + // SMAA, bloom's internal passes — inside the next frame (the last >200 ms task of a load on a + // CPU x6 phone). Each pass carries its own fullscreen scene + camera once `addPass` has + // initialised it, and an effect's internal passes hang off the effect itself. Compiled one per + // slice against the target that pass draws into, then each program's first use per slice. + let warmComposerTarget: any = null; + async function warmComposerPasses() { + await tick(); // the post document's effect rebuilds the stack first + if (!warmComposerTarget) warmComposerTarget = new WebGLRenderTarget(1, 1); + const found: any[] = []; + const seen = new Set(); + const visit = (pass: any) => { + if (!pass || typeof pass !== 'object' || seen.has(pass)) return; + seen.add(pass); + if (pass.fullscreenMaterial && pass.scene && pass.camera) found.push(pass); + for (const effect of pass.effects ?? []) + for (const value of Object.values(effect)) if ((value as any)?.fullscreenMaterial) visit(value); + }; + for (const pass of (composer as any).passes ?? []) visit(pass); + for (const pass of found) + await schedule(() => { + const previous = renderer.getRenderTarget(); + renderer.setRenderTarget(pass.renderToScreen ? null : warmComposerTarget); + try { + renderer.compile(pass.scene, pass.camera); + } finally { + renderer.setRenderTarget(previous); + } + }); + for (const program of [...((renderer.info as any)?.programs ?? [])]) await schedule(() => program.getUniforms?.()); + } + registerComposerWarm(warmComposerPasses); + onDestroy(() => { + registerComposerWarm(null); + warmComposerTarget?.dispose?.(); + }); onDestroy(stopPauseWatch); // 26-D THE INGEST DRAW GAP (26-E's finding): while a big received scene drains through // slow frames, draw at most one frame per gap — every object's parse waits for a frame to @@ -399,6 +439,8 @@ useTask( (delta) => { if (renderIsPaused && !renderer.xr.isPresenting) return; + // 33 L1: a scene load holds the last frame while its programs link off-frame (bounded) + if (framesHeld() && !renderer.xr.isPresenting) return; if (drawGapMs > 0 && !renderer.xr.isPresenting) { const drawNow = performance.now(); if (drawNow - lastDrawAt < drawGapMs) return; diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index b47e1714..1363e02c 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -40,10 +40,12 @@ import { moduleInteractiveGroups, fireClickMiss, runClickHandlers } from '$lib/moduleSDK'; import { updateSpatialAudio } from '$lib/voiceChat'; import { tickAnimatedMixers } from '$lib/animatedImports'; + import { startPackBehaviors, tickPackBehaviors } from '$lib/packBehavior'; import { tickAnimationPreview, captureAutoKey, playheadOf } from '$lib/animationPreview'; import { drawMode, drawTool, strokePointFromRay, endStroke, setDrawScene } from '$lib/drawMode'; import { splinePlaceFromRay, splineToolActive, finishSpline } from '$lib/splineTool'; import { flattenPicking, flattenPickClick } from '$lib/flattenActions'; + import { onLodProxyMoved, onLodProxyDragChanged } from '$lib/lodLevelEdit'; import { splineEditObject, splineEditClick, splineEditRightClick, exitSplineEdit, beginRadiusDrag, radiusDragMove, endRadiusDrag, radiusDragActive, onSplineProxyMoved, onSplineProxyDragChanged, tickSplineEdit } from '$lib/splineEdit'; import { capturePathClick } from '$lib/pathCapture'; import { surfaceSnap, dropToSurface } from '$lib/snapping'; @@ -388,6 +390,7 @@ updateVRControls(); // also manages ray/hover visibility outside sessions updateSpatialAudio(camera.current, scene); // voices follow avatars (throttled) tickAnimatedMixers(); // imported clips run on the synced clock + tickPackBehaviors(); // 33 P2: doors/lids/levers pose from their shared state (rest in Edit) tickAnimationPreview(); // Animation window: local transform preview (not synced) tickMeshEdit(); // vertex handles follow the object if it moves (119) tickEditWireframe(); // ...and the edit wireframe stays parented to it (faceEdit) @@ -424,7 +427,7 @@ // multi-select pivot excludes EVERY member (P3: its drags snap too). if (!event.value) endSnapDrag(); else if (object.userData?.isMultiPivot) beginSnapDrag([...$selectedObjects]); - else if (!object.userData?.isVertexProxy && !object.userData?.isFaceProxy) + else if (!object.userData?.isVertexProxy && !object.userData?.isFaceProxy && !object.userData?.isLodLevelProxy) beginSnapDrag([object.uuid]); // vertex handles record their own history entries if (object.userData?.isVertexProxy) { @@ -441,6 +444,11 @@ onSplineProxyDragChanged(event.value); return; } + // 33: one LOD level's offset — ONE lod write per drag, never a move + if (object.userData?.isLodLevelProxy) { + onLodProxyDragChanged(event.value); + return; + } // the multi-select pivot records per-member entries (multiTransform) if (object.userData?.isMultiPivot) return; if (event.value) { @@ -1435,6 +1443,7 @@ // A2: the hand that hit gets a buzz — LOCAL, the same seam shape as the hand poses startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticKnock }); startClap({ hands: handSnapshot }); // 31: the same hand seam + startPackBehaviors({ hands: handSnapshot }); // 33 P2: functional pack items (the knock reads hands) // 30b: game feel in VR (a frame hook + a trigger hook through vrControls' registries) startVrGameInput(); @@ -1492,6 +1501,12 @@ onSplineProxyMoved(); return; } + // 33: a LOD level's proxy — the level follows locally; the release commits + if ($TControls.object?.userData?.isLodLevelProxy) { + $TControls.visible = true; + onLodProxyMoved(); + return; + } // multi-select pivot: multiTransform drives + broadcasts the members, // the pivot itself is local-only (its uuid means nothing to peers) if ($TControls.object?.userData?.isMultiPivot) { diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index f7829e7c..9087370b 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -32,7 +32,7 @@ // posed from the synced clock) — the window used to ignore them entirely, so // a rigged model showed "no movements yet" and its own animations were // reachable only from the Inspector. - import { animatedObjects, setAnimationState, clipInfo } from '$lib/animatedImports'; + import { animatedObjects, setAnimationState, clipInfo, behaviorOf } from '$lib/animatedImports'; import { SkipBack, SkipForward, StepBack, StepForward, Play, Pause, Square, Rewind, ZoomIn, ZoomOut, Maximize2, SquareDashed, Lasso, Ghost @@ -142,6 +142,13 @@ $animatedObjects; return target ? clipInfo(target.uuid) : []; }); + // 33 P2: a FUNCTIONAL item (a door, a lid) has no transport: its clip buttons PREVIEW + // locally (never sent, never saved, back to rest at the end) — it plays for real only + // on its trigger, in Interact or Play + const behavior = $derived.by(() => { + $animatedObjects; + return target ? behaviorOf(target.uuid) : null; + }); // transport is per object now (N objects can play at once); the playhead time // comes from the per-frame `playheads` store, the transport state does not. @@ -1863,7 +1870,7 @@ {#each clips as clip (clip.name)} {/each} + {#if behavior} +

+ {behavior.type === 'loop' && behavior.autoplay ? 'Ambient loop' : 'Functional item'} — runs in + Interact or Play{behavior.type === 'loop' && behavior.autoplay ? '' : ' on its ' + behavior.trigger}. + Here ▶ previews it once; nothing is sent or saved. +

+ {/if}
+ {/each} + {#if info.block.cull} +
+ Culled +
+ {/if} + {#each info.levels as l, i (i)} + {#if i < info.levels.length - 1 || info.block.cull} +
+
+
+ {/if} + {/each} + +
+
+
+ {#each info.levels as l, i (i)} + LOD{i} < {pct(l.screenSize)} + {/each} + now: LOD{info.current < 0 ? ' culled' : info.current} at {pct(Math.min(1, info.size))} +
+ +
+ Force LOD + forceLodLevel(uuid, v === 'auto' ? 'auto' : Number(v))} + /> +
+ + + + + {#if level && sel !== null} +
+
+ LOD{sel} + previewing on this screen +
+

{sourceLabel(level)} · {level.tris != null ? level.tris + ' tris' : level.status}

+ {#if level.error}

{level.error}

{/if} + {#if sel === 0} +

LOD0 is the object itself — edit it with the rest of this panel.

+ {:else} + {#if level.source === 'generated'} + (ratioDraft = v / 100)} + onscrubend={() => updateLodLevel(uuid, /** @type {number} */ (sel), { ratio: ratioDraft })} + /> + + {/if} +
+ Replace with + { + if (!v || sel === null) return; + if (v === '__generated') replaceLodLevel(uuid, sel, { source: 'generated', ratio: level.ratio ?? 0.5 }); + else replaceLodLevel(uuid, sel, { source: 'object', ref: v }); + }} + /> +
+

…or drop a model from the Explorer on the bar.

+
+ {#if $lodLevelGizmo?.uuid === uuid && $lodLevelGizmo?.level === sel} + + {:else} + + {/if} + {#if level.offset} + + {/if} + +
+ + {#if level.material} +
+ Colour + updateLodLevel(uuid, /** @type {number} */ (sel), { material: { ...level.material, color: e.currentTarget.value } })} + /> +
+ updateLodLevel(uuid, /** @type {number} */ (sel), { material: { ...level.material, roughness: v } })} + /> + updateLodLevel(uuid, /** @type {number} */ (sel), { material: { ...level.material, metalness: v } })} + /> + {/if} + {/if} +
+ {:else} +

Click a level on the bar to select it: the viewport shows that level while it is selected.

+ {/if} + +
+ + + + {#if !info.implicit} + + {/if} +
+ {/if} + + + diff --git a/src/components/menu/SceneLoadBar.svelte b/src/components/menu/SceneLoadBar.svelte new file mode 100644 index 00000000..f1ecf322 --- /dev/null +++ b/src/components/menu/SceneLoadBar.svelte @@ -0,0 +1,186 @@ + + +{#if visible && $sceneLoad} + +
+
+
+

+ {verb} {$sceneLoad.name} + — {what} +

+ {#if $sceneLoad.cancellable} + + {/if} +
+
+
+
+
+
+{/if} + + diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 11581476..f8792dd6 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -31,6 +31,7 @@ import { shadowQuality } from '$lib/lightParams'; import { autoQuality } from '$lib/qualityGovernor'; import { lodEnabled } from '$lib/lod'; + import { kitInstancingEnabled } from '$lib/kitInstancing'; import { myHandModel, setMyHandModel } from '$lib/handModels'; import { explorerItems } from '$lib/explorer'; import { pingColor, pingSound } from '$lib/ping'; @@ -1058,6 +1059,10 @@ Draw a lighter version of a dense model (a few thousand triangles or more) when it is far from you, built once per model on THIS machine. Nothing in the scene changes — the full model is what is saved, sent and edited + + + Every copy of one pack piece (a wall, a floor tile, a battlement) is drawn in one go instead of one by one, which is what keeps a level built from the kits inside a headset's budget. Only on THIS machine; an edited or selected piece is always drawn on its own + Show the physics transport (play/pause/stop/reset) at bottom-right. Off by default to avoid confusion with the main play button; the P key still starts/stops the simulation diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index d1d1217a..869c81cc 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -36,6 +36,7 @@ import { sceneCommand } from '$lib/commandsHandler.svelte'; import { objectsGroup, camSave, globalCamera, globalScene } from '../../stores/sceneStore.js'; import { Progressbar, Toast, Button } from 'flowbite-svelte'; + import SceneLoadBar from './SceneLoadBar.svelte'; import { fly } from 'svelte/transition'; import { untrack } from 'svelte'; // P2b: watching follows a peer's camera IN THIS WORLD, so it cannot survive them @@ -465,6 +466,8 @@ $effect(() => { children's z-index, breaking "approvals above modals"), so it centres with auto margins. -->
+ +