diff --git a/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts b/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts index e97202120..a9d36ce65 100644 --- a/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts +++ b/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts @@ -53,6 +53,15 @@ describe('progressive wall budget', () => { expect(shouldDeferWallRebuild(heavy.id, nodes, 0, 100)).toBe(false) }) + test('initial build drains under a 48 ms budget without the count cap; interactive tiers unchanged', () => { + expect(shouldDeferWallRebuild(cheap.id, nodes, 40, 30, true)).toBe(false) + expect(shouldDeferWallRebuild(cheap.id, nodes, 40, 48, true)).toBe(true) + expect(shouldDeferWallRebuild(cheap.id, nodes, 8, 0, true)).toBe(false) + expect(shouldDeferWallRebuild(heavy.id, nodes, 1, 0, true)).toBe(true) + expect(shouldDeferWallRebuild(cheap.id, nodes, 8, 0)).toBe(true) + expect(shouldDeferWallRebuild(cheap.id, nodes, 1, 8)).toBe(true) + }) + test('counts item cutout proxies but skips ordinary items', () => { const item = { id: 'item_budget-test', type: 'item' } as AnyNode const wall = { ...heavy, children: [...heavy.children.slice(0, 5), item.id] } @@ -366,7 +375,7 @@ test('reattaching mid-drain preserves the hydration counters and one continuous try { hydrate(12) const token = useScene.getState().hydrationToken - rebuildCost = 4 + rebuildCost = 24 runWallBuildFrame() expect(stats().firstBuilds).toBe(2) unsubscribe() @@ -379,7 +388,7 @@ test('reattaching mid-drain preserves the hydration counters and one continuous rebuildCost = 0 runWallBuildFrame() expect(stats().firstBuilds).toBe(12) - expect(spans).toEqual([28]) + expect(spans).toEqual([68]) } finally { stop() } }) @@ -447,9 +456,9 @@ test('setScene starts initial build; more than eight cheap walls drain in one fr expect(stats().drainedExits).toBe(1) }) -test('checks the eight millisecond budget between walls and skips first-build neighbour invalidation across frames', () => { +test('checks the initial-build time budget between walls and skips first-build neighbour invalidation across frames', () => { hydrate(12) - rebuildCost = 4 + rebuildCost = 24 runWallBuildFrame() expect(stats().wallsConsumedThisFrame).toBe(2) expect(stats().budgetExits).toBe(1) @@ -695,7 +704,7 @@ test('canvas and live-state owner precede the lazy wall consumer; remount retain const nodes = Object.fromEntries([level, ...walls].map(node => [node.id, node])) const meshes = walls.map(wall => { const mesh = new Mesh(new BoxGeometry()) - mesh.geometry.addEventListener('dispose', () => { now += 4 }) + mesh.geometry.addEventListener('dispose', () => { now += 24 }) sceneRegistry.nodes.set(wall.id, mesh) sceneRegistry.byType.wall.add(wall.id) return mesh diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index ce152367f..7e066b096 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -614,6 +614,12 @@ const DRAG_FLUSH_MS = 80 const MAX_WALL_REBUILDS_PER_FRAME = 8 const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8 +// Initial build (see wall-build-lifecycle) has no interactive gesture to protect: +// the frame is dominated by rendering the still-unbatched scene, so every extra +// frame spent draining walls costs a full scene render. Three display frames of +// wall work per frame drains a 1,600-wall scene in ~1/6 of the frames while every +// frame stays two orders of magnitude under a perceptible freeze. +const WALL_INITIAL_BUILD_TIME_BUDGET_MS = 48 const HEAVY_WALL_OPENINGS = 6 let lastWallDirtyAtMs = 0 let unmountedFrames = 0 @@ -628,7 +634,10 @@ function wallRebuildExitReason( ): 'cap' | 'budget' | 'heavy' | null { if (!initialBuild && rebuiltThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) return 'cap' if (rebuiltThisFrame === 0) return null - if (elapsedMs >= WALL_PROGRESSIVE_TIME_BUDGET_MS) return 'budget' + const budgetMs = initialBuild + ? WALL_INITIAL_BUILD_TIME_BUDGET_MS + : WALL_PROGRESSIVE_TIME_BUDGET_MS + if (elapsedMs >= budgetMs) return 'budget' const wall = nodes[wallId as AnyNodeId] if (wall?.type !== 'wall') return null let cutouts = 0 @@ -654,8 +663,9 @@ export function shouldDeferWallRebuild( nodes: Record, rebuiltThisFrame: number, elapsedMs: number, + initialBuild = false, ): boolean { - return wallRebuildExitReason(wallId, nodes, rebuiltThisFrame, elapsedMs) !== null + return wallRebuildExitReason(wallId, nodes, rebuiltThisFrame, elapsedMs, initialBuild) !== null } /** Rebuilds this system still owes — neighbours deferred during a drag. */ diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index 481fc8309..f13dac05c 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -69,8 +69,9 @@ Unavailable walls do not continually postpone the pending-neighbour quiet clock. `isWallInitialBuildActive()` and `getPendingWallRebuildCount()` remain readable without `?perf`. -Initial build consumes walls under the existing **8 ms budget**, checked between -walls, without the interactive **8 walls/frame** cap. A wall with at least six +Initial build consumes walls under a **48 ms budget** (about three display frames; the +interactive tier keeps **8 ms**), checked between walls, without the interactive +**8 walls/frame** cap. A wall with at least six opening cutouts occupies its own frame. Each wall's first build during active initial build skips adjacency scanning and neighbour re-invalidation because the hydrated inputs are stable and its neighbours are queued for their own first