rayzee 7.18.0 → 7.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.18.0",
3
+ "version": "7.19.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -17,7 +17,7 @@ import { Compositor } from './Stages/Compositor.js';
17
17
  import { RenderPipeline } from './Pipeline/RenderPipeline.js';
18
18
  import { CompletionTracker } from './Pipeline/CompletionTracker.js';
19
19
  import { ENGINE_DEFAULTS as DEFAULT_STATE, PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize } from './EngineDefaults.js';
20
- import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples, disposeObjectFromMemory } from './Processor/utils.js';
20
+ import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples, disposeObjectFromMemory, disposeRenderer } from './Processor/utils.js';
21
21
  import { BuildTimer } from './Processor/BuildTimer.js';
22
22
  import { createLogger, fmt } from './utils/Logger.js';
23
23
  import { InteractionManager } from './managers/InteractionManager.js';
@@ -35,6 +35,7 @@ import { DenoisingManager } from './managers/DenoisingManager.js';
35
35
  import { OverlayManager } from './managers/OverlayManager.js';
36
36
  import { AnimationManager } from './managers/AnimationManager.js';
37
37
  import { TransformManager } from './managers/TransformManager.js';
38
+ import { TransformGizmoHelper } from './managers/helpers/TransformGizmoHelper.js';
38
39
 
39
40
  // One app per canvas — auto-dispose a prior owner if the caller double-
40
41
  // instantiates (StrictMode, HMR, etc.) so its rAF loop can't burn CPU.
