diff --git a/docs/performance/audit-implementation.md b/docs/performance/audit-implementation.md new file mode 100644 index 0000000..fc1c4b3 --- /dev/null +++ b/docs/performance/audit-implementation.md @@ -0,0 +1,71 @@ +# Performance audit implementation — 7 September 2026 + +Based on the supplied performance audit and baseline `1b488d99aae81fae615eecf281fb24e2f9995cdf`. + +This change removes avoidable synchronization, repeated analytical sampling and retained pass targets. Requested render scale, noise octaves, cloud steps, water settings, textures, props and LOD settings are preserved. Full 3D visual parity and device-specific startup/FPS gains remain unverified; this PR should stay a draft until those checks pass. + +## Changes + +- **Minimap:** reuse the existing backend-neutral asynchronous readback helper in both canvas and worker paths. Limit work to one pending image, restore the render target immediately, reuse front/back CPU image buffers, and discard results after an edit, changed view, canvas handoff or disposal. Worker responses receive an owned copy so transferring the RGBA buffer cannot detach the cached map. Coalesce refresh requests while a worker result is pending. +- **Analytical maps:** preserve the 128 × 128 sample grid and 256 × 256 RGBA output. Height/water, noise, biome, slope and props views compute only the field their pixels need. Hover continues to use the complete surface sample. Batches stop after 256 samples or a cooperative 4 ms budget. Map grids and camera overlays reuse the cached base image. A hidden minimap or pending readback no longer forces full Studio scene renders through its dirty flag. +- **Shaders:** reuse the exact centre climate in the live Tile height call, including all composed offsets. Neighbour climate/height samples, Manual/Infinite cache semantics and rasterized shoreline classification are preserved. The same fogged plinth-wall branch runs before expensive terrain shading only outside debug/export modes. +- **Boot:** cloud readiness overlaps terrain/water submission and incremental geometry. The final compile stage joins readiness, refreshes the material list/target and compiles newly required variants before water activation and presentation. Isolated shader benchmarks retain their original resource-readiness precondition. Main-thread staggered submission now overlaps driver readiness instead of waiting for each material to finish linking before submitting the next. +- **Renderer ownership:** select/restore targets inside each scheduled submission, including cube face/mip and exception paths. Keep the scheduler at one submission at a time. Let Three deduplicate actual programs instead of coalescing potentially different topology/target jobs by an incomplete application key. +- **Editor loading:** defer the existing SideDrawer chunk until idle capacity after exact scene readiness, with a 1.2 s idle timeout and normal lazy loading on first use. The production chunk is about 191.9 kB / 52.8 kB gzip; this changes when it loads, not total download size. The landing scene remains mounted. +- **Memory and diagnostics:** release disabled post-processing targets and unused low-resolution cloud buffers. Track registered live/peak estimated bytes without rescanning the ledger for every reservation. Add an on-demand inventory for reachable geometry buffers, textures/atlases, retained materials, water/cloud targets and CPU backing arrays, deduplicating shared resources. The inventory is advisory; it does not change admission budgets or dispose shared resources. +- **Comparison data:** graphics and performance exports include resolved settings, scene parameters, actual/base DPR, buffer dimensions, effective scale, boot stages/render key, shader-run flags and minimap counters. Initial geometry reports queue/batch metrics while keeping the complete existing readiness gate. + +## Verification performed + +- Baseline: 590 tests passed across 71 files; production build passed. +- Updated branch, including the runtime follow-up: 632 tests passed across 74 files; production build passed. Existing Vite warnings about large chunks remain. +- Regression coverage includes single-flight readback, row order, worker buffer ownership, exact cell centres/pixels, cancellation, retries, canvas handoffs, analytical field parity, shader generation for Tile/Manual/Infinite/shared modes, compiler submission overlap and state restoration, cloud failure gating, resource release/recreation, shared-resource counting and reservation rejection. +- CPU benchmark: six map modes × three fixed seeds, non-integer zoom/centre, authored height offsets and varying props masks. All 18 complete RGBA images matched the baseline byte for byte. Raw observations and hashes are in `minimap-audit-results.json`. + +The following are medians of three observations per mode in Node v24.19.0 in this environment, rounded to 0.1 ms. Runs are sequential before/after, not randomized; they are a narrow CPU check, not a browser, RTX, M4, mobile or GPU benchmark. + +| Map | Before: one blocking task (ms) | After: total including yields (ms) | Largest observed new sample batch (ms) | Differing RGBA bytes | +|---|---:|---:|---:|---:| +| height | 435.4 | 156.3 | 6.6 | 0 | +| water | 446.7 | 158.0 | 6.5 | 0 | +| noise | 493.3 | 158.2 | 4.8 | 0 | +| biome | 434.4 | 113.3 | 1.3 | 0 | +| slope | 424.5 | 327.7 | 4.7 | 0 | +| props | 398.1 | 72.7 | 1.4 | 0 | + +The 4 ms budget is cooperative: a single sample, GC or runtime scheduling can exceed it. The observed maximum is reported rather than claiming a strict 4 ms ceiling. Cached requests still copy the transferable RGBA payload but perform no terrain resampling or GPU readback. No FPS or general application-speed multiplier is established by these observations. + +Reproduce from a checkout containing the audit baseline commit: + +```sh +npm ci --ignore-scripts +npm test +npm run build +node tools/benchmark-minimap.mjs > minimap-results.json +``` + +The benchmark's default baseline is the pinned audit revision. It uses the current unchanged CPU height sampler and uniforms for both versions to isolate the minimap implementation. + +## Pending visual/device checks + +The provided browser reported `net::ERR_BLOCKED_BY_CLIENT` for both the local preview and its localhost retry. No 3D screenshots, GPU timings, actual browser cold-start timings, or first-tool latency measurements were obtained. Unit tests and analytical image equality do not establish full scene parity. + +Before marking ready, compare main versus this branch at identical seed, camera, effective profile, output size and animation/history state: detailed Tile slopes and walls; above/below-water shorelines; heavy clouds; large assemblies; Manual, imported and graph terrains; Infinite and Planet. Check debug/export modes, rapid edits/resize/retry/context loss, minimap zoom/pan/hover and repeated pass/mode switches. Record ordinary cold and warm starts separately from isolated shader benchmark runs. Confirm the first visible draw introduces no unexpected cold programs. + +## Audit items kept gated + +The sampled Tile height/climate cache remains disabled: atomic publication alone cannot prove a sampled field matches arbitrary procedural fragment coordinates. The complete initial LOD queue/halo gate remains in place; visible-set metrics need real scene traces before shortening it. Inactive world cache eviction and full atlas/preallocation admission need an ownership and peak-memory trace before changing their policy. The new resource inventory covers known reachable resources, not all driver allocations or measured VRAM. DPR inconsistencies are now observable but this change does not silently reduce requested pixels. + +## Runtime follow-up — 8 September 2026 + +The supplied device trace reports 18,064 ms waiting for `terrain:base` and 19,932 ms in the overall compile stage. It establishes a slow terrain-program startup, not a measurement of texture generation during the later approach. The screenshot separately shows 60 FPS / 1 draw / 1 triangle in the overlay versus 39 FPS / 104 draws / 56K triangles in the status bar. + +- **Interaction cadence:** the worker dispatches input to its canvas/document facades, which do not bubble to the window activity listener. Consequently the medium/low-tier idle cadence could remain active during zoom and dragging. Forwarded input now resets the activity timestamp. Continued dragging also resets it on the main thread, and camera settling bypasses idle pacing. Passive hovering still allows idle pacing. Requested quality, pixels and LOD settings are unchanged. +- **Counters:** both displays now use the same elapsed-time-normalized count of rendered scene frames. The profiler receives scene counters before later fullscreen passes can overwrite `renderer.info`; skipped callbacks do not dilute CPU frame timing. Average FPS uses rendered-frame intervals and includes long stalls. Camera-scene counts include the shared opaque and overlay geometry; they are not a sum of every auxiliary GPU pass. CPU frame time and asynchronous GPU timing remain separate measurements. +- **Near-water warmup:** approaching within 120 units of water previously traversed/sorted the whole scene every frame and could compile hidden materials and alternate topology programs. It also detached the Studio cloud while awaiting those compiles. The underwater effect actually samples an already-rendered scene target, so only its fullscreen composite requires preparation here. Warmup now leaves the scene attached, yields before submission, allows one pending job and rejects obsolete material/target/renderer results. When water is enabled at boot, the composite is prepared under the existing loading cover; enabling water later or resizing retains a small-pass fallback. Scene edits and input-target changes alone no longer invalidate this independent composite. The camera pipeline still owns scene rendering. +- **Terrain work:** procedural detail skips the unused projection at exactly planar/triplanar endpoints and skips fully faded detail except in the raw-grain debug view. Planar surface textures retain their original Y projection, UV salts, channels and normal normalization while avoiding the two zero-weight projections. Prop surface readback packs the same rasterized height before evaluating unused fragment climate, analytic heights and normals. Tile debug precedence is retained. No sampled Tile cache or reduced texture/detail setting is introduced. +- **Inactive reflections:** scene revision serialization is lazy and runs only when the Cinematic planar reflection pass can capture. Other water modes retain their existing disabled-pass behavior without serializing the scene every frame. Active reflection revision/cadence behavior is covered for both eager and lazy keys. + +Regression coverage includes 60 callbacks producing 30 scene frames, fullscreen-pass counter replacement, long frame stalls, structured-clone transport of diagnostics, worker zoom/long drags versus passive hover, boot composite readiness, repeated near-water requests, and resize/disposal/program changes during compilation. The complete suite passes (632 tests); the production build passes. These checks do not establish a measured GPU speedup or reproduce the reported freeze. + +Device verification remains required: compare this branch at the same camera/settings while orbiting down to ground level, crossing the shoreline repeatedly, and reopening the performance overlay. Check textured/detail terrain at near/fade/far distances, planar and triplanar surfaces, raw-grain debug and prop placement. Compare FPS and scene counts at steady state (the two UI polls can briefly straddle a sampling-window update). The exported profiler metrics include `underwaterWarmSubmitMs` and `underwaterWarmTotalMs`. Keep CPU submission, shader waiting and GPU frame time distinct when assessing any remaining stall. diff --git a/docs/performance/minimap-audit-results.json b/docs/performance/minimap-audit-results.json new file mode 100644 index 0000000..f3c6b63 --- /dev/null +++ b/docs/performance/minimap-audit-results.json @@ -0,0 +1,187 @@ +{ + "baseline": "1b488d99aae81fae615eecf281fb24e2f9995cdf", + "environment": "Node v24.19.0, CPU-only", + "conditions": "128x128 exact samples / 256x256 RGBA; five octaves; zoom 2.7; authored height offsets; varied prop masks; sequential before/after (not randomized); no GPU/browser timing", + "runs": [ + { + "mode": "height", + "seed": 17, + "beforeSingleTaskMs": 435.409, + "afterTotalMs": 154.847, + "afterMaxSampleBatchMs": 4.012, + "afterCachedMs": 0.211, + "differingBytes": 0, + "rgbaSha256": "da949dfb1ca2fef7747c0840a91bb4e64c5260f91c96a583089b4bb5a35efdd1" + }, + { + "mode": "height", + "seed": 42, + "beforeSingleTaskMs": 418.399, + "afterTotalMs": 156.289, + "afterMaxSampleBatchMs": 2.813, + "afterCachedMs": 0.153, + "differingBytes": 0, + "rgbaSha256": "81626d4fea77b2536fa14266ebf7d5a85808593dc7a74e5d3bb81387b0f5ad09" + }, + { + "mode": "height", + "seed": 1234, + "beforeSingleTaskMs": 511.514, + "afterTotalMs": 189.114, + "afterMaxSampleBatchMs": 6.608, + "afterCachedMs": 0.24, + "differingBytes": 0, + "rgbaSha256": "ecf1f82480a2a6ce182536aa52cc8f51979c38cbb4aa52ad22b88874fe0ab3fa" + }, + { + "mode": "water", + "seed": 17, + "beforeSingleTaskMs": 446.694, + "afterTotalMs": 131.681, + "afterMaxSampleBatchMs": 1.855, + "afterCachedMs": 0.126, + "differingBytes": 0, + "rgbaSha256": "c05f5b947feaf77eb1fddab1bf45b9d433f81546d55f7d179b27c4e1a4cbb0ba" + }, + { + "mode": "water", + "seed": 42, + "beforeSingleTaskMs": 444.886, + "afterTotalMs": 177.31, + "afterMaxSampleBatchMs": 6.533, + "afterCachedMs": 0.19, + "differingBytes": 0, + "rgbaSha256": "656731118636540e934b07a3dbda2040fea8aae36b43de44f9f4c4ae27456b7d" + }, + { + "mode": "water", + "seed": 1234, + "beforeSingleTaskMs": 499.638, + "afterTotalMs": 158.023, + "afterMaxSampleBatchMs": 3.006, + "afterCachedMs": 0.044, + "differingBytes": 0, + "rgbaSha256": "59ad86f0bd40645d42fb469e7a51352bfb60e4e2a3148e74a8b3a0b46769e596" + }, + { + "mode": "noise", + "seed": 17, + "beforeSingleTaskMs": 493.343, + "afterTotalMs": 158.248, + "afterMaxSampleBatchMs": 1.736, + "afterCachedMs": 0.044, + "differingBytes": 0, + "rgbaSha256": "989cec6e3727fb608aa0a05fa239ded74a837d79530f7ff7217baf5cd61bfd23" + }, + { + "mode": "noise", + "seed": 42, + "beforeSingleTaskMs": 471.098, + "afterTotalMs": 157.044, + "afterMaxSampleBatchMs": 2.694, + "afterCachedMs": 0.042, + "differingBytes": 0, + "rgbaSha256": "569ed1a8b4ce76277d9a7a6c4169d97c63b268234f8215c12ef12620c84281f5" + }, + { + "mode": "noise", + "seed": 1234, + "beforeSingleTaskMs": 513.352, + "afterTotalMs": 183.663, + "afterMaxSampleBatchMs": 4.818, + "afterCachedMs": 0.069, + "differingBytes": 0, + "rgbaSha256": "0efdbb3290e51ffb68b2ac89e336f06fd3bbca21ff180509d7740e2780de917d" + }, + { + "mode": "biome", + "seed": 17, + "beforeSingleTaskMs": 476.002, + "afterTotalMs": 122.748, + "afterMaxSampleBatchMs": 1.182, + "afterCachedMs": 0.078, + "differingBytes": 0, + "rgbaSha256": "3b1e3dd2888a7f00e93bcaf2cf0b13a13fe88d84b9799051ad08a19379b764d6" + }, + { + "mode": "biome", + "seed": 42, + "beforeSingleTaskMs": 434.388, + "afterTotalMs": 113.271, + "afterMaxSampleBatchMs": 0.854, + "afterCachedMs": 0.034, + "differingBytes": 0, + "rgbaSha256": "e18db5f941f2c25dfd2bb649a323fa0eb3d11fe712f5cdc8d8c92a261b6c4112" + }, + { + "mode": "biome", + "seed": 1234, + "beforeSingleTaskMs": 410.745, + "afterTotalMs": 102.411, + "afterMaxSampleBatchMs": 1.343, + "afterCachedMs": 0.112, + "differingBytes": 0, + "rgbaSha256": "bad60861126ecd6bdfe5e5af714d51cda190e7da4b373d2f55b4200fe59b9cbd" + }, + { + "mode": "slope", + "seed": 17, + "beforeSingleTaskMs": 395.614, + "afterTotalMs": 338.077, + "afterMaxSampleBatchMs": 4.176, + "afterCachedMs": 0.036, + "differingBytes": 0, + "rgbaSha256": "0c21ac4913fe32d5213fbfaa2ba287fbb66b98fb35621625e5466904127c385c" + }, + { + "mode": "slope", + "seed": 42, + "beforeSingleTaskMs": 424.509, + "afterTotalMs": 327.746, + "afterMaxSampleBatchMs": 4.723, + "afterCachedMs": 0.038, + "differingBytes": 0, + "rgbaSha256": "ec9232b88965075cd901edaf262a3a2cf16088e0a31a70d7ee8c73e7d7969b5c" + }, + { + "mode": "slope", + "seed": 1234, + "beforeSingleTaskMs": 472.688, + "afterTotalMs": 321.455, + "afterMaxSampleBatchMs": 4.419, + "afterCachedMs": 0.039, + "differingBytes": 0, + "rgbaSha256": "ee802dc46e80c3f0cf1f4e7dc4c7b734040043d3ddaa5ab997dc691ef396812a" + }, + { + "mode": "props", + "seed": 17, + "beforeSingleTaskMs": 398.098, + "afterTotalMs": 72.72, + "afterMaxSampleBatchMs": 0.269, + "afterCachedMs": 0.168, + "differingBytes": 0, + "rgbaSha256": "6447bfbbedb16993dd128d5ead4741921ba41d5756891d02ec611c07f92e894b" + }, + { + "mode": "props", + "seed": 42, + "beforeSingleTaskMs": 393.03, + "afterTotalMs": 71.25, + "afterMaxSampleBatchMs": 0.237, + "afterCachedMs": 0.034, + "differingBytes": 0, + "rgbaSha256": "6447bfbbedb16993dd128d5ead4741921ba41d5756891d02ec611c07f92e894b" + }, + { + "mode": "props", + "seed": 1234, + "beforeSingleTaskMs": 408.419, + "afterTotalMs": 74.762, + "afterMaxSampleBatchMs": 1.373, + "afterCachedMs": 0.03, + "differingBytes": 0, + "rgbaSha256": "6447bfbbedb16993dd128d5ead4741921ba41d5756891d02ec611c07f92e894b" + } + ] +} diff --git a/src/App.jsx b/src/App.jsx index 6249bb1..a6ea157 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -78,10 +78,10 @@ const MODE_LABEL = { studio: 'Tile', infinite: 'Infinite World', planet: 'Planet const NODE_PANEL_IDS = ['explode', 'planet', 'water', 'clouds', 'visuals', 'skybox', 'lighting', 'export', 'performance', 'debug']; const REAL_TERRAIN_PANEL_IDS = ['terrain', 'explode', 'water', 'props', 'clouds', 'visuals', 'skybox', 'lighting', 'export', 'performance', 'history', 'debug']; const PerformanceOverlay = lazy(() => import('./components/perf/PerformanceOverlay.jsx')); -// Start loading the drawer chunk with the app so the first tool click does -// not have to wait for the lazy module before anything can be shown. -const sideDrawerModule = import('./components/ui/SideDrawer.jsx'); -const SideDrawer = lazy(() => sideDrawerModule); +let sideDrawerModule; +const loadSideDrawer = () => (sideDrawerModule ||= import('./components/ui/SideDrawer.jsx') + .catch((error) => { sideDrawerModule = null; throw error; })); +const SideDrawer = lazy(loadSideDrawer); const loadNodeWorkspace = () => import('./components/nodes/NodeWorkspace.jsx'); const NodeWorkspace = lazy(loadNodeWorkspace); const MANUAL_LIBRARY_HEIGHT_KEY = 'terrain-studio:manual-library-height'; @@ -150,6 +150,18 @@ export default function App() { const loading = useLoading(); const landing = useLanding(); + useEffect(() => { + // Keep the landing scene's requests/compilation ahead of editor-only work, + // then warm the drawer before the user reaches the first tool. + if (!landing.bootReady) return undefined; + const preload = () => { void loadSideDrawer().catch(() => {}); }; + if (typeof window.requestIdleCallback === 'function') { + const id = window.requestIdleCallback(preload, { timeout: 1200 }); + return () => window.cancelIdleCallback(id); + } + const id = setTimeout(preload, 0); + return () => clearTimeout(id); + }, [landing.bootReady]); const { showPopup, showConfirm, showChoice } = usePopup(); const landingRef = useRef(landing); landingRef.current = landing; diff --git a/src/components/perf/PerformanceOverlay.jsx b/src/components/perf/PerformanceOverlay.jsx index d8ce4f4..e6c9040 100644 --- a/src/components/perf/PerformanceOverlay.jsx +++ b/src/components/perf/PerformanceOverlay.jsx @@ -136,7 +136,7 @@ export default function PerformanceOverlay({ - + - + ({ name: s.name, avg: round(s.avg), max: round(s.max) })), diff --git a/src/engine/Engine.js b/src/engine/Engine.js index fe7aca9..5cad1b0 100644 --- a/src/engine/Engine.js +++ b/src/engine/Engine.js @@ -145,6 +145,7 @@ import { import { PreparedModeBundle, SharedResourceRegistry } from './mode/PreparedModeBundle.js'; import { readRenderTargetPixelsAsync } from './render/RendererReadback.js'; import { GpuWorkScheduler } from './render/GpuWorkScheduler.js'; +import { inspectRenderResources } from './render/ResourceInventory.js'; import { GpuResourceLedger, rendererAdmission } from './render/GpuResourceLedger.js'; import { materialProgramDescriptor, programHealthError, validatePrograms } from './render/ProgramHealthGate.js'; import { @@ -344,7 +345,7 @@ export class Engine { this._lastHudUpdate = 0; this._lastTimeOfDayEmit = 0; this._frames = 0; - this._fpsTime = 0; + this._fpsTime = null; this._fps = 0; // On-demand studio rendering: skip the scene draw when nothing changed // (static camera, no animated layers). Saves GPU/heat on weak machines. @@ -399,8 +400,8 @@ export class Engine { this._worldCompileGate = null; this._worldCompileSerial = 0; this._bgWork = new Map(); // id → label of non-blocking background compiles - // The underwater render-target program variants are deferred from boot and - // warmed lazily on first approach to water (see _warmUnderwaterShaders). + // Prepare the underwater composite at boot when water is enabled. Newly + // enabled water and resized targets use the same small-pass warmup lazily. this._underwaterWarmed = false; this._underwaterWarmIdentity = null; this._underwaterWarmPromise = null; @@ -645,8 +646,12 @@ export class Engine { // intent-bearing events (not passive mouse movement) so an idle cursor does // not postpone the upgrade forever. this._lastUserActivityAt = performance.now(); - this._onUserActivity = () => { this._lastUserActivityAt = performance.now(); }; + this._onUserActivity = (event) => { + if (event?.type === 'pointermove' && !event.buttons && event.pointerType !== 'touch') return; + this._lastUserActivityAt = performance.now(); + }; window.addEventListener('pointerdown', this._onUserActivity, true); + window.addEventListener('pointermove', this._onUserActivity, { capture: true, passive: true }); window.addEventListener('wheel', this._onUserActivity, { capture: true, passive: true }); window.addEventListener('keydown', this._onUserActivity, true); @@ -2778,9 +2783,12 @@ export class Engine { const overlay = this._rwLoadGroup; const overlayWas = !!overlay && overlay.visible; if (overlayWas) overlay.visible = false; - this.minimap.renderBase(); - if (wasVisible) sky.setVisible(true); - if (overlayWas) overlay.visible = true; + try { + this.minimap.renderBase(); + } finally { + if (wasVisible) sky.setVisible(true); + if (overlayWas) overlay.visible = true; + } } _applyStudioFogFromStyle() { @@ -4787,7 +4795,7 @@ export class Engine { this.scene, this.camera, sceneSize, - revision, + revision ?? (() => this._sceneRevisionKey(sceneSize, true)), ); } finally { this.profiler.end('water-reflection'); @@ -4915,6 +4923,8 @@ export class Engine { this._lastSharedOpaqueStats = { triangles: this.renderer.info.render.triangles, drawCalls: this.renderer.info.render.calls, + points: this.renderer.info.render.points, + lines: this.renderer.info.render.lines, }; } finally { this.renderer.setRenderTarget(previousTarget); @@ -5025,6 +5035,10 @@ export class Engine { + (this.renderer.info.render.triangles || 0), drawCalls: (this._lastSharedOpaqueStats?.drawCalls || 0) + (this.renderer.info.render.calls || 0), + points: (this._lastSharedOpaqueStats?.points || 0) + + (this.renderer.info.render.points || 0), + lines: (this._lastSharedOpaqueStats?.lines || 0) + + (this.renderer.info.render.lines || 0), }; } @@ -5044,18 +5058,19 @@ export class Engine { target, triangles: this.renderer.info.render.triangles, drawCalls: this.renderer.info.render.calls, + points: this.renderer.info.render.points, + lines: this.renderer.info.render.lines, }; } _applyUnderwaterFromSharedTarget(target) { if (!this.underwater?.active || !target) return target; this._ensureUnderwaterWarmTarget(target); - const currentIdentity = this._captureUnderwaterWarmIdentity(target); + const currentIdentity = this._captureUnderwaterWarmIdentity(); if (!this._underwaterWarmed || !this._sameUnderwaterWarmIdentity(this._underwaterWarmIdentity, currentIdentity)) { - // Never enter a post-process variant while its exact scene topology, - // material versions, or render targets are still linking. Keep the - // already-rendered scene visible for this frame. + // The scene has already rendered to its camera target. Only the composite + // program must be ready before this extra pass can consume that image. this._underwaterWarmed = false; this._underwaterWarmIdentity = null; void this._warmUnderwaterShaders(target); @@ -5191,21 +5206,24 @@ export class Engine { if (stagger && list.length > 1) { let done = 0; - const results = []; - for (const pair of pairs) { - results.push(await this._compileMaterialVariants([pair.material], { - canvasOnly, - timeoutMs, - onProgress: (stepDone) => onProgress?.(done + stepDone, total), - renderTarget, - sources: [pair.source], - camera, - targetScene, - logCompile, - })); - done += passesPerMaterial; - await yieldTask(); - } + // The scheduler serializes submissions and yields between them. Driver + // readiness can overlap; awaiting a complete link here serialized every + // material on the main-thread path despite parallel-compile support. + const completed = new Map(); + const results = await Promise.all(pairs.map((pair, index) => this._compileMaterialVariants([pair.material], { + canvasOnly, + timeoutMs, + onProgress: (step) => { + done += step - (completed.get(index) || 0); + completed.set(index, step); + onProgress?.(done, total); + }, + renderTarget, + sources: [pair.source], + camera, + targetScene, + logCompile, + }))); return { ready: results.every((result) => result.ready), timedOut: results.some((result) => result.timedOut), @@ -5236,32 +5254,27 @@ export class Engine { const waitOpts = timeoutMs != null ? { timeoutMs } : undefined; const compileStartedAt = performance.now(); - const previousTarget = renderer.getRenderTarget(); - let pending; let rendererCompileMs = 0; - try { - renderer.setRenderTarget(renderTarget); - const compileKey = `material:${list.map((material) => `${material.id}:${material.version}`).join(',')}` - + `:target:${renderTarget ? `${renderTarget.width}x${renderTarget.height}:${renderTarget.samples || 0}` : 'canvas'}`; - pending = this._gpuWorkScheduler - ? await this._gpuWorkScheduler.schedule( - compileKey, - () => { - const startedAt = performance.now(); - const value = renderer.compile(group, camera, targetScene); - rendererCompileMs = performance.now() - startedAt; - return value; - }, - ) - : (() => { - const startedAt = performance.now(); - const value = renderer.compile(group, camera, targetScene); - rendererCompileMs = performance.now() - startedAt; - return value; - })(); - } finally { - renderer.setRenderTarget(previousTarget); - } + const submit = () => { + if (this._disposed || this.renderer !== renderer) return new Set(); + const previousTarget = renderer.getRenderTarget(); + const face = renderer.getActiveCubeFace?.() ?? 0; + const mip = renderer.getActiveMipmapLevel?.() ?? 0; + const startedAt = performance.now(); + try { + renderer.setRenderTarget(renderTarget); + return renderer.compile(group, camera, targetScene); + } finally { + rendererCompileMs = performance.now() - startedAt; + renderer.setRenderTarget(previousTarget, face, mip); + } + }; + // A submission owns renderer state only synchronously. Never hold a target + // across the scheduler yield or another family's readiness wait. Let Three + // deduplicate program keys; topology/target-specific probes must all run. + const pending = this._gpuWorkScheduler + ? await this._gpuWorkScheduler.schedule(null, submit) + : submit(); const syncCompileMs = performance.now() - compileStartedAt; const waitStartedAt = performance.now(); const canvasResult = await this._waitForMaterialsReady(pending, waitOpts); @@ -5334,23 +5347,29 @@ export class Engine { } this.underwater._ensureTarget(renderer); - renderer.setRenderTarget(this.underwater._rt); + const underwaterTarget = this.underwater._rt; const rtCompileStartedAt = performance.now(); - const rtKey = `material:${list.map((material) => `${material.id}:${material.version}`).join(',')}` - + `:target:${this.underwater._rt.width}x${this.underwater._rt.height}:${this.underwater._rt.samples || 0}`; + const submitUnderwater = () => { + if (this._disposed || this.renderer !== renderer) return new Set(); + const previous = renderer.getRenderTarget(); + const face = renderer.getActiveCubeFace?.() ?? 0; + const mip = renderer.getActiveMipmapLevel?.() ?? 0; + try { + renderer.setRenderTarget(underwaterTarget); + return renderer.compile(group, camera, targetScene); + } finally { + renderer.setRenderTarget(previous, face, mip); + } + }; const pendingRt = this._gpuWorkScheduler - ? await this._gpuWorkScheduler.schedule( - rtKey, - () => renderer.compile(group, camera, targetScene), - ) - : renderer.compile(group, camera, targetScene); + ? await this._gpuWorkScheduler.schedule(null, submitUnderwater) + : submitUnderwater(); const rtSyncCompileMs = performance.now() - rtCompileStartedAt; - renderer.setRenderTarget(null); const rtWaitStartedAt = performance.now(); const rtResult = await this._waitForMaterialsReady(pendingRt, waitOpts); const rtAsyncWaitMs = performance.now() - rtWaitStartedAt; const rtHealth = rtResult.ready - ? await this._validateCompiledPrograms(list, group, this.underwater._rt, camera) + ? await this._validateCompiledPrograms(list, group, underwaterTarget, camera) : { ok: false, code: 'PROGRAM_NOT_READY', diagnostics: [] }; if (logCompile) { console.info( @@ -5882,11 +5901,18 @@ export class Engine { : []; if (this.params.cloudsEnabled) { - const cloudReady = await this.studioCloud?.waitUntilReady?.({ + context.cloudReadyPromise = Promise.resolve(this.studioCloud?.waitUntilReady?.({ isCurrent: () => !this._disposed && context.runId === this._bootPipeline?.runId, + })).then((ready) => { + context.assertCurrent(); + if (ready === false) throw new Error('Final cloud material did not become ready'); + return ready; }); - context.assertCurrent(); - if (cloudReady === false) throw new Error('Final cloud material did not become ready'); + // Geometry can outlive a cancelled boot. Observe the promise immediately + // while the compile stage remains responsible for propagating failures. + context.cloudReadyPromise.catch(() => {}); + // Isolated shader benchmarks retain their existing resource precondition. + if (this._shaderBenchmarkOptions) await context.cloudReadyPromise; } // Shader linking is independent from the incremental visible-chunk build. @@ -5923,13 +5949,17 @@ export class Engine { const dependencyHalo = 1.5; this.board?.prioritizeInitialCameraSet?.(this.camera, { dependencyHalo }); let guard = 0; + const initialLodQueue = this.board?._lodRebuildQueue?.length || 0; + let maxBatchMs = 0; while (!this._disposed && this.board && (!this.board.initialCameraSetReady(this.camera, { dependencyHalo }) || this.board._lodRebuildQueue.length > 0)) { context.assertCurrent(); + const batchStartedAt = performance.now(); this.board.processBuildQueue({ maxItems: 24, maxMs: 8 }); this.board.updateLOD(this.camera.position); + maxBatchMs = Math.max(maxBatchMs, performance.now() - batchStartedAt); context.progress( 'Building visible terrain…', this.board.activeChunkCount, @@ -5947,6 +5977,10 @@ export class Engine { activeChunks: this.board?.activeChunkCount ?? 0, deferredChunks: this.board?.remainingChunks ?? 0, dependencyHalo, + geometryBatches: guard, + geometryMaxBatchMs: maxBatchMs, + initialLodQueue, + remainingLodQueue: this.board?._lodRebuildQueue?.length || 0, }; } @@ -6327,6 +6361,29 @@ export class Engine { async _compileFinalBootGraph(context) { context.assertCurrent(); + if (context.cloudReadyPromise) { + await context.cloudReadyPromise; + context.assertCurrent(); + const finalTarget = this._resolveCameraCompileTarget(); + const finalMaterials = this._finalBootMaterials( + context.preparedWaterMaterials, this.visualPost?._plan, + ); + const targetChanged = finalTarget.renderTarget !== context.compileTarget?.renderTarget; + const missing = targetChanged ? finalMaterials + : finalMaterials.filter((material) => !context.compileMaterials?.includes(material)); + // Readiness can reveal the cloud/composite or replace a target. Warm that + // exact final variant too; never reveal the cover with an incomplete graph. + context.remainingCompilePromise = missing.length + ? this._compileMaterialVariants(missing, { + canvasOnly: true, stagger: !this._renderWorker, timeoutMs: 120000, + renderTarget: finalTarget.renderTarget, + }) + : Promise.resolve({ ready: true }); + context.remainingCompilePromise.catch(() => {}); + context.compileMaterials = finalMaterials; + context.compileTarget = finalTarget; + context.compilePlan = this._buildFinalBootCompilePlan(finalMaterials, finalTarget.renderTarget); + } const target = context.compileTarget || this._resolveCameraCompileTarget(); const materials = context.compileMaterials || this._finalBootMaterials( context.preparedWaterMaterials, @@ -6337,11 +6394,14 @@ export class Engine { context.compilePlan = this._buildFinalBootCompilePlan(materials, target.renderTarget); } this._submitFinalBootCompilePlan(context); - const result = await context.compilePromise; + const [result, remaining] = await Promise.all([ + context.compilePromise, + context.remainingCompilePromise || Promise.resolve({ ready: true }), + ]); context.assertCurrent(); - if (result?.ready !== true) { + if (result?.ready !== true || remaining?.ready !== true) { const error = new Error('Final shader graph did not become ready'); - error.code = result?.timedOut ? 'SHADER_TIMEOUT' : 'SHADER_COMPILE_FAILED'; + error.code = result?.timedOut || remaining?.timedOut ? 'SHADER_TIMEOUT' : 'SHADER_COMPILE_FAILED'; throw error; } if (context.waterRequired) { @@ -6355,12 +6415,23 @@ export class Engine { this._waterWarmRetryCount = 0; this._waterWarmFailed = false; if (this.water?.material) this.water.visible = true; + if (this.underwater?.enabled) { + // Prepare the small fullscreen pass under the boot cover. The world + // programs are already prepared for their camera target above. + const underwaterReady = await this._warmUnderwaterShaders(target.renderTarget); + context.assertCurrent(); + if (!underwaterReady) throw new Error('Underwater composite shader did not become ready'); + } } context.progress('Compiling final shaders…', materials.length, materials.length); return { compiledMaterials: materials.length, shaderCompileMs: (result.syncCompileMs || 0) + (result.asyncWaitMs || 0), - shaderReadyTimeline: result.readyTimeline || [], + shaderSubmitMs: result.rendererCompileMs ?? result.syncCompileMs ?? 0, + shaderWaitMs: result.asyncWaitMs || 0, + finalVariantCompileMs: (remaining.syncCompileMs || 0) + (remaining.asyncWaitMs || 0), + cloudsReady: !this.params.cloudsEnabled || this.studioCloud?.ready === true, + shaderReadyTimeline: [...(result.readyTimeline || []), ...(remaining.readyTimeline || [])], renderTarget: target.usesSceneTarget ? 'scene-target' : 'canvas', }; } @@ -7373,67 +7444,26 @@ export class Engine { return true; } - /** - * Lazily compile the underwater render-target program variants that were - * deferred from boot. Runs WITHOUT bumping _compiling, so the scene keeps - * rendering normally (the canvas programs are already linked) while the driver - * builds the RT variants on its own threads. Kicked off when the camera nears - * the surface so the programs are cached before the first submerged frame — - * no dive hitch, and zero cost for sessions that never touch water. - */ - _activeUnderwaterTerrainMaterial() { - if (this.worldMode === 'infinite') return this._infiniteTerrainMat ?? null; - if (this.worldMode === 'planet') return this.planetWorld?.materials?.[0] ?? null; - return this.terrainMaterial ?? null; - } - - _captureUnderwaterWarmIdentity(cameraRenderTarget = null) { - const unique = new Map(); - this.scene?.traverse?.((obj) => { - if (!obj.material) return; - const topology = obj.isInstancedMesh ? 'instanced' : 'mesh'; - const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; - for (const material of mats) { - if (!material) continue; - const key = `${material.id}:${topology}`; - if (!unique.has(key)) { - unique.set(key, { - material, - version: material.version ?? 0, - topology, - }); - } - } - }); - const variants = [...unique.values()].sort((a, b) => { - const idDelta = (a.material?.id ?? 0) - (b.material?.id ?? 0); - return idDelta || a.topology.localeCompare(b.topology); - }); - const quadMaterial = this.underwater?._material ?? null; + // The underwater composite samples a completed scene color/depth target. + // Its shader depends only on its own material and output target: recompiling + // every scene material here used to introduce cold hidden/instanced programs + // at the near-water threshold and detach clouds throughout that long wait. + _captureUnderwaterWarmIdentity() { + const material = this.underwater?._material ?? null; return { - worldMode: this.worldMode, - cameraRenderTarget, + renderer: this.renderer, underwaterRenderTarget: this.underwater?._rt ?? null, - quadMaterial, - quadVersion: quadMaterial?.version ?? 0, - variants, + quadMaterial: material, + quadVersion: material?.version ?? 0, }; } _sameUnderwaterWarmIdentity(a, b) { - if (!a || !b - || a.worldMode !== b.worldMode - || a.cameraRenderTarget !== b.cameraRenderTarget - || a.underwaterRenderTarget !== b.underwaterRenderTarget - || a.quadMaterial !== b.quadMaterial - || a.quadVersion !== b.quadVersion - || a.variants.length !== b.variants.length) return false; - return a.variants.every((variant, index) => { - const current = b.variants[index]; - return variant.material === current.material - && variant.version === current.version - && variant.topology === current.topology; - }); + return !!a && !!b + && a.renderer === b.renderer + && a.underwaterRenderTarget === b.underwaterRenderTarget + && a.quadMaterial === b.quadMaterial + && a.quadVersion === b.quadVersion; } _ensureUnderwaterWarmTarget(cameraRenderTarget = null) { @@ -7450,128 +7480,65 @@ export class Engine { _warmUnderwaterShaders(cameraRenderTarget = null) { if (this._disposed) return Promise.resolve(false); - + if (this._underwaterWarmPromise) return this._underwaterWarmPromise; this._ensureUnderwaterWarmTarget(cameraRenderTarget); - const requestedIdentity = this._captureUnderwaterWarmIdentity(cameraRenderTarget); + const preparedIdentity = this._captureUnderwaterWarmIdentity(); if (this._underwaterWarmed - && this._sameUnderwaterWarmIdentity(this._underwaterWarmIdentity, requestedIdentity)) { + && this._sameUnderwaterWarmIdentity(this._underwaterWarmIdentity, preparedIdentity)) { return Promise.resolve(true); } - if (this._underwaterWarmed) { - this._underwaterWarmed = false; - this._underwaterWarmIdentity = null; - } - if (this._underwaterWarmPromise) return this._underwaterWarmPromise; + this._underwaterWarmed = false; + this._underwaterWarmIdentity = null; const job = (async () => { - // The RT variants must be compiled from the full fragment. Finish the - // boot upgrade first so readiness cannot belong to a discarded source. - if (this.worldMode === 'studio' - && this.terrainMaterial?.userData?.minimalFragment) { - const upgraded = await this._upgradeMinimalTerrain(); - if (upgraded?.ready !== true || this._disposed) return false; - } - - const preparedMode = this.worldMode; - const preparedCameraTarget = this._resolveCameraCompileTarget(); - if (cameraRenderTarget - && preparedCameraTarget.renderTarget !== cameraRenderTarget) return false; - this._ensureUnderwaterWarmTarget(preparedCameraTarget.renderTarget); - const preparedIdentity = this._captureUnderwaterWarmIdentity( - preparedCameraTarget.renderTarget, - ); - const preparedTerrain = this._activeUnderwaterTerrainMaterial(); - let sceneResult = null; - let instancedResult = { ready: true }; - let quadResult = null; - - await this._withStudioCloudDetached(async () => { - const skipMaterials = new Set(); - - // Infinite terrain's live draw defines USE_INSTANCING. A plain warm - // mesh is a different program and was the source of first-dive stalls. - if (preparedMode === 'infinite' && preparedTerrain) { - const source = this.infiniteWorld?.batches?.meshes?.find(Boolean); - if (!source?.geometry) throw new Error('Infinite terrain geometry unavailable'); - instancedResult = await this._compileInstancedMaterialVariant( - preparedTerrain, - source.geometry, - preparedCameraTarget.renderTarget, - ); - skipMaterials.add(preparedTerrain); - } - - // Planet chunks own distinct material objects but share one source and - // define set. Compile one representative instead of yielding hundreds - // of equivalent warm meshes. - if (preparedMode === 'planet') { - for (const material of this.planetWorld?.materials?.slice(1) ?? []) { - skipMaterials.add(material); - } - } - - sceneResult = await this._compileSceneStaggered(preparedCameraTarget.renderTarget, { - skipMaterials, - timeoutMs: 120000, - }); - if (instancedResult?.ready !== true || sceneResult?.ready !== true) { - throw new Error('Underwater scene shaders did not become ready'); - } - - const quad = this.underwater._quadScene?.children?.[0] ?? null; - quadResult = await this._compileExactPass({ - scene: this.underwater._quadScene, - camera: this.underwater._quadCam, - mesh: quad, - material: this.underwater._material, - renderTarget: this.underwater._rt, - }, { timeoutMs: 120000 }); - if (quadResult?.ready !== true) { - throw new Error('Underwater composite shader did not become ready'); - } - }); - - const currentCameraTarget = this._resolveCameraCompileTarget(); - this._ensureUnderwaterWarmTarget(currentCameraTarget.renderTarget); - const currentIdentity = this._captureUnderwaterWarmIdentity( - currentCameraTarget.renderTarget, - ); - if (this._disposed || this.worldMode !== preparedMode - || !this._sameUnderwaterWarmIdentity(preparedIdentity, currentIdentity)) { + // Never submit a cold shader inside the approach frame's render call. + await yieldTask(); + if (this._disposed + || !this._sameUnderwaterWarmIdentity(preparedIdentity, this._captureUnderwaterWarmIdentity())) { + return false; + } + const startedAt = performance.now(); + const result = await this._compileExactPass({ + scene: this.underwater._quadScene, + camera: this.underwater._quadCam, + mesh: this.underwater._quadScene?.children?.[0], + material: preparedIdentity.quadMaterial, + renderTarget: preparedIdentity.underwaterRenderTarget, + }, { timeoutMs: 120000 }); + this.profiler?.setMetric?.('underwaterWarmSubmitMs', result?.syncCompileMs ?? 0); + this.profiler?.setMetric?.('underwaterWarmTotalMs', performance.now() - startedAt); + if (result?.ready !== true || this._disposed + || !this._sameUnderwaterWarmIdentity(preparedIdentity, this._captureUnderwaterWarmIdentity())) { return false; } this._underwaterWarmIdentity = preparedIdentity; this._underwaterWarmed = true; + this._needsRender = true; return true; })().catch((error) => { - this._underwaterWarmed = false; - this._underwaterWarmIdentity = null; console.warn('Underwater shader warmup failed', error); return false; }); const pending = job.finally(() => { - if (this._underwaterWarmPromise === pending) { - this._underwaterWarmPromise = null; - } + if (this._underwaterWarmPromise === pending) this._underwaterWarmPromise = null; }); this._underwaterWarmPromise = pending; return pending; } - /** Trigger the deferred underwater compile once the camera approaches water. */ - _maybeWarmUnderwater() { + /** Fallback for newly enabled water or a resized target after boot. */ + _maybeWarmUnderwater(cameraRenderTarget = null) { if (this._bootPending || !this.underwater?.enabled) return; const wl = this._waterLevel(); if (wl == null || this.camera.position.y - wl >= 120) return; - const cameraTarget = this._resolveCameraCompileTarget().renderTarget; - this._ensureUnderwaterWarmTarget(cameraTarget); - const currentIdentity = this._captureUnderwaterWarmIdentity(cameraTarget); + this._ensureUnderwaterWarmTarget(cameraRenderTarget); + if (this._underwaterWarmPromise) return; if (this._underwaterWarmed - && this._sameUnderwaterWarmIdentity(this._underwaterWarmIdentity, currentIdentity)) return; - this._underwaterWarmed = false; - this._underwaterWarmIdentity = null; - void this._warmUnderwaterShaders(cameraTarget); + && this._sameUnderwaterWarmIdentity( + this._underwaterWarmIdentity, this._captureUnderwaterWarmIdentity(), + )) return; + void this._warmUnderwaterShaders(cameraRenderTarget); } _renderInitialStudioFrame({ frozen = false } = {}) { if (this.worldMode !== 'studio' || !this.board?.chunks?.length) return null; @@ -8900,9 +8867,8 @@ export class Engine { : tileDebugView === 'biome' ? 3 : 0) : 0; this._markTerrainFieldDirty(); // uFrequency / falloff change with the mode - // The new mode's materials need their own underwater RT-variant programs; - // re-arm the lazy warm so they compile on first approach to water (three's - // program cache makes the recompile instant if already built this session). + // Recheck the composite output target after a mode transition. Scene + // material variants are owned by the mode/camera pipeline itself. this._underwaterWarmed = false; if (mode === 'infinite') return this._enterInfiniteMode({ deferCompile }); @@ -11501,7 +11467,7 @@ export class Engine { } const captureStats = this._renderCameraCapture(); - this.profiler.captureRenderer(this.renderer); + this.profiler.captureRenderer(this.renderer, captureStats); const performanceSnapshot = this.profiler.snapshot(); const diagnostics = this.getPerfDiagnostics(); const png = await this._canvasToBlob(this.renderer.domElement, 'image/png'); @@ -11611,10 +11577,7 @@ export class Engine { const planetLowRes = this.worldMode === 'planet' && !!this.planetCloudLayer?.usesLowRes; const sharedOpaqueTarget = this._prepareSharedOpaque(plan, sceneSize); - this._captureWaterPlanarReflection( - sceneSize, - this._sceneRevisionKey(sceneSize, true), - ); + this._captureWaterPlanarReflection(sceneSize); let rendered; if (typeof this.renderer.render === 'function') { rendered = this._renderPreparedCameraScene(target, sharedOpaqueTarget); @@ -11866,6 +11829,15 @@ export class Engine { } applyInputFrame(frame = {}) { + // Worker event targets do not bubble to window, where the main-thread + // activity listener lives. Without this, interacting workers stay at the + // medium/low-tier idle cadence even while dragging or zooming. + const interacting = frame.wheel || frame.dragEvents?.length + || frame.keyEvents?.length || frame.touchInput + || frame.pointerEvents?.some((event) => event.type === 'pointerdown' + || event.type === 'pointerup' || event.buttons > 0 + || event.pointerType === 'touch'); + if (interacting) this._lastUserActivityAt = performance.now(); const dispatch = (target, type, payload = {}) => { if (target?.dispatchTerrainEvent) target.dispatchTerrainEvent(type, payload); }; @@ -11906,8 +11878,80 @@ export class Engine { }; } + _performanceComparisonSnapshot() { + const renderer = this.renderer; + const boot = this._bootPipeline?.result; + return { + transport: this._renderWorker ? 'worker' : 'main-thread', + worldMode: this.worldMode, + projectMode: this.projectMode, + settings: { ...(this.perf || {}) }, + scene: this._paramsSnapshot(), + pixels: { + cssWidth: this._lastViewportWidth ?? null, + cssHeight: this._lastViewportHeight ?? null, + drawingBufferWidth: renderer?.domElement?.width ?? null, + drawingBufferHeight: renderer?.domElement?.height ?? null, + actualPixelRatio: renderer?.getPixelRatio?.() ?? null, + basePixelRatio: this._basePixelRatio ?? null, + pixelRatioCeiling: this._pixelRatioCeiling ?? null, + requestedRenderScale: this.perf?.renderScale ?? null, + effectiveRenderScale: this._effectiveRenderScale(), + cameraPipeline: this.visualPost?.diagnostics?.() ?? null, + }, + boot: { + state: this._bootPipeline?.state ?? null, + runId: this._bootPipeline?.runId ?? null, + durationMs: boot?.duration ?? null, + renderKey: boot?.renderKey?.serialized ?? null, + manifest: boot?.manifest ?? null, + }, + shaderRun: { + benchmark: !!this._shaderBenchmarkOptions, + family: this._shaderBenchmarkOptions?.requestedFamily ?? null, + coldSource: !!this._shaderColdRun, + }, + minimap: this.minimap?.getDiagnostics?.() ?? null, + retainedModes: { + count: this._modeResourceCache?.size ?? 0, + maxInactive: this._modeResourceCache?.maxInactive ?? 0, + }, + }; + } + + _inspectRenderResources() { + const targets = { + 'post:scene': this.visualPost?._sceneRT, + 'post:look': this.visualPost?._lookRT, + 'post:opaque': this.visualPost?._opaqueRT, + underwater: this.underwater?._rt, + minimap: this.minimap?.target, + 'water:refraction': this.waterSystem?._surfacePass?.target, + 'water:reflection': this.waterSystem?._planarReflectionPass?.target, + }; + const materials = [this.terrainMaterial, this.waterMaterial]; + for (const [name, layer] of Object.entries({ + studio: this.studioCloud, infinite: this.infiniteCloud, planet: this.planetCloudLayer, + })) { + targets[`cloud:${name}:depth`] = layer?._depthTarget; + targets[`cloud:${name}:color`] = layer?._lowResPass?.rt; + for (const [index, target] of (layer?._occupancyPass?.targets || []).entries()) { + targets[`cloud:${name}:occupancy:${index}`] = target; + } + materials.push(layer?.material, layer?._lowResPass?._composite); + } + for (const entry of this._modeResourceCache?.entries?.values?.() || []) { + for (const { resource } of entry.value?.resources?.values?.() || []) { + if (resource?.isMaterial) materials.push(resource); + } + } + return inspectRenderResources({ scene: this.scene, targets, materials }); + } + getGraphicsDiagnostics() { return { + comparison: this._performanceComparisonSnapshot(), + resourceInventory: this._inspectRenderResources(), capturedAt: new Date().toISOString(), gpuName: this.gpuNameFull || this.gpuName, rendererConfig: this.rendererConfig, @@ -12095,7 +12139,6 @@ export class Engine { this._releaseWarmMaterialsAfterRender(); this._autoPerfTick(now); - this.profiler.captureRenderer(this.renderer); this.profiler.endFrame(); } _isRenderedWaterActive() { @@ -12262,6 +12305,7 @@ export class Engine { */ _continuousRenderInterval(now = performance.now()) { if (this._debug?.forceRender || this.exploreMode !== 'none') return 0; + if (this.controls?.isSettling) return 0; if (now - (this._lastUserActivityAt ?? now) < 900) return 0; if (this._landingShowcase) return 1000 / 30; if (this.gpuTier === 'low') return 1000 / 24; @@ -12274,13 +12318,19 @@ export class Engine { return interval <= 0 || now - this._lastRenderAt >= interval - 0.5; } - _recordRenderedFrame(now) { - this._frames++; - if (now - this._fpsTime >= 1000) { - this._fps = this._frames; + _recordRenderedFrame(now, sceneStats) { + if (this._fpsTime == null) this._fpsTime = now; + else this._frames++; + const elapsed = now - this._fpsTime; + if (elapsed >= 1000) { + this._fps = Math.round(this._frames * 1000 / elapsed); this._frames = 0; this._fpsTime = now; } + this._lastTris = sceneStats.triangles; + this._lastDraws = sceneStats.drawCalls; + this.profiler.recordRenderedFrame(now, this._fps); + this.profiler.captureRenderer(this.renderer, sceneStats); } _tickStudio(dt, now) { @@ -12327,7 +12377,11 @@ export class Engine { manualEditing || this.board?.isBuilding || (!this._debug.freezeLod && this.board._lodRebuildQueue.length > 0); - const minimapDirty = this.minimap._dirty && now - this._minimapDirtyAt > 280; + // Hidden minimaps and worker maps (requested through getMinimapFrame) have + // no local canvas. Their dirty flag must not keep the main scene rendering + // forever, or force extra scene frames while an async map is already pending. + const minimapDirty = !!this.minimap.baseCtx && !this.minimap._pendingFrame + && this.minimap._dirty && now - this._minimapDirtyAt > 280; // Heartbeat safety net: redraw at least ~1 Hz so any state change that // forgot to invalidate self-heals within a second (cheap insurance). const heartbeat = now - this._lastRenderAt > 1000; @@ -12394,12 +12448,9 @@ export class Engine { // pixel instead of re-evaluating the full height field. this._ensureTerrainHeightTexSafely(); - this._maybeWarmUnderwater(); + this._maybeWarmUnderwater(cameraTarget); const sharedOpaqueTarget = this._prepareSharedOpaque(cameraPlan, cameraSceneSize); - this._captureWaterPlanarReflection( - cameraSceneSize, - this._sceneRevisionKey(cameraSceneSize, true), - ); + this._captureWaterPlanarReflection(cameraSceneSize); const rendered = this._renderPreparedCameraScene(cameraTarget, sharedOpaqueTarget); // capture the scene's tri/draw counts BEFORE the low-res cloud composite — // renderer.info auto-resets each render(), so the fullscreen composite quad @@ -12414,7 +12465,7 @@ export class Engine { this.visualPost.finish(this.renderer); this.profiler.gpu?.frameEnd(); this.profiler.end('render'); - this._recordRenderedFrame(now); + this._recordRenderedFrame(now, rendered); // minimap: re-render base only after params settle, marker every frame this.profiler.begin('minimap'); @@ -12501,18 +12552,15 @@ export class Engine { if (!this._needsRender && !this._renderCadenceDue(now)) return; this._needsRender = false; this._lastRenderAt = now; - this._maybeWarmUnderwater(); this.profiler.begin('render'); this.profiler.gpu?.frameBegin(); const cameraPlan = this._prepareCameraPipeline(); const cameraSceneSize = this._cameraSceneSize(cameraPlan); const cameraTarget = cameraPlan.usesSceneTarget ? this.visualPost.inputTarget : null; const infiniteLowRes = !!this.infiniteCloud?.usesLowRes; + this._maybeWarmUnderwater(cameraTarget); const sharedOpaqueTarget = this._prepareSharedOpaque(cameraPlan, cameraSceneSize); - this._captureWaterPlanarReflection( - cameraSceneSize, - this._sceneRevisionKey(cameraSceneSize, true), - ); + this._captureWaterPlanarReflection(cameraSceneSize); const rendered = this._renderPreparedCameraScene(cameraTarget, sharedOpaqueTarget); const triangles = rendered.triangles; const drawCalls = rendered.drawCalls; @@ -12528,7 +12576,7 @@ export class Engine { if (this.infiniteWorld) this.infiniteWorld.notifyTriangles(triangles); // HUD updates at ~6 Hz - this._recordRenderedFrame(now); + this._recordRenderedFrame(now, rendered); if (now - this._lastHudUpdate > 160) { this._lastHudUpdate = now; if (this.cb.onInfiniteStats) { @@ -12621,7 +12669,7 @@ export class Engine { this.profiler.end('render'); if (this.planetWorld) this.planetWorld.notifyTriangles(triangles); - this._recordRenderedFrame(now); + this._recordRenderedFrame(now, rendered); if (now - this._lastHudUpdate > 160) { this._lastHudUpdate = now; if (this.cb.onInfiniteStats) { @@ -12669,6 +12717,7 @@ export class Engine { const waterEnabled = !!this.waterSystem?.isEnabled(); const diag = { + comparison: this._performanceComparisonSnapshot(), performance: this.profiler?.snapshot?.() || null, version: APP_VERSION, mode: this.worldMode, @@ -12911,6 +12960,7 @@ export class Engine { if (this._onVisibility) document.removeEventListener('visibilitychange', this._onVisibility); if (this._onUserActivity) { window.removeEventListener('pointerdown', this._onUserActivity, true); + window.removeEventListener('pointermove', this._onUserActivity, true); window.removeEventListener('wheel', this._onUserActivity, true); window.removeEventListener('keydown', this._onUserActivity, true); this._onUserActivity = null; diff --git a/src/engine/Minimap.js b/src/engine/Minimap.js index 13abc86..db63731 100644 --- a/src/engine/Minimap.js +++ b/src/engine/Minimap.js @@ -1,7 +1,13 @@ import * as THREE from 'three'; +import { readRenderTargetPixelsAsync } from './render/RendererReadback.js'; const SIZE = 256; const SAMPLE_RES = 128; +const SAMPLE_BATCH = 256; +const SAMPLE_BUDGET_MS = 4; +const yieldTask = () => new Promise((resolve) => setTimeout(resolve, 0)); +const sameView = (a, b) => !!a && !!b && a.halfSpan === b.halfSpan + && a.centerX === b.centerX && a.centerZ === b.centerZ; function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } function lerp(a, b, t) { return a + (b - a) * t; } @@ -39,6 +45,12 @@ export class Minimap { this.boardSize = 2048; this.maxHeight = 256; this._dirty = true; + this._revision = 0; + this._pendingFrame = null; + this._baseData = null; + this._spareData = null; + this._disposed = false; + this._stats = { completed: 0, discarded: 0, samples: 0, maxBatchMs: 0, errors: 0 }; this._hover = null; this._lastView = null; this._baseImage = null; @@ -65,6 +77,7 @@ export class Minimap { setCanvases(baseCanvas, overlayCanvas) { if (!baseCanvas || !overlayCanvas) { + this.requestRedraw(); this.baseCanvas = null; this.overlayCanvas = null; this.baseCtx = null; this.overlayCtx = null; return; } @@ -95,7 +108,6 @@ export class Minimap { if ( prev.mode !== this.config.mode || prev.zoom !== this.config.zoom - || prev.showChunkGrid !== this.config.showChunkGrid ) { this.requestRedraw(); } @@ -106,9 +118,16 @@ export class Minimap { } requestRedraw() { + this._revision += 1; this._dirty = true; } + getDiagnostics() { + return { ...this._stats, pending: !!this._pendingFrame, revision: this._revision, + resolution: SIZE, sampleResolution: SAMPLE_RES, batchSamples: SAMPLE_BATCH, + batchBudgetMs: SAMPLE_BUDGET_MS }; + } + _viewState() { const boardHalf = this.boardSize / 2; const zoom = clamp(this.config.zoom || 1, 1, 6); @@ -156,6 +175,37 @@ export class Minimap { return { ...surface, propsMask }; } + _sampleForMode(x, z) { + const sampler = this.sources.sampler; + // Analytical views consume only one field. Keep _sample's full surface + // result for hover details and samplers exposing only sampleSurfaceInfo. + switch (this.config.mode) { + case 'height': + case 'water': + if (sampler?.heightAt) { + const height = sampler.heightAt(x, z) + (this.sources.getPaintHeightOffset?.(x, z) ?? 0); + return { height, water: height <= (this.sources.getWaterLevel?.() ?? 0) + 0.01 }; + } + break; + case 'noise': + if (sampler?.shapeAt) return { noise: sampler.shapeAt(x, z) }; + break; + case 'biome': + if (sampler?.biomeAt) { + return { biome: sampler.biomeAt(x, z, this.sources.getPaintBiomeWeights?.(x, z) ?? null).label }; + } + break; + case 'slope': + if (sampler?.normalAt) return { slope: clamp(1 - sampler.normalAt(x, z, 2.0).y, 0, 1) }; + break; + case 'props': + return { propsMask: this.sources.getPropsMask?.(x, z) ?? { grass: 0, flowers: 0, mixed: 0 } }; + default: + break; + } + return this._sample(x, z); + } + infoAtCanvas(px, py) { const { x, z } = this.canvasToWorld(px, py); const sample = this._sample(x, z); @@ -225,47 +275,93 @@ export class Minimap { } renderBase() { - if (!this._dirty || !this.baseCtx) return; - this._dirty = false; - - const sampler = this.sources.sampler; - if (!sampler) return; + if (!this.baseCtx || this._disposed || this._pendingFrame) return; + const context = this.baseCtx; + // Rendering/readback submission is synchronous up to the first await. The + // engine can restore hidden sky/overlay objects immediately after this call. + void this._requestBaseFrame().then((rgba) => { + if (!rgba || this._disposed || this.baseCtx !== context) return; + if (!this._baseImage) this._baseImage = context.createImageData(SIZE, SIZE); + this._baseImage.data.set(rgba); + context.putImageData(this._baseImage, 0, 0); + }).catch(() => { /* Preserve the last valid image; the next dirty draw retries. */ }); + } - if (this.config.mode === 'color') { - this._renderSceneColor(); - this._lastView = this._viewState(); - return; - } + _requestBaseFrame() { + if (this._disposed) return Promise.resolve(null); + if (this._pendingFrame) return this._pendingFrame; + const view = this._viewState(); + if (!sameView(view, this._lastView)) this._dirty = true; + if (!this._dirty && this._baseData) return Promise.resolve(this._baseData); + if (!this.sources.sampler) return Promise.resolve(null); + + const revision = this._revision; + const current = () => !this._disposed && revision === this._revision + && sameView(view, this._viewState()); + const rgba = this._spareData || new Uint8ClampedArray(SIZE * SIZE * 4); + this._spareData = null; + const task = this.config.mode === 'color' + ? this._renderSceneColor(view, rgba) + : this._renderAnalytical(view, rgba, current); + this._pendingFrame = task.then(() => { + if (!current()) { + this._stats.discarded += 1; + this._spareData = rgba; + return null; + } + this._spareData = this._baseData; + this._baseData = rgba; + this._lastView = view; + this._dirty = false; + this._stats.completed += 1; + return rgba; + }, (error) => { + this._spareData = rgba; + this._stats.errors += 1; + throw error; + }).finally(() => { this._pendingFrame = null; }); + return this._pendingFrame; + } - const img = this.baseCtx.createImageData(SIZE, SIZE); + async _renderAnalytical(view, rgba, current) { const cell = SIZE / SAMPLE_RES; - for (let sy = 0; sy < SAMPLE_RES; sy++) { - for (let sx = 0; sx < SAMPLE_RES; sx++) { - const px = sx * cell + cell * 0.5; - const py = sy * cell + cell * 0.5; - const world = this.canvasToWorld(px, py); - const sample = this._sample(world.x, world.z); + const minX = view.centerX - view.halfSpan; + const minZ = view.centerZ - view.halfSpan; + const spanX = (view.centerX + view.halfSpan) - minX; + const spanZ = (view.centerZ + view.halfSpan) - minZ; + let index = 0; + while (index < SAMPLE_RES * SAMPLE_RES && current()) { + const startedAt = performance.now(); + let count = 0; + do { + const sx = index % SAMPLE_RES; + const sy = Math.floor(index / SAMPLE_RES); + // Same cell centres and lerp arithmetic as canvasToWorld, evaluated + // against one immutable view instead of rebuilding it 16,384 times. + const x = minX + spanX * ((sx * cell + cell * 0.5) / SIZE); + const z = minZ + spanZ * ((sy * cell + cell * 0.5) / SIZE); + const sample = this._sampleForMode(x, z); const pixel = sample ? this._pixelForMode(sample) : { r: 0, g: 0, b: 0 }; - for (let oy = 0; oy < cell; oy++) { - for (let ox = 0; ox < cell; ox++) { - const x = sx * cell + ox; - const y = sy * cell + oy; - const idx = (y * SIZE + x) * 4; - img.data[idx] = pixel.r; - img.data[idx + 1] = pixel.g; - img.data[idx + 2] = pixel.b; - img.data[idx + 3] = 255; + for (let oy = 0; oy < cell; oy += 1) { + for (let ox = 0; ox < cell; ox += 1) { + const offset = (((sy * cell + oy) * SIZE) + sx * cell + ox) * 4; + rgba[offset] = pixel.r; + rgba[offset + 1] = pixel.g; + rgba[offset + 2] = pixel.b; + rgba[offset + 3] = 255; } } - } + index += 1; + count += 1; + } while (index < SAMPLE_RES * SAMPLE_RES && count < SAMPLE_BATCH + && performance.now() - startedAt < SAMPLE_BUDGET_MS); + this._stats.samples += count; + this._stats.maxBatchMs = Math.max(this._stats.maxBatchMs, performance.now() - startedAt); + if (index < SAMPLE_RES * SAMPLE_RES) await yieldTask(); } - this.baseCtx.putImageData(img, 0, 0); - this._baseImage = img; - this._lastView = this._viewState(); } - _renderSceneColor() { - const view = this._viewState(); + async _renderSceneColor(view, rgba) { this.camera.left = view.centerX - view.halfSpan; this.camera.right = view.centerX + view.halfSpan; this.camera.top = view.centerZ + view.halfSpan; @@ -275,20 +371,24 @@ export class Minimap { this.camera.updateProjectionMatrix(); this.camera.updateMatrixWorld(true); - const prevTarget = this.renderer.getRenderTarget(); - this.renderer.setRenderTarget(this.target); - this.renderer.clear(); - this.renderer.render(this.scene, this.camera); - this.renderer.readRenderTargetPixels(this.target, 0, 0, SIZE, SIZE, this._pixels); - this.renderer.setRenderTarget(prevTarget); - - const img = this.baseCtx.createImageData(SIZE, SIZE); - for (let y = 0; y < SIZE; y++) { - const src = (SIZE - 1 - y) * SIZE * 4; - img.data.set(this._pixels.subarray(src, src + SIZE * 4), y * SIZE * 4); + const renderer = this.renderer; + const previous = renderer.getRenderTarget(); + const face = renderer.getActiveCubeFace?.() ?? 0; + const mip = renderer.getActiveMipmapLevel?.() ?? 0; + let readback; + try { + renderer.setRenderTarget(this.target); + renderer.clear(); + renderer.render(this.scene, this.camera); + readback = readRenderTargetPixelsAsync(renderer, this.target, 0, 0, SIZE, SIZE, this._pixels); + } finally { + renderer.setRenderTarget(previous, face, mip); + } + const pixels = await readback; + for (let y = 0; y < SIZE; y += 1) { + const source = (SIZE - 1 - y) * SIZE * 4; + rgba.set(pixels.subarray(source, source + SIZE * 4), y * SIZE * 4); } - this.baseCtx.putImageData(img, 0, 0); - this._baseImage = img; } _drawChunkGrid(ctx) { @@ -373,52 +473,11 @@ export class Minimap { } } - createFramePacket(controls = this.sources.controls) { - const rgba = new Uint8ClampedArray(SIZE * SIZE * 4); - if (this.config.mode === 'color') { - const view = this._viewState(); - this.camera.left = view.centerX - view.halfSpan; - this.camera.right = view.centerX + view.halfSpan; - this.camera.top = view.centerZ + view.halfSpan; - this.camera.bottom = view.centerZ - view.halfSpan; - this.camera.position.set(view.centerX, this.maxHeight + 2000, view.centerZ); - this.camera.lookAt(view.centerX, 0, view.centerZ); - this.camera.updateProjectionMatrix(); - this.camera.updateMatrixWorld(true); - const previous = this.renderer.getRenderTarget(); - try { - this.renderer.setRenderTarget(this.target); - this.renderer.clear(); - this.renderer.render(this.scene, this.camera); - this.renderer.readRenderTargetPixels(this.target, 0, 0, SIZE, SIZE, this._pixels); - } finally { - this.renderer.setRenderTarget(previous); - } - for (let y = 0; y < SIZE; y += 1) { - const source = (SIZE - 1 - y) * SIZE * 4; - rgba.set(this._pixels.subarray(source, source + SIZE * 4), y * SIZE * 4); - } - } else { - const cell = SIZE / SAMPLE_RES; - for (let sy = 0; sy < SAMPLE_RES; sy += 1) { - for (let sx = 0; sx < SAMPLE_RES; sx += 1) { - const px = sx * cell + cell * 0.5; - const py = sy * cell + cell * 0.5; - const world = this.canvasToWorld(px, py); - const sample = this._sample(world.x, world.z); - const pixel = sample ? this._pixelForMode(sample) : { r: 0, g: 0, b: 0 }; - for (let oy = 0; oy < cell; oy += 1) { - for (let ox = 0; ox < cell; ox += 1) { - const index = (((sy * cell + oy) * SIZE) + sx * cell + ox) * 4; - rgba[index] = pixel.r; - rgba[index + 1] = pixel.g; - rgba[index + 2] = pixel.b; - rgba[index + 3] = 255; - } - } - } - } - } + async createFramePacket(controls = this.sources.controls) { + const data = await this._requestBaseFrame(); + if (!data || this._disposed) return null; + // The worker transfers ownership. Never detach the cached image/buffers. + const rgba = data.slice(); const focus = controls?.target ? this.worldToCanvas(controls.target.x, controls.target.z) : null; const view = this._viewState(); return { @@ -438,6 +497,17 @@ export class Minimap { } dispose() { - this.target.dispose(); + if (this._disposed) return; + this._disposed = true; + this.requestRedraw(); + // Three's async readback still owns its PBO/fence until it settles. + const release = () => { + this.target.dispose(); + this._baseData = null; + this._spareData = null; + this._baseImage = null; + }; + if (this._pendingFrame) this._pendingFrame.then(release, release); + else release(); } } diff --git a/src/engine/MinimapPresenter.js b/src/engine/MinimapPresenter.js index 7f3e0e4..befa978 100644 --- a/src/engine/MinimapPresenter.js +++ b/src/engine/MinimapPresenter.js @@ -4,24 +4,36 @@ export class MinimapPresenter { this.overlayCanvas = null; this.requestFrame = null; this.pending = false; + this.revision = 0; + this.refreshAgain = false; } setCanvases(baseCanvas, overlayCanvas) { + this.revision += 1; this.baseCanvas = baseCanvas; this.overlayCanvas = overlayCanvas; if (baseCanvas && overlayCanvas) void this.refresh(); } async refresh() { - if (this.pending || !this.baseCanvas || !this.overlayCanvas || !this.requestFrame) return; + if (!this.baseCanvas || !this.overlayCanvas || !this.requestFrame) return; + if (this.pending) { this.refreshAgain = true; return; } this.pending = true; + const revision = this.revision; try { const packet = await this.requestFrame(); - if (!packet || !this.baseCanvas || !this.overlayCanvas) return; + if (!packet || revision !== this.revision || !this.baseCanvas || !this.overlayCanvas) return; this._drawBase(packet); this._drawOverlay(packet.overlay || {}, packet.width, packet.height); + } catch { + // Context loss or a cancelled worker request must retain the last map, + // not produce an unhandled rejection from the periodic refresh. } finally { this.pending = false; + if (this.refreshAgain) { + this.refreshAgain = false; + void this.refresh(); + } } } diff --git a/src/engine/perf/PerformanceProfiler.js b/src/engine/perf/PerformanceProfiler.js index 62ffea1..44b76aa 100644 --- a/src/engine/perf/PerformanceProfiler.js +++ b/src/engine/perf/PerformanceProfiler.js @@ -6,10 +6,10 @@ // a single snapshot instead of scattered booleans and ad-hoc timers. // // Design rules (see TASK): -// - Cheap when the overlay is CLOSED: only frame time + FPS are tracked, with +// - Cheap when the overlay is CLOSED: scene counters + frame time/FPS use // a couple of fixed rolling buffers (no allocations per frame, no history -// arrays that grow). Detailed section timers and renderer.info capture only -// run while `active` (overlay open). +// arrays that grow). Detailed section timers and renderer memory/program +// capture only run while `active` (overlay open). // - No console spam, no forced GPU sync (GPU timing lives in GPUProfiler and // is polled, never awaited). // - The hot-path methods (`beginFrame`/`endFrame`/`begin`/`end`) must stay @@ -77,7 +77,8 @@ class PerformanceProfiler { this.active = false; this.frame = new RollingStat(SHORT_WINDOW); // full-frame CPU time (ms) - this.fpsBuf = new RollingStat(FPS_WINDOW); // instantaneous fps samples + this.frameIntervals = new RollingStat(FPS_WINDOW); // rendered-frame intervals + this.fps = 0; // same one-second value as HUD this.sections = new Map(); // name -> { stat, _start } this.metrics = Object.create(null); @@ -85,10 +86,11 @@ class PerformanceProfiler { this.tasks = new Map(); // id -> task object this._taskSeq = 0; - this._frameStart = 0; - this._lastNow = 0; + this._frameStart = null; + this._lastRenderedAt = null; + this._renderedThisFrame = false; - // renderer.info mirror, refreshed each active frame + // Camera-scene counters survive later fullscreen passes and idle callbacks. this.render = { calls: 0, triangles: 0, points: 0, lines: 0, geometries: 0, textures: 0, programs: 0, @@ -107,18 +109,25 @@ class PerformanceProfiler { beginFrame(now) { this._frameStart = now; - if (this._lastNow) { - const dtMs = now - this._lastNow; - if (dtMs > 0 && dtMs < 1000) this.fpsBuf.push(1000 / dtMs); + this._renderedThisFrame = false; + } + + recordRenderedFrame(now, fps) { + if (this._lastRenderedAt != null && now > this._lastRenderedAt) { + // Keep long intervals too: a stall must not disappear from the average. + this.frameIntervals.push(now - this._lastRenderedAt); } - this._lastNow = now; + this._lastRenderedAt = now; + this._renderedThisFrame = true; + this.fps = fps; } endFrame() { - if (!this._frameStart) return; - const ms = performance.now() - this._frameStart; - this.frame.push(ms); - this._frameStart = 0; + if (this._frameStart == null) return; + // Idle/compiling callbacks do not submit a scene frame. Including their + // near-zero CPU time made a slow renderer look fast in the overlay. + if (this._renderedThisFrame) this.frame.push(performance.now() - this._frameStart); + this._frameStart = null; } // ------------------------------------------------------------- section timers @@ -157,15 +166,16 @@ class PerformanceProfiler { // --------------------------------------------------------- renderer capture - captureRenderer(renderer) { - if (!this.active || !renderer) return; + captureRenderer(renderer, sceneStats) { + if (!renderer) return; const info = renderer.info; - const r = info.render; + const r = sceneStats || info.render; + this.render.calls = r.drawCalls ?? r.calls ?? 0; + this.render.triangles = r.triangles ?? 0; + this.render.points = r.points ?? 0; + this.render.lines = r.lines ?? 0; + if (!this.active) return; const m = info.memory; - this.render.calls = r.calls; - this.render.triangles = r.triangles; - this.render.points = r.points; - this.render.lines = r.lines; this.render.geometries = m.geometries; this.render.textures = m.textures; this.render.programs = info.programs ? info.programs.length : 0; @@ -247,8 +257,8 @@ class PerformanceProfiler { return { time: now, - fps: Math.round(this.fpsBuf.avg), - fpsAvg: Math.round(this.fpsBuf.avg), + fps: this.fps, + fpsAvg: this.frameIntervals.avg > 0 ? Math.round(1000 / this.frameIntervals.avg) : 0, frame: { cur: this.frame.cur, avg: this.frame.avg, diff --git a/src/engine/render/GpuResourceLedger.js b/src/engine/render/GpuResourceLedger.js index 330381e..7d0dbe7 100644 --- a/src/engine/render/GpuResourceLedger.js +++ b/src/engine/render/GpuResourceLedger.js @@ -14,7 +14,10 @@ const bytesPerType = (type) => ({ int: 4, float: 4, 1009: 1, // THREE.UnsignedByteType - 1011: 2, // THREE.UnsignedShortType + 1010: 1, // THREE.ByteType + 1011: 2, // THREE.ShortType + 1012: 2, // THREE.UnsignedShortType + 1013: 4, // THREE.IntType 1014: 4, // THREE.UnsignedIntType 1015: 4, // THREE.FloatType 1016: 2, // THREE.HalfFloatType @@ -41,6 +44,8 @@ export class GpuResourceLedger { this.tier = tier in DEFAULT_BUDGETS ? tier : 'medium'; this.budgetBytes = Math.max(1, budgetBytes || DEFAULT_BUDGETS[this.tier]); this.entries = new Map(); + this._totalBytes = 0; + this.peakBytes = 0; } reserve(id, descriptor = {}) { @@ -49,11 +54,8 @@ export class GpuResourceLedger { ? Math.max(0, Math.ceil(descriptor.bytes)) : estimateRenderTargetBytes(descriptor); const previous = this.entries.get(id); - this.entries.set(id, { ...descriptor, id, bytes }); - if (this.totalBytes > this.budgetBytes) { - const attemptedTotalBytes = this.totalBytes; - if (previous) this.entries.set(id, previous); - else this.entries.delete(id); + const attemptedTotalBytes = this._totalBytes - (previous?.bytes || 0) + bytes; + if (attemptedTotalBytes > this.budgetBytes) { const error = new Error( `GPU resource budget exceeded: ${attemptedTotalBytes} bytes requested, ${this.budgetBytes} available`, ); @@ -63,17 +65,27 @@ export class GpuResourceLedger { error.budgetBytes = this.budgetBytes; throw error; } + this.entries.set(id, { ...descriptor, id, bytes }); + this._totalBytes = attemptedTotalBytes; + this.peakBytes = Math.max(this.peakBytes, this._totalBytes); return this.entries.get(id); } - release(id) { return this.entries.delete(id); } - clear() { this.entries.clear(); } - get totalBytes() { return [...this.entries.values()].reduce((sum, entry) => sum + entry.bytes, 0); } + release(id) { + const entry = this.entries.get(id); + if (!entry) return false; + this._totalBytes -= entry.bytes; + return this.entries.delete(id); + } + clear() { this.entries.clear(); this._totalBytes = 0; } + get totalBytes() { return this._totalBytes; } snapshot() { return Object.freeze({ tier: this.tier, budgetBytes: this.budgetBytes, totalBytes: this.totalBytes, + peakBytes: this.peakBytes, + scope: 'registered render targets; estimates, not measured VRAM', entries: [...this.entries.values()].map((entry) => ({ ...entry })), }); } diff --git a/src/engine/render/ResourceInventory.js b/src/engine/render/ResourceInventory.js new file mode 100644 index 0000000..9a7f06e --- /dev/null +++ b/src/engine/render/ResourceInventory.js @@ -0,0 +1,92 @@ +import { estimateRenderTargetBytes } from './GpuResourceLedger.js'; + +// Three's public pixel formats (including integer textures). +const CHANNELS = { 1021: 1, 1022: 3, 1023: 4, 1026: 1, 1027: 1, 1028: 1, 1029: 1, 1030: 2, 1031: 2, 1032: 3, 1033: 4 }; + +/** On-demand estimates only. Walking geometry/materials must never run in _tick. */ +export function inspectRenderResources({ scene, targets = {}, materials = [] } = {}) { + const textures = new Set(); + const attributes = new Set(); + const seenMaterials = new Set(); + const seenTargets = new Set(); + const cpuBuffers = new Set(); + const entries = []; + let unknownTextures = 0; + const trackCpu = (array) => { if (ArrayBuffer.isView(array)) cpuBuffers.add(array.buffer); }; + const addTexture = (texture, owner) => { + if (!texture?.isTexture || textures.has(texture)) return; + textures.add(texture); + const images = Array.isArray(texture.image) ? texture.image : [texture.image]; + let bytes = 0; + for (const source of images) { + const img = source?.image || source; + trackCpu(img?.data); + if (texture.isCompressedTexture) { + for (const mip of source?.mipmaps || texture.mipmaps || []) { + bytes += mip.data?.byteLength || 0; + trackCpu(mip.data); + } + } else if (img?.width && img?.height) { + bytes += estimateRenderTargetBytes({ width: img.width, height: img.height, + type: texture.type, channels: CHANNELS[texture.format] || 4, depthBytes: 0, + mipmaps: texture.generateMipmaps }) * Math.max(1, img.depth || 1); + } + } + if (!bytes) unknownTextures += 1; + entries.push({ owner, kind: 'texture', bytes, revision: texture.version }); + }; + // Attachments are included in their target, never counted again as uniforms. + for (const [owner, target] of Object.entries(targets)) { + if (!target || seenTargets.has(target)) continue; + seenTargets.add(target); + const colors = target.textures || [target.texture]; + let bytes = 0; + for (const texture of colors) { + if (!texture) continue; + textures.add(texture); + const color = estimateRenderTargetBytes({ width: target.width, height: target.height, + type: texture.type, channels: CHANNELS[texture.format] || 4, + depthBytes: 0, mipmaps: texture.generateMipmaps }); + // Multisampled attachments also retain a single-sample resolve texture. + bytes += color * (target.samples > 0 ? target.samples + 1 : 1); + } + if (target.depthTexture) textures.add(target.depthTexture); + if (target.depthBuffer !== false) { + bytes += target.width * target.height * 4 * Math.max(1, target.samples || 1); + if (target.samples > 0 && target.depthTexture) bytes += target.width * target.height * 4; + } + entries.push({ owner, kind: 'render-target', bytes, width: target.width, height: target.height }); + } + const addMaterial = (material, owner) => { + if (!material || seenMaterials.has(material)) return; + seenMaterials.add(material); + for (const value of Object.values(material)) if (value?.isTexture) addTexture(value, owner); + for (const uniform of Object.values(material.uniforms || {})) { + const values = Array.isArray(uniform.value) ? uniform.value : [uniform.value]; + for (const value of values) if (value?.isTexture) addTexture(value, owner); + } + }; + scene?.traverse?.((object) => { + const owner = object.name || object.type || 'scene'; + const geometry = object.geometry; + const list = [geometry?.index, ...Object.values(geometry?.attributes || {}), + ...Object.values(geometry?.morphAttributes || {}).flat(), object.instanceMatrix, object.instanceColor]; + for (const attribute of list) { + const buffer = attribute?.isInterleavedBufferAttribute ? attribute.data : attribute; + if (!buffer?.array || attributes.has(buffer)) continue; + attributes.add(buffer); + trackCpu(buffer.array); + entries.push({ owner, kind: 'geometry-buffer', bytes: buffer.array.byteLength }); + } + for (const material of Array.isArray(object.material) ? object.material : [object.material]) addMaterial(material, owner); + }); + for (const material of materials) addMaterial(material, 'retained-material'); + addTexture(scene?.background, 'background'); + addTexture(scene?.environment, 'environment'); + return { + scope: 'reachable scene, retained materials and known pass resources; estimates, not measured VRAM', + estimatedBytes: entries.reduce((total, entry) => total + entry.bytes, 0), + cpuBackingBufferBytes: [...cpuBuffers].reduce((total, buffer) => total + buffer.byteLength, 0), + unknownTextures, entries, + }; +} diff --git a/src/engine/render/VisualPostProcess.js b/src/engine/render/VisualPostProcess.js index 3be0df3..ca1ec03 100644 --- a/src/engine/render/VisualPostProcess.js +++ b/src/engine/render/VisualPostProcess.js @@ -422,6 +422,9 @@ export class VisualPostProcess { true, requireSceneDepth || requireSharedOpaque ); + } else { + this._sceneRT?.dispose(); + this._sceneRT = null; } if (requireSharedOpaque) { this._opaqueRT = this._ensureTarget( @@ -431,9 +434,15 @@ export class VisualPostProcess { true, true ); + } else { + this._opaqueRT?.dispose(); + this._opaqueRT = null; } if (plan.lookEnabled && plan.needsFinalPass) { this._lookRT = this._ensureTarget(this._lookRT, plan.sceneWidth, plan.sceneHeight, false); + } else { + this._lookRT?.dispose(); + this._lookRT = null; } this.update(params || {}, time, sunScreen, sunColor); return plan; diff --git a/src/engine/sky/CloudLowResPass.js b/src/engine/sky/CloudLowResPass.js index c61eada..ca4b04c 100644 --- a/src/engine/sky/CloudLowResPass.js +++ b/src/engine/sky/CloudLowResPass.js @@ -127,6 +127,13 @@ export class CloudLowResPass { setMeshLayer(mesh, lowRes) { if (!mesh) return; mesh.layers.set(lowRes ? CLOUD_LOWRES_LAYER : 0); + if (!lowRes && this.rt) { + this.rt.dispose(); + this.rt = null; + this._didRender = false; + this._composite.uniforms.tCloud.value = null; + this._composite.uniforms.tSceneDepth.value = null; + } } _ensureRT(renderer, baseSize = null) { diff --git a/src/engine/terrain/TerrainDetailMaterial.js b/src/engine/terrain/TerrainDetailMaterial.js index 4c0e5d3..9745859 100644 --- a/src/engine/terrain/TerrainDetailMaterial.js +++ b/src/engine/terrain/TerrainDetailMaterial.js @@ -71,9 +71,14 @@ float terrainDetailNoiseTri(vec3 worldPos, vec3 n, float scale) { } float terrainDetailNoise(vec3 worldPos, vec3 n, float scale) { + float triplanar = clamp(uTerrainTriplanar, 0.0, 1.0); + // At the endpoints the other projection contributes exactly zero. Preserve + // the original mix for intermediate values (including animated controls). + if (triplanar == 0.0) return terrainDetailNoise2D(worldPos.xz, scale); + if (triplanar == 1.0) return terrainDetailNoiseTri(worldPos, n, scale); float planar = terrainDetailNoise2D(worldPos.xz, scale); float tri = terrainDetailNoiseTri(worldPos, n, scale); - return mix(planar, tri, clamp(uTerrainTriplanar, 0.0, 1.0)); + return mix(planar, tri, triplanar); } // Relief height for the normal pass — the broad fine band plus an optional @@ -144,6 +149,18 @@ TerrainDetailResult applyTerrainDetailLayer( ) { TerrainDetailResult outD; float fade = terrainDetailFadeAt(worldPos); + float rockMask = max(tc.rockBlend, terrainRockMask(slope, jitter)); + float shoreMask = terrainShoreMask(hRel); + // Outside the existing fade range, neither albedo nor normal detail can + // affect the image. The raw-grain debug view still needs its noise sample. + if (fade == 0.0 && !(uTerrainDetailDebug >= 4.5 && uTerrainDetailDebug < 5.5)) { + outD.albedo = tc.albedo; + outD.detail = 0.0; + outD.fade = fade; + outD.rockMask = rockMask; + outD.shoreMask = shoreMask; + return outD; + } float quality = terrainDetailQualityFactor(); float scale = uTerrainDetailScale * mix(0.55, 1.25, quality); @@ -160,8 +177,6 @@ TerrainDetailResult applyTerrainDetailLayer( float microSigned = ds.micro * 2.0 - 1.0; float macroSigned = ds.macro * 2.0 - 1.0; // -1..1 large weathering patch field - float rockMask = max(tc.rockBlend, terrainRockMask(slope, jitter)); - float shoreMask = terrainShoreMask(hRel); float desertGround = clamp(max(bw.desert, tc.sandBand > 0.0 ? 1.0 - smoothstep(tc.sandBand * 0.4, tc.sandBand, hRel) : 0.0), 0.0, 1.0); float wetGround = clamp(max(bw.wetland, shoreMask * 0.65), 0.0, 1.0); float vegGround = clamp((1.0 - desertGround) * (1.0 - bw.canyon) * (1.0 - tc.snow) * tc.flatness * smoothstep(0.20, 0.72, cl.moist), 0.0, 1.0); diff --git a/src/engine/terrain/TerrainMaterial.js b/src/engine/terrain/TerrainMaterial.js index 5a0ef62..6c630d9 100644 --- a/src/engine/terrain/TerrainMaterial.js +++ b/src/engine/terrain/TerrainMaterial.js @@ -680,6 +680,27 @@ void main() { // height. The radial wall (vWallMesh) sits ON the perimeter, so it is exempt. if (uInfiniteMode < 0.5 && uTileShape > 0.5 && vWallMesh < 0.5 && tileOccupiedAt(xz) < 0.5) discard; + // Ordinary plinth pixels have a constant fogged colour. Debug/export modes + // still need the exact terrain field, including along the perimeter wall. + if (vWall > 0.02 && uTileDebugView <= 0.5 && uColorMode <= 0.5) { + float wd = length(cameraPosition - vWorldPos); + float wfog = 1.0 - exp(-uFogDensity * uFogDensity * wd * wd); + vec3 wcol = mix(uPlinthColor, uFogColor, clamp(wfog, 0.0, 1.0)); + gl_FragColor = vec4(wcol, 1.0); + return; + } + + // Prop placement reads the rasterized height, which is already interpolated + // from the displaced vertices. No analytic climate/height/normal evaluations + // are needed for this readback. Keep Tile debug's existing precedence. + if (uColorMode > 2.5 && uTileDebugView <= 0.5) { + float h01 = clamp(vWorldPos.y / max(uHeightScale, 1e-3), 0.0, 1.0); + float hi = floor(h01 * 255.0) / 255.0; + float lo = fract(h01 * 255.0); + gl_FragColor = vec4(hi, lo, 0.0, 1.0); + return; + } + // Correctness path: procedural terrain shading uses the same exact climate // function as terrain formation. The low-resolution climate cache introduced // visible color blocks and stale biome classifications after water changes. @@ -735,7 +756,11 @@ ${features.manual ? /* glsl */ ` hZ = texture2D(uTerrainHeightTex, uv + vec2(0.0, duv.y)).a * uHeightScale; nGeo = normalize(packedHeightNormal.rgb * 2.0 - 1.0); } else { - hC = terrainCachedHeightAt(xz); + // Tile's live heightAt wrapper computes this identical centre climate. + // Neighbours retain their own climate and every composed height offset. + hC = ${worldMode === 'studio' || features.tileOnly + ? 'heightAtWithClimate(xz, cl)' + : 'terrainCachedHeightAt(xz)'}; float normalDistance = length(cameraPosition - vWorldPos); bool farInfiniteNormal = uInfiniteMode > 0.5 && (vLod > 1.5 || normalDistance > max(uChunkSize * 7.0, 900.0)); @@ -790,19 +815,6 @@ ${features.manual ? '' : /* glsl */ ` return; } - // perimeter plinth wall: flat plinth colour (with fog), no terrain shading. - // Placed after the export/debug early-outs so heightmap/minimap stay clean. - // vWall interpolates 0 (surface rim vertex) -> 1 (skirt vertex at the base), - // so a small threshold colours the whole wall, leaving only a hairline of - // terrain colour at the rim where it meets the surface (a natural transition). - if (vWall > 0.02) { - float wd = length(cameraPosition - vWorldPos); - float wfog = 1.0 - exp(-uFogDensity * uFogDensity * wd * wd); - vec3 wcol = mix(uPlinthColor, uFogColor, clamp(wfog, 0.0, 1.0)); - gl_FragColor = vec4(wcol, 1.0); - return; - } - vec3 n = normalize(vec3(nGeo.x * uNormalStrength, 1.0, nGeo.z * uNormalStrength)); vec3 surfaceBaseNormal = n; diff --git a/src/engine/terrain/surface/terrainSurfaceTextureGLSL.js b/src/engine/terrain/surface/terrainSurfaceTextureGLSL.js index cc8340a..be27e7c 100644 --- a/src/engine/terrain/surface/terrainSurfaceTextureGLSL.js +++ b/src/engine/terrain/surface/terrainSurfaceTextureGLSL.js @@ -162,6 +162,10 @@ vec3 surfTriRole(sampler2D atlas, int roleIndex, vec3 wp, vec3 blend, float tile float inv = surfTileInv(tile); float roleFi = float(roleIndex); float rowFi = surfRenderRowFi(roleIndex); + if (uSurfTriplanar <= 0.5) { + vec2 uvY = surfRandomizedUV(wp.xz * inv, roleFi, 2.0); + return texture2D(atlas, surfAtlasUV(rowFi, uvY)).rgb; + } vec2 uvX = surfRandomizedUV(wp.zy * inv, roleFi, 1.0); vec2 uvY = surfRandomizedUV(wp.xz * inv, roleFi, 2.0); vec2 uvZ = surfRandomizedUV(wp.xy * inv, roleFi, 3.0); @@ -175,6 +179,13 @@ vec3 surfTriNormalRole(int roleIndex, vec3 wp, vec3 blend, float tile, vec3 nGeo float inv = surfTileInv(tile); float roleFi = float(roleIndex); float rowFi = surfRenderRowFi(roleIndex); + if (uSurfTriplanar <= 0.5) { + vec2 uvY = surfRandomizedUV(wp.xz * inv, roleFi, 5.0); + vec2 ty = texture2D(uSurfProps, surfAtlasUV(rowFi, uvY)).rg * 2.0 - 1.0; + ty.y = -ty.y; + vec3 wY = normalize(nGeo + vec3(ty.x, 0.0, ty.y)); + return normalize(wY); + } vec2 uvX = surfRandomizedUV(wp.zy * inv, roleFi, 4.0); vec2 uvY = surfRandomizedUV(wp.xz * inv, roleFi, 5.0); vec2 uvZ = surfRandomizedUV(wp.xy * inv, roleFi, 6.0); @@ -194,6 +205,10 @@ vec2 surfTriPropertiesRole(int roleIndex, vec3 wp, vec3 blend, float tile) { float inv = surfTileInv(tile); float roleFi = float(roleIndex); float rowFi = surfRenderRowFi(roleIndex); + if (uSurfTriplanar <= 0.5) { + vec2 uvY = surfRandomizedUV(wp.xz * inv, roleFi, 9.0); + return texture2D(uSurfProps, surfAtlasUV(rowFi, uvY)).ba; + } vec2 uvX = surfRandomizedUV(wp.zy * inv, roleFi, 8.0); vec2 uvY = surfRandomizedUV(wp.xz * inv, roleFi, 9.0); vec2 uvZ = surfRandomizedUV(wp.xy * inv, roleFi, 10.0); diff --git a/src/engine/water/WaterPlanarReflectionPass.js b/src/engine/water/WaterPlanarReflectionPass.js index 478d766..a800144 100644 --- a/src/engine/water/WaterPlanarReflectionPass.js +++ b/src/engine/water/WaterPlanarReflectionPass.js @@ -207,6 +207,10 @@ export class WaterPlanarReflectionPass { return false; } + // Building a scene revision serializes terrain settings and camera matrices. + // Legacy/Realistic/disabled water must not pay that cost every scene frame. + if (typeof revision === 'function') revision = revision(); + const fallbackSize = renderer.getDrawingBufferSize(new THREE.Vector2()); const sourceWidth = sceneSize?.x ?? sceneSize?.width ?? fallbackSize.x; const sourceHeight = sceneSize?.y ?? sceneSize?.height ?? fallbackSize.y; diff --git a/src/manual/ManualTerrainModeManager.js b/src/manual/ManualTerrainModeManager.js index 9fc197b..429949b 100644 --- a/src/manual/ManualTerrainModeManager.js +++ b/src/manual/ManualTerrainModeManager.js @@ -200,7 +200,9 @@ export class ManualTerrainModeManager { this.domElement.addEventListener('dragover', this._onDragOver); this.domElement.addEventListener('drop', this._onDrop); this.domElement.addEventListener('wheel', this._onWheel, { passive: false }); - window.addEventListener('pointerup', this._onPointerUp); + window.addEventListener('pointerup', this._onPointerUp, true); + window.addEventListener('pointercancel', this._onPointerUp, true); + window.addEventListener('blur', this._onPointerUp); window.addEventListener('keydown', this._onKeyDown, true); this._syncUniforms(); } @@ -900,6 +902,11 @@ export class ManualTerrainModeManager { } _handlePointerMove(event) { + // A release outside the viewport (or intercepted by UI) must not leave + // the brush painting on hover when the pointer returns. + if ((this._sculpting || this._surfacePainting) && !(event.buttons & 1)) { + this._handlePointerUp(); + } if (this.enabled && this.workspaceActive && this.texturePaint.enabled) { const point = this.picker.pickEvent(event, { quality: this._surfacePainting ? 'preview' : 'final' }); this._updateSurfaceHit(point); @@ -1190,7 +1197,9 @@ export class ManualTerrainModeManager { this.domElement.removeEventListener('dragover', this._onDragOver); this.domElement.removeEventListener('drop', this._onDrop); window.removeEventListener('keydown', this._onKeyDown, true); - window.removeEventListener('pointerup', this._onPointerUp); + window.removeEventListener('pointerup', this._onPointerUp, true); + window.removeEventListener('pointercancel', this._onPointerUp, true); + window.removeEventListener('blur', this._onPointerUp); this.domElement.removeEventListener('wheel', this._onWheel); this.transform.detach?.(); this.transformHelper?.parent?.remove(this.transformHelper); diff --git a/tests/AuditPerformance.test.js b/tests/AuditPerformance.test.js new file mode 100644 index 0000000..d5a0721 --- /dev/null +++ b/tests/AuditPerformance.test.js @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as THREE from 'three'; +import { Engine } from '../src/engine/Engine.js'; +import { GpuWorkScheduler } from '../src/engine/render/GpuWorkScheduler.js'; +import { GpuResourceLedger } from '../src/engine/render/GpuResourceLedger.js'; +import { VisualPostProcess } from '../src/engine/render/VisualPostProcess.js'; +import { CloudLowResPass } from '../src/engine/sky/CloudLowResPass.js'; + +const deferred = () => { + let resolve, reject; + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + return { promise, resolve, reject }; +}; +const flush = async () => { for (let i = 0; i < 24; i++) await Promise.resolve(); }; +function compileHarness() { + const engine = Object.create(Engine.prototype); + let target = { name: 'previous' }; + const renderer = { + getRenderTarget: () => target, + getActiveCubeFace: () => 3, + getActiveMipmapLevel: () => 2, + setRenderTarget: vi.fn((value) => { target = value; }), + compile: vi.fn((group) => new Set(group.children.map((child) => child.material))), + }; + Object.assign(engine, { + renderer, _warmGeo: new THREE.PlaneGeometry(1, 1), + camera: new THREE.PerspectiveCamera(), scene: new THREE.Scene(), + _gpuWorkScheduler: new GpuWorkScheduler(), + _validateCompiledPrograms: vi.fn(async () => ({ ok: true })), + }); + return engine; +} + +describe('audit compilation scheduling', () => { + it('submits the second staggered program before the first finishes linking', async () => { + const engine = compileHarness(); + const link = deferred(); + engine._waitForMaterialsReady = vi.fn(() => link.promise); + const materials = [new THREE.ShaderMaterial(), new THREE.ShaderMaterial()]; + const original = engine.renderer.getRenderTarget(); + const target = { name: 'scene' }; + const run = engine._compileMaterialVariants(materials, { canvasOnly: true, stagger: true, renderTarget: target }); + expect(engine.renderer.getRenderTarget()).toBe(original); + await flush(); + expect(engine.renderer.compile).toHaveBeenCalledTimes(2); + expect(engine._waitForMaterialsReady).toHaveBeenCalledTimes(2); + expect(engine.renderer.getRenderTarget()).toBe(original); + expect(engine._validateCompiledPrograms).not.toHaveBeenCalled(); + link.resolve({ ready: true, pendingCount: 0, waitMs: 0 }); + expect(await run).toMatchObject({ ready: true, materialCount: 2 }); + engine._warmGeo.dispose(); materials.forEach((m) => m.dispose()); + }); + + it('restores target, cube face and mip even when submission fails', async () => { + const engine = compileHarness(); + const original = engine.renderer.getRenderTarget(); + engine.renderer.compile.mockImplementation(() => { throw new Error('driver failure'); }); + const material = new THREE.ShaderMaterial(); + await expect(engine._compileMaterialVariants([material], { canvasOnly: true })).rejects.toThrow('driver failure'); + expect(engine.renderer.setRenderTarget).toHaveBeenLastCalledWith(original, 3, 2); + engine._warmGeo.dispose(); material.dispose(); + }); + + it.each([true, false])('overlaps cloud readiness and geometry, but requires cloud success=%s before activation', async (ready) => { + const engine = Object.create(Engine.prototype); + const cloud = deferred(); + const water = { id: 2 }; + const terrain = { id: 1 }; + const cloudMaterial = { id: 3 }; + const context = { runId: 1, assertCurrent: vi.fn(), progress: vi.fn() }; + Object.assign(engine, { + _bootPipeline: { runId: 1 }, _renderWorker: true, + _applyCompatibilityBootProfile: vi.fn(), _prepareCameraPipeline: vi.fn(), + _prepareStudioHeightCacheAsync: vi.fn(async () => true), + params: { cloudsEnabled: true, waterEnabled: true, seaLevel: 20, waterMode: 'realistic' }, + worldMode: 'studio', visualPost: { _plan: {} }, + studioCloud: { waitUntilReady: () => cloud.promise, ready: false }, + waterSystem: { prepareInitialMaterials: () => [water], activateInitialMaterials: vi.fn() }, + _resolveCameraCompileTarget: () => ({ renderTarget: null }), + _finalBootMaterials: () => engine.studioCloud.ready ? [terrain, water, cloudMaterial] : [terrain, water], + _buildFinalBootCompilePlan: (materials, renderTarget) => ({ materials, renderTarget }), + _compileMaterialVariants: vi.fn(async () => ({ ready: true })), + }); + await engine._prepareFinalBootResources(context); + expect(engine._compileMaterialVariants).toHaveBeenCalledOnce(); + const compile = engine._compileFinalBootGraph(context); + expect(engine.waterSystem.activateInitialMaterials).not.toHaveBeenCalled(); + engine.studioCloud.ready = ready; + cloud.resolve(ready); + if (ready) { + await compile; + expect(engine._compileMaterialVariants).toHaveBeenLastCalledWith([cloudMaterial], expect.anything()); + expect(engine.waterSystem.activateInitialMaterials).toHaveBeenCalledOnce(); + } else { + await expect(compile).rejects.toThrow('Final cloud material'); + expect(engine.waterSystem.activateInitialMaterials).not.toHaveBeenCalled(); + } + }); +}); + +describe('inactive render resource lifetime', () => { + it('releases disabled post targets and recreates the same requested configuration', () => { + const post = new VisualPostProcess(); + const renderer = { getDrawingBufferSize: (size) => size.set(640, 480) }; + const request = { params: { visualsPostEnabled: true, visualsCrtEnabled: true }, perf: {}, worldMode: 'studio', requireSharedOpaque: true }; + const firstPlan = post.prepare(renderer, request); + const targets = [post._sceneRT, post._opaqueRT, post._lookRT]; + expect(targets.every(Boolean)).toBe(true); + const dispose = targets.map((target) => vi.spyOn(target, 'dispose')); + post.prepare(renderer, { params: { visualsPostEnabled: false }, perf: {}, worldMode: 'studio' }); + expect([post._sceneRT, post._opaqueRT, post._lookRT]).toEqual([null, null, null]); + dispose.forEach((fn) => expect(fn).toHaveBeenCalledOnce()); + expect(post.prepare(renderer, request)).toEqual(firstPlan); + expect(post._sceneRT).not.toBe(targets[0]); + expect(post._sceneRT.width).toBe(640); + post.dispose(); + }); + + it('releases a disabled low-resolution cloud target without disposing its material', () => { + const pass = new CloudLowResPass(); + pass.rt = new THREE.WebGLRenderTarget(64, 64); + const dispose = vi.spyOn(pass.rt, 'dispose'); + const materialDispose = vi.spyOn(pass._composite, 'dispose'); + const mesh = new THREE.Mesh(); + pass.setMeshLayer(mesh, false); + expect(dispose).toHaveBeenCalledOnce(); + expect(materialDispose).not.toHaveBeenCalled(); + expect(pass.rt).toBeNull(); + expect(mesh.layers.mask).toBe(1); + pass.dispose(); + }); + + it('accounts replacements, releases and peak estimates without admitting rejected bytes', () => { + const ledger = new GpuResourceLedger({ budgetBytes: 100 }); + ledger.reserve('a', { bytes: 60 }); + ledger.reserve('a', { bytes: 30 }); + ledger.reserve('b', { bytes: 50 }); + expect(ledger.totalBytes).toBe(80); + expect(() => ledger.reserve('a', { bytes: 70 })).toThrow(); + expect(ledger.totalBytes).toBe(80); + ledger.release('b'); + expect(ledger.totalBytes).toBe(30); + expect(ledger.snapshot().peakBytes).toBe(80); + ledger.clear(); + expect(ledger.totalBytes).toBe(0); + }); +}); + +describe('on-demand resource inventory', () => { + it('counts shared targets/textures and interleaved attributes once, including CPU copies', async () => { + const { inspectRenderResources } = await import('../src/engine/render/ResourceInventory.js'); + const scene = new THREE.Scene(); + const target = new THREE.WebGLRenderTarget(16, 16, { depthBuffer: false }); + const texture = new THREE.DataTexture(new Uint8Array(8 * 8 * 4), 8, 8); + texture.generateMipmaps = false; + const data = new THREE.InterleavedBuffer(new Float32Array(6 * 6), 6); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.InterleavedBufferAttribute(data, 3, 0)); + geometry.setAttribute('normal', new THREE.InterleavedBufferAttribute(data, 3, 3)); + const material = new THREE.ShaderMaterial({ uniforms: { + atlas: { value: texture }, duplicate: { value: texture }, sceneColor: { value: target.texture }, + } }); + scene.add(new THREE.Mesh(geometry, material), new THREE.Mesh(geometry, material)); + const result = inspectRenderResources({ scene, targets: { a: target, b: target }, materials: [material] }); + expect(result.estimatedBytes).toBe(16 * 16 * 4 + 8 * 8 * 4 + 6 * 6 * 4); + expect(result.cpuBackingBufferBytes).toBe(8 * 8 * 4 + 6 * 6 * 4); + expect(result.entries).toHaveLength(3); + expect(result.unknownTextures).toBe(0); + geometry.dispose(); material.dispose(); texture.dispose(); target.dispose(); + }); +}); diff --git a/tests/CameraPipelineReuse.test.js b/tests/CameraPipelineReuse.test.js index 6a6a05a..a8de38c 100644 --- a/tests/CameraPipelineReuse.test.js +++ b/tests/CameraPipelineReuse.test.js @@ -66,6 +66,8 @@ describe('camera pipeline reuse', () => { expect(engine._renderSharedOpaqueOverlays(sourceTarget, target)).toEqual({ triangles: 112, drawCalls: 9, + points: 0, + lines: 0, }); expect(renderer.copyTextureToTexture).toHaveBeenNthCalledWith( 1, diff --git a/tests/EngineLifecycleOwnership.test.js b/tests/EngineLifecycleOwnership.test.js index cd1a708..b2d0050 100644 --- a/tests/EngineLifecycleOwnership.test.js +++ b/tests/EngineLifecycleOwnership.test.js @@ -184,7 +184,7 @@ describe('engine lifecycle ownership', () => { material.dispose(); }); - it('skips underwater compositing when a material version or target changes', () => { + it('reuses the underwater composite across scene edits but gates changes to its own program', () => { const engine = Object.create(Engine.prototype); const scene = new THREE.Scene(); const material = new THREE.MeshBasicMaterial(); @@ -209,21 +209,27 @@ describe('engine lifecycle ownership', () => { _warmUnderwaterShaders: vi.fn(async () => true), _underwaterWarmed: true, }); - engine._underwaterWarmIdentity = engine._captureUnderwaterWarmIdentity(sourceA); + engine._underwaterWarmIdentity = engine._captureUnderwaterWarmIdentity(); expect(engine._applyUnderwaterFromSharedTarget(sourceA)).toBe(underwaterTarget); expect(composite).toHaveBeenCalledTimes(1); material.needsUpdate = true; + expect(engine._applyUnderwaterFromSharedTarget(sourceB)).toBe(underwaterTarget); + expect(composite).toHaveBeenCalledTimes(2); + expect(engine._warmUnderwaterShaders).not.toHaveBeenCalled(); + + engine.underwater._material.needsUpdate = true; expect(engine._applyUnderwaterFromSharedTarget(sourceA)).toBe(sourceA); - expect(composite).toHaveBeenCalledTimes(1); + expect(composite).toHaveBeenCalledTimes(2); expect(engine._warmUnderwaterShaders).toHaveBeenCalledWith(sourceA); engine._underwaterWarmed = true; - engine._underwaterWarmIdentity = engine._captureUnderwaterWarmIdentity(sourceA); + engine._underwaterWarmIdentity = engine._captureUnderwaterWarmIdentity(); engine._warmUnderwaterShaders.mockClear(); + engine.underwater._rt = {}; // resized or recreated output target expect(engine._applyUnderwaterFromSharedTarget(sourceB)).toBe(sourceB); - expect(composite).toHaveBeenCalledTimes(1); + expect(composite).toHaveBeenCalledTimes(2); expect(engine._warmUnderwaterShaders).toHaveBeenCalledWith(sourceB); scene.children[0].geometry.dispose(); diff --git a/tests/ManualTerrainInteraction.test.js b/tests/ManualTerrainInteraction.test.js new file mode 100644 index 0000000..152cb63 --- /dev/null +++ b/tests/ManualTerrainInteraction.test.js @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ManualTerrainModeManager } from '../src/manual/ManualTerrainModeManager.js'; + +function createManager(mode) { + return Object.assign(Object.create(ManualTerrainModeManager.prototype), { + enabled: true, + workspaceActive: true, + sculpt: { enabled: mode === 'sculpt', tool: 'raise' }, + texturePaint: { enabled: mode === 'surface', mode: 'surface', tool: 'paint' }, + transform: { axis: null }, + _sculpting: false, + _surfacePainting: false, + _surfaceRevision: 0, + picker: { pickEvent: () => ({ x: 10, z: 20 }) }, + _updateSculptHit: vi.fn(), + _updateSurfaceHit: vi.fn(), + _stampSculpt: vi.fn(), + _stampSurface: vi.fn(), + _emit: vi.fn(), + onStableAction: vi.fn(), + }); +} + +describe.each(['sculpt', 'surface'])('Manual %s pointer interaction', (mode) => { + it('only applies the selected brush during a left-button stroke', () => { + const manager = createManager(mode); + const stamp = mode === 'sculpt' ? manager._stampSculpt : manager._stampSurface; + manager._handlePointerMove({ buttons: 0 }); + expect(stamp).not.toHaveBeenCalled(); + manager._handlePointerDown({ button: 0, preventDefault() {}, stopPropagation() {} }); + manager._handlePointerMove({ buttons: 1 }); + expect(stamp).toHaveBeenCalledTimes(2); + manager._handlePointerUp(); + manager._handlePointerMove({ buttons: 0 }); + expect(stamp).toHaveBeenCalledTimes(2); + expect(manager.onStableAction).toHaveBeenCalledTimes(1); + }); + + it.each([0, 2])('ends a missed release before moving with buttons=%s', (buttons) => { + const manager = createManager(mode); + const stamp = mode === 'sculpt' ? manager._stampSculpt : manager._stampSurface; + manager._handlePointerDown({ button: 0, preventDefault() {}, stopPropagation() {} }); + manager._handlePointerMove({ buttons }); + manager._handlePointerMove({ buttons: 1 }); + expect(stamp).toHaveBeenCalledTimes(1); + expect(manager._sculpting).toBe(false); + expect(manager._surfacePainting).toBe(false); + expect(manager.onStableAction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/MinimapAsync.test.js b/tests/MinimapAsync.test.js new file mode 100644 index 0000000..5f74012 --- /dev/null +++ b/tests/MinimapAsync.test.js @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Minimap } from '../src/engine/Minimap.js'; + +const deferred = () => { + let resolve, reject; + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + return { promise, resolve, reject }; +}; +function harness() { + let target = { name: 'main' }; + const initialTarget = target; + const read = deferred(); + const renderer = { + isWebGLRenderer: true, + getRenderTarget: () => target, + getActiveCubeFace: () => 2, + getActiveMipmapLevel: () => 1, + setRenderTarget: vi.fn((next) => { target = next; }), + clear: vi.fn(), render: vi.fn(), + readRenderTargetPixels: vi.fn(), + readRenderTargetPixelsAsync: vi.fn((_rt, _x, _y, _w, _h, buffer) => { + for (let y = 0; y < 256; y++) { + for (let x = 0; x < 256; x++) buffer.set([x, y, 19, 255], (y * 256 + x) * 4); + } + return read.promise; + }), + }; + const map = new Minimap(renderer, {}, null, null); + const sampler = { sampleSurfaceInfo: vi.fn((x, z) => ({ + height: 128, noise: 0.675, slope: 0.5, biome: 'Forest', water: x < 0, + })) }; + const controls = { target: { x: 0, z: 0 }, theta: 0.3 }; + map.setSources({ sampler, controls, getPropsMask: () => ({ grass: 0.5, flowers: 1, mixed: 0 }) }); + return { map, renderer, read, sampler, controls, initialTarget }; +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe('asynchronous minimap', () => { + it('submits one readback, restores the target immediately, flips rows and caches an owned image', async () => { + const { map, renderer, read, initialTarget } = harness(); + const a = map.createFramePacket(); + const b = map.createFramePacket(); + expect(renderer.getRenderTarget()).toBe(initialTarget); + expect(renderer.setRenderTarget).toHaveBeenLastCalledWith(initialTarget, 2, 1); + expect(renderer.readRenderTargetPixelsAsync).toHaveBeenCalledOnce(); + expect(renderer.readRenderTargetPixels).not.toHaveBeenCalled(); + read.resolve(); + const [one, two] = await Promise.all([a, b]); + expect([...one.rgba.slice(0, 4)]).toEqual([0, 255, 19, 255]); + expect([...one.rgba.slice(-4)]).toEqual([255, 0, 19, 255]); + expect(one.rgba).toEqual(two.rgba); + structuredClone(one.rgba, { transfer: [one.rgba.buffer] }); + const cached = await map.createFramePacket(); + expect(cached.rgba.byteLength).toBe(256 * 256 * 4); + expect(renderer.render).toHaveBeenCalledOnce(); + map.dispose(); + }); + + it.each(['edit', 'zoom', 'pan', 'dispose'])('discards a readback superseded by %s', async (change) => { + const { map, read, controls } = harness(); + map.setConfig({ zoom: 2 }); + const dispose = vi.spyOn(map.target, 'dispose'); + const packet = map.createFramePacket(); + if (change === 'edit') map.requestRedraw(); + if (change === 'zoom') map.setConfig({ zoom: 3 }); + if (change === 'pan') controls.target.x = 100; + if (change === 'dispose') map.dispose(); + expect(dispose).not.toHaveBeenCalled(); + read.resolve(); + expect(await packet).toBeNull(); + if (change === 'dispose') expect(dispose).toHaveBeenCalledOnce(); + else map.dispose(); + }); + + it('retains a valid image on failure and allows a later retry', async () => { + const { map, renderer, read } = harness(); + const initial = map.createFramePacket(); + read.resolve(); + await initial; + const prior = map._baseData; + map.requestRedraw(); + renderer.readRenderTargetPixelsAsync.mockRejectedValueOnce(new Error('context lost')); + await expect(map.createFramePacket()).rejects.toThrow('context lost'); + expect(map._baseData).toBe(prior); + expect(map.getDiagnostics()).toMatchObject({ errors: 1, pending: false }); + expect(await map.createFramePacket()).not.toBeNull(); + map.dispose(); + }); + + it.each([ + ['height', [128, 128, 128, 255]], ['noise', [128, 128, 128, 255]], + ['slope', [128, 128, 128, 255]], ['biome', [76, 138, 87, 255]], + ['water', [74, 168, 255, 255]], ['props', [255, 128, 0, 255]], + ])('keeps exact %s map samples and pixels while yielding bounded batches', async (mode, expected) => { + const { map, sampler, renderer } = harness(); + map.setConfig({ mode }); + const pending = map.createFramePacket(); + if (mode !== 'props') expect(sampler.sampleSurfaceInfo.mock.calls.length).toBeGreaterThan(0); + expect(sampler.sampleSurfaceInfo.mock.calls.length).toBeLessThanOrEqual(256); + await vi.runAllTimersAsync(); + const packet = await pending; + expect(sampler.sampleSurfaceInfo).toHaveBeenCalledTimes(mode === 'props' ? 0 : 16384); + if (mode !== 'props') { + expect(sampler.sampleSurfaceInfo.mock.calls[0].slice(0, 2)).toEqual([-1016, -1016]); + expect(sampler.sampleSurfaceInfo.mock.calls.at(-1).slice(0, 2)).toEqual([1016, 1016]); + } + expect([...packet.rgba.slice(0, 4)]).toEqual(expected); + expect([...packet.rgba.slice(4, 8)]).toEqual(expected); + expect([...packet.rgba.slice(256 * 4, 256 * 4 + 4)]).toEqual(expected); + expect(renderer.render).not.toHaveBeenCalled(); + await map.createFramePacket(); + expect(sampler.sampleSurfaceInfo).toHaveBeenCalledTimes(mode === 'props' ? 0 : 16384); + map.dispose(); + }); + + it('cancels analytical sampling between batches after a terrain edit', async () => { + const { map, sampler } = harness(); + map.setConfig({ mode: 'height' }); + const pending = map.createFramePacket(); + const before = sampler.sampleSurfaceInfo.mock.calls.length; + map.requestRedraw(); + await vi.runAllTimersAsync(); + expect(await pending).toBeNull(); + expect(sampler.sampleSurfaceInfo).toHaveBeenCalledTimes(before); + const latest = map.createFramePacket(); + await vi.runAllTimersAsync(); + expect(await latest).not.toBeNull(); + map.dispose(); + }); +}); + + +describe('analytical field reuse', () => { + it('matches full surface sampling at varied coordinates with authored offsets and masks', async () => { + const { TerrainHeightSampler } = await import('../src/engine/terrain/TerrainHeightSampler.js'); + const { createTerrainUniforms } = await import('../src/engine/terrain/TerrainMaterial.js'); + const uniforms = createTerrainUniforms(); + uniforms.uSeedOffset.value.set(171.73, -95.17); + const sampler = new TerrainHeightSampler(uniforms, () => ({ octaves: 5, infinite: false })); + const { map } = harness(); + map.setSources({ sampler, getWaterLevel: () => 140, + getPaintHeightOffset: (x, z) => Math.sin(x) * 12 + Math.cos(z) * 7, + getPaintBiomeWeights: (x) => ({ desert: x < 0 ? 1 : 0, wetland: x > 0 ? 1 : 0 }), + }); + for (const mode of ['height', 'water', 'noise', 'biome', 'slope', 'props']) { + map.setConfig({ mode }); + for (let index = 0; index < 32; index++) { + const x = Math.sin(index * 3.17) * 1000; + const z = Math.cos(index * 1.19) * 1000; + expect(map._pixelForMode(map._sampleForMode(x, z))).toEqual(map._pixelForMode(map._sample(x, z))); + } + } + map.dispose(); + }); +}); + +describe('worker minimap presentation', () => { + it('coalesces refreshes during an in-flight frame and ignores detached canvases', async () => { + const { MinimapPresenter } = await import('../src/engine/MinimapPresenter.js'); + const presenter = new MinimapPresenter(); + const first = deferred(); + presenter.requestFrame = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValueOnce({ width: 256, height: 256, overlay: {} }); + presenter._drawBase = vi.fn(); + presenter._drawOverlay = vi.fn(); + presenter.setCanvases({}, {}); + presenter.setCanvases({}, {}); + await presenter.refresh(); + expect(presenter.requestFrame).toHaveBeenCalledOnce(); + first.resolve({ width: 256, height: 256 }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + expect(presenter.requestFrame).toHaveBeenCalledTimes(2); + expect(presenter._drawBase).toHaveBeenCalledOnce(); + presenter.setCanvases(null, null); + }); +}); diff --git a/tests/RuntimePerformance.test.js b/tests/RuntimePerformance.test.js new file mode 100644 index 0000000..44cb181 --- /dev/null +++ b/tests/RuntimePerformance.test.js @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as THREE from 'three'; +import { Engine } from '../src/engine/Engine.js'; +import { PerformanceProfiler } from '../src/engine/perf/PerformanceProfiler.js'; +import { UnderwaterEffect } from '../src/engine/render/UnderwaterEffect.js'; + +afterEach(() => vi.restoreAllMocks()); + +describe('rendered frame diagnostics', () => { + it('reports the same scene FPS and counts as the HUD despite skipped callbacks and post passes', () => { + const engine = Object.create(Engine.prototype); + let now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const profiler = new PerformanceProfiler(); + profiler.setActive(true); + Object.assign(engine, { + profiler, _frames: 0, _fpsTime: null, _fps: 0, + renderer: { info: { + render: { calls: 1, triangles: 1, points: 0, lines: 0 }, // final fullscreen pass + memory: { geometries: 8, textures: 12 }, programs: [{}, {}], + } }, + }); + for (let callback = 0; callback < 90; callback++) { + now = callback * 1000 / 60; + profiler.beginFrame(now); + if (callback % 2 === 0) { + engine._recordRenderedFrame(now, { triangles: 56000, drawCalls: 104, lines: 12 }); + now += 8; + } else now += 0.1; + profiler.endFrame(); + } + const snapshot = structuredClone(profiler.snapshot()); // worker transport + expect(snapshot.fps).toBe(engine._fps); + expect(snapshot.fps).toBe(30); + expect(snapshot.fpsAvg).toBe(30); + expect(snapshot.render).toMatchObject({ + triangles: engine._lastTris, calls: engine._lastDraws, + lines: 12, geometries: 8, textures: 12, programs: 2, + }); + expect(snapshot.render.calls).toBe(104); + expect(profiler.frame.count).toBe(45); + expect(snapshot.frame.avg).toBeCloseTo(8); + }); + + it('keeps long stalls in rendered FPS and normalizes the HUD sampling window', () => { + const engine = Object.create(Engine.prototype); + Object.assign(engine, { + profiler: new PerformanceProfiler(), _frames: 0, _fpsTime: null, _fps: 0, + renderer: { info: { render: {} } }, + }); + for (const now of [1000, 1016, 3032]) { + engine._recordRenderedFrame(now, { triangles: 10, drawCalls: 2 }); + } + expect(engine.profiler.snapshot()).toMatchObject({ fps: 1, fpsAvg: 1 }); + expect(engine.profiler.render.calls).toBe(2); // kept with overlay closed too + }); +}); + +describe('worker interaction cadence', () => { + it.each(['low', 'medium'])('leaves %s-tier idle pacing while zooming and throughout a long drag', (gpuTier) => { + const engine = Object.create(Engine.prototype); + let now = 2000; + vi.spyOn(performance, 'now').mockImplementation(() => now); + Object.assign(engine, { + _lastUserActivityAt: 0, gpuTier, exploreMode: 'none', + canvas: { dispatchTerrainEvent: vi.fn() }, controls: {}, + }); + const idleInterval = engine._continuousRenderInterval(now); + expect(idleInterval).toBeGreaterThan(30); + engine.applyInputFrame({ wheel: { deltaY: 10 } }); + expect(engine._continuousRenderInterval(now)).toBe(0); + expect(engine.canvas.dispatchTerrainEvent).toHaveBeenCalledWith('wheel', { deltaY: 10 }); + for (let second = 0; second < 4; second++) { + now += 1000; + engine.applyInputFrame({ pointerEvents: [{ type: 'pointermove', buttons: 2 }] }); + expect(engine._continuousRenderInterval(now)).toBe(0); + } + now += 1000; + engine.applyInputFrame({ pointerEvents: [{ type: 'pointermove', buttons: 0 }] }); + expect(engine._continuousRenderInterval(now)).toBe(idleInterval); + engine.controls.isSettling = true; + expect(engine._continuousRenderInterval(now)).toBe(0); + }); +}); + +function underwaterHarness() { + const engine = Object.create(Engine.prototype); + const underwater = new UnderwaterEffect(); + const cloud = new THREE.Mesh(); + const scene = new THREE.Scene(); + scene.add(cloud); + Object.assign(engine, { + underwater, scene, studioCloud: { mesh: cloud }, + worldMode: 'studio', camera: { position: { y: 100 } }, + renderer: { getDrawingBufferSize: (size) => size.set(640, 360) }, + _waterLevel: () => 20, profiler: new PerformanceProfiler(), + _compileSceneStaggered: vi.fn(), _compileMaterialVariants: vi.fn(), + }); + return { engine, underwater, cloud, scene }; +} + +describe('underwater approach warmup', () => { + it('waits for the underwater composite under the final boot cover', async () => { + const engine = Object.create(Engine.prototype); + let finish; + const target = { width: 640, height: 360 }; + Object.assign(engine, { + params: {}, worldMode: 'studio', underwater: { enabled: true }, + waterSystem: { activateInitialMaterials: vi.fn() }, + _warmUnderwaterShaders: vi.fn(() => new Promise((resolve) => { finish = resolve; })), + }); + const context = { + assertCurrent: vi.fn(), progress: vi.fn(), waterRequired: true, + bootParams: {}, compilePlan: {}, compileMaterials: [], + compileTarget: { renderTarget: target, usesSceneTarget: true }, + compilePromise: Promise.resolve({ ready: true }), + }; + let completed = false; + const run = engine._compileFinalBootGraph(context).then(() => { completed = true; }); + await vi.waitFor(() => expect(engine._warmUnderwaterShaders).toHaveBeenCalledWith(target)); + expect(completed).toBe(false); + finish(true); + await run; + expect(completed).toBe(true); + }); + + it('warms only the composite, yields before submission and leaves clouds attached', async () => { + const { engine, underwater, cloud, scene } = underwaterHarness(); + let finish; + engine._compileExactPass = vi.fn(() => new Promise((resolve) => { finish = resolve; })); + const source = { width: 640, height: 360 }; + const promise = engine._warmUnderwaterShaders(source); + expect(engine._compileExactPass).not.toHaveBeenCalled(); + expect(engine._warmUnderwaterShaders(source)).toBe(promise); + await vi.waitFor(() => expect(engine._compileExactPass).toHaveBeenCalledOnce()); + for (let frame = 0; frame < 5; frame++) engine._maybeWarmUnderwater(source); + expect(engine._compileExactPass).toHaveBeenCalledOnce(); + expect(engine._compileExactPass).toHaveBeenCalledWith(expect.objectContaining({ + material: underwater._material, scene: underwater._quadScene, renderTarget: underwater._rt, + }), expect.anything()); + expect(engine._compileSceneStaggered).not.toHaveBeenCalled(); + expect(engine._compileMaterialVariants).not.toHaveBeenCalled(); + expect(cloud.parent).toBe(scene); + finish({ ready: true, syncCompileMs: 2 }); + expect(await promise).toBe(true); + expect(engine._underwaterWarmed).toBe(true); + const traverse = vi.spyOn(scene, 'traverse'); + for (let frame = 0; frame < 5; frame++) engine._maybeWarmUnderwater(source); + expect(engine._compileExactPass).toHaveBeenCalledOnce(); + expect(traverse).not.toHaveBeenCalled(); + underwater.dispose(); cloud.geometry.dispose(); cloud.material.dispose(); + }); + + it.each(['resize', 'dispose', 'program'])('does not publish readiness after %s during compilation', async (change) => { + const { engine, underwater, cloud } = underwaterHarness(); + let finish; + engine._compileExactPass = vi.fn(() => new Promise((resolve) => { finish = resolve; })); + const promise = engine._warmUnderwaterShaders(); + await vi.waitFor(() => expect(engine._compileExactPass).toHaveBeenCalledOnce()); + if (change === 'resize') underwater._ensureTarget(engine.renderer, 800, 600); + if (change === 'dispose') engine._disposed = true; + if (change === 'program') underwater._material.needsUpdate = true; + finish({ ready: true }); + expect(await promise).toBe(false); + expect(engine._underwaterWarmed).toBe(false); + expect(engine._underwaterWarmPromise).toBeNull(); + underwater.dispose(); cloud.geometry.dispose(); cloud.material.dispose(); + }); +}); diff --git a/tests/TerrainMaterial.test.js b/tests/TerrainMaterial.test.js index 9fffaaa..0126652 100644 --- a/tests/TerrainMaterial.test.js +++ b/tests/TerrainMaterial.test.js @@ -341,3 +341,32 @@ describe('shared Tile and Infinite terrain program', () => { expect(boot.fragmentShader).toContain('return fallback;'); }); }); + + +describe('audit shader invariants', () => { + it.each(['base', 'full', 'hybrid', 'manual'])('preserves debug/export wall handling in %s', (variant) => { + const material = createTerrainMaterial(createTerrainUniforms(), 5, undefined, { variant }); + materials.push(material); + const fragment = material.fragmentShader.slice(material.fragmentShader.lastIndexOf('void main()')); + expect(fragment.indexOf('tileOccupiedAt(xz) < 0.5) discard')).toBeLessThan(fragment.indexOf('if (vWall > 0.02')); + expect(fragment.indexOf('if (vWall > 0.02')).toBeLessThan(fragment.indexOf('Climate cl =')); + expect(fragment).toContain('vWall > 0.02 && uTileDebugView <= 0.5 && uColorMode <= 0.5'); + expect(fragment).toContain('if (uTileDebugView > 0.5)'); + expect(fragment).toContain('if (uColorMode > 0.5)'); + expect(fragment).toContain('float hRel = vWorldPos.y - uSeaLevel;'); + }); + + it('reuses only the exact Tile centre climate and leaves Manual/Infinite cache semantics intact', () => { + for (const [options, reuse] of [ + [{ variant: 'full' }, true], [{ variant: 'hybrid' }, true], + [{ variant: 'manual' }, false], [{ worldMode: 'infinite' }, false], + [{ worldMode: 'shared' }, false], + ]) { + const material = createTerrainMaterial(createTerrainUniforms(), 5, undefined, options); + materials.push(material); + expect(material.fragmentShader.includes('hC = heightAtWithClimate(xz, cl);')).toBe(reuse); + expect(material.fragmentShader).toContain('hX = terrainCachedHeightAt(xz + vec2(eps, 0.0));'); + expect(material.fragmentShader).toContain('hZ = terrainCachedHeightAt(xz + vec2(0.0, eps));'); + } + }); +}); diff --git a/tests/WaterPlanarReflectionPass.test.js b/tests/WaterPlanarReflectionPass.test.js index 8ea2663..2cf5669 100644 --- a/tests/WaterPlanarReflectionPass.test.js +++ b/tests/WaterPlanarReflectionPass.test.js @@ -59,6 +59,19 @@ describe('WaterPlanarReflectionPass', () => { expect(isPlanarReflectionMode('cinematic')).toBe(true); }); + it.each(['off', 'legacy', 'realistic', 'volumetric'])('does not build scene revisions for %s water', (mode) => { + const pass = new WaterPlanarReflectionPass(); + const revision = vi.fn(() => 'expensive-scene-snapshot'); + const renderer = createRenderer(); + expect(pass.capture(renderer, {}, createCamera(), { + params: cinematicParams, mode, worldMode: 'studio', revision, + })).toBe(false); + expect(revision).not.toHaveBeenCalled(); + expect(renderer.render).not.toHaveBeenCalled(); + expect(pass.target).toBeNull(); + pass.dispose(); + }); + it('scales resolution from reflection quality and the shared render scale', () => { expect(resolveWaterPlanarReflectionSize({ width: 1920, @@ -187,7 +200,7 @@ describe('WaterPlanarReflectionPass', () => { pass.dispose(); }); - it('keeps a static reflection until its scene revision changes', () => { + it.each([false, true])('keeps a static reflection until its scene revision changes (lazy=%s)', (lazy) => { const pass = new WaterPlanarReflectionPass(); const renderer = createRenderer(); const material = createReflectionMaterial(); @@ -198,7 +211,7 @@ describe('WaterPlanarReflectionPass', () => { mode: 'cinematic', worldMode: 'studio', materials: [material], - revision: 'camera:1|terrain:4', + revision: lazy ? () => 'camera:1|terrain:4' : 'camera:1|terrain:4', }; pass.capture(renderer, {}, camera, options); @@ -208,7 +221,7 @@ describe('WaterPlanarReflectionPass', () => { pass.capture(renderer, {}, camera, { ...options, - revision: 'camera:2|terrain:4', + revision: lazy ? () => 'camera:2|terrain:4' : 'camera:2|terrain:4', }); expect(renderer.render).toHaveBeenCalledTimes(2); pass.dispose(); diff --git a/tests/WaterStartup.test.js b/tests/WaterStartup.test.js index 4a51686..708f7ee 100644 --- a/tests/WaterStartup.test.js +++ b/tests/WaterStartup.test.js @@ -518,7 +518,7 @@ describe('water startup shaders', () => { canvasOnly: true, renderTarget: sceneTarget, })).resolves.toMatchObject({ ready: true }); - expect(engine.renderer.setRenderTarget.mock.calls).toEqual([[sceneTarget], [previousTarget]]); + expect(engine.renderer.setRenderTarget.mock.calls).toEqual([[sceneTarget], [previousTarget, 0, 0]]); material.dispose(); engine._warmGeo.dispose(); diff --git a/tools/benchmark-minimap.mjs b/tools/benchmark-minimap.mjs new file mode 100644 index 0000000..356335b --- /dev/null +++ b/tools/benchmark-minimap.mjs @@ -0,0 +1,64 @@ +// CPU responsiveness + exact RGBA comparison against a Git baseline. +// Usage: node tools/benchmark-minimap.mjs [baseline-ref] > results.json +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { Minimap } from '../src/engine/Minimap.js'; +import { createTerrainUniforms } from '../src/engine/terrain/TerrainMaterial.js'; +import { TerrainHeightSampler } from '../src/engine/terrain/TerrainHeightSampler.js'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const baseline = process.argv[2] || '1b488d99aae81fae615eecf281fb24e2f9995cdf'; +const cache = resolve(root, '.cache'); +mkdirSync(cache, { recursive: true }); +const temporary = mkdtempSync(join(cache, 'minimap-parity-')); +const baselinePath = join(temporary, 'Minimap.js'); +const round = (value) => Math.round(value * 1000) / 1000; + +try { + writeFileSync(baselinePath, execFileSync('git', ['show', `${baseline}:src/engine/Minimap.js`], { cwd: root })); + const { Minimap: Baseline } = await import(pathToFileURL(baselinePath)); + const make = (Class, seed, mode) => { + const uniforms = createTerrainUniforms(); + uniforms.uSeedOffset.value.set(seed, seed * 0.31); + const sampler = new TerrainHeightSampler(uniforms, () => ({ octaves: 5, infinite: false })); + const map = new Class({}, {}, null, null); + map.setBoard(2048, 420); + map.setSources({ sampler, controls: { target: { x: 173.712, z: -247.618 } }, + getWaterLevel: () => 95, + getPaintHeightOffset: (x, z) => Math.sin(x * 0.1) * 9 + Math.cos(z * 0.2) * 5, + getPropsMask: (x, z) => ({ grass: (Math.sin(x) + 1) / 2, flowers: (Math.cos(z) + 1) / 2, mixed: 0.25 }), + }); + map.setConfig({ mode, zoom: 2.7 }); + return map; + }; + const runs = []; + for (const mode of ['height', 'water', 'noise', 'biome', 'slope', 'props']) { + for (const seed of [17, 42, 1234]) { + const before = make(Baseline, seed, mode); + const startedAt = performance.now(); + const reference = before.createFramePacket(); + const beforeMs = performance.now() - startedAt; + const after = make(Minimap, seed, mode); + const nextStartedAt = performance.now(); + const candidate = await after.createFramePacket(); + const afterMs = performance.now() - nextStartedAt; + let differingBytes = 0; + for (let i = 0; i < reference.rgba.length; i++) differingBytes += reference.rgba[i] !== candidate.rgba[i]; + const cachedAt = performance.now(); + await after.createFramePacket(); + runs.push({ mode, seed, beforeSingleTaskMs: round(beforeMs), afterTotalMs: round(afterMs), + afterMaxSampleBatchMs: round(after.getDiagnostics().maxBatchMs), afterCachedMs: round(performance.now() - cachedAt), + differingBytes, rgbaSha256: createHash('sha256').update(candidate.rgba).digest('hex') }); + before.dispose(); after.dispose(); + } + } + console.log(JSON.stringify({ baseline, environment: `Node ${process.version}, CPU-only`, + conditions: '128x128 exact samples / 256x256 RGBA; five octaves; zoom 2.7; authored height offsets; varied prop masks; sequential before/after (not randomized); no GPU/browser timing', + runs }, null, 2)); + if (runs.some((run) => run.differingBytes > 0)) process.exitCode = 1; +} finally { + rmSync(temporary, { recursive: true, force: true }); +}