rayzee 8.0.0 → 8.2.0

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.
@@ -27,6 +27,47 @@ const CADENCE_COST_BUDGET_MS = 120;
27
27
  // refreshes — 2x is the better trade while a person is watching the image resolve.
28
28
  const CADENCE_DENOISE_PERIODS = 2;
29
29
 
30
+ // The first refresh of a fresh accumulation is the one replacing a held frame that is now stale, so
31
+ // it waits one denoise rather than two. Measured over a 2 s reset storm (a slider being dragged):
32
+ // 8.4 -> 12.5-15.3 refreshes/sec for 75.5 -> 70-73 fps.
33
+ const CADENCE_FIRST_REFRESH_PERIODS = 1;
34
+
35
+ // While the camera moves, the denoised frames ARE the picture — the raw render is never shown — so
36
+ // the refresh rate is the frame rate a person sees, and back to back is the right cadence. Path
37
+ // tracing costs little there (1 bounce, no accumulation), and `isDenoising` serialises the runs.
38
+ const CADENCE_INTERACTION_PERIODS = 0;
39
+
40
+ // A denoise slower than this cannot follow a camera: holding the last one would turn navigation
41
+ // into a slideshow, so it goes back to the raw render instead. Measured on a 1.9M-triangle scene
42
+ // at `fast`: 512² 59 ms (35 refreshes/sec while moving, 121 fps), 1024² 135 ms (7/sec, 47 fps),
43
+ // 1536² 1.46 s. Set to keep 1024² on the clean side of the line and reject 1536².
44
+ const INTERACTION_HOLD_BUDGET_MS = 300;
45
+
46
+ // How many refresh timings the navigation decision is taken over. A single denoise is easily twice
47
+ // its own typical cost — a GC pause, a texture upload, another tab — and deciding on one of those
48
+ // left the next camera move entirely raw. The median of a few is stable and still cheap.
49
+ //
50
+ // Only refreshes taken WHILE MOVING count: that is a different regime, because the render loop
51
+ // stops tracing frames nobody will see (skipsTrace) and hands the GPU to the denoise. Measured
52
+ // inside a room at 1024², the same denoise is 596 ms with the loop tracing over it and ~95 ms
53
+ // without, so a still-camera timing would rule out navigation that in fact works.
54
+ //
55
+ // Taken as the MINIMUM, not the median, and reset for each move. The first refresh of a move is
56
+ // always slow — the GPU is still finishing the frame before it — and the estimate has to survive
57
+ // that, because a demoted move stops refreshing and so stops measuring: one unlucky reading used
58
+ // to pin navigation to the raw render for the rest of the session.
59
+ const DENOISE_COST_SAMPLES = 5;
60
+
61
+ // A refresh this far past the budget is not a warm-up reading, it is the wrong resolution for
62
+ // denoised navigation. Remembered until the size changes, so only the first move pays for finding
63
+ // out; anything short of it gets a second reading before navigation is given back to the raw render.
64
+ const INTERACTION_HOPELESS_FACTOR = 3;
65
+
66
+ // Consecutive refreshes that deliver nothing before the held frame is given up and the raw render
67
+ // comes back. Counted in runs, not milliseconds: the loop stops while a finished render sits on
68
+ // screen, so any clock would read a perfectly good frame as abandoned the moment it woke.
69
+ const HELD_FRAME_MAX_FAILURES = 3;
70
+
30
71
  /**
31
72
  * Orchestrates all denoising, post-processing, and AI upscaling:
32
73
  * - Real-time denoiser strategy switching (ASVGF / NRD / EdgeAware / None)
@@ -44,23 +85,18 @@ export class DenoisingManager extends EventDispatcher {
44
85
  * @param {Object} params
45
86
  * @param {import('three/webgpu').WebGPURenderer} params.renderer
46
87
  * @param {HTMLCanvasElement} params.mainCanvas - The primary rendering canvas
47
- * @param {import('three').Scene} params.scene
48
- * @param {import('three').PerspectiveCamera} params.camera
49
88
  * @param {Object} params.stages - Named references to pipeline stages
50
89
  * @param {import('../Pipeline/RenderPipeline.js').RenderPipeline} params.pipeline
51
90
  * @param {Function} params.getExposure - () => current exposure value
52
91
  * @param {Function} params.getSaturation - () => current saturation value
53
- * @param {Function} params.getTransparentBg - () => boolean
54
92
  */
