From 7a811459b720f9818df9dd9fb430655f484c541e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:44:03 +0300 Subject: [PATCH 01/15] [feat] 33 L1 P0: scene-load trace probe (long tasks, frame gaps, profile per long task) - scripts/scene-load-trace.cjs: a fresh phone context per scene (412x915 isMobile @2.6, real GPU, CPU throttle 6), the Templates open path and autosave -> reload -> Restore, each reporting wall time, PerformanceObserver long tasks, the longest rAF gap, the CPU profile's top self time, app call chains, and the three longest tasks broken down. - Baseline (c7018be): Castle Courtyard Restore = ONE 89 s task (91 s frozen), open 525 ms tasks / 2.1 s frozen; Tavern Restore 84 s; Forest gets NO Restore at all (its autosave is over the 50 MB cap). Table in the 33-scene-load handover. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/scene-load-trace.cjs | 262 +++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 scripts/scene-load-trace.cjs diff --git a/scripts/scene-load-trace.cjs b/scripts/scene-load-trace.cjs new file mode 100644 index 00000000..24a42fa2 --- /dev/null +++ b/scripts/scene-load-trace.cjs @@ -0,0 +1,262 @@ +// 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 +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) { + 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/**', (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)) return route.fulfill({ body: fs.readFileSync(file) }); + } + 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(); +}); From 6333b431b2f2f42e7b9f93cf0bbad11ba8c8fdc3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:44:18 +0300 Subject: [PATCH 02/15] [feat] 33 L1 P1: progressive scene loads - time-sliced build, kit pieces warmed and placeheld - sceneLoader.js (leaf): slice(job) yields when the 10 ms budget is spent and throws LoadCancelled at a clean point; schedule(fn) runs a burst of continuations a slice at a time; ONE load job (sceneLoad store) - a new load supersedes the running one; cancel hooks; throttledPoke (250 ms) for the build loop. - sessions.applySession: the object loop is time-sliced with progress, kit stubs refill at once (counted when they land), every await re-checks the job, Cancel clears what the load added (replicated like the load), a load interrupting one mid-build writes no "Backup before" of the half scene. - packRefs: a refill's clone/attach runs through schedule() (every copy of a piece used to attach in the one task that resolved its template); warmTemplate uploads textures one per slice and compileAsync's the programs against the live scene before any copy draws; refills poke at most every 200 ms; fingerprint checksums cached per geometry by buffer versions; packRef.box (root-frame bounds, additive) drawn as ONE scene-root InstancedMesh of grey placeholder blocks while a piece is on its way. - App.svelte debug hook: + sceneLoader (220/220/220). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 7 +- src/lib/packRefs.js | 299 ++++++++++++++++++++++++++++++++++++----- src/lib/sceneLoader.js | 294 ++++++++++++++++++++++++++++++++++++++++ src/lib/sessions.js | 98 +++++++++++--- 4 files changed, 642 insertions(+), 56 deletions(-) create mode 100644 src/lib/sceneLoader.js diff --git a/src/App.svelte b/src/App.svelte index 4d640087..a41e4e15 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -494,9 +494,10 @@ 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/sceneLoader') + ]).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, sceneLoaderLib]) => { + 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, sceneLoader: sceneLoaderLib } }) } }) diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js index a9a8f7d7..59aaeeae 100644 --- a/src/lib/packRefs.js +++ b/src/lib/packRefs.js @@ -7,10 +7,11 @@ import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; // @ts-ignore import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; import { writable, get } from 'svelte/store'; -import { objectsGroup, pokeScene } from '../stores/sceneStore'; +import { objectsGroup, pokeScene, globalRenderer, globalCamera, globalScene } from '../stores/sceneStore'; import { showToast } from '../stores/appStore'; import { PACKS_BASE } from './packs'; import { hashBytes } from './explorer'; +import { schedule } from './sceneLoader'; // 30c — KIT REFERENCES: a pack piece in a scene is a REFERENCE, not a copy. // @@ -48,7 +49,7 @@ import { hashBytes } from './explorer'; // commandsHandler import this; nothing here reaches back. /** - * @typedef {{pack: string, item: string, path: string, hash?: string, kids?: string[]}} PackRef + * @typedef {{pack: string, item: string, path: string, hash?: string, kids?: string[], box?: number[]}} PackRef */ /** How many refills are in flight — the author script and the suite wait on it. @@ -67,6 +68,9 @@ const sharedTextures = new Map(); /** root uuid -> its refill, so two scans never fill one root twice * @type {Map>} */ const filling = new Map(); +/** roots hollowed for an autosave export this very moment — never refilled meanwhile + * @type {WeakSet} */ +const parkedRoots = new WeakSet(); /** urls already reported as unreachable (one toast per piece, not per copy) */ const reported = new Set(); @@ -189,6 +193,7 @@ export function loadPackTemplate(url) { const scene = gltf.scene; scene.updateMatrixWorld(true); fingerprints.set(hash, fingerprintOf(scene)); + await warmTemplate(scene); return { hash, scene }; })(); templates.set(url, job); @@ -197,6 +202,34 @@ export function loadPackTemplate(url) { return job; } +/** + * 33 L1: upload a piece's textures and compile its programs BEFORE its copies are on screen. + * Otherwise the first frame that draws a new piece does both inside the render call — on a + * phone that one frame was the longest task of a whole load. `initTexture` is one upload per + * scheduled slice; `compileAsync` (KHR_parallel_shader_compile where the driver has it) is + * given the live scene as the target so the programs match its lights and fog. Best effort: + * a failure here only means the first frame pays as it always did. + * @param {any} scene + */ +async function warmTemplate(scene) { + /** @type {any} */ + const renderer = get(globalRenderer); + if (!renderer || renderer.xr?.isPresenting) return; + const textures = new Set(); + scene.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + for (const material of Array.isArray(node.material) ? node.material : [node.material]) + for (const slot of MAP_SLOTS) if (material?.[slot]) textures.add(material[slot]); + }); + try { + for (const texture of textures) await schedule(() => renderer.initTexture(texture)); + const camera = get(globalCamera); + if (camera && typeof renderer.compileAsync === 'function') await renderer.compileAsync(scene, camera, get(globalScene) ?? null); + } catch { + /* the first frame compiles instead */ + } +} + // ---- the fingerprint --------------------------------------------------------- /** Seven significant digits, and anything under 1e-6 is zero — a GLTF round trip turns a @@ -218,17 +251,30 @@ function checksum(array) { return num(a) + '/' + num(b); } +/** 33 L1: the checksums of a geometry, kept until one of its buffers changes. A save asks + * "is this piece still pristine?" of every kit piece, and summing every vertex of a 180-piece + * castle on each autosave and each backup was a long task of its own. Keyed by the buffers' + * `version`s, which every edit path bumps (needsUpdate) — an edit no renderer would see is + * the only one this could miss. + * @type {WeakMap} */ +const geometryKeys = new WeakMap(); + /** @param {any} geometry */ function geometryKey(geometry) { if (!geometry?.attributes) return '-'; const names = Object.keys(geometry.attributes).sort(); + const ver = names.map((name) => name + geometry.attributes[name].version + ':' + geometry.attributes[name].array?.length).join(',') + '|' + (geometry.index ? geometry.index.version + ':' + geometry.index.array?.length : '-'); + const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); + const cached = geometryKeys.get(geometry); + if (cached && cached.ver === ver) return cached.key + '|' + groups; const parts = names.map((name) => { const attr = geometry.attributes[name]; return name + ':' + attr.itemSize + ':' + attr.count + ':' + (attr.normalized ? 1 : 0) + ':' + checksum(attr.array); }); const index = geometry.index ? 'i' + geometry.index.count + ':' + checksum(geometry.index.array) : 'noindex'; - const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); - return parts.join(',') + '|' + index + '|' + groups; + const key = parts.join(',') + '|' + index; + geometryKeys.set(geometry, { ver, key }); + return key + '|' + groups; } /** 8×8 RGBA of a texture's image, cached per image. Null when it cannot be read (no @@ -361,6 +407,30 @@ function descendantUuids(root) { return out; } +/** + * 33 L1: a piece's bounds in its ROOT's own frame, [minX, minY, minZ, maxX, maxY, maxZ] to + * the millimetre — carried on the reference (`packRef.box`) so a stub can be drawn as a grey + * block while its pack file is still on its way. Additive: a reference without one simply + * shows nothing until it fills, which is how every file written before 1.19 behaves. + * @param {any} root @returns {number[] | undefined} + */ +function boxOf(root) { + const box = new THREE.Box3(); + const part = new THREE.Box3(); + root.updateWorldMatrix(true, true); + const toRoot = new THREE.Matrix4().copy(root.matrixWorld).invert(); + const m = new THREE.Matrix4(); + root.traverse((/** @type {any} */ node) => { + if (!node.isMesh || !node.geometry) return; + if (!node.geometry.boundingBox) node.geometry.computeBoundingBox(); + part.copy(node.geometry.boundingBox).applyMatrix4(m.multiplyMatrices(toRoot, node.matrixWorld)); + box.union(part); + }); + if (box.isEmpty()) return undefined; + const r = (/** @type {number} */ v) => Math.round(v * 1000) / 1000; + return [r(box.min.x), r(box.min.y), r(box.min.z), r(box.max.x), r(box.max.y), r(box.max.z)]; +} + /** * Stamp a freshly imported pack piece BEFORE it replicates: its reference, its children's * uuids, and — since it IS the file right now — the pristine fingerprint for its hash. @@ -369,7 +439,7 @@ function descendantUuids(root) { export function stampPackRef(root, ref, hash) { if (!root || !ref) return; root.updateMatrixWorld(true); - root.userData = { ...(root.userData ?? {}), packRef: { pack: ref.pack, item: ref.item, path: ref.path, hash, kids: descendantUuids(root) } }; + root.userData = { ...(root.userData ?? {}), packRef: { pack: ref.pack, item: ref.item, path: ref.path, hash, kids: descendantUuids(root), box: boxOf(root) } }; if (hash && !fingerprints.has(hash)) fingerprints.set(hash, fingerprintOf(root)); } @@ -406,11 +476,62 @@ export function stubElementOf(root) { hollow.renderOrder = root.renderOrder; hollow.layers.mask = root.layers.mask; const kids = root.userData.packStub ? ref.kids ?? [] : descendantUuids(root); - hollow.userData = JSON.parse(JSON.stringify({ ...root.userData, packRef: { ...ref, kids }, packStub: true })); + const box = ref.box ?? (root.userData.packStub ? undefined : boxOf(root)); + hollow.userData = JSON.parse(JSON.stringify({ ...root.userData, packRef: { ...ref, kids, box }, packStub: true })); hollow.updateMatrix(); return hollow.toJSON(); } +/** + * 33 L1 — THE AUTOSAVE WRITES KIT PIECES AS STUBS TOO. 30c kept the autosave's GLTF FULL, + * and that is what made Restore hang: Castle Courtyard's snapshot measured 51 MB (every + * piece's geometry, its textures as PNG data URIs), right at the 50 MB cap — a bigger level + * would silently get no crash recovery at all — and restoring it parsed all of that, then + * serialized every child again for the wire. The export has no per-child filter (the + * transient-objects ritual says the same), so a pristine piece is HOLLOWED for the export: + * its children detached, `packStub` set and its kids recorded, exactly the shape + * `stubElementOf` writes for a save, and put back afterwards. The restore refills it from + * the pack like any other stub (the browser has the file cached from the session it + * came from); an unreachable pack keeps the stub and says so, never dropping the piece. + * @param {any} group the tree about to be exported + * @returns {() => void} the unpark, to run once the export has read the tree + */ +export function parkPackPieces(group) { + /** @type {{root: any, children: any[], userData: any}[]} */ + const parked = []; + if (!group) return () => {}; + /** @type {any[]} */ + const roots = []; + group.traverse((/** @type {any} */ node) => { + if (node !== group && packRefOf(node) && !node.userData.packStub && node.children.length) roots.push(node); + }); + for (const root of roots) { + // a piece nested inside another piece is parked with its ancestor + if (parked.some((p) => p.children.some((c) => c === root || isAncestor(c, root)))) continue; + if (!isPristinePackRef(root)) continue; + const userData = root.userData; + const ref = /** @type {PackRef} */ (packRefOf(root)); + const children = [...root.children]; + root.userData = { ...userData, packRef: { ...ref, kids: descendantUuids(root), box: ref.box ?? boxOf(root) }, packStub: true }; + for (const child of children) root.remove(child); + parkedRoots.add(root); + parked.push({ root, children, userData }); + } + return () => { + for (const { root, children, userData } of parked) { + parkedRoots.delete(root); + for (const child of children) root.add(child); + root.userData = userData; + } + }; +} + +/** @param {any} ancestor @param {any} node */ +function isAncestor(ancestor, node) { + for (let up = node.parent; up; up = up.parent) if (up === ancestor) return true; + return false; +} + /** How many nodes a stub stands for (the root + what it refills to), for object budgets. * @param {any} node a serialized node (element.object) */ export function stubNodeCount(node) { @@ -437,6 +558,59 @@ function instanceOf(scene) { return copy; } +/** + * The synchronous half of a refill: clone the parsed piece and hang its children under the + * stub. Returns false when the stub was replaced (a clear, a reload of the same file) or + * filled while the template was on its way. + * @param {any} root @param {PackRef} ref @param {string} hash @param {any} scene + */ +function attachCopy(root, ref, hash, scene) { + if (!root.userData.packStub || root.children.length || parkedRoots.has(root)) return false; + // still in the scene? (an ancestor walk, not a tree search per piece) + const group = get(objectsGroup); + let up = root.parent; + while (up && up !== group) up = up.parent; + if (!group || up !== group) return false; + const copy = instanceOf(scene); + /** @type {any[]} */ + const nodes = []; + copy.traverse((/** @type {any} */ node) => { + if (node !== copy) nodes.push(node); + }); + const kids = Array.isArray(ref.kids) ? ref.kids : []; + // a stub that lost its kids (a merge import re-uuids the tree) still has to give + // every peer the SAME child uuids: derive them from the root's + nodes.forEach((node, i) => { + node.uuid = typeof kids[i] === 'string' && kids[i] ? kids[i] : derivedUuid(root.uuid, i); + }); + const hideShadow = root.userData.shadow === false; + for (const child of [...copy.children]) root.add(child); + if (hideShadow) + root.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + node.castShadow = false; + node.receiveShadow = false; + node.userData.shadow = false; + }); + delete root.userData.packStub; + root.userData.packRef = { ...ref, hash, kids: nodes.map((node) => node.uuid), box: ref.box ?? boxOf(root) }; + pokeSoon(); + return true; +} + +/** @type {any} */ +let pokeTimer = null; +/** 33 L1: refills poke the scene at most ~5 times a second — a castle refills 180 pieces, + * and every poke re-runs every objectsGroup subscriber (the object list re-renders whole). + * A timer, so the last refill of a burst is always announced. */ +function pokeSoon() { + if (pokeTimer) return; + pokeTimer = setTimeout(() => { + pokeTimer = null; + pokeScene(); + }, 200); +} + /** * Refill a stub from its pack. Resolves true when it filled, false when it could not (or * is no longer in the scene, or was filled meanwhile). The children take the recorded @@ -444,7 +618,7 @@ function instanceOf(scene) { * @param {any} root @returns {Promise} */ export function fillPackRef(root) { - if (!root?.userData?.packStub) return Promise.resolve(false); + if (!root?.userData?.packStub || parkedRoots.has(root)) return Promise.resolve(false); const ref = packRefOf(root); if (!ref) return Promise.resolve(false); const inflight = filling.get(root.uuid); @@ -454,34 +628,10 @@ export function fillPackRef(root) { const job = (async () => { try { const { hash, scene } = await loadPackTemplate(url); - // replaced (a clear, a reload of the same file) or filled while we fetched - if (!root.userData.packStub || root.children.length) return false; - if (get(objectsGroup)?.getObjectByProperty('uuid', root.uuid) !== root) return false; - const copy = instanceOf(scene); - /** @type {any[]} */ - const nodes = []; - copy.traverse((/** @type {any} */ node) => { - if (node !== copy) nodes.push(node); - }); - const kids = Array.isArray(ref.kids) ? ref.kids : []; - // a stub that lost its kids (a merge import re-uuids the tree) still has to give - // every peer the SAME child uuids: derive them from the root's - nodes.forEach((node, i) => { - node.uuid = typeof kids[i] === 'string' && kids[i] ? kids[i] : derivedUuid(root.uuid, i); - }); - const hideShadow = root.userData.shadow === false; - for (const child of [...copy.children]) root.add(child); - if (hideShadow) - root.traverse((/** @type {any} */ node) => { - if (!node.isMesh) return; - node.castShadow = false; - node.receiveShadow = false; - node.userData.shadow = false; - }); - delete root.userData.packStub; - root.userData.packRef = { ...ref, hash, kids: nodes.map((node) => node.uuid) }; - pokeScene(); - return true; + // 33 L1: every copy of one piece waits on the SAME template promise, so without + // this all of them clone and attach inside the one task that resolved it (a + // castle's walls were one 500 ms block). The scheduler runs them a slice at a time. + return await schedule(() => attachCopy(root, ref, hash, scene)); } catch (error) { if (!reported.has(url)) { reported.add(url); @@ -517,15 +667,92 @@ export function packRefsSettled() { function scan() { const group = get(objectsGroup); if (!group) return; + /** @type {any[]} */ + const hollow = []; group.traverse((/** @type {any} */ node) => { const ref = packRefOf(node); if (!ref) return; if (node.userData.packStub) { - if (!node.children.length) fillPackRef(node); + if (!node.children.length) { + fillPackRef(node); + if (Array.isArray(ref.box) && ref.box.length === 6 && !parkedRoots.has(node)) hollow.push(node); + } } else if (ref.hash && !fingerprints.has(ref.hash)) { loadPackTemplate(packRefUrl(ref)).catch(() => {}); } }); + drawPlaceholders(hollow); +} + +// ---- placeholders (33 L1) ---------------------------------------------------------------- + +const PLACEHOLDER_NAME = 'kit-placeholders'; +/** @type {any} */ +let placeholders = null; + +/** + * A grey block where each kit piece will be, while its pack file is still on its way: a load + * shows the level's SHAPE at once and the real pieces replace the blocks as they arrive. ONE + * InstancedMesh at the SCENE ROOT (golden rule 5 — never inside objectsGroup, so never saved, + * sent or undone), not pickable, rebuilt on each scan (which every refill's poke triggers). + * @param {any[]} stubs hollow stubs carrying a `packRef.box` + */ +function drawPlaceholders(stubs) { + /** @type {any} */ + const scene = get(globalScene); + if (!scene) return; + if (!stubs.length) { + if (placeholders) { + placeholders.parent?.remove(placeholders); + placeholders.geometry.dispose(); + placeholders.material.dispose(); + placeholders = null; + } + return; + } + if (!placeholders || placeholders.userData.capacity < stubs.length) { + if (placeholders) { + placeholders.parent?.remove(placeholders); + placeholders.geometry.dispose(); + placeholders.material.dispose(); + } + const capacity = Math.max(32, stubs.length); + placeholders = new THREE.InstancedMesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial({ color: 0x9aa0a8, roughness: 1, metalness: 0 }), + capacity + ); + placeholders.name = PLACEHOLDER_NAME; + placeholders.frustumCulled = false; + placeholders.raycast = () => {}; + placeholders.userData.capacity = capacity; + } + // beside objectsGroup (in its parent — the world rig a VR world-grab moves), never in it + /** @type {any} */ + const host = get(objectsGroup)?.parent ?? scene; + if (placeholders.parent !== host) host.add(placeholders); + host.updateWorldMatrix(true, false); + const toHost = new THREE.Matrix4().copy(host.matrixWorld).invert(); + const m = new THREE.Matrix4(); + const local = new THREE.Matrix4(); + const pos = new THREE.Vector3(); + const size = new THREE.Vector3(); + const q = new THREE.Quaternion(); + stubs.forEach((stub, i) => { + const [x0, y0, z0, x1, y1, z1] = stub.userData.packRef.box; + stub.updateWorldMatrix(true, false); + pos.set((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2); + size.set(Math.max(0.01, x1 - x0), Math.max(0.01, y1 - y0), Math.max(0.01, z1 - z0)); + local.compose(pos, q, size); + placeholders.setMatrixAt(i, m.multiplyMatrices(toHost, stub.matrixWorld).multiply(local)); + }); + placeholders.count = stubs.length; + placeholders.instanceMatrix.needsUpdate = true; +} + +/** How many grey blocks are drawn (the suite reads it). */ +export function placeholderCount() { + return placeholders?.parent ? placeholders.count : 0; } let started = false; diff --git a/src/lib/sceneLoader.js b/src/lib/sceneLoader.js new file mode 100644 index 00000000..3b1cd950 --- /dev/null +++ b/src/lib/sceneLoader.js @@ -0,0 +1,294 @@ +import { writable, get } from 'svelte/store'; + +// 33 L1 — A SCENE LOAD MAY TAKE TIME; IT MAY NOT TAKE THE WINDOW. +// +// THE REPORT: on a phone, Restore after a reload with Castle Courtyard open hung the whole +// window, and so did opening other scenes. Every load path ran its object loop in ONE task +// (parse every element, add it, broadcast its toJSON), and every kit piece refilled in one +// more burst when its pack file resolved — all copies of a wall continue in the same task. +// The measurement is in the 33-scene-load handover; this leaf is the answer to it: +// +// * `slice()` — a cooperative yield for a LOOP: returns at once while the current slice is +// under budget, otherwise gives the event loop a turn (input, paint, the progress bar) +// before the loop continues. A load is therefore many ~10 ms tasks instead of one long one. +// * `schedule(fn)` — the same budget for work that arrives as MANY independent promise +// continuations (twelve walls waiting on one pack file): each becomes a queue item and one +// pump runs them a slice at a time, because N continuations that each check a clock all +// run inside the one task that resolved their promise. +// * `sceneLoad` — the job being loaded ({name, total, done, phase}), rendered by +// SceneLoadBar.svelte as a non-modal bar with Cancel. ONE job at a time: starting a load +// supersedes the previous one, whose loop sees `cancelled` at its next slice and stops. +// +// A LEAF: svelte/store only. sessions, autosave and packRefs import it; it imports nothing +// back, so it can sit under any of the documented cycles. + +/** Work budget per slice. 10 ms leaves a 60 Hz frame room to paint and take input. */ +export const SLICE_MS = 10; + +/** + * @typedef {{id: number, name: string, verb: string, total: number, done: number, phase: string, + * cancellable: boolean, startedAt: number, cancelled: boolean, interrupted: boolean}} LoadJob + * `interrupted`: this load superseded one that was still BUILDING its scene — what is on + * screen is half of somebody else's load, not the user's work (no backup is owed for it). + */ + +/** The load in progress, or null. LOCAL — never replicated, saved or undone. + * @type {import('svelte/store').Writable} */ +export const sceneLoad = writable(/** @type {LoadJob | null} */ (null)); + +/** Thrown out of `slice(job)` when that job was cancelled or superseded. */ +export class LoadCancelled extends Error { + constructor() { + super('scene load cancelled'); + this.name = 'LoadCancelled'; + } +} + +const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +/** @type {MessageChannel | null} */ +let channel = null; +/** @type {(() => void)[]} */ +const waiting = []; + +/** + * Give the event loop one turn. `scheduler.yield()` where it exists (it keeps our + * continuation ahead of other queued work); otherwise a MessageChannel message, which — + * unlike setTimeout — carries no 4 ms clamp and is not stretched to 1 s in a background tab. + * @returns {Promise} + */ +export function yieldToEventLoop() { + const g = /** @type {any} */ (globalThis); + if (g.scheduler?.yield) return g.scheduler.yield(); + if (typeof MessageChannel === 'undefined') return new Promise((r) => setTimeout(r, 0)); + if (!channel) { + channel = new MessageChannel(); + channel.port1.onmessage = () => waiting.shift()?.(); + } + return new Promise((resolve) => { + waiting.push(resolve); + /** @type {MessageChannel} */ (channel).port2.postMessage(0); + }); +} + +let sliceStart = now(); + +/** + * Inside a load loop: return immediately while the slice has budget left, else yield and + * start a new slice. With a job, a cancelled/superseded job throws `LoadCancelled` here, so + * a loop stops at a clean point between two objects. + * @param {LoadJob | null} [job] + */ +export async function slice(job = null) { + if (job?.cancelled) throw new LoadCancelled(); + if (now() - sliceStart < SLICE_MS) return; + await yieldToEventLoop(); + sliceStart = now(); + if (job?.cancelled) throw new LoadCancelled(); +} + +/** Start a fresh slice clock (call before a loop that must not inherit a spent budget). */ +export function resetSlice() { + sliceStart = now(); +} + +/** @type {{fn: () => any, resolve: (v: any) => void, reject: (e: any) => void}[]} */ +const queue = []; +let pumping = false; + +async function pump() { + if (pumping) return; + pumping = true; + try { + while (queue.length) { + const start = now(); + while (queue.length && now() - start < SLICE_MS) { + const item = /** @type {any} */ (queue.shift()); + try { + item.resolve(item.fn()); + } catch (error) { + item.reject(error); + } + } + if (queue.length) await yieldToEventLoop(); + } + } finally { + pumping = false; + } +} + +/** + * Run a piece of SYNCHRONOUS work inside the shared slice budget. Items run in arrival + * order, as many per task as fit in `SLICE_MS` (at least one), so a burst of continuations + * spreads over several tasks instead of one. + * @template T @param {() => T} fn @returns {Promise} + */ +export function schedule(fn) { + return new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + // a microtask, so a burst that queues in one task pumps once + queueMicrotask(pump); + }); +} + +/** How many scheduled items wait (the suite and the bar read it). */ +export function scheduledCount() { + return queue.length; +} + +/** Pokes the scene at most every this many ms while a load builds it. Every poke re-runs + * every objectsGroup subscriber (the object list re-renders the whole tree), so a poke per + * object turned the list into the load's biggest cost; four a second still shows it grow. */ +export const LOAD_POKE_MS = 250; + +/** + * A throttle for a load loop's scene pokes: call it per object, it calls `poke` at most + * every LOAD_POKE_MS. The loop pokes once more when it ends. + * @param {() => void} poke @returns {() => void} + */ +export function throttledPoke(poke) { + let last = 0; + return () => { + const t = now(); + if (t - last < LOAD_POKE_MS) return; + last = t; + poke(); + }; +} + +let nextId = 0; +/** @type {LoadJob | null} */ +let current = null; +/** @type {Set<() => void>} */ +const cancelHooks = new Set(); + +function publish() { + sceneLoad.set(current ? { ...current } : null); +} + +/** + * Begin a load. A load already running is SUPERSEDED (cancelled) — starting another scene + * cancels the first one cleanly, at its next slice. + * @param {string} name what the bar says ("Loading ") + * @param {number} total objects this load will bring + * @param {{cancellable?: boolean, phase?: string, verb?: string}} [opts] + * @returns {LoadJob} + */ +export function beginLoad(name, total, opts = {}) { + // only the BUILDING phase leaves a half scene behind ('preparing'/'reading' have not + // touched the scene yet; 'models' means it is whole) + const interrupted = !!current && current.phase === 'objects'; + if (current) cancelLoad({ superseded: true }); + current = { + interrupted, + id: ++nextId, + name: String(name || 'scene'), + verb: opts.verb ?? 'Loading', + total: Math.max(0, total | 0), + done: 0, + phase: opts.phase ?? 'objects', + cancellable: opts.cancellable !== false, + startedAt: Date.now(), + cancelled: false + }; + resetSlice(); + publish(); + return current; +} + +/** Throttled progress publication: the bar is DOM, so ~10 updates a second is plenty. */ +let publishTimer = /** @type {any} */ (null); +function publishSoon() { + if (publishTimer) return; + publishTimer = setTimeout(() => { + publishTimer = null; + publish(); + }, 100); +} + +/** @param {LoadJob} job @param {number} [n] */ +export function progress(job, n = 1) { + if (job !== current) return; + job.done = Math.min(job.total, job.done + n); + publishSoon(); +} + +/** @param {LoadJob} job @param {{total?: number, phase?: string, cancellable?: boolean}} patch */ +export function updateLoad(job, patch) { + if (job !== current) return; + if (patch.total != null) job.total = Math.max(job.done, patch.total | 0); + if (patch.phase != null) job.phase = patch.phase; + if (patch.cancellable != null) job.cancellable = patch.cancellable; + publish(); +} + +/** @param {LoadJob | null} job */ +export function endLoad(job) { + if (!job || job !== current) return; + current = null; + cancelHooks.clear(); + clearTimeout(publishTimer); + publishTimer = null; + publish(); +} + +/** Is THIS job still the one loading (not cancelled, not superseded)? @param {LoadJob} job */ +export function isLive(job) { + return job === current && !job.cancelled; +} + +/** Is any load running? Autosave and the bar read it. */ +export function loading() { + return current !== null; +} + +/** + * Register what Cancel must undo for the running load (the caller's own cleanup — a + * session load clears what it had added). Hooks run once, on cancel only. + * @param {() => void} fn + */ +export function onCancel(fn) { + cancelHooks.add(fn); + return () => cancelHooks.delete(fn); +} + +/** + * Cancel the running load: the bar's Cancel, or a newer load SUPERSEDING it. Only a user + * cancel runs the load's undo hooks — a superseding load clears the scene itself. + * @param {{superseded?: boolean}} [opts] + */ +export function cancelLoad(opts = {}) { + const job = current; + if (!job) return; + job.cancelled = true; + const hooks = opts.superseded ? [] : [...cancelHooks]; + current = null; + cancelHooks.clear(); + clearTimeout(publishTimer); + publishTimer = null; + publish(); + for (const hook of hooks) { + try { + hook(); + } catch {} + } +} + +/** For the suite: the job object itself (not the published copy). */ +export function currentJob() { + return current; +} + +/** Resolve when no load is running. */ +export function loadSettled() { + return new Promise((resolve) => { + if (!get(sceneLoad)) return resolve(true); + /** @type {any} */ + let off = null; + off = sceneLoad.subscribe((v) => { + if (v) return; + queueMicrotask(() => off?.()); + resolve(true); + }); + }); +} diff --git a/src/lib/sessions.js b/src/lib/sessions.js index b43a8fad..5b10e251 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -1,12 +1,13 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls, pokeScene } from '../stores/sceneStore'; +import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls, pokeScene, beginSceneBatch, endSceneBatch } from '../stores/sceneStore'; import { restoreGraphs, clearGraphs, SCENE_GRAPH, allNodes } from '../stores/flowStore'; import { serializeGraphs, copyGraphFrom } from './flowGraphs'; import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { stripEditOverlays } from './editOverlays'; -import { isPristinePackRef, stubElementOf, stubNodeCount } from './packRefs'; +import { isPristinePackRef, stubElementOf, stubNodeCount, fillPackRef } from './packRefs'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke } from './sceneLoader'; // B7: a spawner's copies exist only while the world runs — never in a scene file import { isTransient } from './transientObjects'; import { @@ -1297,12 +1298,31 @@ function reportUnknownNodes(payload) { * @param {{backup?: boolean, replicate?: boolean, game?: boolean, workspace?: boolean}} [opts] */ export async function applySession(payload, opts = {}) { + // 33 L1: ONE load at a time, and it says what it is doing. Starting this one supersedes + // a load still running (its loop stops at its next slice) — opening scene B while A is + // still arriving is how a user cancels A, and it must not leave A's objects in B. + const job = beginLoad(payload?.name ?? 'scene', (payload?.objects ?? []).length, { phase: 'preparing' }); + try { + await applySessionNow(payload, opts, job); + } catch (error) { + // a load that FAILED must not leave its bar up (endLoad ignores a superseded job) + endLoad(job); + throw error; + } +} + +/** @param {any} payload @param {{backup?: boolean, replicate?: boolean, game?: boolean, workspace?: boolean}} opts + * @param {import('./sceneLoader').LoadJob} job */ +async function applySessionNow(payload, opts, job) { const { backup = true, replicate = true, game = true, workspace = true } = opts; // 31 K3: the file's own name is the game's identity for its per-game settings (a // Games-tab load is unnamed afterwards, but its session name is the game's title) noteSceneFileName(payload?.name); const group = get(objectsGroup); - if (backup && group?.children.length) await saveSession('Backup before "' + payload.name + '"'); + // 33 L1: a load that interrupted another one still building finds HALF of that scene on + // screen — nothing anybody made, so no "Backup before" of it + const hadContent = !!group?.children.length && !job.interrupted; + if (backup && hadContent) await saveSession('Backup before "' + payload.name + '"'); // R22-R8: a session saved by "Save into session" carries the whole Explorer library // beside the scene, because that gesture EMPTIES the library and the save is the only // thing standing between the user and losing it. Restoring it is hash-deduped, so a @@ -1324,30 +1344,68 @@ export async function applySession(payload, opts = {}) { } catch { /* physics failing to load must never block a scene load */ } + // a load superseded while the backup or the library restore awaited stops here + if (!isLive(job)) return; if (replicate) sceneCommand('/clear all'); // replicated clear (objects + module content) else clearSceneLocal(); + updateLoad(job, { phase: 'objects' }); /** @type {any} */ const peer = get(peers); - for (const element of payload.objects ?? []) { - let object; - try { - object = new THREE.ObjectLoader().parse(element); - } catch { - continue; + // 33 L1: Cancel (the load bar) takes back what this load had added — clearing for the + // room as the load itself replicated. A load SUPERSEDED by another one is not cleared + // here: the newer load's own clear does that. + onCancel(() => { + if (replicate) sceneCommand('/clear all'); + else clearSceneLocal(); + showToast( + 'Stopped loading "' + (payload?.name ?? 'scene') + '"' + + (backup && hadContent ? ' — your previous scene is in Sessions as "Backup before ' + payload.name + '"' : '') + ); + }); + /** @type {Promise[]} kit pieces still refilling from their pack */ + const refills = []; + const poke = throttledPoke(pokeScene); + beginSceneBatch(); + try { + // 33 L1: TIME-SLICED. This loop used to build every object in one task — a castle of + // 180 pieces parsed, attached and broadcast before the window could paint again. + // `slice` yields whenever the ~10 ms budget is spent, and stops at a clean point + // between two objects when the load was cancelled or superseded. + for (const element of payload.objects ?? []) { + await slice(job); + let object; + try { + object = new THREE.ObjectLoader().parse(element); + } catch { + progress(job); + continue; + } + // A scene saved while a mesh-edit session was open carries the edit + // wireframe as a real child object — it comes back as a permanent, + // un-updatable wireframe nobody can switch off, and it accumulates on + // every save/load round trip (the reported "wireframe glitch"). Drop it + // on the way in; the peers do the same in `createObject`. + stripEditOverlays(object); + group.add(object); // keep original uuids — every peer converges on them + if (replicate && peer) peer.send({ type: 'object', element }); + // a kit piece is not loaded until its pack refilled it: counted when it lands, + // and asked for NOW rather than on the scan's next debounce + if (object.userData?.packStub) refills.push(fillPackRef(object).finally(() => progress(job))); + else progress(job); + poke(); } - // A scene saved while a mesh-edit session was open carries the edit - // wireframe as a real child object — it comes back as a permanent, - // un-updatable wireframe nobody can switch off, and it accumulates on - // every save/load round trip (the reported "wireframe glitch"). Drop it - // on the way in; the peers do the same in `createObject`. - stripEditOverlays(object); - group.add(object); // keep original uuids — every peer converges on them - if (replicate && peer) peer.send({ type: 'object', element }); + } catch (error) { + if (error instanceof LoadCancelled) return; + throw error; + } finally { + endSceneBatch(); } pokeScene(); // animated imports come back from their original bytes (mixers rebuilt, peers // reparse the same file) and authored tracks from the payload await animatedImportsRestore(payload.animated ?? [], replicate); + // superseded while the rigs parsed: the newer load owns the scene now + if (!isLive(job)) return; // replicate: a loaded scene's movements reach the peers already in the room, // the way each restored joint is re-broadcast below animationsRestore(payload.animations ?? {}, replicate); @@ -1414,6 +1472,12 @@ export async function applySession(payload, opts = {}) { // flow editor's badge is invisible when the dock is closed — which it is for most // players loading a game. Runs after restoreGraphs, so the count is the real one. reportUnknownNodes(payload); + // 33 L1: the bar stays up while kit pieces are still arriving from their pack, and it + // is no longer cancellable then — the scene is whole, only the models are on their way + if (refills.length) { + updateLoad(job, { phase: 'models', cancellable: false }); + void Promise.allSettled(refills).then(() => endLoad(job)); + } else endLoad(job); showToast('Session loaded: ' + payload.name + ' (' + (payload.count ?? 0) + ' objects)'); } From a7d4ceec2c8fc535c47ac334ba450cb515bfb073 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:44:18 +0300 Subject: [PATCH 03/15] [feat] 33 L1 P2: the scene-load bar - "Loading - n / N objects", Cancel - SceneLoadBar.svelte in the toast stack's first slot (the spectator banner's spot, so arriving toasts never shove it): name, count, a progress bar (indeterminate while preparing/reading), Cancel while objects are being BUILT; once only kit models are still arriving it keeps counting without Cancel. Non-modal, shown after 250 ms so a small scene does not flash it. Ids #scene-load-bar (data-done/total/phase), #scene-load-cancel. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/SceneLoadBar.svelte | 180 ++++++++++++++++++++++++ src/components/menu/Toasts.svelte | 3 + 2 files changed, 183 insertions(+) create mode 100644 src/components/menu/SceneLoadBar.svelte diff --git a/src/components/menu/SceneLoadBar.svelte b/src/components/menu/SceneLoadBar.svelte new file mode 100644 index 00000000..d2d3d4f0 --- /dev/null +++ b/src/components/menu/SceneLoadBar.svelte @@ -0,0 +1,180 @@ + + +{#if visible && $sceneLoad} +
+
+
+

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