@@ -216,7 +217,7 @@ export class PathTracerApp extends EventDispatcher {
216
217
  this._initScenes();
217
218
  this._initAssetPipeline();
218
219
  this._initPipeline();
219
- this._initManagers();
220
+ await this._initManagers();
220
221
  this._wireEvents();
221
222
 
222
223
  // Seed path tracer with minimal empty scene data
@@ -511,35 +512,19 @@ export class PathTracerApp extends EventDispatcher {
511
512
  this.scene?.clear();
512
513
  this.scene = null;
513
514
 
514
- // Three.js 0.184 leaks (confirmed via heap-snapshot retainer analysis):
515
+ // Three.js 0.184 leak (confirmed via heap-snapshot retainer analysis): the
516
+ // Textures manager (one per renderer) registers a per-texture 'dispose'
517
+ // listener that closes over `this = Textures` — which transitively captures
518
+ // backend → renderer. These listeners are removed only when the texture
519
+ // itself is destroyed. For module-level singletons like EmptyTexture (new
520
+ // Texture in TextureNode.js) and its CubeTexture counterpart, the texture is
521
+ // never destroyed, so every renderer ever created leaks through the
522
+ // singleton's listener array.
515
523
  //
516
- // 1) Renderer.dispose() does not remove the 'resize' listener it installs on
517
- // _canvasTarget. The bound handler closes over the renderer, pinning the
518
- // entire WebGPU graph (Backend, Nodes, Bindings, Pipelines, GPUDevice,
519
- // every TSL node) alive indefinitely.
520
- // See three/src/renderers/common/Renderer.js:292 (attach) and
521
- // :2503 (dispose — missing removal).
522
- //
523
- // 2) Textures manager (one per renderer) registers a per-texture 'dispose'
524
- // listener that closes over `this = Textures` — which transitively
525
- // captures backend → renderer. These listeners are removed only when
526
- // the texture itself is destroyed. For module-level singletons like
527
- // EmptyTexture (new Texture in TextureNode.js) and its CubeTexture
528
- // counterpart, the texture is never destroyed, so every renderer ever
529
- // created leaks through the singleton's listener array.
530
- //
531
- // Both workarounds are safe when only a single PathTracerApp is active at a
532
- // time. If you run multiple in parallel, reset listeners only on the renderer
533
- // being disposed (not the shared singletons).
534
- if ( this.renderer?._canvasTarget && this.renderer._onCanvasTargetResize ) {
535
-
536
- this.renderer._canvasTarget.removeEventListener(
537
- 'resize',
538
- this.renderer._onCanvasTargetResize
539
- );
540
-
541
- }
542
-
524
+ // Safe when only a single PathTracerApp is active at a time. If you run
525
+ // multiple in parallel, reset listeners only on the renderer being disposed
526
+ // (not the shared singletons). The sibling _canvasTarget leak is handled by
527
+ // disposeRenderer().
543
528
  try {
544
529
 
545
530
  const emptyTex = _tslTexture().value;
@@ -553,8 +538,7 @@ export class PathTracerApp extends EventDispatcher {
553
538
 
554
539
  }
555
540
 
556
- this.renderer?.dispose();
557
- if ( this.renderer ) this.renderer._canvasTarget = null;
541
+ disposeRenderer( this.renderer );
558
542
  this.renderer = null;
559
543
 
560
544
  this.stages = {};
@@ -1231,10 +1215,12 @@ export class PathTracerApp extends EventDispatcher {
1231
1215
  *
1232
1216
  * MUST be called before the pipeline/stages are constructed (they pre-allocate at this value and cannot
1233
1217
  * resize). Calling it after init only takes effect on the next full pipeline (re)build.
1234
- * @param {number} requestedPx desired reserved size (longest edge)
1218
+ * @param {number} requestedPx desired reserved size (longest edge)
1219
+ * @param {Object} [opts]
1220
+ * @param {boolean} [opts.allowLower=false] permit lowering, paying a rebuild, to reclaim VRAM
1235
1221
  * @returns {number} the applied reserved size
1236
1222
  */
1237
- setReservedRenderResolution( requestedPx ) {
1223
+ setReservedRenderResolution( requestedPx, { allowLower = false } = {} ) {
1238
1224
 
1239
1225
  const limits = this.renderer?.backend?.device?.limits;
1240
1226
  const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
@@ -1243,7 +1229,11 @@ export class PathTracerApp extends EventDispatcher {
1243
1229
  const deviceSafeMax = ( deviceMemGB >= 8 && maxBinding >= 1024 * 1024 * 1024 )
1244
1230
  ? MAX_RESERVABLE_RENDER_SIZE : 2048;
1245
1231
  const prev = MAX_STORAGE_TEXTURE_SIZE;
1246
- const applied = setReservedRenderSize( Math.min( requestedPx, deviceSafeMax ) );
1232
+
1233
+ // Monotonic up: UI-driven callers request whatever the current view needs, so honouring decreases made
1234
+ // the reserve oscillate on every preview↔render switch and paid a full kernel rebuild each time.
1235
+ const target = allowLower ? requestedPx : Math.max( requestedPx, prev );
1236
+ const applied = setReservedRenderSize( Math.min( target, deviceSafeMax ) );
1247
1237
 
1248
1238
  // If the pipeline is already built and the reserved size changed, re-init the reserved GPU storage in
1249
1239
  // place: each stage recreates its pre-allocated StorageTextures at the new size + rebuilds its compute
@@ -1256,6 +1246,17 @@ export class PathTracerApp extends EventDispatcher {
1256
1246
  this.pauseRendering = true;
1257
1247
  try {
1258
1248
 
1249
+ // Lowering below the live backing store makes copyToReadTargets read past the end of the
1250
+ // (now smaller) write textures — a GPUValidationError. Shrink the backing store first.
1251
+ // renderer.setSize, not setCanvasSize/onResize: those also rewrite camera.aspect.
1252
+ const backing = this.renderer.domElement;
1253
+ if ( backing.width > applied || backing.height > applied ) {
1254
+
1255
+ this.renderer.setSize(
1256
+ Math.min( backing.width, applied ), Math.min( backing.height, applied ), false );
1257
+
1258
+ }
1259
+
1259
1260
  for ( const stage of Object.values( this.stages ) ) stage?.reallocateReservedStorage?.();
1260
1261
 
1261
1262
  } finally {
@@ -1273,6 +1274,16 @@ export class PathTracerApp extends EventDispatcher {
1273
1274
 
1274
1275
  }
1275
1276
 
1277
+ /**
1278
+ * The current reserved (pre-allocated) square render size in px.
1279
+ * @returns {number}
1280
+ */
1281
+ getReservedRenderResolution() {
1282
+
1283
+ return MAX_STORAGE_TEXTURE_SIZE;
1284
+
1285
+ }
1286
+
1276
1287
  /**
1277
1288
  * Guard against render resolutions the compute pipeline can't support.
1278
1289
  * Per-resolution StorageTextures are pre-allocated at MAX_STORAGE_TEXTURE_SIZE
@@ -1304,13 +1315,6 @@ export class PathTracerApp extends EventDispatcher {
1304
1315
  this.cameraManager.camera.aspect = width / height;
1305
1316
  this.cameraManager.camera.updateProjectionMatrix();
1306
1317
 
1307
- // Overlay helpers always render at display resolution
1308
- const dpr = window.devicePixelRatio || 1;
1309
- this.overlayManager?.setSize(
1310
- Math.round( width * dpr ),
1311
- Math.round( height * dpr )
1312
- );
1313
-
1314
1318
  const lastW = this.denoisingManager?._lastRenderWidth ?? 0;
1315
1319
  const lastH = this.denoisingManager?._lastRenderHeight ?? 0;
1316
1320
  if ( width === lastW && height === lastH ) return;
@@ -1367,28 +1371,27 @@ export class PathTracerApp extends EventDispatcher {
1367
1371
 
1368
1372
  this.cameraManager.controls.enabled = ! isProduction;
1369
1373
 
1370
- // Batch uniform updates via settings
1374
+ // Anything with a SETTING_ROUTES entry must go through settings, not setUniform: set() early-returns on
1375
+ // `prev === value`, so a uniform written behind the map leaves it stale and the next set() silently no-ops.
1371
1376
  this.settings.setMany( {
1372
1377
  maxSamples: config.maxSamples,
1373
1378
  maxBounces: config.bounces,
1374
1379
  transmissiveBounces: config.transmissiveBounces,
1375
1380
  maxSubsurfaceSteps: config.maxSubsurfaceSteps,
1381
+ enableAlphaShadows: config.enableAlphaShadows ?? false,
1382
+ // Tier-1 convergence early-stop
1383
+ useAdaptiveSampling: config.useAdaptiveSampling ?? false,
1384
+ noiseThreshold: config.noiseThreshold ?? DEFAULT_STATE.noiseThreshold,
1385
+ adaptiveStopFraction: config.adaptiveStopFraction ?? DEFAULT_STATE.adaptiveStopFraction,
1386
+ adaptiveMinSamples: config.adaptiveMinSamples ?? DEFAULT_STATE.adaptiveMinSamples,
1387
+ // Tier-2 per-pixel freeze
1388
+ usePixelFreeze: config.usePixelFreeze ?? false,
1389
+ pixelFreezeThreshold: config.pixelFreezeThreshold ?? DEFAULT_STATE.pixelFreezeThreshold,
1390
+ pixelFreezeStability: config.pixelFreezeStability ?? DEFAULT_STATE.pixelFreezeStability,
1376
1391
  }, { silent: true } );
1377
1392
 
1393
+ // renderMode has no SETTING_ROUTES entry
1378
1394
  this.stages.pathTracer?.setUniform( 'renderMode', parseInt( config.renderMode ) );
1379
- this.stages.pathTracer?.setUniform( 'enableAlphaShadows', config.enableAlphaShadows ?? false );
1380
-
1381
- // Tier-1 convergence early-stop (live uniforms, no kernel rebuild). Threshold/min-samples fall back to
1382
- // engine defaults when a config doesn't override them.
1383
- this.stages.pathTracer?.setUniform( 'useAdaptiveSampling', config.useAdaptiveSampling ?? false );
1384
- this.stages.pathTracer?.setUniform( 'noiseThreshold', config.noiseThreshold ?? DEFAULT_STATE.noiseThreshold );
1385
- this.stages.pathTracer?.setUniform( 'adaptiveStopFraction', config.adaptiveStopFraction ?? DEFAULT_STATE.adaptiveStopFraction );
1386
- this.stages.pathTracer?.setUniform( 'adaptiveMinSamples', config.adaptiveMinSamples ?? DEFAULT_STATE.adaptiveMinSamples );
1387
-
1388
- // Tier-2 per-pixel freeze (per-mode; falls back off when a config doesn't set it).
1389
- this.stages.pathTracer?.setUniform( 'usePixelFreeze', config.usePixelFreeze ?? false );
1390
- this.stages.pathTracer?.setUniform( 'pixelFreezeThreshold', config.pixelFreezeThreshold ?? DEFAULT_STATE.pixelFreezeThreshold );
1391
- this.stages.pathTracer?.setUniform( 'pixelFreezeStability', config.pixelFreezeStability ?? DEFAULT_STATE.pixelFreezeStability );
1392
1395
 
1393
1396
  this.stages.pathTracer?.updateCompletionThreshold?.();
1394
1397
 
@@ -1905,7 +1908,7 @@ export class PathTracerApp extends EventDispatcher {
1905
1908
 
1906
1909
  }
1907
1910
 
1908
- _initManagers() {
1911
+ async _initManagers() {
1909
1912
 
1910
1913
  this.interactionManager = new InteractionManager( {
1911
1914
  scene: this.meshScene,
@@ -1929,7 +1932,7 @@ export class PathTracerApp extends EventDispatcher {
1929
1932
  onReset: () => this.reset(),
1930
1933
  } );
1931
1934
  this._setupDenoisingManager();
1932
- this._setupOverlayManager();
1935
+ await this._setupOverlayManager();
1933
1936
 
1934
1937
  this.transformManager = new TransformManager( {
1935
1938
  camera: this.cameraManager.camera,
@@ -1938,6 +1941,10 @@ export class PathTracerApp extends EventDispatcher {
1938
1941
  app: this,
1939
1942
  } );
1940
1943
 
1944
+ // The gizmo is part of the scene overlay layer, so it draws on the same
1945
+ // view-resolution surface as the light helpers and the outline.
1946
+ this.overlayManager.register( 'transform', new TransformGizmoHelper( this.transformManager ) );
1947
+
1941
1948
  // Wire cross-manager dependencies
1942
1949
  this.interactionManager.setDependencies( {
1943
1950
  overlayManager: this.overlayManager,
@@ -2209,13 +2216,12 @@ export class PathTracerApp extends EventDispatcher {
2209
2216
 
2210
2217
  this.scene.updateMatrixWorld();
2211
2218
  this.overlayManager?.render();
2212
- this.transformManager?.render( this.renderer );
2213
2219
 
2214
2220
  }
2215
2221
 
2216
- _setupOverlayManager() {
2222
+ async _setupOverlayManager() {
2217
2223
 
2218
- this.overlayManager = new OverlayManager( this.renderer, this.cameraManager.camera );
2224
+ this.overlayManager = new OverlayManager( this.cameraManager.camera );
2219
2225
  this.overlayManager.setupDefaultHelpers( {
2220
2226
  helperScene: this._sceneHelpers,
2221
2227
  meshScene: this.meshScene,
@@ -2226,6 +2232,13 @@ export class PathTracerApp extends EventDispatcher {
2226
2232
  renderHeight: this.denoisingManager?._lastRenderHeight || this.canvas.clientHeight || 1,
2227
2233
  } );
2228
2234
 
2235
+ // Helpers draw at the size the canvas is displayed at, not the size it is
2236
+ // rendered at. Shares the main device, so this is a swapchain, not a context.
2237
+ await this.overlayManager.initViewRenderer( {
2238
+ device: this.renderer.backend?.device,
2239
+ sizeSource: this.canvas,
2240
+ } );
2241
+
2229
2242
  this._container = this._container || this.canvas.parentNode || null;
2230
2243
  this.overlayManager.mount( this._container );
2231
2244
 
@@ -102,6 +102,36 @@ export function distroyBuffers( { material, geometry, children } ) {
102
102
 
103
103
  }
104
104
 
105
+ /**
106
+ * Disposes a renderer without leaking it.
107
+ *
108
+ * Three.js 0.184–0.185: `Renderer.dispose()` does not remove the 'resize' listener
109
+ * it installs on `_canvasTarget`. The bound handler closes over the renderer,
110
+ * pinning the entire WebGPU graph (Backend, Nodes, Bindings, Pipelines, GPUDevice,
111
+ * every TSL node) alive indefinitely — confirmed via heap-snapshot retainer
112
+ * analysis. See three/src/renderers/common/Renderer.js:292 (attach) and :2503
113
+ * (dispose — missing removal).
114
+ *
115
+ * Renderers constructed with an external `device` do not destroy it here; three
116
+ * only destroys a device it created itself.
117
+ *
118
+ * @param {import('three/webgpu').WebGPURenderer} renderer
119
+ */
120
+ export function disposeRenderer( renderer ) {
121
+
122
+ if ( ! renderer ) return;
123
+
124
+ if ( renderer._canvasTarget && renderer._onCanvasTargetResize ) {
125
+
126
+ renderer._canvasTarget.removeEventListener( 'resize', renderer._onCanvasTargetResize );
127
+
128
+ }
129
+
130
+ renderer.dispose();
131
+ renderer._canvasTarget = null;
132
+
133
+ }
134
+
105
135
  export function disposeObjectFromMemory( object, exeptions = [] ) {
106
136
 
107
137
  if ( ! object ) return;
@@ -65,6 +65,16 @@ export class SceneHelpers {
65
65
 
66
66
  }
67
67
 
68
+ /**
69
+ * Whether a render() would actually put anything on screen. `visible` alone
70
+ * is the enable flag and can be true with no lights in the scene.
71
+ */
72
+ get isDrawing() {
73
+
74
+ return this.visible && this._helpers.size > 0;
75
+
76
+ }
77
+
68
78
  /**
69
79
  * Syncs all helpers with their source objects.
70
80
  */
@@ -132,7 +142,7 @@ export class SceneHelpers {
132
142
  */
133
143
  render( renderer, camera ) {
134
144
 
135
- if ( ! this.visible || this._helpers.size === 0 ) return;
145
+ if ( ! this.isDrawing ) return;
136
146
 
137
147
  const prevAutoClear = renderer.autoClear;
138
148
  renderer.autoClear = false;
@@ -769,8 +769,11 @@ export class PathTracer extends PathTracerStage {
769
769
 
770
770
  if ( ! this._packedBuffers || ! ( this.materialData?.materialCount > 0 ) ) return;
771
771
 
772
- const w = this.storageTextures.renderWidth || 1;
773
- const h = this.storageTextures.renderHeight || 1;
772
+ // Clamp into the (possibly just-lowered) reserve: create() sizes the write StorageTextures at
773
+ // MAX_STORAGE_TEXTURE_SIZE but the readTarget at what's passed here, and the tracked render size still
774
+ // holds the pre-change value — unclamped, copyToReadTargets reads past the write textures.
775
+ const w = Math.min( this.storageTextures.renderWidth || 1, MAX_STORAGE_TEXTURE_SIZE );
776
+ const h = Math.min( this.storageTextures.renderHeight || 1, MAX_STORAGE_TEXTURE_SIZE );
774
777
 
775
778
  // Recreate the MRT pool at the new reserved size (create() reallocates the write StorageTextures at the
776
779
  // live MAX_STORAGE_TEXTURE_SIZE; the read RenderTarget follows the current render size).
@@ -1,21 +1,34 @@
1
1
  import { TileHelper } from './helpers/TileHelper.js';
2
2
  import { OutlineHelper } from './helpers/OutlineHelper.js';
3
+ import { ViewOverlayRenderer } from './helpers/ViewOverlayRenderer.js';
4
+ import { createOverlayCanvas, viewPixelSize } from './helpers/overlaySurface.js';
5
+ import { createLogger } from '../utils/Logger.js';
6
+
7
+ const log = createLogger( 'overlay' );
8
+
9
+ /** Whether a helper wants to draw into the given layer this frame. */
10
+ const drawsOn = ( helper, layer ) => helper.visible && helper.layer === layer && helper.render;
3
11
 
4
12
  /**
5
13
  * OverlayManager — Unified overlay system for visual helpers.
6
14
  *
7
- * Two rendering layers:
8
- * 1. **HelperScene** — A Three.js Scene rendered on top of the WebGPU backbuffer
9
- * (light gizmos, bounding boxes, outlines). Renders at display resolution.
15
+ * Two rendering layers, both sized to the **view** (what the user sees on
16
+ * screen) rather than to the path tracer's render resolution:
17
+ * 1. **Scene layer** — Three.js content (light gizmos, transform gizmo,
18
+ * selection outline) drawn by {@link ViewOverlayRenderer} into its own
19
+ * transparent canvas stacked over the main one.
10
20
  * 2. **HUDCanvas** — A 2D `<canvas>` element overlaid via CSS for screen-space
11
- * elements (AF points, debug labels). Completely separate
12
- * from the WebGPU canvas, so it is never captured in saved images.
21
+ * elements (denoise/upscale tile progress).
22
+ *
23
+ * Neither layer touches the main canvas, so helpers can never be captured in a
24
+ * saved image.
13
25
  *
14
26
  * Helpers are registered by name and implement a simple interface:
15
27
  * { update?(), render?(ctx, w, h), show(), hide(), dispose(), visible, layer }
16
28
  *
17
29
  * @example
18
- * const overlay = new OverlayManager( renderer, camera );
30
+ * const overlay = new OverlayManager( camera );
31
+ * await overlay.initViewRenderer( { device, sizeSource: canvas } );
19
32
  * overlay.register( 'outline', new OutlineHelper() );
20
33
  * overlay.show( 'outline' );
21
34
  * // in animate():
@@ -24,25 +37,51 @@ import { OutlineHelper } from './helpers/OutlineHelper.js';
24
37
  export class OverlayManager {
25
38
 
26
39
  /**
27
- * @param {import('three/webgpu').WebGPURenderer} renderer
28
40
  * @param {import('three').PerspectiveCamera} camera
29
41
  */
30
- constructor( renderer, camera ) {
42
+ constructor( camera ) {
31
43
 
32
- this.renderer = renderer;
33
44
  this.camera = camera;
34
45
 
35
46
  /** @type {Map<string, Object>} */
36
47
  this._helpers = new Map();
37
48
 
38
49
  // ── HUD Canvas (2D overlay) ──
39
- this._hudCanvas = document.createElement( 'canvas' );
40
- this._hudCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;';
50
+ this._hudCanvas = createOverlayCanvas();
41
51
  this._hudCtx = this._hudCanvas.getContext( '2d' );
42
52
 
43
53
  // ── HelperScene reference (set via setHelperScene) ──
44
54
  this._helperScene = null;
45
55
 
56
+ /** @type {ViewOverlayRenderer|null} */
57
+ this._viewOverlay = null;
58
+
59
+ }
60
+
61
+ /**
62
+ * Creates the view-resolution surface the scene layer draws into. On failure
63
+ * the scene layer stays off rather than degrading onto the main backbuffer —
64
+ * drawing there would bake helpers into saved images.
65
+ *
66
+ * @param {Object} config
67
+ * @param {GPUDevice} config.device - Shared with the main renderer
68
+ * @param {HTMLElement} config.sizeSource - Element whose on-screen box defines the size
69
+ */
70
+ async initViewRenderer( { device, sizeSource } ) {
71
+
72
+ if ( ! device || ! sizeSource ) return;
73
+
74
+ try {
75
+
76
+ this._viewOverlay = await new ViewOverlayRenderer().init( { device, sizeSource } );
77
+
78
+ } catch ( err ) {
79
+
80
+ log.error( 'overlay surface unavailable — light gizmos, transform gizmo and outlines are disabled', err );
81
+ this._viewOverlay = null;
82
+
83
+ }
84
+
46
85
  }
47
86
 
48
87
  /**
@@ -67,14 +106,19 @@ export class OverlayManager {
67
106
  }
68
107
 
69
108
  /**
70
- * Mounts the HUD canvas into the given container. Idempotent — safe to call
71
- * multiple times; re-mounts only when the parent differs.
109
+ * Mounts the overlay canvases into the given container. Idempotent — safe to
110
+ * call multiple times; re-mounts only when the parent differs. The 3D surface
111
+ * goes in first so the HUD stacks above it.
72
112
  * @param {HTMLElement} container
73
113
  */
74
114
  mount( container ) {
75
115
 
76
- if ( ! container || this._hudCanvas.parentElement === container ) return;
77
- container.appendChild( this._hudCanvas );
116
+ if ( ! container ) return;
117
+
118
+ const viewCanvas = this._viewOverlay?.canvas;
119
+ if ( viewCanvas && viewCanvas.parentElement !== container ) container.appendChild( viewCanvas );
120
+
121
+ if ( this._hudCanvas.parentElement !== container ) container.appendChild( this._hudCanvas );
78
122
 
79
123
  }
80
124
 
@@ -116,7 +160,7 @@ export class OverlayManager {
116
160
  this._wireDenoiserTileEvents( tileHelper, denoisingManager );
117
161
 
118
162
  // ── Outline helper ──
119
- const outlineHelper = new OutlineHelper( this.renderer, meshScene, this.camera );
163
+ const outlineHelper = new OutlineHelper( meshScene, this.camera );
120
164
  this.register( 'outline', outlineHelper );
121
165
 
122
166
  }
@@ -170,7 +214,7 @@ export class OverlayManager {
170
214
 
171
215
  if ( this._helpers.has( name ) ) {
172
216
 
173
- console.warn( `OverlayManager: helper "${name}" already registered — replacing.` );
217
+ log.warn( `helper "${name}" already registered — replacing.` );
174
218
  this._helpers.get( name ).dispose?.();
175
219
 
176
220
  }
@@ -259,44 +303,56 @@ export class OverlayManager {
259
303
  */
260
304
  render() {
261
305
 
262
- // 1. Render 3D HelperScene overlay (light gizmos, etc.)
263
- if ( this._helperScene ) {
264
-
265
- this._helperScene.render( this.renderer, this.camera );
306
+ const overlay = this._viewOverlay;
266
307
 
267
- }
308
+ if ( overlay ) {
268
309
 
269
- // 2. Render scene-layer helpers (outline, etc.)
270
- for ( const helper of this._helpers.values() ) {
310
+ const hasSceneOverlay = this._hasVisibleSceneOverlay();
271
311
 
272
- if ( helper.visible && helper.layer === 'scene' && helper.render ) {
312
+ if ( hasSceneOverlay || overlay.isVisible ) {
273
313
 
274
- helper.render( this.renderer, this.camera );
314
+ overlay.syncSize();
315
+ // Doubles as the wipe of last frame's content when parking.
316
+ overlay.begin();
317
+ if ( hasSceneOverlay ) this._renderSceneLayer( overlay.renderer );
318
+ overlay.setVisible( hasSceneOverlay );
275
319
 
276
320
  }
277
321
 
278
322
  }
279
323
 
280
- // 3. Draw HUD canvas (2D helpers)
281
324
  this.refreshHUD();
282
325
 
283
326
  }
284
327
 
285
- /**
286
- * Forwards display dimensions to helpers that need resize.
287
- * @param {number} width - Display width in pixels
288
- * @param {number} height - Display height in pixels
289
- */
290
- setSize( width, height ) {
328
+ /** Draws the helper scene + every visible scene-layer helper. */
329
+ _renderSceneLayer( renderer ) {
330
+
331
+ this._helperScene?.render( renderer, this.camera );
291
332
 
292
333
  for ( const helper of this._helpers.values() ) {
293
334
 
294
- helper.setSize?.( width, height );
335
+ if ( drawsOn( helper, 'scene' ) ) helper.render( renderer, this.camera );
295
336
 
296
337
  }
297
338
 
298
339
  }
299
340
 
341
+ /** Whether anything at all wants to draw into the scene layer this frame. */
342
+ _hasVisibleSceneOverlay() {
343
+
344
+ if ( this._helperScene?.isDrawing ) return true;
345
+
346
+ for ( const helper of this._helpers.values() ) {
347
+
348
+ if ( drawsOn( helper, 'scene' ) ) return true;
349
+
350
+ }
351
+
352
+ return false;
353
+
354
+ }
355
+
300
356
  /**
301
357
  * Redraws the HUD canvas. Safe to call outside the animation loop
302
358
  * (e.g. during async OIDN tile progress).
@@ -310,7 +366,7 @@ export class OverlayManager {
310
366
  let hasVisibleHUD = false;
311
367
  for ( const helper of this._helpers.values() ) {
312
368
 
313
- if ( helper.visible && helper.layer === 'hud' && helper.render ) {
369
+ if ( drawsOn( helper, 'hud' ) ) {
314
370
 
315
371
  hasVisibleHUD = true;
316
372
  break;
@@ -326,30 +382,32 @@ export class OverlayManager {
326
382
 
327
383
  }
328
384
 
329
- const dpr = window.devicePixelRatio || 1;
385
+ // Un-park BEFORE measuring: a display:none canvas reports clientWidth 0, which returns early below and
386
+ // skips the un-park at the end — so once parked the HUD could never come back.
387
+ if ( canvas.style.display !== '' ) canvas.style.display = '';
388
+
389
+ // Helpers draw in CSS-pixel coordinates of the unscaled layout box; the
390
+ // backing store is sized off the on-screen box, so nothing is drawn below
391
+ // view resolution.
330
392
  const displayW = canvas.clientWidth;
331
393
  const displayH = canvas.clientHeight;
332
- const pixelW = Math.round( displayW * dpr );
333
- const pixelH = Math.round( displayH * dpr );
394
+ const size = displayW > 0 && displayH > 0 ? viewPixelSize( canvas ) : null;
395
+ if ( ! size ) return;
334
396
 
335
- if ( canvas.width !== pixelW || canvas.height !== pixelH ) {
397
+ if ( canvas.width !== size.width || canvas.height !== size.height ) {
336
398
 
337
- canvas.width = pixelW;
338
- canvas.height = pixelH;
399
+ canvas.width = size.width;
400
+ canvas.height = size.height;
339
401
 
340
402
  }
341
403
 
342
- ctx.clearRect( 0, 0, pixelW, pixelH );
404
+ ctx.clearRect( 0, 0, size.width, size.height );
343
405
  ctx.save();
344
- ctx.scale( dpr, dpr );
406
+ ctx.scale( size.width / displayW, size.height / displayH );
345
407
 
346
408
  for ( const helper of this._helpers.values() ) {
347
409
 
348
- if ( helper.visible && helper.layer === 'hud' && helper.render ) {
349
-
350
- helper.render( ctx, displayW, displayH );
351
-
352
- }
410
+ if ( drawsOn( helper, 'hud' ) ) helper.render( ctx, displayW, displayH );
353
411
 
354
412
  }
355
413
 
@@ -372,6 +430,9 @@ export class OverlayManager {
372
430
 
373
431
  this._helpers.clear();
374
432
 
433
+ this._viewOverlay?.dispose();
434
+ this._viewOverlay = null;
435
+
375
436
  if ( this._hudCanvas.parentElement ) {
376
437
 
377
438
  this._hudCanvas.parentElement.removeChild( this._hudCanvas );