55
- constructor( { renderer, mainCanvas, scene, camera, stages, pipeline, getExposure, getSaturation, getTransparentBg } ) {
93
+ constructor( { renderer, mainCanvas, stages, pipeline, getExposure, getSaturation } ) {
56
94
 
57
95
  super();
58
96
 
59
97
  this.renderer = renderer;
60
98
  this.mainCanvas = mainCanvas;
61
- this.denoiserCanvas = this._createDenoiserCanvas( mainCanvas );
62
- this.scene = scene;
63
- this.camera = camera;
99
+ this.upscalerCanvas = this._createUpscalerCanvas( mainCanvas );
64
100
  this.pipeline = pipeline;
65
101
 
66
102
  // Stage references — only used internally for orchestration
@@ -68,7 +104,6 @@ export class DenoisingManager extends EventDispatcher {
68
104
 
69
105
  this._getExposure = getExposure;
70
106
  this._getSaturation = getSaturation;
71
- this._getTransparentBg = getTransparentBg;
72
107
 
73
108
  this.denoiser = null;
74
109
  this.upscaler = null;
@@ -86,6 +121,16 @@ export class DenoisingManager extends EventDispatcher {
86
121
  // denoise while performance.now() is still below the interval.
87
122
  this._lastCadenceAt = - Infinity;
88
123
  this._lastCadenceSamples = 0;
124
+ // Consecutive refreshes that delivered nothing, for the held-frame health check.
125
+ this._failedRefreshes = 0;
126
+ // A final render suspends the live-view refresh without forgetting that the host chose it.
127
+ this._cadenceSuspended = false;
128
+ // Refresh timings from the current camera move, and whether this resolution has already
129
+ // proved far too slow to navigate denoised.
130
+ this._movingDenoiseMs = [];
131
+ this._movingHopeless = false;
132
+ // Whether this camera move is being shown denoised. Decided once per move; null between.
133
+ this._holdWhileMoving = null;
89
134
  this._onReset = null;
90
135
  this._onPostProcessRefresh = null;
91
136
 
@@ -107,7 +152,12 @@ export class DenoisingManager extends EventDispatcher {
107
152
 
108
153
  }
109
154
 
110
- _createDenoiserCanvas( mainCanvas ) {
155
+ /**
156
+ * The one canvas the engine keeps besides the renderer's own, for the AI upscaler: it works in
157
+ * ordinary pixels and shows a picture larger than the render, neither of which the renderer's
158
+ * canvas can do. Hidden until the upscaler has something to show.
159
+ */
160
+ _createUpscalerCanvas( mainCanvas ) {
111
161
 
112
162
  const parent = mainCanvas.parentNode;
113
163
  if ( ! parent ) return null;
@@ -119,6 +169,7 @@ export class DenoisingManager extends EventDispatcher {
119
169
  dc.style.inset = '0';
120
170
  dc.style.width = '100%';
121
171
  dc.style.height = '100%';
172
+ dc.style.display = 'none';
122
173
 
123
174
  parent.insertBefore( dc, mainCanvas );
124
175
  return dc;
@@ -139,27 +190,48 @@ export class DenoisingManager extends EventDispatcher {
139
190
  // Cleared only when the tier or the resolution changes — a denoise costs 14 ms at 512²
140
191
  // and 800 ms at 2048², so the affordability verdict does not survive a resize.
141
192
  this._cadenceDowngraded = false;
193
+ this._holdWhileMoving = null;
194
+ this._movingDenoiseMs.length = 0;
195
+ this._movingHopeless = false;
196
+ // A run in flight was sized for the old resolution and setSize rebuilds the network under
197
+ // it. Resets no longer cancel a run while its frame is on screen, so this has to.
198
+ this.denoiser?.abort();
199
+ // The picture on screen is the old size, and the Compositor would stretch it over the new
200
+ // frame until a denoise lands. Back to the raw render until then.
201
+ this._unpublishOutput();
142
202
  this.denoiser?.setSize( width, height );
203
+
204
+ // The 2D canvas exists for the upscaler alone, and sizing it here keeps that ownership in
205
+ // one place. Assigning width or height clears it, so only do it when it actually changed.
206
+ if ( this.upscalerCanvas && ( this.upscalerCanvas.width !== width || this.upscalerCanvas.height !== height ) ) {
207
+
208
+ this.upscalerCanvas.width = width;
209
+ this.upscalerCanvas.height = height;
210
+
211
+ }
212
+
143
213
  this.upscaler?.setBaseSize( width, height );
144
214
 
145
215
  }
146
216
 
147
217
 
148
218
  /**
149
- * Restores the denoiser canvas to base render resolution after upscaling.
219
+ * Puts the upscaler's canvas back to the render size — it leaves it two or four times larger.
150
220
  * @returns {boolean} true if the canvas was resized
151
221
  */
152
222
  restoreBaseResolution() {
153
223
 
154
- if ( ! this.denoiserCanvas || ! this._lastRenderWidth || ! this._lastRenderHeight ) return false;
224
+ if ( ! this.upscalerCanvas || ! this._lastRenderWidth || ! this._lastRenderHeight ) return false;
155
225
 
156
- const wasResized = this.denoiserCanvas.width !== this._lastRenderWidth
157
- || this.denoiserCanvas.height !== this._lastRenderHeight;
226
+ const wasResized = this.upscalerCanvas.width !== this._lastRenderWidth
227
+ || this.upscalerCanvas.height !== this._lastRenderHeight;
158
228
 
159
- this.denoiserCanvas.width = this._lastRenderWidth;
160
- this.denoiserCanvas.height = this._lastRenderHeight;
229
+ if ( ! wasResized ) return false;
161
230
 
162
- return wasResized;
231
+ this.upscalerCanvas.width = this._lastRenderWidth;
232
+ this.upscalerCanvas.height = this._lastRenderHeight;
233
+
234
+ return true;
163
235
 
164
236
  }
165
237
 
@@ -168,11 +240,14 @@ export class DenoisingManager extends EventDispatcher {
168
240
  */
169
241
  setupDenoiser() {
170
242
 
171
- if ( ! this.denoiserCanvas ) return;
243
+ if ( ! this.upscalerCanvas ) return;
172
244
 
173
245
  const pt = this._stages.pathTracer;
174
246
 
175
- this.denoiser = new OIDNDenoiser( this.denoiserCanvas, this.renderer, this.scene, this.camera, {
247
+ // No canvas: the denoiser hands its result to the pipeline as a picture, and the
248
+ // Compositor decides what the single canvas shows. The exposure, grade and tone curve
249
+ // come from the renderer's own output pass, so it is not told about them either.
250
+ this.denoiser = new OIDNDenoiser( this.renderer, {
176
251
  ...DEFAULT_STATE,
177
252
 
178
253
  backendParams: () => ( {
@@ -180,13 +255,6 @@ export class DenoisingManager extends EventDispatcher {
180
255
  adapterInfo: null
181
256
  } ),
182
257
 
183
- refreshInput: () => {
184
-
185
- const ctx = this.pipeline?.context;
186
- if ( this._stages.compositor && ctx ) this._stages.compositor.render( ctx );
187
-
188
- },
189
-
190
258
  getGPUTextures: () => {
191
259
 
192
260
  if ( ! pt?.storageTextures?.readTarget ) return null;
@@ -199,11 +267,6 @@ export class DenoisingManager extends EventDispatcher {
199
267
  };
200
268
 
201
269
  },
202
-
203
- getExposure: () => this._getEffectiveExposure(),
204
- getToneMapping: () => this._getToneMapping(),
205
- getSaturation: () => this._getSaturation(),
206
- getTransparentBackground: () => this._getTransparentBg(),
207
270
  } );
208
271
 
209
272
  this._syncOIDNInUse();
@@ -211,8 +274,13 @@ export class DenoisingManager extends EventDispatcher {
211
274
  // Forward lifecycle events (store refs for removal on re-setup / dispose)
212
275
  this._denoiserStartHandler = e =>
213
276
  this.dispatchEvent( { type: EngineEvents.DENOISING_START, continuous: !! e.continuous } );
214
- this._denoiserEndHandler = e =>
277
+ this._denoiserEndHandler = e => {
278
+
279
+ if ( this.denoiser?.hasOutput ) this._publishOutput();
215
280
  this.dispatchEvent( { type: EngineEvents.DENOISING_END, continuous: !! e.continuous } );
281
+
282
+ };
283
+
216
284
  this.denoiser.addEventListener( 'start', this._denoiserStartHandler );
217
285
  this.denoiser.addEventListener( 'end', this._denoiserEndHandler );
218
286
 
@@ -223,20 +291,19 @@ export class DenoisingManager extends EventDispatcher {
223
291
  */
224
292
  setupUpscaler() {
225
293
 
226
- if ( ! this.denoiserCanvas ) return;
294
+ if ( ! this.upscalerCanvas ) return;
227
295
 
228
296
  const pt = this._stages.pathTracer;
229
297
 
230
- this.upscaler = new AIUpscaler( this.denoiserCanvas, this.renderer, {
298
+ this.upscaler = new AIUpscaler( this.upscalerCanvas, this.renderer, {
231
299
  scaleFactor: DEFAULT_STATE.upscalerScale || 2,
232
300
  quality: DEFAULT_STATE.upscalerQuality || 'fast',
233
301
 
234
- getSourceCanvas: () => {
235
-
236
- if ( this.denoiser?.enabled ) return null;
237
- return this.renderer.domElement;
238
-
239
- },
302
+ // One source: the render canvas shows whatever the Compositor picked — the denoised
303
+ // picture when OIDN is on, the raw render otherwise — so the upscaler always enlarges
304
+ // what the viewer is actually looking at. It used to read its own canvas when the
305
+ // denoiser was on, which only worked while the denoiser painted there.
306
+ getSourceCanvas: () => this.renderer.domElement,
240
307
 
241
308
  refreshInput: () => {
242
309
 
@@ -514,6 +581,85 @@ export class DenoisingManager extends EventDispatcher {
514
581
 
515
582
  }
516
583
 
584
+ /**
585
+ * Whether the view is moving and the denoiser is quick enough to be that view — in which case the
586
+ * raw render is never shown and every reset keeps the last denoised frame up.
587
+ *
588
+ * Decided once per move rather than per frame, which would flip the viewport between clean and
589
+ * noisy mid-drag. Nothing is known at the first move of a size, so it tries once and then knows.
590
+ * Demotion is the exception: a move that turns out to be a slideshow gives the view back rather
591
+ * than finishing at 1.5 refreshes a second.
592
+ */
593
+ get holdsWhileMoving() {
594
+
595
+ if ( ! this._stages.pathTracer?.viewIsChanging ) return false;
596
+ if ( ! this.continuousDenoise || this._cadenceSuspended || ! this.denoiser?.enabled ) return false;
597
+ if ( this._movingHopeless ) return false;
598
+
599
+ const best = this.movingCostMs;
600
+ const seen = this._movingDenoiseMs.length;
601
+
602
+ if ( best > INTERACTION_HOLD_BUDGET_MS * INTERACTION_HOPELESS_FACTOR ) this._movingHopeless = true;
603
+
604
+ // One reading is a warm-up; two that are both over budget are the answer.
605
+ const affordable = ! this._movingHopeless && ( seen < 2 || best <= INTERACTION_HOLD_BUDGET_MS );
606
+ this._holdWhileMoving = this._holdWhileMoving === null ? affordable : this._holdWhileMoving && affordable;
607
+
608
+ return this._holdWhileMoving;
609
+
610
+ }
611
+
612
+ /**
613
+ * Whether the render loop should leave this frame untraced. True only while the denoised frame
614
+ * is the live view and a denoise is running — the one case where a traced frame reaches nobody:
615
+ * accumulation is off while the camera moves, the canvas is hidden, and the next denoise reads
616
+ * whatever the newest frame is. Measured inside a room at 512²: 19 -> 32 refreshes/sec.
617
+ */
618
+ skipsTrace() {
619
+
620
+ return this.holdsWhileMoving && !! this.denoiser?.state.isDenoising;
621
+
622
+ }
623
+
624
+ // The best a refresh has managed during this camera move. Zero until one has been timed, which
625
+ // reads as free and buys a first attempt rather than a verdict from the wrong regime.
626
+ get movingCostMs() {
627
+
628
+ return this._movingDenoiseMs.length ? Math.min( ...this._movingDenoiseMs ) : 0;
629
+
630
+ }
631
+
632
+ /**
633
+ * Hands the denoised picture to the pipeline. The Compositor prefers it over the raw render
634
+ * from the next frame on, and keeps drawing it until it is taken away again — which is what
635
+ * makes "hold the last clean frame" free rather than a rule.
636
+ */
637
+ _publishOutput() {
638
+
639
+ const ctx = this.pipeline?.context;
640
+ const tex = this.denoiser?.outputTexture;
641
+ if ( ctx && tex ) ctx.setTexture( 'oidn:output', tex );
642
+
643
+ }
644
+
645
+ // Gives the viewport back to the raw render.
646
+ _unpublishOutput() {
647
+
648
+ this.pipeline?.context?.removeTexture( 'oidn:output' );
649
+ this.denoiser?.invalidateOutput();
650
+
651
+ }
652
+
653
+ // A picture the denoiser has stopped replacing describes a view that is long gone, so the raw
654
+ // render takes the viewport back until a refresh delivers again.
655
+ _checkHeldFrameHealthy() {
656
+
657
+ if ( ! this.denoiser?.hasOutput || this._failedRefreshes < HELD_FRAME_MAX_FAILURES ) return;
658
+
659
+ this._unpublishOutput();
660
+
661
+ }
662
+
517
663
  /**
518
664
  * Denoises the accumulating mean on a cadence, so a preview shows a clean image while it
519
665
  * refines instead of only once it finishes. Driven from the render loop; call every frame.
@@ -524,20 +670,32 @@ export class DenoisingManager extends EventDispatcher {
524
670
  tickContinuousDenoise( sampleCount ) {
525
671
 
526
672
  const dn = this.denoiser;
527
- if ( ! this.continuousDenoise || ! dn ) return false;
528
- if ( dn.state.isDenoising || dn.state.isLoading ) return false;
673
+ if ( ! this.continuousDenoise || this._cadenceSuspended || ! dn ) return false;
674
+
675
+ this._checkHeldFrameHealthy();
676
+
677
+ // Moving with a denoise too slow to follow: the raw render owns the view, and a refresh
678
+ // would only paint a stale clean frame over a moving one.
679
+ const interacting = !! this._stages.pathTracer?.viewIsChanging;
680
+ if ( ! interacting ) {
529
681
 
530
- // While the camera moves, reset() hides the output every frame and a denoise would paint
531
- // a view that is already stale. Denoise once the motion stops.
532
- if ( this._stages.pathTracer?.interactionMode ) return false;
682
+ this._holdWhileMoving = null;
683
+ this._movingDenoiseMs.length = 0;
684
+
685
+ } else if ( ! this.holdsWhileMoving ) return false;
686
+
687
+ if ( dn.state.isDenoising || dn.state.isLoading ) return false;
533
688
 
534
- // Nothing new has landed since the last refresh, so it would repaint the same image.
535
- if ( sampleCount <= this._lastCadenceSamples ) return false;
689
+ // frameCount is frozen while the camera moves — those frames are 1-SPP feedback and do not
690
+ // count toward completion — so this gate would refuse every tick. The clock governs there.
691
+ if ( ! interacting && sampleCount <= this._lastCadenceSamples ) return false;
536
692
 
537
693
  const now = performance.now();
538
694
  // `continuousDenoiseInterval` is only the floor's lower bound: on a cheap denoise it is
539
695
  // what binds, and past ~1024² the denoise's own cost is.
540
- const minGap = Math.max( this.continuousDenoiseInterval, dn.lastDenoiseMs * CADENCE_DENOISE_PERIODS );
696
+ const periods = interacting ? CADENCE_INTERACTION_PERIODS
697
+ : this._lastCadenceSamples === 0 ? CADENCE_FIRST_REFRESH_PERIODS : CADENCE_DENOISE_PERIODS;
698
+ const minGap = Math.max( this.continuousDenoiseInterval, dn.lastDenoiseMs * periods );
541
699
  if ( now - this._lastCadenceAt < minGap ) return false;
542
700
 
543
701
  this._lastCadenceAt = now;
@@ -552,7 +710,16 @@ export class DenoisingManager extends EventDispatcher {
552
710
  const want = this._cadenceDowngraded ? this.previewQuality() : this._finalQuality;
553
711
  if ( dn.quality !== want ) dn.updateQuality( want );
554
712
 
555
- dn.start( { continuous: true } );
713
+ dn.start( { continuous: true } ).then( ok => {
714
+
715
+ this._failedRefreshes = ok ? 0 : this._failedRefreshes + 1;
716
+ if ( ! ok || ! interacting ) return;
717
+
718
+ this._movingDenoiseMs.push( dn.lastDenoiseMs );
719
+ if ( this._movingDenoiseMs.length > DENOISE_COST_SAMPLES ) this._movingDenoiseMs.shift();
720
+
721
+ } );
722
+
556
723
  return true;
557
724
 
558
725
  }
@@ -571,6 +738,27 @@ export class DenoisingManager extends EventDispatcher {
571
738
 
572
739
  }
573
740
 
741
+ /**
742
+ * Stops the live-view refresh for the duration of a final render, without changing what the
743
+ * host picked in the denoiser list. A final render shows the accumulation and denoises once at
744
+ * the end, so refreshing through it spends samples on frames nobody keeps — and a refresh
745
+ * landing while the renderer resizes itself on the way in raced its output pass, which the
746
+ * device reported as a write to a destroyed buffer.
747
+ *
748
+ * @param {boolean} suspended
749
+ */
750
+ setCadenceSuspended( suspended ) {
751
+
752
+ this._cadenceSuspended = !! suspended;
753
+ if ( ! this._cadenceSuspended ) return;
754
+
755
+ // The accumulation is what a final render shows; a held preview frame would otherwise sit
756
+ // over it until the render finished.
757
+ this.denoiser?.abort();
758
+ this._unpublishOutput();
759
+
760
+ }
761
+
574
762
  setContinuousDenoise( enabled ) {
575
763
 
576
764
  this.continuousDenoise = !! enabled;
@@ -684,22 +872,36 @@ export class DenoisingManager extends EventDispatcher {
684
872
 
685
873
  /**
686
874
  * Aborts any in-progress denoising/upscaling (called on reset).
687
- * @param {HTMLCanvasElement} canvas
875
+ *
876
+ * @param {HTMLCanvasElement} mainCanvas
877
+ * @param {Object} [options]
878
+ * @param {boolean} [options.keepDisplay] - The viewpoint did not move, so the frame already on
879
+ * screen still describes this view. It stays up until the next denoise replaces it, instead
880
+ * of dropping back to the raw render for one denoise. Ignored unless OIDN owns the live view:
881
+ * nothing else would ever replace the held frame.
688
882
  */
689
- abort( mainCanvas ) {
883
+ abort( mainCanvas, { keepDisplay = false } = {} ) {
690
884
 
691
885
  // Remove stale completion-chain listener before aborting
692
886
  this._cleanupCompletionListener();
693
887
 
888
+ // The 2D canvas is the upscaler's alone: a reset means its enlarged result no longer
889
+ // describes anything, so the render canvas comes back.
694
890
  if ( mainCanvas ) mainCanvas.style.opacity = '1';
695
-
891
+ if ( this.upscalerCanvas ) this.upscalerCanvas.style.display = 'none';
696
892
  if ( this.upscaler ) this.upscaler.abort();
697
893
 
698
- if ( this.denoiser ) {
894
+ const moving = !! this._stages.pathTracer?.viewIsChanging;
895
+ const hold = keepDisplay && this.continuousDenoise && ! this._cadenceSuspended && !! this.denoiser?.hasOutput
896
+ && ( ! moving || this.holdsWhileMoving );
897
+
898
+ if ( this.denoiser && ! hold ) {
699
899
 
900
+ // A held picture means the run in flight is the frame that replaces what is on screen.
901
+ // Cancelling it every time reset() runs — which is every frame of a camera drag — means
902
+ // none of them ever lands.
700
903
  if ( this.denoiser.enabled ) this.denoiser.abort();
701
- if ( this.denoiser.output ) this.denoiser.output.style.display = 'none';
702
- this.denoiser.invalidateLatch();
904
+ this._unpublishOutput();
703
905
 
704
906
  }
705
907
 
@@ -707,10 +909,26 @@ export class DenoisingManager extends EventDispatcher {
707
909
 
708
910
  }
709
911
 
912
+ /**
913
+ * Takes the denoised frame off screen and throws it away. For a change that makes the frame
914
+ * wrong rather than stale — the scene it describes is gone — where holding it would leave the
915
+ * previous model on screen over an empty or half-built one.
916
+ */
917
+ dropDisplay() {
918
+
919
+ this._unpublishOutput();
920
+
921
+ if ( this.upscalerCanvas ) this.upscalerCanvas.style.display = 'none';
922
+ if ( this.mainCanvas ) this.mainCanvas.style.opacity = '1';
923
+
924
+ }
925
+
710
926
  dispose() {
711
927
 
712
928
  // Remove pending completion-chain listener
713
929
  this._cleanupCompletionListener();
930
+ // Before the denoiser destroys the picture the Compositor would otherwise still sample.
931
+ this._unpublishOutput();
714
932
 
715
933
  if ( this.denoiser ) {
716
934
 
@@ -739,10 +957,10 @@ export class DenoisingManager extends EventDispatcher {
739
957
  this._upscalerProgressHandler = null;
740
958
  this._upscalerEndHandler = null;
741
959
 
742
- if ( this.denoiserCanvas?.parentNode ) {
960
+ if ( this.upscalerCanvas?.parentNode ) {
743
961
 
744
- this.denoiserCanvas.parentNode.removeChild( this.denoiserCanvas );
745
- this.denoiserCanvas = null;
962
+ this.upscalerCanvas.parentNode.removeChild( this.upscalerCanvas );
963
+ this.upscalerCanvas = null;
746
964
 
747
965
  }
748
966
 
@@ -1014,7 +1232,7 @@ export class DenoisingManager extends EventDispatcher {
1014
1232
  const keys = [
1015
1233
  'asvgf:output', 'asvgf:demodulated', 'asvgf:gradient',
1016
1234
  'variance:output', 'bilateralFiltering:output',
1017
- 'edgeFiltering:output', 'nrd:output',
1235
+ 'edgeFiltering:output', 'nrd:output', 'oidn:output',
1018
1236
  ];
1019
1237
  keys.forEach( k => ctx.removeTexture( k ) );
1020
1238
 
@@ -321,6 +321,10 @@ export class VideoRenderManager {
321
321
 
322
322
  app.pauseRendering = state.pauseRendering ?? false;
323
323
 
324
+ // configureForMode( 'production' ) suspended the live-view refresh and nothing here switches
325
+ // back, so the viewport would stay un-denoised until the host changed tab.
326
+ app.denoisingManager?.setCadenceSuspended( false );
327
+
324
328
  // Stop animation playback that seekTo may have started
325
329
  app.animationManager?.stop();
326
330