diff --git a/CHANGELOG.md b/CHANGELOG.md index e01361e7..24e601fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,88 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.19.0 โ€” Doors that open, scenes that load, games that switch ๐Ÿšช + +### ๐Ÿงฑ New packs and levels + +- ๐Ÿ“ฆ **Four new packs**: **Interiors** (furniture, kitchen, bar, lights and wall trims that snap to the + architecture walls), **Town & Market** (seamless street tiles, a fountain, stalls, a clock tower, an + opening garden gate), **Interactive** (doors WITH their frames, gates, a portcullis, a trapdoor, + shutters, chests, drawers, a lever, a pressure plate, an ambient banner, torch and fan) and + **Arcane Study** (a wizard's hero props). +- ๐Ÿฐ **Two new example levels** in Templates โ–ธ General โ€” the **Wizard's Tower** (alchemy lab, study, + bedchamber, a roof telescope) and **Market Town Square** (fountain, stalls, a bakery you walk into) โ€” + and the **Tavern Interior** rebuilt: its doors open, it is furnished from the Interiors kit and it + stands on a street. The **Architecture shell** template is built from the kits too, with a door + that opens. +- ๐ŸงŠ **No more flicker** on kit pieces while you move around (the modular block and twelve others). + +### ๐Ÿšช Things that move, only when asked + +- ๐Ÿšช **Doors, lids and levers work** in Interact and Play: click (or point the VR laser, poke, or knock) + and a door swings open with a sound, and you can walk through; click again and it shuts. Everyone in + the session sees the same door. A sliding door can open as you walk up to it. +- ๐Ÿ›‘ **Nothing animates by itself any more.** Placing or loading an animated model no longer starts it + looping; only ambient pieces (a banner, a fan, a torch) move, and only in Interact/Play. The + Animation panel can preview a clip in Edit without changing the scene. Animated pack items carry a + small โ–ถ badge in the Explorer. + +### ๐Ÿ”ญ Levels of detail, like a pro tool + +- ๐Ÿ”ญ **Every pack object has LODs**, and far-away pieces draw with far fewer triangles. +- ๐ŸŽš๏ธ **Properties โ–ธ LOD**: a level bar whose edges you drag, Force LOD, select a level to preview and + move it, replace a level with another object, generate levels, a "Show LOD level" overlay โ€” and a + LOD row in the VR properties panel. +- ๐Ÿงฎ **Kit levels draw far fewer calls**: every copy of a pack piece is drawn in one go (Settings โ–ธ + Performance โ–ธ Draw repeated kit pieces together) โ€” the Tavern went from 394 to ~135 draw calls in a + headset. + +### โณ Scenes that load without freezing + +- โณ **Opening and restoring big scenes no longer freezes the app**: a progress bar with Cancel, pieces + stream in, and the autosave of a kit level is ~50ร— smaller (Castle Courtyard's restore on a phone + went from a 90-second freeze to a responsive load). +- ๐Ÿ” **Opening another scene asks whether to keep the modules** the last one brought (Settings โ–ธ Scene: + Ask, Keep or Unload). **Clear scene** asks too: clear the objects, or everything including the game + setup and its modules. One game no longer leaks its music, gun, menu or spawn into the next. + +### ๐ŸŽฎ The games + +- ๐Ÿ™Œ **Towers: two hands, two blocks** โ€” hold a block in each hand; letting go of one never freezes + the other. +- ๐Ÿ“ฑ **Stars Room on phones**: phones start at a lighter quality (half the draw calls and render + passes), so it no longer lags. +- ๐Ÿชข **Untangle 2.4.0**: in VR push the stick up/down to send the board or the held globe away or bring + it closer (as with an object in Edit); the level picker and the Globe / 2D tabs show over the menu + board. +- ๐Ÿ”ค **Crisper VR menus** (the game board and the wrist card render at full headset resolution) and + the menu's **Menu / Top strip** buttons fit inside the board in every game. + +### ๐Ÿ› ๏ธ Editor + +- ๐Ÿงฐ **Toolbar order**: Move, Rotate, Scale, **Interact**, **Play**, Object list, Node editor, + Explorer, Animation. The Interact button no longer shows a red border on click or leaves two + un-highlighted corners beside Play. +- โœ‹ **Edit inside a game**: every object can be moved while a game runs โ€” a dropped body stays where + you put it until you leave Edit. In VR, select a wall or floor first to grip it. +- ๐Ÿ“ˆ **FPS and draw calls** as a setting (Settings โ–ธ Interface โ–ธ Viewport, and in VR): fps, frame + time, draw calls (amber above 120, red above 150 โ€” the Quest budget) and triangles. +- ๐Ÿ•น๏ธ **VR stick push/pull**: holding the world with one grip, the stick sends it away or brings it + closer, as with an object in Edit. + +### ๐Ÿฉน Fixes + +- ๐Ÿšถ In Play the walker no longer bumps into pass-through objects (grass, flowers, rugs); an open + garden gate lets you through. +- ๐Ÿšช A door or chest from a pack saves as a reference to its pack file (a few hundred bytes instead of + up to 1.4 MB). + +### ๐Ÿงฉ For module makers + +- `api.behavior` (list / state / trigger functional items) ยท `api.lod` handles gain `force(n)` and + `levels()` ยท `api.claimInput('sticks')` ยท `addSetting({..., onLevels: true})` ยท `api.inScene()` + (false while your module is left behind by a scene switch). + ## 1.18.0 โ€” Menus, levels and smooth frames in a headset ๐Ÿฅฝ ### โธ๏ธ Every game has a real menu diff --git a/CLAUDE.md b/CLAUDE.md index ae245e52..9bda31b0 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 @@ -2368,6 +2409,101 @@ loadable play content. Everything a user does must be visible to connected peers template โ€” `transientObjects.spawnedFromOf`), `pointGrab.js` (Point Grab node gates the VR grip RAY and the desktop carry; touch still holds), Game Setting node. +- **ROADMAP 33 โ€” MODULES ACROSS A SCENE SWITCH (33-scene-switch, L2/L3/L4)**. A module is a SCENE + SCRIPT more than an editor plugin, so the modules a scene came with are what a switch is about. + ยท `sceneScope.js` (a LEAF): the modules the scene USED when it was cleared (`noteSceneLeaving`, + first line of `clearSceneLocal`) and the next scene does not use are LEFT BEHIND + (`leftBehindModules`, `ownerInScope(owner)`); judged at applySession's end (`sceneArrived` -> + `settleScope(declared)`) and, for a peer's replacement (clear + objects + nodes, no applySession + here), by a debounced recompute on flowGraphs/objectsGroup/moduleNodeGroups while anything is + pending. "Used" = the payload's declared `modules` โˆช moduleRequirements() (registered by + sceneSwitch through `registerSceneUsage`). A fresh install in a blank scene is IN scope (nothing + left it behind), which is what keeps every existing suite/flow byte-unchanged. + ยท What a left-behind owner registered STOPS COUNTING and comes back with its scene, no + re-registration: gameShell levels/help kept PER OWNER (newest in-scope shows), restart hooks + carry an owner, gameSettings rows (`allRows` -> in-scope `gameSettingRows`), `gameMusic` tracks + are OWNED (`playGameMusic(preset, opts, owner)`, a left-behind owner's track stops and it cannot + start one, `stopGameMusic(owner)` stops only that owner's; core '' = flow nodes / the shell), + the runtime spawn, and `playPublishers` (a kept module's group `userData.play` โ€” Untangle's free + cursor + world grab โ€” no longer overrides the next game). `api.inScene()` lets a module stand + down its own drawing (Waves' gun) โ€” the one thing core cannot scope. + ยท Teardown journal holes closed: `api.music` (a module's track outlived its unload), + `api.followCam`. Core Towers' onSceneClear now unregisters its levels/help (it reset `wasActive` + only, so its twelve levels stayed in the next game's menu). + ยท `sceneSwitch.js` (not a leaf; boot `startSceneSwitch`): the setting `modulesOnOpen` + (`scenes:modulesOnOpen`, ask default / keep / unload, Settings โ–ธ Scene `#modules-on-open`) and + `prepareSceneSwitch(payload)` โ€” the ask `#confirm-keep-modules` lists loaded USER modules the + scene being left USES and the incoming one does not need (a tool no scene uses, and every core + module, is never asked about); Unload primary, Keep, "Remember my choice"; Cancel aborts the open. + It returns a `run()` the caller fires when the load really applies (`requestLoadPayload` solo, + or the proposal's `beforeApply`). Unload = `deactivateModule` + `disabledModules` (persisted) + + a toast with [Modules]; the "This scene uses modules" prompt's Enable is LIVE now + (`enableRequired` re-activates the user record / core module). The Explorer's Open passes + `travelToLevel(hash, '', {askModules: true, freshGame: true})` โ€” the ask, and the file's OWN game + state instead of fork 3's carry (the carry stays for the travel NODE and Go-to). + ยท Clear scene (`sceneTemplates.confirmClearScene`, Sidebar `#clear-scene`) is ONE modal + (`#confirm-clear-scene`): "Clear objects" primary + one box "Also reset the game setup and unload + its modules" (OFF, ON when there are no objects) relabelling it "Clear everything" = + `sceneSwitch.clearSceneEverything` = unload the scene's modules + `applySession(emptySession + Payload, {backup:false, workspace:false, quiet:true})` (replicated: flow, HUD, game state, play + block, music, sky/look reset for everyone). Objects-only toasts "Still here: โ€ฆ" + [Clear those + too]. The Templates Blank card = `confirmClearScene({blank: true})` (`#confirm-blank-scene`, + always the full reset). `confirmDialog.showChoiceEx` = choices + `items` + one `checkbox` + + per-choice `checkedLabel`, resolving `{value, checked}`. + ยท Suites `scene-switch` / `-keep` / `-clear` / `-games` (shared `sceneSwitchShared.cjs`; real + .tpscene + zips) + vitest `sceneScope`. TIMING: an open into a non-empty world is 35-50 s on the + shared box, measured identical on pristine c7018be (the backup stash + load) โ€” โ‰ค5 opens a suite. +- **ROADMAP 33 โ€” LOD GROUPS (33-lod-editor, contract P1)** (`lodGroupCore.js` pure + vitest, `lodGroup.js` runtime, + `lodGroupActions.js` write path, `lodLevelEdit.js`, `menu/LodGroupPanel.svelte`). **No THREE.LOD node in the tree** + (objectsGroup is the replicated document, and an animated item's mixer binds by node NAME, so three copies would + freeze two): a group is `userData.lod = {mode: auto|forced, forced?, bias?, cull?, levels: [{source: self|pack| + generated|explorer|object, ref?, ratio?, screenSize, offset?, material?}]}` applied at RENDER time through lod.js's + `registerLodPass` seam โ€” a level whose meshes match by node name is a per-mesh GEOMETRY SWAP on the same tree (LOD0's + own material, animation keeps playing, picking/physics/serializers see LOD0); an unrelated model is a scene-root + LOCAL substitute; culled = hidden for that render. `screenSize` = projected bounding-sphere height / viewport (Unity's + transition height), defaults 25 % / 10 % (0.30/0.12/0.04 for three levels), 15 % hysteresis (a head bob switches + once), the governor's `lodBias` divides every threshold. A pack row's `lods: [{file, ratio}]` becomes the placed + piece's group; a piece placed BEFORE its pack had lods gets an IMPLICIT group from the row (not saved). Replication = + `objectParameters {parameter: 'lod'}` (wireValidate constrains only that parameter), undo = the `props` kind's `lod` + key, a threshold drag = ONE entry + ONE message. The level preview, "Show LOD level" overlay and current level are + LOCAL. `api.lod` handles gain `force(n)`/`levels()`; VR props panel has a LOD row. Suites lod-group, lod-panel (two + peers), lod-real-packs (LOD_PACKS_DIR), vr-props-panel. +- **ROADMAP 33 โ€” FUNCTIONAL PACK ITEMS (33-anim-core, contract P2)** (`behaviorCore.js` pure + vitest, `packBehavior.js`). + **NOTHING AUTOPLAYS**: an animated import registers with its clip selected and NOT playing (the first play schedules + the action). A pack row (or a GLB's `scene.extras.behavior`; the row wins) may carry `behavior {type: door|toggle| + oneshot|loop, clip, closeClip?, trigger: click|proximity|knock, autoplay, sound?, collider?}`: rest pose in Edit, + triggers in Interact/Play through moduleSDK's ONE click dispatch (desktop click, Play tap, VR laser + poke), proximity + 1.5 m, knock = a VR hand >= 0.6 m/s; `loop`+`autoplay` = ambient, Interact/Play only. State = the `behavior` message + `{uuid, on, at (sessionNow), from, n}`, latest-wins, ROOM_SCOPED, wireValidate shape, every peer derives the pose + from the stamp; a late joiner gets `behaviorState` on its `objectfile`; runtime only (never saved, never undone). + The frame becomes custom slab colliders with the doorway cut out, each moving node a following kinematic box (while + a sim runs). Five game sounds door/gate/slide/lever/lid; the Explorer's โ–ถ badge; the Animation panel previews LOCALLY; + `api.behavior {list, state, trigger}`. A sim's "simulate the selection" fallback skips functional items. + **`animRef` (33-scenes)**: an animated import that IS a pack file saves `animRef {pack, item, path}` instead of + 0.5-1.4 MB of base64; restore fetches it once per url (an older core loads such a door as nothing). +- **ROADMAP 33 โ€” KIT INSTANCING (33-scenes)** (`kitInstancing.js` + pure `kitInstancingCore.js`): every pristine copy + of a pack piece draws as ONE InstancedMesh per template mesh, render-time only (lod.js `registerBatchPass`, AFTER the + LOD swaps) โ€” tree, picking, physics and serializers untouched; packRefs records which template mesh each copy cloned + (`kitMeshSource`). Excluded: selected, hidden, transparent, recoloured/edited copies; members are frustum-culled one + by one, 128 m columns. LOCAL pref Settings โ–ธ Performance "Draw repeated kit pieces together" (default on). The + Tavern went 394 -> ~135 calls in the headset analogue. `scripts/perf-levels.cjs` / `level-views.cjs` measure the + General-tab levels per viewpoint. The levels live in `scripts/level-templates.cjs` (six since 33: Castle, Forest, + Tavern, Wizard's Tower, Market Square, and the kit Architecture shell โ€” whose greybox stays the OFFLINE seed via the + author-templates `remote: false` def field). +- **ROADMAP 33 โ€” EDITOR UI + GAMES (33-editor-ui, 33-games, 33-untangle-core)**: the default Controls bar is + Move/Rotate/Scale, Interact, Play, list, nodes, Explorer, Animation (an untouched stored default MIGRATES, a + customised bar wins); the Interact toggle's ring is keyboard-only (`focus-visible`) and the well halves answer + `*:hover`. In Edit inside a GAME scene a gizmo release PARKS a dynamic body (physics hold `'edit'`), released at rest + on leaving Edit; in VR a SELECTED wall/floor is grip-held in Edit (unselected scenery still moves the world). Q1: + Settings โ–ธ Interface โ–ธ "FPS and draw calls" (`fpsMeter.perfStatsShown`, `#show-perf-stats`) โ€” the one desktop + `FpsCounter`, a head-locked `vrPerfStrip.js` in VR, calls amber > 120 / red > 150; XR frames feed the draw-call sampler + (`sceneBudget.noteExternalFrame`) because the window rAF stops in a headset. Games: per-hand VR `grabs[]` (a second + hand's grab overwrote the one slot and FROZE the first block), `footerLayout`/`fitLabel` keep the VR board footer + inside a 720-px board, `PHONE_START_LEVEL` 4 (phones are judged heavy like headsets), board `SCALE` 2 + `WRIST_RES` + 2 (every canvas panel >= 1 texel per Quest pixel). `api.claimInput('sticks')` + the world-pan stick REEL (stick Y + pushes/pulls a one-grip world, as Edit does an object); `addSetting({onLevels: true})` draws a choice as tabs on the + VR Levels page, which now lists every level. + ## Replication golden rules 1. Every mutation = apply locally + `$peers.send({type, ...})`; receivers apply WITHOUT @@ -2462,6 +2598,21 @@ loadable play content. Everything a user does must be visible to connected peers ## Hard-won gotchas (do not rediscover) +- **RAPIER'S KinematicCharacterController COLLIDES WITH SENSORS unless the query passes `EXCLUDE_SENSORS`** โ€” the + walker treated every grass patch, flower bed, rug and trim of the 30c levels as a wall (33-scenes, suite + walker-sensors). And a frame box MINUS a low leaf leaves a wall OVER a garden gate: the passage runs to the frame top + and slivers under 2 cm are dropped (behaviorCore). +- **HEADLESS GPU SCREENSHOTS ON THIS MACHINE (ANGLE/Vulkan, RADV) CAN MISS TEXTURED KIT MESHES** while the canvas read + inside the final render call shows them: compare frames with an in-render read, or on the software backend + (33-scenes QUESTIONS #8); kit-instancing's same-picture check read 39 % different ONCE under load and passed alone. +- **A LEVEL WITH NO DYNAMIC BODY STARTS NO SIMULATION, so its walker collides with nothing** โ€” every kit level ships a + crate or a barrel for that reason (levelTemplates unit test). +- **A sliced load changes what "right after" means.** Since 33-scene-load, `applySession` empties the objects before + it replaces the HUD and the play block, so a check that reads `isGame` the instant the objects are gone reads the + OLD scene (scene-switch-clear 4.5 on the union) โ€” wait for the state, never for the first observable side effect. +- **A two-page "in phase" check must compare CLOCKS, not poses**: reading a pose on each page in two evaluates compares + frames rendered hundreds of ms apart on a loaded box (animated-models was red 3/3, the lane branch included); tick + + read the clock + wall time in ONE task per page. - **A HEADSET'S EYE BUFFER IS NEVER LOWERED** (`XR_FRAMEBUFFER_SCALE = 1`, 31-integrate). 31-perf handed three a 0.85..0.5 framebuffer scale for the NEXT session after any session that reached the governor's resolution steps, and every panel's text went soft ("ALL text in VR menus became blurry") while the panel textures were unchanged. @@ -5090,6 +5241,19 @@ override for e2e โ€” never share 5173 (the user's main-checkout server). locked (replicate the INDEX per-item opt-in; ONE mesh with scenes as tags; scene-is-primary renaming), and the vocabulary settled: **session = the mesh, room = who is in a scene, PocketBase rooms stay DISCOVERY** โ€” that naming blocks R4. +- Status (2026-10-02): **1.19.0 "Doors that open, scenes that load, games that switch" โ€” ROADMAP 33, integrated + released + by lane `33-integrate`** (releases delegated by the owner; acceptance test: cloud + `plans/core/lanes/33/user-feedback-2026-10-01b.md`). Merged core #258 editor-ui ยท #257 lod-editor ยท #259 anim-core ยท + #261 scene-load ยท #263 scene-switch ยท #260 games ยท #255 untangle-core ยท #262 scenes (lod-editor's resolved copies for + the anim-core conflicts; scene-switch's notes for `applySession`); modules #29 (untangle 2.4.0); packs #11 #8 #10 #9 #12 + (four new kits, flicker fix, LODs). Integrate additions: the card actually titled "Architecture shell" rebuilt from the + kits (author-templates `remote: false` keeps the greybox as the offline seed), four suites taught about the union + (sliced loads, clock-based phase, three pages on the GPU, an exact "Restore"), the phone quality toast says "Use full + quality", packs ship `defight-report.json` + refreshed kit reports. Gates: svelte-check 332/47 (ratcheted), vitest 464, + build green; ~75 core suites + 31 modules flights + flicker 222/222 (STEPS=8 โ€” the probe's limit is calibrated there; + its default 48 reads 3-6x higher on 1.18.0 too) green on the union; preview proof 211/0 + round-33 items 58/0. + Not regressions (A/B'd on 1.18.0): modes-audit "football play", sabers 10/2. OWED on device: the list in the cloud + roadmap-33 master. - Status (2026-10-01): **1.18.0 PREVIEW โ€” ROADMAP 31 "the second Quest round", integrated on `feat/1.18` (lane `31-integrate`), HELD AT THE RELEASE GATE** (https://preview-1-18.theprototype.pages.dev, core a896b27, modules dev 3c9bf77, scenes `preview-1-18` 35addc4, packs a39280a). Merged core #253 vr-core (K1 K2) ยท #251 game-shell (K3) ยท @@ -6678,6 +6842,6 @@ onChange?})` + `api.game.setting(id)` (rows in the per-game Settings page, persi `api.game.setHelp(text)` (How to play), `api.game.onRestart(fn)` (after the shell's reset); `api.quality {level (getter, 0 best), max, labels, vr, onChange(fn) -> off}` (cut effects at a higher level); `api.lod(object, {ratios, distances, minTriangles})` -> `{meshes, ready, remove}`; -`api.vrPanel(group)` (a module's VR menu drawn over the scene like core's panels); +`api.vrPanel(group)` (a module's VR menu drawn over the scene like core's panels); **33:** `api.inScene()` โ€” false once a scene switch LEFT this module behind (the person kept it loaded; the open scene does not use it): stand your own drawing/listening down (Waves' gun); core already scopes your levels/help/settings/Restart/music/spawn/play contract; `api.music.stop()` only stops YOUR track; `api.locomotion = {boundedTeleport, worldGrab}` (the K1 probe). A scene's play block may say `play.locomotion.worldGrab`, `play.bounds {min, max}` and `play.reach` (metres). Feature-detect all. diff --git a/MODULES.md b/MODULES.md index 3c527c4d..7859b8fd 100644 --- a/MODULES.md +++ b/MODULES.md @@ -430,8 +430,11 @@ api.onInput((kind, code) => {}); // 'down'/'up' events; returns unsubscri // pause the HOST's use of an input scope while your module drives: // 'keys' โ€” WASD camera fly + play-mode movement // 'locomotion' โ€” VR left-stick locomotion +// 'sticks' โ€” (1.19) BOTH VR sticks: left-stick move, right-stick snap/smooth turn + teleport +// (a held thing reeled on Y and scaled on X, the way an Edit grab does) api.claimInput('keys'); // ALWAYS release when your mode ends api.releaseInput('keys'); +const sticksOk = api.claimInput('sticks') === true; // true = this core knows the scope (older: undefined) // reading a keydown YOURSELF (a toolbox's own handler)? resolve the key the way the // editor does โ€” `event.key` when it is an ASCII letter/digit, the physical @@ -502,10 +505,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 +550,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 @@ -693,6 +731,8 @@ api.game.addSetting?.({ onChange: (v) => setBoard(v) }); setBoard(api.game.setting?.('board') ?? 'globe'); // the stored choice at load +// (1.19) a CHOICE that decides which levels you see: `onLevels: true` also draws it as tabs +// above the Levels page's grid (desktop + headset) โ€” Untangle's Board [Globe] [2D board] api.game.setSetting?.('board', '2d'); // your in-game button writes the same row api.game.onSettingsChange?.((values) => console.log(values.board, values.sfx)); ``` 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/check-baseline.json b/check-baseline.json index 12fe5e30..5d0bd580 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 333, + "errors": 332, "warnings": 47, - "measured": "2026-09-23" + "measured": "2026-10-02" } diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index bba26b2b..bb3a0d07 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -47,6 +47,7 @@ // DEF (the file / the card): // kind* 'template' | 'example' | 'game' | 'contest' โ€” decides the folder + index section // seed false keeps a template out of the bundled offline seed (30c: kit levels need the pack CDN) +// remote false keeps a template OUT of --out (33: a seed-only greybox whose online twin is a kit level) // slug* title* description identity + card text; author, license ('CC0-1.0'), tags [] // modules [{id, version}] โ€” the card's module list (games only; must match installModules) // installModules [''] โ€” zips installed from MODULES_REPO before the build (the game shows) @@ -96,7 +97,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: @@ -2023,7 +2027,10 @@ const DEFS = [ }, { kind: 'template', - slug: 'architecture-shell', + // 33-integrate: the ONLINE 'Architecture shell' is the kit room in level-templates.cjs; + // this greybox stays the OFFLINE seed's copy (remote: false โ€” never written to --out) + remote: false, + slug: 'architecture-shell-greybox', title: 'Architecture shell', description: 'Room shell with a door and window opening, columns and a half roof to block out interiors', license: 'CC0-1.0', @@ -2184,6 +2191,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 +2208,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 +2508,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 +2523,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) { @@ -3066,6 +3117,7 @@ const DEFS = [ } } for (const def of defs) { + if (def.remote === false) continue; const section = def.kind === 'template' ? 'templates' : def.kind === 'game' ? 'games' : def.kind === 'contest' ? 'contests' : 'examples'; const row = { diff --git a/scripts/level-templates.cjs b/scripts/level-templates.cjs index 036bd415..405d4e57 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,378 @@ 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: {} +}; + +// ---- 6. Architecture shell --------------------------------------------------------------- +// +// 33-integrate: the General tab's "Architecture shell" card (until 1.18 a grey primitive room) +// rebuilt from the kits, as the user asked ("update architecture shell scene"). Still a SHELL +// to block interiors out in โ€” a 12 ร— 8 m sandstone room on flagstones with a door that OPENS +// (the Interactive Kit's studded door in its frame), a window with shutters that close and one +// with glass, two round columns, oak ceiling beams and a slate half roof over the back โ€” but a +// walkable one now. The greybox it replaces stays the offline seed (author-templates). +function shellObjects() { + const n = namer(); + /** @type {any[]} */ + const o = []; + const XS = [-5, -3, -1, 1, 3, 5]; + const ZS = [-3, -1, 1, 3]; + for (const x of XS) for (const z of ZS) o.push(piece(A, 'FloorStone', n('Floor'), [x, 0, z])); + // back wall and front wall (the door at x = 1) + for (const x of XS) { + o.push(piece(A, 'WallStone', n('Back wall'), [x, 0, -4], PI)); + if (x === 1) { + o.push(piece(A, 'WallStoneDoor', 'Doorway', [x, 0, 4], 0, { physics: COLLIDERS.doorway })); + o.push(piece(X, 'DoorStudded', 'Front door', [x, 0, 4])); + } else o.push(piece(A, 'WallStone', n('Front wall'), [x, 0, 4])); + } + // the west wall is plain; the east wall has a shuttered window and a glazed one + for (const z of ZS) { + o.push(piece(A, 'WallStone', n('West wall'), [-6, 0, z], -H)); + if (z === -1) { + o.push(piece(A, 'WallStoneWindow', n('Window wall'), [6, 0, z], H)); + o.push(piece(X, 'Shutters', 'Shutters', [6, 0, z], H)); + } else if (z === 1) { + o.push(piece(A, 'WallStoneWindow', n('Window wall'), [6, 0, z], H)); + o.push(piece(A, 'Window', n('Window'), [6, 0, z], H)); + } else o.push(piece(A, 'WallStone', n('East wall'), [6, 0, z], H)); + } + for (const [x, z] of [[-6, -4], [6, -4], [-6, 4], [6, 4]]) o.push(piece(A, 'CornerPostStone', n('Corner'), [x, 0, z])); + // two round columns on the room's centre line, oak beams across at storey height + for (const x of [-2, 2]) o.push(piece(A, 'Column', n('Column'), [x, 0.1, 0])); + for (const x of [-4, -2, 0, 2, 4]) o.push(piece(A, 'Beam', n('Ceiling beam'), [x, 3, 0], H)); + // the half roof: one row of slate slopes over the back, its eave over the back wall + for (const x of XS) o.push(piece(A, 'RoofSlope', n('Roof slope'), [x, 3, -3], PI)); + // a path to the door, a wall sconce either side of it + for (const z of [5.4, 7.4]) o.push(piece(N, 'PathTile', n('Path'), [1, 0, z])); + for (const x of [-0.2, 2.2]) o.push(piece(P, 'WallTorch', n('Door torch'), [x, 1.6, 4.125], 0, { physics: COLLIDERS.sensor })); + // something to push about (the sim needs a dynamic body, or a Play starts no world) + for (const [x, z, yaw] of [[-4.6, -2.6, 0.3], [-4.6, -1.5, 1.1]]) o.push(piece(P, 'Crate', n('Crate'), [x, 0.1, z], yaw, { physics: DYNAMIC(15) })); + o.push(piece(P, 'Barrel', n('Barrel'), [4.4, 0.1, -2.7], 0.4, { physics: DYNAMIC(30) })); + o.push({ type: 'light', name: 'Room light', pos: [0, 2.6, 0], color: 0xffd2a0, intensity: 8, distance: 14, decay: 2 }); + return o; +} + +const SHELL_DEF = { + kind: 'template', + seed: false, + slug: 'architecture-shell', + title: 'Architecture shell', + description: + 'A room shell built from the kits to block interiors out in: sandstone walls on flagstones, a door that opens, a window with shutters, columns, ceiling beams and a slate half roof. Press Play and walk in.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['greybox', 'architecture', 'kits', 'walkable', 'doors'], + objects: shellObjects(), + env: { + preset: 'daylight', + exposure: 1.05, + background: { top: '#5a8fd0', bottom: '#e3ebef' }, + fog: { color: '#e3ebef', near: 30, far: 90 }, + ground: { color: '#7d7462', roughness: 1 }, + sun: { color: '#fff1d6', intensity: 2.5, dir: [0.55, 0.7, 0.5] }, + hemi: { sky: '#d3e6ff', ground: '#7d7462', intensity: 1.3 } + }, + // outside, the door ahead + physics: playPhysics([1, 0.3, 8.6], 0), + post: lookPost(0.5), + graphs: { scene: walkGraph() }, + view: { pos: [11, 8, 13], target: [0, 1.2, 0] }, 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, SHELL_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..c02693c9 --- /dev/null +++ b/scripts/level-views.cjs @@ -0,0 +1,45 @@ +// 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] + ], + // 33-integrate: the two rows with no views yet (spawn first, then the heaviest look) + 'forest-clearing': [ + ['spawn', [0, 0.35, 5], 0], + ['the clearing looking back south', [0, 0.35, -4], PI] + ], + 'architecture-shell': [ + ['spawn (outside the door)', [1, 0.3, 8.6], 0], + ['inside, the whole room', [4.6, 0.1, 3.2], 0.9] + ] +}; diff --git a/scripts/perf-games.cjs b/scripts/perf-games.cjs index b4d01ff0..cbaee51d 100644 --- a/scripts/perf-games.cjs +++ b/scripts/perf-games.cjs @@ -3,7 +3,13 @@ // Games-tab games, measured the same way every time so a before/after means something. // // node scripts/perf-games.cjs [--label baseline-1.17] [--only waves,football] [--vr] -// [--seconds 10] [--throttle 4] [--out ] +// [--seconds 10] [--throttle 4] [--out ] [--phone] +// +// 33 G1 `--phone`: a PHONE profile instead of the desktop one โ€” a 412x915 viewport at +// deviceScaleFactor 2.625, isMobile + hasTouch (what reproduces a phone's page scale and +// `(pointer: coarse)`), and CPU x6 unless --throttle says otherwise. The GPU is still this +// machine's, so the fill-rate half of a phone shows up only as the PIXELS it would push: +// the table adds the drawing-buffer size and the post passes per frame. // // Per game, on a FRESH page against a running dev server (APP_URL, default this lane's // https://theprototype.app:5263/): @@ -46,7 +52,8 @@ const flag = (name) => argv.includes('--' + name); const LABEL = arg('label', 'run'); const SECONDS = Number(arg('seconds', '10')); -const THROTTLE = Number(arg('throttle', '4')); +const PHONE = flag('phone'); +const THROTTLE = Number(arg('throttle', PHONE ? '6' : '4')); const VR = flag('vr'); const PROFILE = flag('profile'); const OUT = arg('out', '/home/deck/.code/lanes-30/after-31/31-perf'); @@ -181,6 +188,16 @@ function readScene() { }); if (scene?.background?.isTexture) textures.add(scene.background); if (scene?.environment?.isTexture) textures.add(scene.environment); + // 33 G1: what a PHONE's GPU pays for โ€” pixels per frame, and what shades them + let physical = 0; + let transparent = 0; + scene?.traverseVisible?.((o) => { + if (!o.isMesh) return; + const mats = Array.isArray(o.material) ? o.material : [o.material]; + if (mats.some((m) => m?.isMeshPhysicalMaterial)) physical++; + if (mats.some((m) => m?.transparent)) transparent++; + }); + const buffer = r.getDrawingBufferSize(new s.THREE.Vector2()); let bytes = 0; for (const t of textures) { const img = t.image; @@ -222,7 +239,10 @@ function readScene() { unculled, points, skinned, - pixelRatio: r.getPixelRatio(), + pixelRatio: Math.round(r.getPixelRatio() * 100) / 100, + bufferPx: Math.round((buffer.x * buffer.y) / 1000) / 1000, + physical, + transparent, heapMB: performance.memory ? Math.round((performance.memory.usedJSHeapSize / 1048576) * 10) / 10 : null, heapDeltaMB: performance.memory && rec.heap0 != null ? Math.round(((performance.memory.usedJSHeapSize - rec.heap0) / 1048576) * 10) / 10 : null, lodMeshes: lod ? lod.entries : null, @@ -238,7 +258,10 @@ async function probeGame(browser, game) { const missing = (game.modules || []).filter((m) => !zipFor(m.id)).map((m) => m.id); if (missing.length) return { slug: game.slug, skipped: 'no zip for ' + missing.join(', ') }; - const peer = await h.setupPage(browser, game.slug, { context: { viewport: { width: 1280, height: 720 } } }); + const context = PHONE + ? { viewport: { width: 412, height: 915 }, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true } + : { viewport: { width: 1280, height: 720 } }; + const peer = await h.setupPage(browser, game.slug, { context }); const page = peer.page; try { for (const m of game.modules || []) await installZip(page, m.id, zipFor(m.id)); @@ -391,12 +414,12 @@ const over = (v, cap) => (v != null && v > cap ? ' โš ' : ''); function table(rows) { const head = - '| game | calls/frame | tris/frame | geos | tex (MB) | lights (shadow) | cast meshes | meshes / inst / unculled | LOD (coarse) | quality | p50 ms | p95 ms | p99 ms | heap ฮ” MB | started |\n' + - '|---|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|---:|---|'; + '| game | calls/frame | tris/frame | geos | tex (MB) | lights (shadow) | cast meshes | meshes / inst / unculled | LOD (coarse) | quality | renders/frame | Mpx (dpr) | physical / transparent | p50 ms | p95 ms | p99 ms | heap ฮ” MB | started |\n' + + '|---|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---|---:|---:|---:|---:|---|'; const lines = rows.map((r) => r.skipped || r.error - ? `| ${r.slug} | ${r.skipped ? 'SKIP: ' + r.skipped : 'ERROR: ' + r.error} |||||||||||||| |` - : `| ${r.slug} | ${fmt(r.calls)}${over(r.calls, BUDGET.calls)} | ${fmt(r.triangles)}${over(r.triangles, BUDGET.triangles)} | ${fmt(r.geometries)} | ${fmt(r.textures)} (${fmt(r.textureMB, 1)}) | ${fmt(r.lights)}${over(r.lights, BUDGET.lights)} (${fmt(r.shadowLights)}) | ${fmt(r.castMeshes)} | ${fmt(r.meshes)} / ${fmt(r.instanced)} / ${fmt(r.unculled)} | ${fmt(r.lodMeshes)} (${fmt(r.lodCoarse)}) | ${fmt(r.quality)} | ${fmt(r.p50, 1)} | ${fmt(r.p95, 1)} | ${fmt(r.p99, 1)} | ${fmt(r.heapDeltaMB, 1)} | ${r.started}${r.state ? ' โ†’ ' + r.state : ''} |` + ? `| ${r.slug} | ${r.skipped ? 'SKIP: ' + r.skipped : 'ERROR: ' + r.error} ||||||||||||||||| |` + : `| ${r.slug} | ${fmt(r.calls)}${over(r.calls, BUDGET.calls)} | ${fmt(r.triangles)}${over(r.triangles, BUDGET.triangles)} | ${fmt(r.geometries)} | ${fmt(r.textures)} (${fmt(r.textureMB, 1)}) | ${fmt(r.lights)}${over(r.lights, BUDGET.lights)} (${fmt(r.shadowLights)}) | ${fmt(r.castMeshes)} | ${fmt(r.meshes)} / ${fmt(r.instanced)} / ${fmt(r.unculled)} | ${fmt(r.lodMeshes)} (${fmt(r.lodCoarse)}) | ${fmt(r.quality)} | ${fmt(r.rendersPerFrame, 1)} | ${fmt(r.bufferPx, 2)} (${fmt(r.pixelRatio, 2)}) | ${fmt(r.physical)} / ${fmt(r.transparent)} | ${fmt(r.p50, 1)} | ${fmt(r.p95, 1)} | ${fmt(r.p99, 1)} | ${fmt(r.heapDeltaMB, 1)} | ${r.started}${r.state ? ' โ†’ ' + r.state : ''} |` ); return head + '\n' + lines.join('\n'); } @@ -440,12 +463,12 @@ function table(rows) { seconds: SECONDS, cpuThrottle: THROTTLE, vr: VR, - viewport: '1280x720', + viewport: PHONE ? '412x915@2.625 (phone)' : '1280x720', budget: BUDGET }; const md = `## perf-games โ€” ${LABEL}${VR ? ' (VR-emulated, walking)' : ''}\n\n` + - `${meta.at} ยท ${meta.app} ยท GPU: ${meta.gpu} ยท scenes ${meta.scenes} ยท ${SECONDS} s of Play at CPU x${THROTTLE} ยท 1280x720\n` + + `${meta.at} ยท ${meta.app} ยท GPU: ${meta.gpu} ยท scenes ${meta.scenes} ยท ${SECONDS} s of Play at CPU x${THROTTLE} ยท ${meta.viewport}\n` + `Quest budget: โ‰ค ${BUDGET.calls} calls, โ‰ค ${BUDGET.triangles / 1000}k tris, โ‰ค ${BUDGET.lights} lights (โš  = over). calls/tris are per display frame, every render() pass summed (shadow pass included).\n\n` + table(rows) + '\n'; 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..18d1f149 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' @@ -114,6 +116,8 @@ import { startMusicToolbox } from './lib/musicToolbox' import GameShellMenu from './components/hud/GameShellMenu.svelte' import FpsCounter from './components/hud/FpsCounter.svelte' import { startGameShell } from '$lib/gameShellWire' + // 33 (L2-L4): keep/unload modules on a scene switch, and the scope a kept module lives in + import { startSceneSwitch } from '$lib/sceneSwitch' import HudEditor from './components/editors/HudEditor.svelte' import { importFile, load } from '$lib/fileHandler.svelte' import { showToast, showInfoToast } from './stores/appStore' @@ -141,6 +145,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()) @@ -247,6 +255,7 @@ import { startMusicToolbox } from './lib/musicToolbox' // 21-F3: publish our play mode when it changes, and watch for an abandoned round startGamePresence() startGameShell() + startSceneSwitch() // CO2: the colocation calibration ritual โ€” VR radial entries, trigger hooks, // markers, locomotion suppression and the colocated world-grab divert startColocationCalibration() @@ -494,9 +503,17 @@ 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/sceneSwitch'), + import('./lib/sceneScope'), + 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, sceneSwitchLib, sceneScopeLib, 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, sceneSwitch: sceneSwitchLib, sceneScope: sceneScopeLib, 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..a8c2ddb6 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'; @@ -56,7 +58,7 @@ // the annotation is TS syntax โ€” a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; + import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuids, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; // 30b (vr-play): the game in your hands โ€” hover/press haptics (P1), the sweep (P4) import { startVrGameInput, stopVrGameInput } from '$lib/vrGameInput'; import { gameFeelActive } from '$lib/gameFeel'; @@ -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) { @@ -1433,8 +1441,9 @@ // this seam rather than an import (knock.js stays off vrControls' 3500 lines), // and the two "what am I holding" reads keep a probe off its own carried object. // 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 }); + startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), ...vrGrabbedUuids()], 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} +
+ {/each}
{#each $gameLevels?.list ?? [] as level (level.id)}
{:else if $objectsGroup.children.length > 0} - {#each $objectsGroup.children.filter((/** @type {any} */ c) => !c.userData?.__localOnly) as element} + {#each plainRows.slice(0, rowsMounted) as element} {/each} {/if} diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index 505fc8e4..eb3d354d 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -10,6 +10,7 @@ import ThemedSelect from '../ui/ThemedSelect.svelte'; import PanelHeader from '../ui/PanelHeader.svelte'; import Section from '../ui/Section.svelte'; + import LodGroupPanel from './LodGroupPanel.svelte'; import SliderRow from '../ui/SliderRow.svelte'; import DragRow from '../ui/DragRow.svelte'; import ColorPicker, { ChromeVariant } from 'svelte-awesome-color-picker'; @@ -3202,6 +3203,13 @@ {/if} + {#if !isLight && !multiCount && $selectedObject?.uuid} + +
+ +
+ {/if} + {#if devPrimary}
+
+ 100%screen height0% +
+ +
e.preventDefault()} + ondrop={onDrop} + > + {#each segments as seg (seg.level)} + + {/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..43a068eb 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -13,6 +13,7 @@ import { lightHelperLength } from '$lib/lightHelpers'; import { flowMouseBindings, FLOW_MOUSE_BINDINGS } from '$lib/flowPrefs'; import { helpersInPlay } from '$lib/helperLayer'; + import { perfStatsShown } from '$lib/fpsMeter'; import { gamepadPrefs, setGamepadPrefs, DEADZONE_RANGE, SENSITIVITY_RANGE } from '$lib/gamepadPrefs'; import { drawerSlot, cloudPluginInfo } from '$lib/cloudHooks'; import { versionString } from '$lib/version.js'; @@ -31,6 +32,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'; @@ -44,6 +46,8 @@ removeCustomTheme } from '$lib/themes'; import { autosaveEnabled, autoRestoreEnabled, clearSavedSession } from '$lib/autosave'; + // 33 (L2): keep / unload a scene's modules when another scene opens + import { modulesOnOpen } from '$lib/sceneSwitch'; // 21-G7: how many past versions of each scene keep their bytes on this machine (0 = off) import { keepVersionsSetting } from '$lib/projectManifest'; // R22 round 2: who may take a file out of the shared library (locked answer: anyone, @@ -841,6 +845,12 @@ Add a "Search objectsโ€ฆ" entry to the viewport right-click menu โ€” find a scene object and fly the camera to it + + + A small counter with the frame rate, frame time, draw calls and triangles โ€” in the + corner of the viewport and, in a headset, on a strip at the top of the view. The draw + calls turn amber past 120 and red past 150, the practical limit on a Quest + 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 @@ -1106,6 +1120,21 @@ Restore that snapshot automatically at startup instead of asking. Only ever runs when the scene is still empty; a message tells you what was restored + + + + + + Modules that came with the scene you are leaving (a game's Waves or Untangle) and that the next scene does not use. Ask (the default) shows them and lets you choose; Unload switches them off (back on any time in Modules, and a scene that needs them offers to). A new blank scene always unloads them + diff --git a/src/components/menu/Sidebar.svelte b/src/components/menu/Sidebar.svelte index 5655ce69..d29d03c8 100644 --- a/src/components/menu/Sidebar.svelte +++ b/src/components/menu/Sidebar.svelte @@ -135,7 +135,7 @@ closeMenu.update((value) => !value); } - // 15-J viewer gate + confirm toast live in $lib/sceneTemplates.confirmClearScene + // 15-J viewer gate + 33 (L3) the Clear scene modal live in $lib/sceneTemplates.confirmClearScene // now โ€” shared with the Templates modal's "Blank scene" card. @@ -226,7 +226,7 @@ Configure Scene -