+ {#if $sceneLoad.cancellable} + + {/if} +
+
+
+
+
+
+{/if} + + diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index d1d1217a..869c81cc 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -36,6 +36,7 @@ import { sceneCommand } from '$lib/commandsHandler.svelte'; import { objectsGroup, camSave, globalCamera, globalScene } from '../../stores/sceneStore.js'; import { Progressbar, Toast, Button } from 'flowbite-svelte'; + import SceneLoadBar from './SceneLoadBar.svelte'; import { fly } from 'svelte/transition'; import { untrack } from 'svelte'; // P2b: watching follows a peer's camera IN THIS WORLD, so it cannot survive them @@ -465,6 +466,8 @@ $effect(() => { children's z-index, breaking "approvals above modals"), so it centres with auto margins. -->
+ + +
renderer.initTexture(texture)); - const camera = get(globalCamera); - if (camera && typeof renderer.compileAsync === 'function') { - // BOTH variants. three keys a program by its OUTPUT too: drawn straight to the canvas - // it tone-maps and encodes sRGB, drawn into a render target (the post-processing - // composer — the editor's outline, the scene look) it does neither. Compiling only the - // canvas variant left the composer's programs to link inside the first frame that drew - // a new piece (measured: 260 ms of first-use links after the warm-up). - const previous = renderer.getRenderTarget(); - const ready = [renderer.compileAsync(scene, camera, get(globalScene) ?? null)]; - if (!warmTarget) warmTarget = new THREE.WebGLRenderTarget(1, 1); - renderer.setRenderTarget(warmTarget); - try { - ready.push(renderer.compileAsync(scene, camera, get(globalScene) ?? null)); - } finally { - renderer.setRenderTarget(previous); - } - await Promise.all(ready); - } + } catch { + /* the first frame uploads instead */ + } + await warmPrograms(scene); +} + +/** + * 33 L1: compile every material under `root` for the live scene, and take each program's FIRST + * USE, all a slice at a time — so the frames that follow find their programs linked. + * + * BOTH variants: three keys a program by its OUTPUT too — drawn straight to the canvas it + * tone-maps and encodes sRGB, drawn into a render target (the post-processing composer: the + * editor's outline, the scene look) it does neither, and Outline.svelte switches between the + * two per frame. One compile per MESH per slice (a whole-scene `compile` is itself a long task). + * Then `getUniforms` per program: three reads the link result there, and without the + * parallel-compile extension that read WAITS for the link — measured as 100-270 ms of first-use + * links inside the first frame that drew a loaded scene, with the programs already created. It + * is a cached no-op for a program already used. Best effort: a failure means the first frame + * compiles, as it always did. + * @param {any} root + */ +export async function warmPrograms(root) { + /** @type {any} */ + const renderer = get(globalRenderer); + const camera = get(globalCamera); + const target = get(globalScene) ?? null; + if (!renderer || !camera || !root || renderer.xr?.isPresenting || typeof renderer.compile !== 'function') return; + /** @type {any[]} */ + const drawables = []; + root.traverse((/** @type {any} */ node) => { + if ((node.isMesh || node.isLine || node.isPoints || node.isSprite) && node.material) drawables.push(node); + }); + try { + if (!warmTarget) warmTarget = new THREE.WebGLRenderTarget(1, 1); + for (const node of drawables) + await schedule(() => { + renderer.compile(node, camera, target); + const previous = renderer.getRenderTarget(); + renderer.setRenderTarget(warmTarget); + try { + renderer.compile(node, camera, target); + } finally { + renderer.setRenderTarget(previous); + } + }); + for (const program of [...(renderer.info?.programs ?? [])]) await schedule(() => program.getUniforms?.()); } catch { /* the first frame compiles instead */ } diff --git a/src/lib/sceneLoader.js b/src/lib/sceneLoader.js index 3b1cd950..b3d2ac11 100644 --- a/src/lib/sceneLoader.js +++ b/src/lib/sceneLoader.js @@ -156,6 +156,32 @@ export function throttledPoke(poke) { }; } +/** The longest the viewport may hold its last frame, whatever asked. */ +export const HOLD_MAX_MS = 2000; +let holdUntil = 0; + +/** + * Hold the viewport on its last frame for a moment (bounded by HOLD_MAX_MS): a scene replace + * changes the environment — fog, light count — which re-keys EVERY material's program, and the + * first frame after it linked them all inside one render call (the last long task of a load). + * While held, Outline.svelte skips its render; the canvas keeps what it showed (a frame that + * draws nothing is not composited), and the DOM — the load bar, menus — carries on. + * @param {number} [ms] + */ +export function holdFrames(ms = HOLD_MAX_MS) { + holdUntil = Math.max(holdUntil, now() + Math.min(ms, HOLD_MAX_MS)); +} + +/** Let the viewport draw again. */ +export function releaseFrames() { + holdUntil = 0; +} + +/** Is the viewport being held? Read per frame by Outline.svelte. */ +export function framesHeld() { + return holdUntil > 0 && now() < holdUntil; +} + let nextId = 0; /** @type {LoadJob | null} */ let current = null; diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 58a28f79..fcd535d2 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -6,8 +6,8 @@ import { serializeGraphs, copyGraphFrom } from './flowGraphs'; import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { stripEditOverlays } from './editOverlays'; -import { isPristinePackRef, stubElementOf, stubNodeCount, fillPackRef } from './packRefs'; -import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke } from './sceneLoader'; +import { isPristinePackRef, stubElementOf, stubNodeCount, fillPackRef, warmPrograms } from './packRefs'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames } from './sceneLoader'; // B7: a spawner's copies exist only while the world runs — never in a scene file import { isTransient } from './transientObjects'; import { @@ -1355,6 +1355,10 @@ async function applySessionNow(payload, opts, job) { // previous scene's lights and linked again on its first frame. It also shows the right sky // at once instead of the old one under the arriving level. (A6.1: absent = the default.) environmentRestore(payload.environment, replicate); + // ...and the viewport holds its last frame while the scene's programs re-link for the new + // look a slice at a time and the objects are built; released below (bounded either way) + holdFrames(); + const warming = warmPrograms(get(globalScene)); /** @type {any} */ const peer = get(peers); // 33 L1: Cancel (the load bar) takes back what this load had added — clearing for the @@ -1401,12 +1405,19 @@ async function applySessionNow(payload, opts, job) { poke(); } } catch (error) { - if (error instanceof LoadCancelled) return; + if (error instanceof LoadCancelled) { + releaseFrames(); + return; + } throw error; } finally { endSceneBatch(); } pokeScene(); + // the objects this load built (kit pieces warm themselves as their packs land) — then draw + await warming; + await warmPrograms(group); + releaseFrames(); // animated imports come back from their original bytes (mixers rebuilt, peers // reparse the same file) and authored tracks from the payload await animatedImportsRestore(payload.animated ?? [], replicate); From a3c93a7ac7ea56e905eae88b62982503faa317f6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 01:48:21 +0300 Subject: [PATCH 08/15] [fix] 33 L1: a load applies the scene's look first and lets two near-empty frames compile it - sessions/autosave: environment AND post stack restored before the objects, then nextFrames(2): the composite shader and the helpers re-keyed by the new fog/lights compile on a frame with almost nothing to draw, not on the first frame of the full level. Then the hold + sliced warm. - sceneLoader.nextFrames(n) (rAF, 100 ms fallback where rAF does not run). - Suite: restore max 166 ms (PASS, from 89 468); open max 204 ms (from 525). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/autosave.js | 8 ++++++-- src/lib/sceneLoader.js | 19 +++++++++++++++++++ src/lib/sessions.js | 14 ++++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 1809957f..8c2e173e 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -14,7 +14,7 @@ import { stripEditOverlays } from './editOverlays'; // restore them as permanent scene content import { isTransient, parkTransientObjects } from './transientObjects'; import { parkPackPieces, fillPackRef, isPristinePackRef, stubElementOf, warmPrograms } from './packRefs'; -import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, loading as sceneLoading, loadSettled } from './sceneLoader'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames, loading as sceneLoading, loadSettled } from './sceneLoader'; import { animatedImportsSnapshot, animatedImportsRestore } from './animatedImports'; import { animations, animationsSnapshot, animationsRestore } from './animationPreview'; import { scenePost, scenePostSnapshot, scenePostRestore } from './scenePost'; @@ -632,6 +632,10 @@ async function applyRestore(snapshot, offer = null) { // 33 L1: sky and lights before the objects, so pieces warming as their packs land // compile for THIS scene's lights (see sessions.applySession) environmentRestore(snapshot.environment, true); + // and the look, with two frames of the empty scene to compile it (sessions.applySession) + scenePostRestore(snapshot.post, true); + await nextFrames(2); + if (!isLive(job)) return null; // the viewport holds its last frame while programs re-link and the objects go in holdFrames(); const warming = warmPrograms(get(globalScene)); @@ -714,7 +718,7 @@ async function applyRestore(snapshot, offer = null) { if (snapshot.annotations?.length && annotationsRestorer) annotationsRestorer(snapshot.annotations); // the restored look replicates alongside the objects this function just // re-broadcast, so a restore into a live room is consistent - scenePostRestore(snapshot.post, true); + // (the look was restored before the objects, with the environment) // A6.1: and so do the sky and the gravity (absent = the scene's default, which // is what an older snapshot without these fields means) // (the environment was restored before the objects — see the build loop) diff --git a/src/lib/sceneLoader.js b/src/lib/sceneLoader.js index b3d2ac11..aeed84bd 100644 --- a/src/lib/sceneLoader.js +++ b/src/lib/sceneLoader.js @@ -182,6 +182,25 @@ export function framesHeld() { return holdUntil > 0 && now() < holdUntil; } +/** + * Wait for `n` frames to be drawn (or ~100 ms each where rAF does not run — a background tab). + * A load applies the scene's LOOK first and lets a frame or two of the near-empty scene absorb + * the program changes it causes (the post stack's composite shader, the re-keyed helpers), so + * that work is not stacked onto the first frame of the full scene. + * @param {number} [n] + */ +export async function nextFrames(n = 2) { + for (let i = 0; i < n; i++) + await new Promise((resolve) => { + const timer = setTimeout(resolve, 100); + if (typeof requestAnimationFrame === 'function') + requestAnimationFrame(() => { + clearTimeout(timer); + resolve(true); + }); + }); +} + let nextId = 0; /** @type {LoadJob | null} */ let current = null; diff --git a/src/lib/sessions.js b/src/lib/sessions.js index fcd535d2..c4d1891e 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -7,7 +7,7 @@ import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { stripEditOverlays } from './editOverlays'; import { isPristinePackRef, stubElementOf, stubNodeCount, fillPackRef, warmPrograms } from './packRefs'; -import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames } from './sceneLoader'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames } from './sceneLoader'; // B7: a spawner's copies exist only while the world runs — never in a scene file import { isTransient } from './transientObjects'; import { @@ -1355,8 +1355,14 @@ async function applySessionNow(payload, opts, job) { // previous scene's lights and linked again on its first frame. It also shows the right sky // at once instead of the old one under the arriving level. (A6.1: absent = the default.) environmentRestore(payload.environment, replicate); - // ...and the viewport holds its last frame while the scene's programs re-link for the new - // look a slice at a time and the objects are built; released below (bounded either way) + // and its LOOK (the post stack), then two frames of the still-empty scene: the composite + // shader and the helpers re-keyed by the new fog/lights compile there, on a frame that has + // almost nothing else to draw, instead of on the first frame of the whole level + scenePostRestore(payload.post, replicate); + await nextFrames(2); + if (!isLive(job)) return; + // ...then the viewport holds its last frame while the remaining programs link a slice at a + // time and the objects are built; released below (bounded either way) holdFrames(); const warming = warmPrograms(get(globalScene)); /** @type {any} */ @@ -1451,7 +1457,7 @@ async function applySessionNow(payload, opts, job) { jointsRestore(payload.joints ?? []); // the look replicates on restore too, so loading a scene into a live room // brings its art direction along (the jointsRestore precedent below) - scenePostRestore(payload.post, replicate); + // (the look — post stack — was restored before the objects, with the environment) // A6.1: and so do the sky, the gravity and the music — a game template that // loaded into the room's own sky and gravity was the reason this phase exists. // Each is a no-op when the field is absent (= the scene wants the defaults). From 5ea1d26ca8630f172d24a23e9cf684f8fe79029d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 02:14:29 +0300 Subject: [PATCH 09/15] [fix] 33 L1: the look in two steps, the governor sits out a load, the loaded toast waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sessions/autosave: environment -> two frames -> post stack -> two frames: each re-keys programs on its own near-empty frame (measured ~60 + ~40 ms of links together). - qualityGovernor ignores frames while a scene load runs (desktop and XR): it answered a load's slow frames by dropping the pixel ratio, and that setSize was the longest task of a phone Restore (432 ms at CPU x6, tavern). - 'Session loaded' waits for the kit models (and says nothing for a cancelled/superseded load): its fly entrance forced a whole-document layout mid-load. - CLAUDE.md: the sceneLoader architecture entry. - Suite (CPU x6 phone, slow links): open max 197 ms, restore max 148 ms, menu 66 ms mid-load (26 idle) — 28/28. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 34 ++++++++++++++++++++++++++++++++++ src/lib/autosave.js | 2 ++ src/lib/qualityGovernor.js | 14 ++++++++++++++ src/lib/sessions.js | 18 +++++++++++++++--- tests/e2e/scene-load.test.cjs | 24 +++++++++++++++++------- 5 files changed, 82 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1b68eaa3..6098837d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2130,6 +2130,40 @@ 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: Restore of Castle Courtyard was ONE 80 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, and the template open froze 2 s + (569 ms tasks: texture upload + sync program link in the first frame, every copy of a kit piece + cloned in the one task that resolved its template, the object list re-rendered per poke). + `slice(job)` = a cooperative yield for a LOOP (10 ms budget; throws `LoadCancelled` at a clean + point between two objects), `schedule(fn)` = the same budget for MANY promise continuations (a + burst that resolves together runs as queue items a slice at a time — N continuations each + checking a clock all run in the one task that resolved their promise), `sceneLoad` = the ONE + running job `{name, verb, total, done, phase: preparing|reading|objects|models, cancellable, + interrupted}` rendered by `menu/SceneLoadBar.svelte` (first slot of the toast stack, after + 250 ms, `#scene-load-bar` data-done/total/phase, `#scene-load-cancel`). Starting a load + SUPERSEDES the running one (no hooks run; its loop stops at the next slice, every await in + `applySession`/`applyRestore` re-checks `isLive(job)`); Cancel runs the load's `onCancel` hook + (sessions: replicated clear + toast naming the Backup; restore: clear + RE-OFFER the prompt). + Cancel stays offered through `models` (kit pieces still arriving). A load that interrupts one still building writes no "Backup before" (that + scene is half of somebody's load). `throttledPoke` (250 ms) during the loop; refills poke at + most every 200 ms (packRefs `pokeSoon`). The debounced autosave WAITS OUT a load + (`saveNow` does not). packRefs: refill attach through `schedule`, `warmTemplate` + (`initTexture` per slice + `compileAsync` against the live scene TWICE — canvas AND a 1x1 render + target, because three keys a program by tone mapping + output colour space and the composer + draws into a target), the scene's ENVIRONMENT restored before the objects (its light count is + in every program key), the object list's plain tree mounting in chunks (Controls.svelte, + 40 + 16/frame), `geometryKeys` cache of the + fingerprint checksums keyed by buffer versions, `packRef.box` (root-frame bounds, additive) + + ONE scene-root InstancedMesh of grey `kit-placeholders` for hollow stubs, and + `parkPackPieces` — **the autosave writes pristine kit pieces as STUBS now** (reverses 30c's + "autosave stays full"; hollowed before the GLTF export and put back on the exporter's + `afterParse` hook, i.e. once the tree is READ and before the async encode lets a frame draw a + hollow castle; `parkedRoots` keeps a scan from refilling one mid-export). The restore sends a + wire copy only with an OPEN peer, stubs for pristine pieces. Suite `scene-load` (CPU x6 phone, + no long task > 200 ms on open AND restore) + `scripts/scene-load-trace.cjs` (the P0 probe: + long tasks, longest frame gap, profile top-self + app call chains). - `src/lib/flowLayout.js` + `src/lib/coalesce.js` (R29 S1/S2, both LEAVES, vitest-covered): `freeRegion({w,h,graphId})` is the ONE placement rule for anything that authors nodes on the user's behalf (`hudActions.addBinding` calls it, side 'right', byte-identical); the SDK diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 8c2e173e..4ca26a33 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -633,6 +633,8 @@ async function applyRestore(snapshot, offer = null) { // compile for THIS scene's lights (see sessions.applySession) environmentRestore(snapshot.environment, true); // and the look, with two frames of the empty scene to compile it (sessions.applySession) + await nextFrames(2); + if (!isLive(job)) return null; scenePostRestore(snapshot.post, true); await nextFrames(2); if (!isLive(job)) return null; diff --git a/src/lib/qualityGovernor.js b/src/lib/qualityGovernor.js index e4337e14..89905e82 100644 --- a/src/lib/qualityGovernor.js +++ b/src/lib/qualityGovernor.js @@ -16,6 +16,7 @@ import { } from './sceneBudget'; import { renderPaused } from './overloadGuard'; import { sceneBatchOpen } from '../stores/sceneStore'; +import { loading as sceneLoading } from './sceneLoader'; import { safeStorage } from './safeStorage'; import { xrThresholds, XR_START_LEVEL, xrScaleAfter } from './qualityGovernorCore'; import { globalRenderer } from '../stores/sceneStore'; @@ -159,6 +160,14 @@ export function noteFrameForQuality(ms) { governor.forget(); return; } + // 33 L1: a SCENE LOAD's frames describe the load, not the scene — they are slow because + // programs link and pieces arrive, and the governor answered them by dropping the pixel + // ratio, whose `setSize` then reallocated every buffer: measured as the single longest task + // of a phone Restore (432 ms of setSize at CPU x6). It judges the scene once it is loaded. + if (sceneLoading()) { + governor.forget(); + return; + } governor.noteFrame(ms, t); if (t - lastDecideAt < DECIDE_EVERY_MS) return; lastDecideAt = t; @@ -361,6 +370,11 @@ export function startXRQuality(session, hzOverride) { /** One XR frame interval. Exported for the suite. @param {number} ms @param {number} [t] */ export function noteXRFrame(ms, t = now()) { if (!xr.active) return; + // a scene load (a level hop in the headset) is not the scene being slow — see above + if (sceneLoading()) { + governor.forget(); + return; + } governor.noteFrame(ms, t); if (t - lastDecideAt < DECIDE_EVERY_MS) return; lastDecideAt = t; diff --git a/src/lib/sessions.js b/src/lib/sessions.js index c4d1891e..1e63a7bc 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -1358,6 +1358,10 @@ async function applySessionNow(payload, opts, job) { // and its LOOK (the post stack), then two frames of the still-empty scene: the composite // shader and the helpers re-keyed by the new fog/lights compile there, on a frame that has // almost nothing else to draw, instead of on the first frame of the whole level + // (one at a time: the new fog/lights re-key every helper's program, the post stack builds + // its own — measured ~60 + ~40 ms of links at CPU x6, too much for ONE frame together) + await nextFrames(2); + if (!isLive(job)) return; scenePostRestore(payload.post, replicate); await nextFrames(2); if (!isLive(job)) return; @@ -1498,11 +1502,19 @@ async function applySessionNow(payload, opts, job) { // 33 L1: the bar stays up while kit pieces are still arriving from their pack — still // cancellable: a scene whose models crawl in over a slow link is exactly the one a user // abandons, and Cancel takes the whole load back either way + // The "loaded" toast waits for the models too: it would otherwise slide in while pieces are + // still arriving — saying "loaded" early, and its `fly` entrance forces a whole-document + // layout in the middle of the load (measured inside its longest task). A load that was + // cancelled or superseded meanwhile says nothing. + const loaded = () => { + if (!isLive(job)) return; + endLoad(job); + showToast('Session loaded: ' + payload.name + ' (' + (payload.count ?? 0) + ' objects)'); + }; if (refills.length) { updateLoad(job, { phase: 'models' }); - void Promise.allSettled(refills).then(() => endLoad(job)); - } else endLoad(job); - showToast('Session loaded: ' + payload.name + ' (' + (payload.count ?? 0) + ' objects)'); + void Promise.allSettled(refills).then(loaded); + } else loaded(); } // ---- proposal flow (50.3) -------------------------------------------------- diff --git a/tests/e2e/scene-load.test.cjs b/tests/e2e/scene-load.test.cjs index 785544d3..3506c8be 100644 --- a/tests/e2e/scene-load.test.cjs +++ b/tests/e2e/scene-load.test.cjs @@ -189,13 +189,23 @@ h.run(async () => { // page is slow at everything — the question is whether a load makes it slower) await fresh(); await throttle(CPU); + // timed INSIDE the page, click to the first frame that shows the menu: Playwright's own round + // trips to a CPU x6 page cost seconds and would measure the harness const menuTime = async () => { - const t0 = Date.now(); - await page.locator('#logo-menu').click({ timeout: 5000 }); - const ms = await page - .waitForFunction(() => { const m = document.querySelector('#open-templates'); return !!m && m.getBoundingClientRect().height > 0; }, null, { timeout: 8000 }) - .then(() => Date.now() - t0) - .catch(() => -1); + const ms = await page.evaluate( + () => + new Promise((resolve) => { + const t0 = performance.now(); + /** @type {HTMLElement} */ (document.querySelector('#logo-menu')).click(); + const poll = () => { + const m = document.querySelector('#open-templates'); + if (m && m.getBoundingClientRect().height > 0) return resolve(Math.round(performance.now() - t0)); + if (performance.now() - t0 > 8000) return resolve(-1); + requestAnimationFrame(poll); + }; + requestAnimationFrame(poll); + }) + ); await page.keyboard.press('Escape').catch(() => {}); await page.locator('#logo-menu').click().catch(() => {}); await page.waitForTimeout(800); @@ -207,7 +217,7 @@ h.run(async () => { await h.eventually(() => page.evaluate(() => !!document.querySelector('#scene-load-bar')), (v) => v, '2.0 (premise) a load is under way (the bar is up)', 30000); const menuUp = await menuTime(); const stillLoading = !!(await sceneState(page, null)).job; - h.check(menuUp >= 0 && menuUp < idleMenu + 600 && stillLoading, `2.1 the logo menu opens in ${menuUp} ms while the scene is still loading (${idleMenu} ms with nothing loading; ${stillLoading ? 'still loading' : 'load had ENDED - inconclusive'})`); + h.check(menuUp >= 0 && menuUp < Math.max(400, idleMenu * 2) && stillLoading, `2.1 the logo menu opens in ${menuUp} ms while the scene is still loading (${idleMenu} ms with nothing loading; ${stillLoading ? 'still loading' : 'load had ENDED - inconclusive'})`); await throttle(1); await watch({ marker: 'Castle gate', done: whole }, '2.2 that load still completes'); From 959b8e337d79750b1b8dfa81e8b4375c47b9d686 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 02:42:21 +0300 Subject: [PATCH 10/15] [fix] 33 L1: the look's re-keyed programs link off-frame; no geometry checksum cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sessions/autosave: environment -> hold + sliced warmPrograms of the (empty) scene -> post stack -> release + two frames for the composer's own shaders (compile() cannot reach them) -> hold for the build. The env re-key no longer links inside a frame. - packRefs: the checksum cache keyed by buffer versions is GONE — pack-refs caught an in-place vertex move without needsUpdate reading as pristine, which would save an edited piece as a stub (the edit lost on reload). Counterfactual = that pack-refs check, red with the cache, green without. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 3 +-- src/lib/autosave.js | 20 +++++++++++++------- src/lib/packRefs.js | 21 ++++++--------------- src/lib/sessions.js | 26 +++++++++++++++----------- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6098837d..8468c38e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2154,8 +2154,7 @@ loadable play content. Everything a user does must be visible to connected peers target, because three keys a program by tone mapping + output colour space and the composer draws into a target), the scene's ENVIRONMENT restored before the objects (its light count is in every program key), the object list's plain tree mounting in chunks (Controls.svelte, - 40 + 16/frame), `geometryKeys` cache of the - fingerprint checksums keyed by buffer versions, `packRef.box` (root-frame bounds, additive) + + 40 + 16/frame), `packRef.box` (root-frame bounds, additive) + ONE scene-root InstancedMesh of grey `kit-placeholders` for hollow stubs, and `parkPackPieces` — **the autosave writes pristine kit pieces as STUBS now** (reverses 30c's "autosave stays full"; hollowed before the GLTF export and put back on the exporter's diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 4ca26a33..77ca7af1 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -632,15 +632,19 @@ async function applyRestore(snapshot, offer = null) { // 33 L1: sky and lights before the objects, so pieces warming as their packs land // compile for THIS scene's lights (see sessions.applySession) environmentRestore(snapshot.environment, true); - // and the look, with two frames of the empty scene to compile it (sessions.applySession) - await nextFrames(2); - if (!isLive(job)) return null; + // the look off-frame, then two near-empty frames for the post stack's own shaders, then + // hold for the build (the reasoning is in sessions.applySession) + holdFrames(); + await warmPrograms(get(globalScene)); + if (!isLive(job)) { + releaseFrames(); + return null; + } scenePostRestore(snapshot.post, true); + releaseFrames(); await nextFrames(2); if (!isLive(job)) return null; - // the viewport holds its last frame while programs re-link and the objects go in holdFrames(); - const warming = warmPrograms(get(globalScene)); if (snapshot.scene && group) { const loader = new GLTFLoader(); /** @type {any} */ @@ -648,7 +652,10 @@ async function applyRestore(snapshot, offer = null) { loader.parse(snapshot.scene, '', resolve, reject) ); // cancelled or superseded (another scene opened) while the snapshot parsed - if (!isLive(job)) return null; + if (!isLive(job)) { + releaseFrames(); + return null; + } const container = result.scene.getObjectByName('AuxScene')?.children?.[0] ?? result.scene.children[0] ?? @@ -695,7 +702,6 @@ async function applyRestore(snapshot, offer = null) { endSceneBatch(); } pokeScene(); - await warming; await warmPrograms(group); } releaseFrames(); diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js index b659efd1..b5c197d5 100644 --- a/src/lib/packRefs.js +++ b/src/lib/packRefs.js @@ -296,30 +296,21 @@ function checksum(array) { return num(a) + '/' + num(b); } -/** 33 L1: the checksums of a geometry, kept until one of its buffers changes. A save asks - * "is this piece still pristine?" of every kit piece, and summing every vertex of a 180-piece - * castle on each autosave and each backup was a long task of its own. Keyed by the buffers' - * `version`s, which every edit path bumps (needsUpdate) — an edit no renderer would see is - * the only one this could miss. - * @type {WeakMap} */ -const geometryKeys = new WeakMap(); - /** @param {any} geometry */ function geometryKey(geometry) { + // NOT cached (33 L1 tried a cache keyed by buffer versions and pack-refs caught it): an + // in-place vertex move that has not been flagged needsUpdate yet would read as pristine and + // save an EDITED piece as a stub, i.e. lose the edit on reload. Correctness over the + // checksum's cost — which is still a fraction of the full-geometry export it replaced. if (!geometry?.attributes) return '-'; const names = Object.keys(geometry.attributes).sort(); - const ver = names.map((name) => name + geometry.attributes[name].version + ':' + geometry.attributes[name].array?.length).join(',') + '|' + (geometry.index ? geometry.index.version + ':' + geometry.index.array?.length : '-'); - const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); - const cached = geometryKeys.get(geometry); - if (cached && cached.ver === ver) return cached.key + '|' + groups; const parts = names.map((name) => { const attr = geometry.attributes[name]; return name + ':' + attr.itemSize + ':' + attr.count + ':' + (attr.normalized ? 1 : 0) + ':' + checksum(attr.array); }); const index = geometry.index ? 'i' + geometry.index.count + ':' + checksum(geometry.index.array) : 'noindex'; - const key = parts.join(',') + '|' + index; - geometryKeys.set(geometry, { ver, key }); - return key + '|' + groups; + const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); + return parts.join(',') + '|' + index + '|' + groups; } /** 8×8 RGBA of a texture's image, cached per image. Null when it cannot be read (no diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 1e63a7bc..aa7afb8a 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -1355,20 +1355,25 @@ async function applySessionNow(payload, opts, job) { // previous scene's lights and linked again on its first frame. It also shows the right sky // at once instead of the old one under the arriving level. (A6.1: absent = the default.) environmentRestore(payload.environment, replicate); - // and its LOOK (the post stack), then two frames of the still-empty scene: the composite - // shader and the helpers re-keyed by the new fog/lights compile there, on a frame that has - // almost nothing else to draw, instead of on the first frame of the whole level - // (one at a time: the new fog/lights re-key every helper's program, the post stack builds - // its own — measured ~60 + ~40 ms of links at CPU x6, too much for ONE frame together) - await nextFrames(2); - if (!isLive(job)) return; + // The new fog/lights RE-KEY every program already in the scene (helpers, grid, sky), and the + // first frame after it linked them all at once: hold the viewport and link them a slice at a + // time first. Then the LOOK (post stack): its own shaders are built by the composer on its + // next frames and `compile` cannot reach them, so they get two frames of a near-empty scene + // to themselves. Then hold again for the build. (Measured on a CPU x6 phone: env and post + // links together on one frame were the last tasks over 200 ms.) + holdFrames(); + await warmPrograms(get(globalScene)); + if (!isLive(job)) { + releaseFrames(); + return; + } scenePostRestore(payload.post, replicate); + releaseFrames(); await nextFrames(2); if (!isLive(job)) return; - // ...then the viewport holds its last frame while the remaining programs link a slice at a - // time and the objects are built; released below (bounded either way) + // ...then the viewport holds its last frame while the objects are built and warmed; + // released below (bounded either way) holdFrames(); - const warming = warmPrograms(get(globalScene)); /** @type {any} */ const peer = get(peers); // 33 L1: Cancel (the load bar) takes back what this load had added — clearing for the @@ -1425,7 +1430,6 @@ async function applySessionNow(payload, opts, job) { } pokeScene(); // the objects this load built (kit pieces warm themselves as their packs land) — then draw - await warming; await warmPrograms(group); releaseFrames(); // animated imports come back from their original bytes (mixers rebuilt, peers From 25452f7f84709c2762b87b862bdef287d63f4d79 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 03:25:03 +0300 Subject: [PATCH 11/15] [fix] 33 L1: a load waits for a program warm-up at most 1.2 s (sceneLoader.within) templates-modal (software GL) went red after the whole-scene warm became awaited before the build: a software link is hundreds of ms per program, so the scene stayed empty past the check's wait. The warm carries on in the background; the load never waits on the driver. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/autosave.js | 6 +++--- src/lib/sceneLoader.js | 13 +++++++++++++ src/lib/sessions.js | 6 +++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 77ca7af1..aafb462b 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -14,7 +14,7 @@ import { stripEditOverlays } from './editOverlays'; // restore them as permanent scene content import { isTransient, parkTransientObjects } from './transientObjects'; import { parkPackPieces, fillPackRef, isPristinePackRef, stubElementOf, warmPrograms } from './packRefs'; -import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames, loading as sceneLoading, loadSettled } from './sceneLoader'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames, within, WARM_WAIT_MS, loading as sceneLoading, loadSettled } from './sceneLoader'; import { animatedImportsSnapshot, animatedImportsRestore } from './animatedImports'; import { animations, animationsSnapshot, animationsRestore } from './animationPreview'; import { scenePost, scenePostSnapshot, scenePostRestore } from './scenePost'; @@ -635,7 +635,7 @@ async function applyRestore(snapshot, offer = null) { // the look off-frame, then two near-empty frames for the post stack's own shaders, then // hold for the build (the reasoning is in sessions.applySession) holdFrames(); - await warmPrograms(get(globalScene)); + await within(warmPrograms(get(globalScene)), WARM_WAIT_MS); if (!isLive(job)) { releaseFrames(); return null; @@ -702,7 +702,7 @@ async function applyRestore(snapshot, offer = null) { endSceneBatch(); } pokeScene(); - await warmPrograms(group); + await within(warmPrograms(group), WARM_WAIT_MS); } releaseFrames(); // multi-material meshes come back from their toJSON, REPLACING the Group of diff --git a/src/lib/sceneLoader.js b/src/lib/sceneLoader.js index aeed84bd..b66ee804 100644 --- a/src/lib/sceneLoader.js +++ b/src/lib/sceneLoader.js @@ -201,6 +201,19 @@ export async function nextFrames(n = 2) { }); } +/** How long a load waits for a program warm-up before going on regardless. */ +export const WARM_WAIT_MS = 1200; + +/** + * Wait for `promise`, but never longer than `ms`: the work it stands for carries on either way. + * A program warm-up on a slow driver (software GL links a program in hundreds of ms) must + * delay a load by a bounded moment, never by however long the driver takes. + * @param {Promise} promise @param {number} ms + */ +export function within(promise, ms) { + return Promise.race([promise, new Promise((resolve) => setTimeout(resolve, ms))]); +} + let nextId = 0; /** @type {LoadJob | null} */ let current = null; diff --git a/src/lib/sessions.js b/src/lib/sessions.js index aa7afb8a..3a5f57a3 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -7,7 +7,7 @@ import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { stripEditOverlays } from './editOverlays'; import { isPristinePackRef, stubElementOf, stubNodeCount, fillPackRef, warmPrograms } from './packRefs'; -import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames } from './sceneLoader'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames, within, WARM_WAIT_MS } from './sceneLoader'; // B7: a spawner's copies exist only while the world runs — never in a scene file import { isTransient } from './transientObjects'; import { @@ -1362,7 +1362,7 @@ async function applySessionNow(payload, opts, job) { // to themselves. Then hold again for the build. (Measured on a CPU x6 phone: env and post // links together on one frame were the last tasks over 200 ms.) holdFrames(); - await warmPrograms(get(globalScene)); + await within(warmPrograms(get(globalScene)), WARM_WAIT_MS); if (!isLive(job)) { releaseFrames(); return; @@ -1430,7 +1430,7 @@ async function applySessionNow(payload, opts, job) { } pokeScene(); // the objects this load built (kit pieces warm themselves as their packs land) — then draw - await warmPrograms(group); + await within(warmPrograms(group), WARM_WAIT_MS); releaseFrames(); // animated imports come back from their original bytes (mixers rebuilt, peers // reparse the same file) and authored tracks from the payload From 0a914615093e06b3e98c9ace35a052acefef619c Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 03:43:59 +0300 Subject: [PATCH 12/15] [fix] 33 L1: the composer's pass shaders compile off-frame; a heavier ordinary-objects fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Outline.svelte registers warmComposerPasses through sceneLoader.registerComposerWarm: every pass (and each effect's internal passes) compiled against the target it draws into, one per slice, then first use per slice. The load paths call it (bounded by within) while the viewport is held, after restoring the post stack. - scene-load section 7 builds 1200 ordinary meshes (300 measured 166 ms even unsliced — too small to prove the slicing guard). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Outline.svelte | 41 ++++++++++++++++++++++++++++++++++- src/lib/autosave.js | 3 ++- src/lib/sceneLoader.js | 17 +++++++++++++++ src/lib/sessions.js | 10 +++++---- tests/e2e/scene-load.test.cjs | 35 ++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 98655ec9..b0e218db 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -37,7 +37,9 @@ import { viewPrefs } from '$lib/viewPrefs'; import { shadowQuality } from '$lib/lightParams'; import { useTask, useThrelte } from '@threlte/core'; - import { framesHeld } from '$lib/sceneLoader'; + import { framesHeld, registerComposerWarm, schedule } from '$lib/sceneLoader'; + import { WebGLRenderTarget } from 'three'; + import { tick } from 'svelte'; import { BlendFunction, EffectComposer, @@ -388,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 diff --git a/src/lib/autosave.js b/src/lib/autosave.js index aafb462b..0589cd64 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -14,7 +14,7 @@ import { stripEditOverlays } from './editOverlays'; // restore them as permanent scene content import { isTransient, parkTransientObjects } from './transientObjects'; import { parkPackPieces, fillPackRef, isPristinePackRef, stubElementOf, warmPrograms } from './packRefs'; -import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames, within, WARM_WAIT_MS, loading as sceneLoading, loadSettled } from './sceneLoader'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames, within, WARM_WAIT_MS, warmComposer, loading as sceneLoading, loadSettled } from './sceneLoader'; import { animatedImportsSnapshot, animatedImportsRestore } from './animatedImports'; import { animations, animationsSnapshot, animationsRestore } from './animationPreview'; import { scenePost, scenePostSnapshot, scenePostRestore } from './scenePost'; @@ -641,6 +641,7 @@ async function applyRestore(snapshot, offer = null) { return null; } scenePostRestore(snapshot.post, true); + await within(warmComposer(), WARM_WAIT_MS); releaseFrames(); await nextFrames(2); if (!isLive(job)) return null; diff --git a/src/lib/sceneLoader.js b/src/lib/sceneLoader.js index b66ee804..f0d2de77 100644 --- a/src/lib/sceneLoader.js +++ b/src/lib/sceneLoader.js @@ -214,6 +214,23 @@ export function within(promise, ms) { return Promise.race([promise, new Promise((resolve) => setTimeout(resolve, ms))]); } +/** @type {(() => Promise) | null} */ +let composerWarm = null; + +/** + * The post-processing composer's warm-up, registered by Outline.svelte (which owns the + * composer — a seam, the registerToneMappingOwner shape, so no module imports the component). + * @param {(() => Promise) | null} fn + */ +export function registerComposerWarm(fn) { + composerWarm = fn; +} + +/** Compile the composer's pass shaders off-frame (a no-op with no composer registered). */ +export function warmComposer() { + return composerWarm ? composerWarm().catch(() => {}) : Promise.resolve(); +} + let nextId = 0; /** @type {LoadJob | null} */ let current = null; diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 3a5f57a3..cebbb97e 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -7,7 +7,7 @@ import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { stripEditOverlays } from './editOverlays'; import { isPristinePackRef, stubElementOf, stubNodeCount, fillPackRef, warmPrograms } from './packRefs'; -import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames, within, WARM_WAIT_MS } from './sceneLoader'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke, holdFrames, releaseFrames, nextFrames, within, WARM_WAIT_MS, warmComposer } from './sceneLoader'; // B7: a spawner's copies exist only while the world runs — never in a scene file import { isTransient } from './transientObjects'; import { @@ -1357,9 +1357,9 @@ async function applySessionNow(payload, opts, job) { environmentRestore(payload.environment, replicate); // The new fog/lights RE-KEY every program already in the scene (helpers, grid, sky), and the // first frame after it linked them all at once: hold the viewport and link them a slice at a - // time first. Then the LOOK (post stack): its own shaders are built by the composer on its - // next frames and `compile` cannot reach them, so they get two frames of a near-empty scene - // to themselves. Then hold again for the build. (Measured on a CPU x6 phone: env and post + // time first. Then the LOOK (post stack): its pass shaders are compiled off-frame through + // the composer seam Outline registers, and whatever that misses gets two frames of a + // near-empty scene to itself. Then hold again for the build. (Measured on a CPU x6 phone: env and post // links together on one frame were the last tasks over 200 ms.) holdFrames(); await within(warmPrograms(get(globalScene)), WARM_WAIT_MS); @@ -1368,6 +1368,8 @@ async function applySessionNow(payload, opts, job) { return; } scenePostRestore(payload.post, replicate); + // the composer's own pass shaders, off-frame too (Outline registers the warm-up) + await within(warmComposer(), WARM_WAIT_MS); releaseFrames(); await nextFrames(2); if (!isLive(job)) return; diff --git a/tests/e2e/scene-load.test.cjs b/tests/e2e/scene-load.test.cjs index 3506c8be..43a4e51d 100644 --- a/tests/e2e/scene-load.test.cjs +++ b/tests/e2e/scene-load.test.cjs @@ -18,6 +18,10 @@ // 6. RESTORE (the real button in the restore toast) after a reload: "Restoring …" with // progress, no long task > 200 ms, the castle is whole again (and the edited piece kept // its edit). +// 7. 1200 ORDINARY meshes (no kit references) build with no long task > 200 ms — the case where +// the time-sliced build loop itself is what keeps the window alive. +// Counterfactuals measured (one guard removed each): autosave stubs off -> 5.2 + 6.3 red (51 MB, +// a 1 212 ms restore task); object-list chunking off -> 1.4 + 6.3 red (307 / 328 ms). // // EVERY measured load starts from a FRESH page: packRefs keeps parsed pack files in memory, so a // re-open in the same page finishes before anything can be observed (the first version of this @@ -304,5 +308,36 @@ h.run(async () => { h.check(kept === 0x12ab34, '6.4 the EDITED piece came back with its edit (colour ' + (kept == null ? 'missing' : '#' + kept.toString(16)) + ')'); h.check(six.last?.top === pieces, `6.5 every object is back (${six.last?.top} / ${pieces})`); + // ---- 7. a scene of ORDINARY objects: the build loop itself is time-sliced -------------- + // The kit levels are references (cheap stubs) — this is the scene they are not: 1200 plain + // meshes, each parsed, added and announced on its own. Unsliced, that loop is one task (300 + // was measured too small to tell: 166 ms unsliced at CPU x6). + await fresh(); + const many = await page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + for (let i = 0; i < 1200; 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(); + const payload = s.sessions.buildSessionPayload('Many boxes'); + window.__manyPayload = payload; + return payload.objects.length; + }); + await page.evaluate(() => window.__stores.sceneLoader.cancelLoad()); + await arm(page); + await throttle(CPU); + await page.evaluate(() => void window.__stores.sessions.applySession(window.__manyPayload, { backup: false })); + await h.eventually(() => sceneState(page, null), (s) => s.top === many && !s.job, '7.0 a scene of ' + many + ' ordinary meshes loads', 120000); + await page.waitForTimeout(1500); + await throttle(1); + probe = await disarm(page); + h.check(probe.max <= LONG_MS, `7.1 no long task over ${LONG_MS} ms while ${many} ordinary objects are built (max ${probe.max} ms of ${probe.longtasks.length})`); + console.log(' ordinary long tasks: ' + JSON.stringify(probe.longtasks.slice().sort((a, b) => b - a).slice(0, 10))); + await h.finish(browser); }); From 7103d01848eff0d3df521b3cfc615a04341b0bd9 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 03:49:14 +0300 Subject: [PATCH 13/15] [fix] 33 L1: one slice budget across pump runs; the program warm-up compiles in batches of twins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sceneLoader.pump keeps the SHARED slice clock: a loop awaiting one scheduled item at a time started a fresh pump per item through microtasks, and the whole loop ran as ONE task — measured 881 ms of program warm-up on a 1200-mesh scene, and the source of several earlier residual tasks inside pump. - packRefs.warmPrograms: compile(object, camera, scene) walks the whole scene for lights per call, so per-mesh compiles were quadratic. Plain meshes compile in batches of 48 throwaway twins sharing their geometry + material (which prepares the real material); skinned / instanced / other drawables compile as themselves. - scene-load-trace: SLUGS=many-boxes (MANY=1200) — a synthetic scene of ordinary meshes. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/scene-load-trace.cjs | 27 +++++++++++++++++++++++++ src/lib/packRefs.js | 39 ++++++++++++++++++++++++++++++++---- src/lib/sceneLoader.js | 26 ++++++++++++++---------- 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/scripts/scene-load-trace.cjs b/scripts/scene-load-trace.cjs index cb68e19b..7495ab6b 100644 --- a/scripts/scene-load-trace.cjs +++ b/scripts/scene-load-trace.cjs @@ -180,6 +180,33 @@ h.run(async () => { 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); diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js index b5c197d5..63b8f36b 100644 --- a/src/lib/packRefs.js +++ b/src/lib/packRefs.js @@ -237,7 +237,7 @@ async function warmTemplate(scene) { * BOTH variants: three keys a program by its OUTPUT too — drawn straight to the canvas it * tone-maps and encodes sRGB, drawn into a render target (the post-processing composer: the * editor's outline, the scene look) it does neither, and Outline.svelte switches between the - * two per frame. One compile per MESH per slice (a whole-scene `compile` is itself a long task). + * two per frame. One BATCH of twins per slice (a whole-scene `compile` is itself a long task). * Then `getUniforms` per program: three reads the link result there, and without the * parallel-compile extension that read WAITS for the link — measured as 100-270 ms of first-use * links inside the first frame that drew a loaded scene, with the programs already created. It @@ -256,15 +256,46 @@ export async function warmPrograms(root) { root.traverse((/** @type {any} */ node) => { if ((node.isMesh || node.isLine || node.isPoints || node.isSprite) && node.material) drawables.push(node); }); + // BATCHED through TWINS. `compile(object, camera, scene)` walks the whole target scene for its + // lights on every call, so one call per mesh was quadratic (1200 meshes = 1200 walks of 1200 + // nodes). A plain mesh's program depends on its material, geometry attributes and the + // scene, not on which Object3D holds them — so a throwaway Mesh sharing the SAME geometry and + // material prepares the real material, and a batch of them compiles in one walk. Skinned and + // instanced meshes (whose object IS part of the key) and anything else compile as themselves. + const BATCH = 48; + /** @type {any[][]} */ + const batches = []; + /** @type {any[]} */ + let twins = []; + for (const node of drawables) { + if (node.isMesh && !node.isSkinnedMesh && !node.isInstancedMesh && !node.isBatchedMesh && node.constructor === THREE.Mesh) { + twins.push(node); + if (twins.length === BATCH) { + batches.push(twins); + twins = []; + } + } else batches.push([node]); + } + if (twins.length) batches.push(twins); try { if (!warmTarget) warmTarget = new THREE.WebGLRenderTarget(1, 1); - for (const node of drawables) + for (const batch of batches) await schedule(() => { - renderer.compile(node, camera, target); + /** @type {any} */ + let subject = batch[0]; + if (batch.length > 1 || batch[0].constructor === THREE.Mesh) { + subject = new THREE.Group(); + for (const node of batch) { + const twin = new THREE.Mesh(node.geometry, node.material); + twin.matrixAutoUpdate = false; + subject.add(twin); + } + } + renderer.compile(subject, camera, target); const previous = renderer.getRenderTarget(); renderer.setRenderTarget(warmTarget); try { - renderer.compile(node, camera, target); + renderer.compile(subject, camera, target); } finally { renderer.setRenderTarget(previous); } diff --git a/src/lib/sceneLoader.js b/src/lib/sceneLoader.js index f0d2de77..cebf3319 100644 --- a/src/lib/sceneLoader.js +++ b/src/lib/sceneLoader.js @@ -101,16 +101,20 @@ async function pump() { pumping = true; try { while (queue.length) { - const start = now(); - while (queue.length && now() - start < SLICE_MS) { - const item = /** @type {any} */ (queue.shift()); - try { - item.resolve(item.fn()); - } catch (error) { - item.reject(error); - } + // the budget is SHARED with every other pump run and every `slice()`: a loop that + // awaits one scheduled item at a time starts a fresh pump per item, chained through + // microtasks — with a per-run budget that whole loop was ONE task (measured: an 881 ms + // task of program warm-up items, each well under the budget on its own) + if (now() - sliceStart >= SLICE_MS) { + await yieldToEventLoop(); + sliceStart = now(); + } + const item = /** @type {any} */ (queue.shift()); + try { + item.resolve(item.fn()); + } catch (error) { + item.reject(error); } - if (queue.length) await yieldToEventLoop(); } } finally { pumping = false; @@ -119,8 +123,8 @@ async function pump() { /** * Run a piece of SYNCHRONOUS work inside the shared slice budget. Items run in arrival - * order, as many per task as fit in `SLICE_MS` (at least one), so a burst of continuations - * spreads over several tasks instead of one. + * order, as many per task as fit in `SLICE_MS` — the same clock `slice()` keeps — so a burst + * of continuations, or a loop awaiting one item at a time, spreads over several tasks. * @template T @param {() => T} fn @returns {Promise} */ export function schedule(fn) { From 55a1f8af05313f72e8a170dabd7e8612209cd09a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 03:52:54 +0300 Subject: [PATCH 14/15] [test] 33 L1: section 7 parses real vertex data per object (1000 icospheres) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/scene-load.test.cjs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/e2e/scene-load.test.cjs b/tests/e2e/scene-load.test.cjs index 43a4e51d..e5c7e045 100644 --- a/tests/e2e/scene-load.test.cjs +++ b/tests/e2e/scene-load.test.cjs @@ -18,7 +18,7 @@ // 6. RESTORE (the real button in the restore toast) after a reload: "Restoring …" with // progress, no long task > 200 ms, the castle is whole again (and the edited piece kept // its edit). -// 7. 1200 ORDINARY meshes (no kit references) build with no long task > 200 ms — the case where +// 7. 1000 ORDINARY meshes (960 vertices each) (no kit references) build with no long task > 200 ms — the case where // the time-sliced build loop itself is what keeps the window alive. // Counterfactuals measured (one guard removed each): autosave stubs off -> 5.2 + 6.3 red (51 MB, // a 1 212 ms restore task); object-list chunking off -> 1.4 + 6.3 red (307 / 328 ms). @@ -309,16 +309,17 @@ h.run(async () => { h.check(six.last?.top === pieces, `6.5 every object is back (${six.last?.top} / ${pieces})`); // ---- 7. a scene of ORDINARY objects: the build loop itself is time-sliced -------------- - // The kit levels are references (cheap stubs) — this is the scene they are not: 1200 plain - // meshes, each parsed, added and announced on its own. Unsliced, that loop is one task (300 - // was measured too small to tell: 166 ms unsliced at CPU x6). + // The kit levels are references (cheap stubs) — this is the scene they are not: 1000 plain + // meshes with real vertex data, each parsed, added and announced on its own. Unsliced, that + // loop is one task (300 and 1200 bare boxes were measured too cheap to tell, 166 ms unsliced). await fresh(); const many = await page.evaluate(() => { const s = window.__stores; /** @type {any} */ let g; s.objectsGroup.subscribe((v) => (g = v))(); - for (let i = 0; i < 1200; 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 })); + for (let i = 0; i < 1000; i++) { + // real vertex data per object (an icosphere: 960 vertices) — parsing is the per-object cost + const m = new s.THREE.Mesh(new s.THREE.IcosahedronGeometry(0.3, 2), 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); From 9fb06a2bf01986ae118b42d3552222d0fcc46df9 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 03:56:05 +0300 Subject: [PATCH 15/15] [docs] CLAUDE.md: the sceneLoader entry as shipped (33 L1) Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 72 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8468c38e..d46d4a3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2131,38 +2131,46 @@ loadable play content. Everything a user does must be visible to connected peers 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: Restore of Castle Courtyard was ONE 80 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, and the template open froze 2 s - (569 ms tasks: texture upload + sync program link in the first frame, every copy of a kit piece - cloned in the one task that resolved its template, the object list re-rendered per poke). - `slice(job)` = a cooperative yield for a LOOP (10 ms budget; throws `LoadCancelled` at a clean - point between two objects), `schedule(fn)` = the same budget for MANY promise continuations (a - burst that resolves together runs as queue items a slice at a time — N continuations each - checking a clock all run in the one task that resolved their promise), `sceneLoad` = the ONE - running job `{name, verb, total, done, phase: preparing|reading|objects|models, cancellable, - interrupted}` rendered by `menu/SceneLoadBar.svelte` (first slot of the toast stack, after - 250 ms, `#scene-load-bar` data-done/total/phase, `#scene-load-cancel`). Starting a load - SUPERSEDES the running one (no hooks run; its loop stops at the next slice, every await in - `applySession`/`applyRestore` re-checks `isLive(job)`); Cancel runs the load's `onCancel` hook - (sessions: replicated clear + toast naming the Backup; restore: clear + RE-OFFER the prompt). - Cancel stays offered through `models` (kit pieces still arriving). A load that interrupts one still building writes no "Backup before" (that - scene is half of somebody's load). `throttledPoke` (250 ms) during the loop; refills poke at - most every 200 ms (packRefs `pokeSoon`). The debounced autosave WAITS OUT a load - (`saveNow` does not). packRefs: refill attach through `schedule`, `warmTemplate` - (`initTexture` per slice + `compileAsync` against the live scene TWICE — canvas AND a 1x1 render - target, because three keys a program by tone mapping + output colour space and the composer - draws into a target), the scene's ENVIRONMENT restored before the objects (its light count is - in every program key), the object list's plain tree mounting in chunks (Controls.svelte, - 40 + 16/frame), `packRef.box` (root-frame bounds, additive) + - ONE scene-root InstancedMesh of grey `kit-placeholders` for hollow stubs, and - `parkPackPieces` — **the autosave writes pristine kit pieces as STUBS now** (reverses 30c's - "autosave stays full"; hollowed before the GLTF export and put back on the exporter's - `afterParse` hook, i.e. once the tree is READ and before the async encode lets a frame draw a - hollow castle; `parkedRoots` keeps a scan from refilling one mid-export). The restore sends a - wire copy only with an OPEN peer, stubs for pristine pieces. Suite `scene-load` (CPU x6 phone, - no long task > 200 ms on open AND restore) + `scripts/scene-load-trace.cjs` (the P0 probe: - long tasks, longest frame gap, profile top-self + app call chains). + 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