rayzee 7.30.2 → 7.31.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.
@@ -404,6 +404,7 @@ export class PathTracerApp extends EventDispatcher {
404
404
  if ( ! this.stages.pathTracer.isComplete ) {
405
405
 
406
406
  this.completion.updateTime();
407
+ this.denoisingManager?.tickContinuousDenoise( this.stages.pathTracer.frameCount );
407
408
 
408
409
  }
409
410
 
@@ -1797,8 +1798,11 @@ export class PathTracerApp extends EventDispatcher {
1797
1798
  if ( denoiser ) {
1798
1799
 
1799
1800
  denoiser.abort();
1800
- denoiser.enabled = config.enableOIDN;
1801
- denoiser.updateQuality( config.oidnQuality );
1801
+ // Through the manager both times: `denoiser.enabled` is the union of its two jobs and
1802
+ // `denoiser.quality` dips to a cheaper model between refreshes, so neither is the
1803
+ // record of what the host asked for.
1804
+ this.denoisingManager.applyOIDNEnabled( config.enableOIDN );
1805
+ this.denoisingManager.applyOIDNQuality( config.oidnQuality );
1802
1806
 
1803
1807
  }
1804
1808
 
@@ -1933,6 +1937,7 @@ export class PathTracerApp extends EventDispatcher {
1933
1937
  dynamicDispatch: stage._useDynamicDispatch,
1934
1938
  autoFocusMode: this.cameraManager?.autoFocusMode,
1935
1939
  autoExposure: this.stages.autoExposure?.enabled,
1940
+ continuousDenoise: this.denoisingManager?.continuousDenoise,
1936
1941
  };
1937
1942
 
1938
1943
  }
@@ -1966,6 +1971,8 @@ export class PathTracerApp extends EventDispatcher {
1966
1971
 
1967
1972
  this.cameraManager?.setAutoFocusMode( 'manual' );
1968
1973
  if ( this.stages.autoExposure ) this.stages.autoExposure.enabled = false;
1974
+ // Cadence denoising is wall-clock driven, so which frame it lands on is not reproducible.
1975
+ this.denoisingManager?.setContinuousDenoise( false );
1969
1976
 
1970
1977
  // The seed axis free-runs across accumulation resets so a camera drag gets fresh
1971
1978
  // sequences; offline rendering needs the opposite. Pinning it makes seedFrame track
@@ -1990,6 +1997,12 @@ export class PathTracerApp extends EventDispatcher {
1990
1997
 
1991
1998
  }
1992
1999
 
2000
+ if ( prev.continuousDenoise !== undefined ) {
2001
+
2002
+ this.denoisingManager?.setContinuousDenoise( prev.continuousDenoise );
2003
+
2004
+ }
2005
+
1993
2006
  stage._pinSeedToFrame = false;
1994
2007
 
1995
2008
  this._deterministicRestore = null;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * WGSL tone mapping, a transcription of ToneMapCPU.js.
3
+ *
4
+ * The two must stay identical: the same image is produced on the CPU by `toneMapToRGBA8`
5
+ * (renderToBuffer, upscaler) and on the GPU by this (the OIDN output pack). Order is part of
6
+ * the contract — exposure, saturation, clamp-negative, curve, sRGB OETF — and moving any step
7
+ * shifts every mid-tone.
8
+ */
9
+ import {
10
+ NoToneMapping, LinearToneMapping, ReinhardToneMapping,
11
+ CineonToneMapping, ACESFilmicToneMapping, AgXToneMapping, NeutralToneMapping
12
+ } from 'three';
13
+
14
+ const SUPPORTED = new Set( [
15
+ NoToneMapping, LinearToneMapping, ReinhardToneMapping,
16
+ CineonToneMapping, ACESFilmicToneMapping, AgXToneMapping, NeutralToneMapping
17
+ ] );
18
+
19
+ /**
20
+ * Three.js ToneMapping constant → shader mode. Anything unsupported falls back to ACES,
21
+ * matching `TONE_MAP_FNS.get( x ) || TONE_MAP_FNS.get( ACESFilmicToneMapping )`.
22
+ * @param {number} toneMapping
23
+ * @returns {number}
24
+ */
25
+ export function toneMapMode( toneMapping ) {
26
+
27
+ return SUPPORTED.has( toneMapping ) ? toneMapping : ACESFilmicToneMapping;
28
+
29
+ }
30
+
31
+ /**
32
+ * Prelude defining `toneMapPixel( linear, gain, saturation, mode ) -> vec3f` (sRGB, [0,1]).
33
+ * Concatenate ahead of a kernel that wants it.
34
+ */
35
+ export const TONE_MAP_WGSL = /* wgsl */`
36
+ fn tmSaturation( c: vec3f, saturation: f32 ) -> vec3f {
37
+ if ( saturation == 1.0 ) { return c; }
38
+ let luma = dot( c, vec3f( 0.2126, 0.7152, 0.0722 ) );
39
+ return vec3f( luma ) + ( c - vec3f( luma ) ) * saturation;
40
+ }
41
+
42
+ fn tmSrgb1( c: f32 ) -> f32 {
43
+ if ( c <= 0.0031308 ) { return 12.92 * c; }
44
+ return 1.055 * pow( c, 1.0 / 2.4 ) - 0.055;
45
+ }
46
+
47
+ fn tmSrgb( c: vec3f ) -> vec3f {
48
+ return vec3f( tmSrgb1( c.r ), tmSrgb1( c.g ), tmSrgb1( c.b ) );
49
+ }
50
+
51
+ fn tmReinhard( c: vec3f ) -> vec3f {
52
+ return clamp( c / ( c + vec3f( 1.0 ) ), vec3f( 0.0 ), vec3f( 1.0 ) );
53
+ }
54
+
55
+ fn tmCineon1( x: f32 ) -> f32 {
56
+ let c = max( x - 0.004, 0.0 );
57
+ return pow( ( c * ( 6.2 * c + 0.5 ) ) / ( c * ( 6.2 * c + 1.7 ) + 0.06 ), 2.2 );
58
+ }
59
+
60
+ fn tmCineon( c: vec3f ) -> vec3f {
61
+ return vec3f( tmCineon1( c.r ), tmCineon1( c.g ), tmCineon1( c.b ) );
62
+ }
63
+
64
+ fn tmAcesFit( c: f32 ) -> f32 {
65
+ return ( c * ( c + 0.0245786 ) - 0.000090537 ) / ( c * ( 0.983729 * c + 0.4329510 ) + 0.238081 );
66
+ }
67
+
68
+ fn tmAces( cIn: vec3f ) -> vec3f {
69
+ let c = cIn / 0.6;
70
+ var i = vec3f(
71
+ 0.59719 * c.r + 0.35458 * c.g + 0.04823 * c.b,
72
+ 0.07600 * c.r + 0.90834 * c.g + 0.01566 * c.b,
73
+ 0.02840 * c.r + 0.13383 * c.g + 0.83777 * c.b
74
+ );
75
+ i = vec3f( tmAcesFit( i.r ), tmAcesFit( i.g ), tmAcesFit( i.b ) );
76
+ let o = vec3f(
77
+ 1.60475 * i.r - 0.53108 * i.g - 0.07367 * i.b,
78
+ -0.10208 * i.r + 1.10813 * i.g - 0.00605 * i.b,
79
+ -0.00327 * i.r - 0.07276 * i.g + 1.07602 * i.b
80
+ );
81
+ return clamp( o, vec3f( 0.0 ), vec3f( 1.0 ) );
82
+ }
83
+
84
+ fn tmAgxApprox( x: f32 ) -> f32 {
85
+ let x2 = x * x;
86
+ let x4 = x2 * x2;
87
+ return 15.5 * x4 * x2 - 40.14 * x4 * x + 31.96 * x4 - 6.868 * x2 * x + 0.4298 * x2 + 0.1191 * x - 0.00232;
88
+ }
89
+
90
+ fn tmAgx( r: vec3f ) -> vec3f {
91
+ let c = vec3f(
92
+ 0.6274 * r.r + 0.3293 * r.g + 0.0433 * r.b,
93
+ 0.0691 * r.r + 0.9195 * r.g + 0.0113 * r.b,
94
+ 0.0164 * r.r + 0.0880 * r.g + 0.8956 * r.b
95
+ );
96
+ var a = vec3f(
97
+ 0.856627153315983 * c.r + 0.0951212405381588 * c.g + 0.0482516061458583 * c.b,
98
+ 0.137318972929847 * c.r + 0.761241990602591 * c.g + 0.101439036467562 * c.b,
99
+ 0.11189821299995 * c.r + 0.0767994186031903 * c.g + 0.811302368396859 * c.b
100
+ );
101
+
102
+ let agxMinEv = -12.47393;
103
+ let agxMaxEv = 4.026069;
104
+ let range = agxMaxEv - agxMinEv;
105
+ a = clamp( ( log2( max( a, vec3f( 1e-10 ) ) ) - vec3f( agxMinEv ) ) / range, vec3f( 0.0 ), vec3f( 1.0 ) );
106
+ a = vec3f( tmAgxApprox( a.r ), tmAgxApprox( a.g ), tmAgxApprox( a.b ) );
107
+
108
+ var o = vec3f(
109
+ 1.1271005818144368 * a.r - 0.11060664309660323 * a.g - 0.016493938717834573 * a.b,
110
+ -0.1413297634984383 * a.r + 1.157823702216272 * a.g - 0.016493938717834257 * a.b,
111
+ -0.14132976349843826 * a.r - 0.11060664309660294 * a.g + 1.2519364065950405 * a.b
112
+ );
113
+ o = pow( max( o, vec3f( 0.0 ) ), vec3f( 2.2 ) );
114
+
115
+ return clamp( vec3f(
116
+ 1.6605 * o.r - 0.5876 * o.g - 0.0728 * o.b,
117
+ -0.1246 * o.r + 1.1329 * o.g - 0.0083 * o.b,
118
+ -0.0182 * o.r - 0.1006 * o.g + 1.1187 * o.b
119
+ ), vec3f( 0.0 ), vec3f( 1.0 ) );
120
+ }
121
+
122
+ fn tmNeutral( cIn: vec3f ) -> vec3f {
123
+ let startCompression = 0.8 - 0.04;
124
+ let desaturation = 0.15;
125
+
126
+ let x = min( cIn.r, min( cIn.g, cIn.b ) );
127
+ var offset = 0.04;
128
+ if ( x < 0.08 ) { offset = x - 6.25 * x * x; }
129
+
130
+ var c = cIn - vec3f( offset );
131
+ let peak = max( c.r, max( c.g, c.b ) );
132
+ if ( peak < startCompression ) { return c; }
133
+
134
+ let d = 1.0 - startCompression;
135
+ let newPeak = 1.0 - d * d / ( peak + d - startCompression );
136
+ c = c * ( newPeak / peak );
137
+ let gFactor = 1.0 - 1.0 / ( desaturation * ( peak - newPeak ) + 1.0 );
138
+
139
+ return c + ( vec3f( newPeak ) - c ) * gFactor;
140
+ }
141
+
142
+ fn tmCurve( cIn: vec3f, mode: u32 ) -> vec3f {
143
+ // Three.js clamps the fragment output before tone mapping ("force unsigned floats"), and
144
+ // AgX/Neutral mix negatives across channels rather than clipping them.
145
+ let c = max( cIn, vec3f( 0.0 ) );
146
+ switch mode {
147
+ case 0u, 1u: { return clamp( c, vec3f( 0.0 ), vec3f( 1.0 ) ); }
148
+ case 2u: { return tmReinhard( c ); }
149
+ case 3u: { return tmCineon( c ); }
150
+ case 6u: { return tmAgx( c ); }
151
+ case 7u: { return tmNeutral( c ); }
152
+ default: { return tmAces( c ); }
153
+ }
154
+ }
155
+
156
+ fn toneMapPixel( linear: vec3f, gain: f32, saturation: f32, mode: u32 ) -> vec3f {
157
+ var c = linear * gain;
158
+ c = tmSaturation( c, saturation );
159
+ c = tmCurve( c, mode );
160
+ return tmSrgb( c );
161
+ }
162
+ `;
@@ -4,6 +4,29 @@ import { AIUpscaler } from '../Passes/AIUpscaler.js';
4
4
  import { EngineEvents } from '../EngineEvents.js';
5
5
  import { ENGINE_DEFAULTS as DEFAULT_STATE, ASVGF_QUALITY_PRESETS, NRD_DEFAULTS, NRD_QUALITY_PRESETS, NRD_PRESET_KEYS } from '../EngineDefaults.js';
6
6
 
7
+ // A refresh slower than this is a slideshow, not a live view, so the cadence swaps to a cheaper
8
+ // model and puts the chosen one back for the finished image. Resolution-aware by construction:
9
+ // at 512² every tier is under budget, at 1024² only `high` trips it.
10
+ //
11
+ // This replaced a second, time-based trigger ("downgrade once the render passes 2 s"), which
12
+ // measured identically at 1024² for balance and high because the cost rule had already tripped —
13
+ // but which also downgraded tiers that were affordable, on machines fast enough to run them.
14
+ const CADENCE_COST_BUDGET_MS = 120;
15
+
16
+ // Start-to-start gap as a multiple of what the last denoise actually cost. This is the whole
17
+ // throttle: it spends half the wall clock on denoising at most, at any resolution, on any GPU.
18
+ // A fixed millisecond floor cannot do that — the same tier measures 14 ms at 512², 48 ms at
19
+ // 1024² and 800 ms at 2048².
20
+ //
21
+ // It replaced a sample-growth rule (refresh only once the sample count had grown 1.4x), which
22
+ // was written when a denoise cost 100-330 ms. Measured after the denoise got cheap, that rule
23
+ // only ever cost refreshes: 512² 2/s -> 32/s and 1024² 2/s -> 8.6/s, both at unchanged sample
24
+ // rate, while 1536²/2048² did not move at all because this floor already bound there.
25
+ //
26
+ // 3x measured 97 % of the sample rate at 1536² against 89 % for 2x, but at a quarter of the
27
+ // refreshes — 2x is the better trade while a person is watching the image resolve.
28
+ const CADENCE_DENOISE_PERIODS = 2;
29
+
7
30
  /**
8
31
  * Orchestrates all denoising, post-processing, and AI upscaling:
9
32
  * - Real-time denoiser strategy switching (ASVGF / NRD / EdgeAware / None)
@@ -50,6 +73,19 @@ export class DenoisingManager extends EventDispatcher {
50
73
  this.denoiser = null;
51
74
  this.upscaler = null;
52
75
 
76
+ // The tier the finished image uses. The loaded tier is not always this one: while the
77
+ // image is still accumulating we run a cheaper model (see previewQuality).
78
+ this._finalQuality = DEFAULT_STATE.oidnQuality;
79
+ // Two independent decisions. `finalDenoise` is the OIDN pass on the finished image;
80
+ // `continuousDenoise` is OIDN as the live-view denoiser. `denoiser.enabled` means only
81
+ // "OIDN is in use at all", which is what the aux G-buffer wiring needs.
82
+ this.finalDenoise = DEFAULT_STATE.enableOIDN;
83
+ this.continuousDenoise = DEFAULT_STATE.continuousDenoise;
84
+ this.continuousDenoiseInterval = DEFAULT_STATE.continuousDenoiseInterval;
85
+ // -Infinity, not 0: 0 reads as "denoised at time zero", which blocks the first cadence
86
+ // denoise while performance.now() is still below the interval.
87
+ this._lastCadenceAt = - Infinity;
88
+ this._lastCadenceSamples = 0;
53
89
  this._onReset = null;
54
90
  this._onPostProcessRefresh = null;
55
91
 
@@ -57,8 +93,9 @@ export class DenoisingManager extends EventDispatcher {
57
93
  this._lastRenderWidth = 0;
58
94
  this._lastRenderHeight = 0;
59
95
 
60
- // Track the current completion-chain listener so it can be removed on re-trigger
96
+ // Track the current completion-chain listeners so they can be removed on re-trigger
61
97
  this._pendingStartUpscaler = null;
98
+ this._pendingCloseDenoise = null;
62
99
 
63
100
  // Bound event forwarding handlers (stored for removal on re-setup / dispose)
64
101
  this._denoiserStartHandler = null;
@@ -97,6 +134,11 @@ export class DenoisingManager extends EventDispatcher {
97
134
 
98
135
  this._lastRenderWidth = width;
99
136
  this._lastRenderHeight = height;
137
+ // Sticky across resets: the device does not get faster between camera moves, and
138
+ // re-deciding per accumulation cost one slow denoise every time the camera stopped.
139
+ // Cleared only when the tier or the resolution changes — a denoise costs 14 ms at 512²
140
+ // and 800 ms at 2048², so the affordability verdict does not survive a resize.
141
+ this._cadenceDowngraded = false;
100
142
  this.denoiser?.setSize( width, height );
101
143
  this.upscaler?.setBaseSize( width, height );
102
144
 
@@ -162,16 +204,15 @@ export class DenoisingManager extends EventDispatcher {
162
204
  getToneMapping: () => this._getToneMapping(),
163
205
  getSaturation: () => this._getSaturation(),
164
206
  getTransparentBackground: () => this._getTransparentBg(),
165
- getMRTRenderTarget: () => pt?.storageTextures?.readTarget ?? null,
166
207
  } );
167
208
 
168
- this.denoiser.enabled = DEFAULT_STATE.enableOIDN;
209
+ this._syncOIDNInUse();
169
210
 
170
211
  // Forward lifecycle events (store refs for removal on re-setup / dispose)
171
- this._denoiserStartHandler = () =>
172
- this.dispatchEvent( { type: EngineEvents.DENOISING_START } );
173
- this._denoiserEndHandler = () =>
174
- this.dispatchEvent( { type: EngineEvents.DENOISING_END } );
212
+ this._denoiserStartHandler = e =>
213
+ this.dispatchEvent( { type: EngineEvents.DENOISING_START, continuous: !! e.continuous } );
214
+ this._denoiserEndHandler = e =>
215
+ this.dispatchEvent( { type: EngineEvents.DENOISING_END, continuous: !! e.continuous } );
175
216
  this.denoiser.addEventListener( 'start', this._denoiserStartHandler );
176
217
  this.denoiser.addEventListener( 'end', this._denoiserEndHandler );
177
218
 
@@ -244,6 +285,10 @@ export class DenoisingManager extends EventDispatcher {
244
285
  */
245
286
  get denoiserStrategy() {
246
287
 
288
+ // OIDN refreshing the accumulating image is a live-view denoiser like the others, so it
289
+ // belongs in the same one-of-N choice. Two of these running at once would mean paying for
290
+ // a per-frame denoise whose result the OIDN overlay then covers.
291
+ if ( this.continuousDenoise ) return 'oidn';
247
292
  if ( this._stages.asvgf?.enabled ) return 'asvgf';
248
293
  if ( this._stages.nrd?.enabled ) return 'nrd';
249
294
  if ( this._stages.edgeFilter?.enabled ) return 'edgeaware';
@@ -269,6 +314,10 @@ export class DenoisingManager extends EventDispatcher {
269
314
 
270
315
  this._clearDenoiserTextures();
271
316
 
317
+ // Which denoiser owns the live view. Deliberately says nothing about the finished image:
318
+ // wanting OIDN on the viewport is not the same as opting into a final pass.
319
+ this.setContinuousDenoise( strategy === 'oidn' );
320
+
272
321
  switch ( strategy ) {
273
322
 
274
323
  case 'asvgf':
@@ -422,7 +471,11 @@ export class DenoisingManager extends EventDispatcher {
422
471
 
423
472
  // Clean-aux normal: temporally accumulate the aux normal only for OIDN clean-aux models
424
473
  // (balanced/high). 'fast' and the real-time denoisers keep the point-sampled bump normal.
425
- s.pathTracer?.setCleanAuxNormal?.( !! this.denoiser?.enabled && this.denoiser?.quality !== 'fast' );
474
+ // Keyed on the chosen tier, not the loaded one — the loaded tier dips to a cheaper model
475
+ // during refreshes, and following that would restart the aux accumulation mid-render.
476
+ s.pathTracer?.setCleanAuxNormal?.(
477
+ !! this.denoiser?.enabled && !! this.denoiser?.expectsCleanAux( this._finalQuality )
478
+ );
426
479
 
427
480
  // Reclaim VRAM: free the big 2048² StorageTextures of any denoiser/G-buffer stage that ended up
428
481
  // disabled (lazily re-created on the next dispatch after re-enable). Every strategy/denoiser
@@ -449,13 +502,99 @@ export class DenoisingManager extends EventDispatcher {
449
502
  */
450
503
  _cleanupCompletionListener() {
451
504
 
452
- if ( this._pendingStartUpscaler && this.denoiser ) {
505
+ if ( this.denoiser ) {
453
506
 
454
- this.denoiser.removeEventListener( 'end', this._pendingStartUpscaler );
507
+ if ( this._pendingStartUpscaler ) this.denoiser.removeEventListener( 'end', this._pendingStartUpscaler );
508
+ if ( this._pendingCloseDenoise ) this.denoiser.removeEventListener( 'end', this._pendingCloseDenoise );
455
509
 
456
510
  }
457
511
 
458
512
  this._pendingStartUpscaler = null;
513
+ this._pendingCloseDenoise = null;
514
+
515
+ }
516
+
517
+ /**
518
+ * Denoises the accumulating mean on a cadence, so a preview shows a clean image while it
519
+ * refines instead of only once it finishes. Driven from the render loop; call every frame.
520
+ *
521
+ * @param {number} sampleCount - accumulated samples (PathTracer.frameCount)
522
+ * @returns {boolean} whether a denoise was started this call
523
+ */
524
+ tickContinuousDenoise( sampleCount ) {
525
+
526
+ const dn = this.denoiser;
527
+ if ( ! this.continuousDenoise || ! dn ) return false;
528
+ if ( dn.state.isDenoising || dn.state.isLoading ) return false;
529
+
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;
533
+
534
+ // Nothing new has landed since the last refresh, so it would repaint the same image.
535
+ if ( sampleCount <= this._lastCadenceSamples ) return false;
536
+
537
+ const now = performance.now();
538
+ // `continuousDenoiseInterval` is only the floor's lower bound: on a cheap denoise it is
539
+ // what binds, and past ~1024² the denoise's own cost is.
540
+ const minGap = Math.max( this.continuousDenoiseInterval, dn.lastDenoiseMs * CADENCE_DENOISE_PERIODS );
541
+ if ( now - this._lastCadenceAt < minGap ) return false;
542
+
543
+ this._lastCadenceAt = now;
544
+ this._lastCadenceSamples = sampleCount;
545
+
546
+ // A tier too slow to be a live view refreshes with a cheaper model and is put back for the
547
+ // finished image. One-way: letting it flip back would reload weights on every tick.
548
+ if ( dn.lastDenoiseMs > CADENCE_COST_BUDGET_MS ) this._cadenceDowngraded = true;
549
+
550
+ // updateQuality flags the load synchronously, and start() below defers itself until the
551
+ // weights land.
552
+ const want = this._cadenceDowngraded ? this.previewQuality() : this._finalQuality;
553
+ if ( dn.quality !== want ) dn.updateQuality( want );
554
+
555
+ dn.start( { continuous: true } );
556
+ return true;
557
+
558
+ }
559
+
560
+ /**
561
+ * The tier used for refreshes while the image is still accumulating: the cheapest model that
562
+ * reads the same kind of aux buffer as the final one.
563
+ *
564
+ * Matching the aux kind is not optional. `setCleanAuxNormal()` throws away the accumulated
565
+ * albedo/normal, so if the refreshes and the final denoise disagreed about it, the final
566
+ * denoise would run against an aux buffer one sample deep.
567
+ */
568
+ previewQuality() {
569
+
570
+ return this.denoiser?.expectsCleanAux( this._finalQuality ) ? 'fast-clean' : 'fast';
571
+
572
+ }
573
+
574
+ setContinuousDenoise( enabled ) {
575
+
576
+ this.continuousDenoise = !! enabled;
577
+ this._syncOIDNInUse();
578
+ this._lastCadenceAt = - Infinity;
579
+ this._resetCadence();
580
+
581
+ }
582
+
583
+ // Puts the finished image back on the tier the user asked for, after the refreshes ran a
584
+ // cheaper one. A no-op when they match, which is the common case in preview.
585
+ _useFinalQuality() {
586
+
587
+ const dn = this.denoiser;
588
+ if ( dn && dn.quality !== this._finalQuality ) dn.updateQuality( this._finalQuality );
589
+
590
+ }
591
+
592
+ // Only the sample gate resets, so a fresh accumulation denoises promptly. The clock is
593
+ // deliberately kept: reset() runs every frame of a camera drag, and rearming it there
594
+ // would spend a denoise per frame on views that are already stale.
595
+ _resetCadence() {
596
+
597
+ this._lastCadenceSamples = 0;
459
598
 
460
599
  }
461
600
 
@@ -464,9 +603,16 @@ export class DenoisingManager extends EventDispatcher {
464
603
  // Remove any stale completion-chain listener from a previous render cycle
465
604
  this._cleanupCompletionListener();
466
605
 
467
- // Chain: denoise first (if enabled), then upscale (if enabled)
606
+ // What closes the render, if anything. A full pass at the chosen tier when that switch is
607
+ // on; otherwise, if OIDN owns the live view, one last cheap refresh — the cadence's last
608
+ // tick lands a few samples short, and the picture should match the render that finished.
609
+ const closing = this.finalDenoise ? 'final' : this.continuousDenoise ? 'refresh' : null;
610
+
611
+ // Registered only once the closing denoise has been launched, so the next 'end' is
612
+ // unambiguously that one — a cadence run's end can look identical otherwise.
468
613
  const startUpscaler = () => {
469
614
 
615
+ this.denoiser?.removeEventListener( 'end', startUpscaler );
470
616
  this._pendingStartUpscaler = null;
471
617
 
472
618
  if ( ! isStillComplete() ) return;
@@ -479,15 +625,58 @@ export class DenoisingManager extends EventDispatcher {
479
625
 
480
626
  };
481
627
 
482
- if ( this.denoiser?.enabled ) {
628
+ if ( ! closing ) {
629
+
630
+ startUpscaler();
631
+ return;
632
+
633
+ }
634
+
635
+ const launchClosing = () => {
636
+
637
+ if ( ! isStillComplete() ) return;
483
638
 
484
639
  this._pendingStartUpscaler = startUpscaler;
485
- this.denoiser.addEventListener( 'end', startUpscaler, { once: true } );
486
- this.denoiser.start();
640
+ this.denoiser.addEventListener( 'end', startUpscaler );
641
+
642
+ if ( closing === 'final' ) {
643
+
644
+ this._useFinalQuality();
645
+ this.denoiser.start();
646
+
647
+ } else {
648
+
649
+ // Whatever model the refreshes have been using — no reload for a picture the user
650
+ // never asked to be denoised at full quality.
651
+ this.denoiser.start( { continuous: true } );
652
+
653
+ }
654
+
655
+ };
656
+
657
+ this._resetCadence();
658
+
659
+ if ( this.denoiser.state.isDenoising ) {
660
+
661
+ // A cadence run is mid-flight against a lower sample count. Let it finish first —
662
+ // start() would be refused right now.
663
+ // three.js EventDispatcher.addEventListener takes (type, listener) and silently
664
+ // ignores an options object, so a listener that restarts the denoiser MUST remove
665
+ // itself: `{ once: true }` here re-fired on its own end, forever.
666
+ const onCadenceEnd = () => {
667
+
668
+ this.denoiser?.removeEventListener( 'end', onCadenceEnd );
669
+ if ( this._pendingCloseDenoise === onCadenceEnd ) this._pendingCloseDenoise = null;
670
+ launchClosing();
671
+
672
+ };
673
+
674
+ this._pendingCloseDenoise = onCadenceEnd;
675
+ this.denoiser.addEventListener( 'end', onCadenceEnd );
487
676
 
488
677
  } else {
489
678
 
490
- startUpscaler();
679
+ launchClosing();
491
680
 
492
681
  }
493
682
 
@@ -510,9 +699,12 @@ export class DenoisingManager extends EventDispatcher {
510
699
 
511
700
  if ( this.denoiser.enabled ) this.denoiser.abort();
512
701
  if ( this.denoiser.output ) this.denoiser.output.style.display = 'none';
702
+ this.denoiser.invalidateLatch();
513
703
 
514
704
  }
515
705
 
706
+ this._resetCadence();
707
+
516
708
  }
517
709
 
518
710
  dispose() {
@@ -647,23 +839,60 @@ export class DenoisingManager extends EventDispatcher {
647
839
  // ── OIDN ─────────────────────────────────────────────────────
648
840
 
649
841
  /** Enables or disables Intel OIDN denoiser. */
842
+ /**
843
+ * Records the final-pass decision and syncs `enabled`. No host refresh — for callers like
844
+ * configureForMode that drive the whole change themselves.
845
+ */
846
+ applyOIDNEnabled( enabled ) {
847
+
848
+ this.finalDenoise = !! enabled;
849
+ this._syncOIDNInUse();
850
+
851
+ }
852
+
853
+ /** The tier the finished image uses — not `denoiser.quality`, which dips during refreshes. */
854
+ get oidnQuality() {
855
+
856
+ return this._finalQuality;
857
+
858
+ }
859
+
860
+ /** Denoise the finished image with OIDN. Independent of which denoiser owns the live view. */
650
861
  setOIDNEnabled( enabled ) {
651
862
 
652
- if ( this.denoiser ) this.denoiser.enabled = enabled;
863
+ this.applyOIDNEnabled( enabled );
653
864
  // OIDN reads the PathTracer aux MRT; re-sync so the wavefront produces it while OIDN is on.
654
865
  this._syncGBufferStages();
655
866
  this._onPostProcessRefresh?.();
656
867
 
657
868
  }
658
869
 
659
- /** Sets OIDN denoiser quality. */
660
- setOIDNQuality( quality ) {
870
+ // `enabled` on the denoiser is the union of its two jobs — everything downstream (the aux MRT,
871
+ // VRAM retention) only needs to know whether OIDN runs at all.
872
+ _syncOIDNInUse() {
873
+
874
+ if ( this.denoiser ) this.denoiser.enabled = this.finalDenoise || this.continuousDenoise;
875
+
876
+ }
661
877
 
878
+ /**
879
+ * Records the tier the finished image should use, and loads it. No host refresh — for callers
880
+ * like configureForMode that drive the whole tier change themselves.
881
+ */
882
+ applyOIDNQuality( quality ) {
883
+
884
+ this._finalQuality = quality;
885
+ this._cadenceDowngraded = false;
662
886
  this.denoiser?.updateQuality( quality );
663
- // Clean-aux normal follows the model, not the tier name — see OIDNDenoiser.QUALITY_MODELS.
664
- this._stages.pathTracer?.setCleanAuxNormal?.(
665
- !! this.denoiser?.enabled && !! this.denoiser?.expectsCleanAux( quality )
666
- );
887
+
888
+ }
889
+
890
+ setOIDNQuality( quality ) {
891
+
892
+ this.applyOIDNQuality( quality );
893
+ // _syncGBufferStages owns the clean-aux decision (it follows the model, not the tier name —
894
+ // see OIDNDenoiser.QUALITY_MODELS). Inlining a second copy here let the two disagree.
895
+ this._syncGBufferStages();
667
896
  this._onPostProcessRefresh?.();
668
897
 
669
898
  }
@@ -179,6 +179,10 @@ export class OverlayManager {
179
179
 
180
180
  source.addEventListener( 'tileProgress', ( e ) => {
181
181
 
182
+ // A cadence denoise of the still-accumulating preview runs many times a second and
183
+ // is a single tile — showing the border for those just strobes it.
184
+ if ( e.continuous ) return;
185
+
182
186
  if ( e.tile ) {
183
187
 
184
188
  tileHelper.setRenderSize( e.imageWidth, e.imageHeight );
@@ -123,7 +123,7 @@ export class VideoRenderManager {
123
123
  if ( this._cancelled ) break;
124
124
 
125
125
  // 4. Denoise if enabled
126
- if ( enableOIDN && app.denoisingManager?.denoiser?.enabled ) {
126
+ if ( enableOIDN && app.denoisingManager?.finalDenoise ) {
127
127
 
128
128
  await this._waitForDenoise( app );
129
129
 
@@ -280,8 +280,8 @@ export class VideoRenderManager {
280
280
  transmissiveBounces: app.settings.get( 'transmissiveBounces' ),
281
281
  renderMode: app.stages.pathTracer?.renderMode?.value,
282
282
  controlsEnabled: app.cameraManager.controls?.enabled,
283
- oidnEnabled: app.denoisingManager?.denoiser?.enabled,
284
- oidnQuality: app.denoisingManager?.denoiser?.quality,
283
+ oidnEnabled: app.denoisingManager?.finalDenoise,
284
+ oidnQuality: app.denoisingManager?.oidnQuality,
285
285
  wasPlaying: app.animationManager?.isPlaying,
286
286
  pauseRendering: app.pauseRendering,
287
287
  };
@@ -314,8 +314,8 @@ export class VideoRenderManager {
314
314
 
315
315
  if ( app.denoisingManager?.denoiser ) {
316
316
 
317
- app.denoisingManager.denoiser.enabled = state.oidnEnabled ?? false;
318
- if ( state.oidnQuality ) app.denoisingManager.denoiser.updateQuality( state.oidnQuality );
317
+ app.denoisingManager.applyOIDNEnabled( state.oidnEnabled ?? false );
318
+ if ( state.oidnQuality ) app.denoisingManager.applyOIDNQuality( state.oidnQuality );
319
319
 
320
320
  }
321
321