rayzee 8.7.0 → 8.8.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.
@@ -447,6 +447,7 @@ export class PathTracerApp extends EventDispatcher {
447
447
  }
448
448
 
449
449
  this.pipeline.render();
450
+ this.denoisingManager?.afterTrace();
450
451
 
451
452
  if ( ! this.stages.pathTracer.isComplete ) {
452
453
 
@@ -567,8 +568,15 @@ export class PathTracerApp extends EventDispatcher {
567
568
  /**
568
569
  * Resets the accumulation buffer.
569
570
  * @param {boolean} soft - When true, preserves ASVGF temporal history
571
+ * @param {Object} [options]
572
+ * @param {boolean} [options.motion] - only object placements or geometry moved; keeps the OIDN
573
+ * motion history, which rejects what moved per pixel
570
574
  */
571
- reset( soft = false ) {
575
+ reset( soft = false, { motion = false } = {} ) {
576
+
577
+ // Objects moving is the view changing, as far as the live denoiser's refresh cadence goes.
578
+ if ( motion ) this.stages.pathTracer?.noteViewChanged();
579
+ this.denoisingManager?.beforeReset( { keepHistory: soft || motion } );
572
580
 
573
581
  if ( this.pipeline ) {
574
582
 
@@ -582,6 +590,8 @@ export class PathTracerApp extends EventDispatcher {
582
590
 
583
591
  }
584
592
 
593
+ this.denoisingManager?.afterReset();
594
+
585
595
  // Whatever is on screen stays until its replacement is ready, including while the camera
586
596
  // moves: the denoising manager decides, since only it knows something is coming.
587
597
  this._abortPostProcess( { keepDisplay: true } );
@@ -1617,7 +1627,7 @@ export class PathTracerApp extends EventDispatcher {
1617
1627
 
1618
1628
  this.stages.pathTracer.updateTriangleData( this._sdf.triangles );
1619
1629
  this.stages.pathTracer.updateBVHData( this._sdf.bvh );
1620
- this.reset();
1630
+ this.reset( false, { motion: true } );
1621
1631
 
1622
1632
  return result;
1623
1633
 
@@ -1639,7 +1649,7 @@ export class PathTracerApp extends EventDispatcher {
1639
1649
 
1640
1650
  const { triRanges, bvhRanges } = this._sdf.computeBLASDirtyRanges( affectedMeshIndices );
1641
1651
  this.stages.pathTracer.updateBufferRanges( triRanges, bvhRanges );
1642
- this.reset();
1652
+ this.reset( false, { motion: true } );
1643
1653
 
1644
1654
  // Kick off background rebuild for optimal SAH quality
1645
1655
  this._sdf.scheduleBackgroundRebuild( affectedMeshIndices, ( meshIndex ) => {
@@ -1648,7 +1658,7 @@ export class PathTracerApp extends EventDispatcher {
1648
1658
  // re-upload here is gigabytes on a large scene, for one mesh's worth of change.
1649
1659
  const dirty = this._sdf.computeBLASDirtyRanges( [ meshIndex ] );
1650
1660
  this.stages.pathTracer.updateBufferRanges( dirty.triRanges, dirty.bvhRanges );
1651
- this.reset();
1661
+ this.reset( false, { motion: true } );
1652
1662
 
1653
1663
  } );
1654
1664
 
@@ -1664,6 +1674,7 @@ export class PathTracerApp extends EventDispatcher {
1664
1674
 
1665
1675
  if ( meshIndices.length > 0 ) {
1666
1676
 
1677
+ this._notePlacementsMoving( meshIndices );
1667
1678
  this._sdf.updateMeshTransforms( meshIndices );
1668
1679
  this.stages.pathTracer?.updateBufferRanges( [], [ this._sdf.computeTLASDirtyRange() ] );
1669
1680
  if ( this._sdf.movesEmitters( meshIndices ) ) {
@@ -1689,7 +1700,24 @@ export class PathTracerApp extends EventDispatcher {
1689
1700
 
1690
1701
  if ( cameras.length > 0 && this._followAnimatedCamera( cameras ) ) changed = true;
1691
1702
 
1692
- if ( changed ) this.reset();
1703
+ if ( changed ) this.reset( false, { motion: true } );
1704
+
1705
+ }
1706
+
1707
+ /** @private */
1708
+ _notePlacementsMoving( meshIndices ) {
1709
+
1710
+ const dm = this.denoisingManager;
1711
+ const table = this._sdf?.instanceTable;
1712
+ if ( ! dm?.historyActive || ! table ) return;
1713
+
1714
+ for ( const meshIndex of meshIndices ) {
1715
+
1716
+ const run = table.placementRunOf( meshIndex );
1717
+ if ( ! run ) continue;
1718
+ for ( let p = run.start; p < run.start + run.count; p ++ ) dm.notePlacementMoving( table.tlasLeafIndex[ p ], table.world, p * 16 );
1719
+
1720
+ }
1693
1721
 
1694
1722
  }
1695
1723
 
@@ -1758,10 +1786,11 @@ export class PathTracerApp extends EventDispatcher {
1758
1786
  */
1759
1787
  updateMeshTransforms( meshIndices ) {
1760
1788
 
1789
+ this._notePlacementsMoving( meshIndices );
1761
1790
  const result = this._sdf.updateMeshTransforms( meshIndices );
1762
1791
 
1763
1792
  this.stages.pathTracer.updateBufferRanges( [], [ this._sdf.computeTLASDirtyRange() ] );
1764
- this.reset();
1793
+ this.reset( false, { motion: true } );
1765
1794
 
1766
1795
  return result;
1767
1796
 
@@ -3444,6 +3473,12 @@ export class PathTracerApp extends EventDispatcher {
3444
3473
  this.cameraManager.setInteractionManager( this.interactionManager );
3445
3474
  this.lightManager = new LightManager( this.scene, this._sceneHelpers, this.stages.pathTracer, {
3446
3475
  onReset: () => this.reset(),
3476
+ // The gizmo stays attached to a removed light and warns every frame it draws.
3477
+ onLightRemoved: light => {
3478
+
3479
+ if ( this.interactionManager?.selectedObject === light ) this.interactionManager.deselect();
3480
+
3481
+ },
3447
3482
  } );
3448
3483
  this.goboManager = new GoboManager( this.stages.pathTracer, {
3449
3484
  onReset: () => this.reset(),
@@ -1,7 +1,7 @@
1
- import { Fn, vec2, vec3, vec4, float, int, uint, uvec2, uniform, min, storage, If,
1
+ import { Fn, vec2, vec3, vec4, float, int, uint, uvec2, uvec4, uniform, min, storage, If,
2
2
  textureStore, workgroupId, localId } from 'three/tsl';
3
3
  import { RenderTarget, StorageTexture } from 'three/webgpu';
4
- import { HalfFloatType, RGBAFormat, NearestFilter, Matrix4, Box2, Vector2 } from 'three';
4
+ import { HalfFloatType, RGBAFormat, RedIntegerFormat, UnsignedIntType, NearestFilter, Matrix4, Box2, Vector2 } from 'three';
5
5
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
6
6
  import { MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
7
7
  import { Ray, HitInfo, RayTracingMaterial, UVCache } from '../TSL/Struct.js';
@@ -32,7 +32,8 @@ import { computeUVCache, processNormal, processBump, processMetalnessRoughness,
32
32
  * Its .w is the sampled material roughness (NRD's normal+roughness guide).
33
33
  *
34
34
  * Publishes: pathtracer:normalDepth, pathtracer:prevNormalDepth,
35
- * pathtracer:shadingNormal (rgb = shading normal·0.5+0.5, a = roughness)
35
+ * pathtracer:shadingNormal (rgb = shading normal·0.5+0.5, a = roughness),
36
+ * pathtracer:instanceLeaf (r32uint: the hit's transformed TLAS leaf + 1, 0 = none)
36
37
  */
37
38
  export class NormalDepth extends RenderStage {
38
39
 
@@ -85,6 +86,10 @@ export class NormalDepth extends RenderStage {
85
86
  stencilBuffer: false
86
87
  } );
87
88
 
89
+ // Created only while a consumer asks for it (setInstanceLeafOutput).
90
+ this._leafStorageTex = null;
91
+ this._leafRT = null;
92
+
88
93
  this._srcRegion = new Box2( new Vector2( 0, 0 ), new Vector2( 0, 0 ) );
89
94
 
90
95
  // Ping-pong RTs share format with the StorageTexture so copyTextureToTexture works.
@@ -215,6 +220,7 @@ export class NormalDepth extends RenderStage {
215
220
  const resH = this.resolutionHeight;
216
221
  const outputTex = this._outputStorageTex;
217
222
  const shadingTex = this._shadingStorageTex;
223
+ const leafTex = this._leafStorageTex;
218
224
 
219
225
  const WG_SIZE = 8;
220
226
 
@@ -256,6 +262,16 @@ export class NormalDepth extends RenderStage {
256
262
  result
257
263
  ).toWriteOnly();
258
264
 
265
+ if ( leafTex ) {
266
+
267
+ textureStore(
268
+ leafTex,
269
+ uvec2( uint( gx ), uint( gy ) ),
270
+ uvec4( uint( hit.instanceLeaf.add( 1 ) ), 0, 0, 0 )
271
+ ).toWriteOnly();
272
+
273
+ }
274
+
259
275
  // Shading normal: perturb the geometric normal by the normal/bump map
260
276
  // from the SAME hit (deterministic UV → jitter-free). Miss → geo default.
261
277
  const shadingNormal = hit.normal.toVar();
@@ -339,6 +355,7 @@ export class NormalDepth extends RenderStage {
339
355
  context.setTexture( 'pathtracer:normalDepth', currentRT.texture );
340
356
  context.setTexture( 'pathtracer:prevNormalDepth', currentRT.texture );
341
357
  context.setTexture( 'pathtracer:shadingNormal', this._shadingRT.texture );
358
+ this._publishLeaf( context );
342
359
  return;
343
360
 
344
361
  }
@@ -367,6 +384,7 @@ export class NormalDepth extends RenderStage {
367
384
  this._srcRegion.max.set( writeRT.width, writeRT.height );
368
385
  this.renderer.copyTextureToTexture( this._outputStorageTex, writeRT.texture, this._srcRegion );
369
386
  this.renderer.copyTextureToTexture( this._shadingStorageTex, this._shadingRT.texture, this._srcRegion );
387
+ if ( this._leafRT ) this.renderer.copyTextureToTexture( this._leafStorageTex, this._leafRT.texture, this._srcRegion );
370
388
 
371
389
  // First dispatch: seed prev from current so ASVGF doesn't see false
372
390
  // disocclusion on frame 1.
@@ -380,6 +398,7 @@ export class NormalDepth extends RenderStage {
380
398
  context.setTexture( 'pathtracer:normalDepth', writeRT.texture );
381
399
  context.setTexture( 'pathtracer:prevNormalDepth', prevRT.texture );
382
400
  context.setTexture( 'pathtracer:shadingNormal', this._shadingRT.texture );
401
+ this._publishLeaf( context );
383
402
 
384
403
  this._dirty = false;
385
404
 
@@ -391,6 +410,7 @@ export class NormalDepth extends RenderStage {
391
410
 
392
411
  this._outputStorageTex?.dispose();
393
412
  this._shadingStorageTex?.dispose();
413
+ this._leafStorageTex?.dispose();
394
414
  this.reset();
395
415
  // The textures backing the ping-pong are gone, so the prev-frame G-buffer is too.
396
416
  this._hasHistory = false;
@@ -417,6 +437,13 @@ export class NormalDepth extends RenderStage {
417
437
  this._rtB.texture.needsUpdate = true;
418
438
  this._shadingRT.setSize( width, height );
419
439
  this._shadingRT.texture.needsUpdate = true;
440
+ if ( this._leafRT ) {
441
+
442
+ this._leafRT.setSize( width, height );
443
+ this._leafRT.texture.needsUpdate = true;
444
+
445
+ }
446
+
420
447
  this._hasHistory = false;
421
448
  this.resolutionWidth.value = width;
422
449
  this.resolutionHeight.value = height;
@@ -451,7 +478,69 @@ export class NormalDepth extends RenderStage {
451
478
 
452
479
  this._outputStorageTex = mk();
453
480
  this._shadingStorageTex = mk();
481
+ if ( this._leafStorageTex ) {
482
+
483
+ this._leafStorageTex.dispose();
484
+ this._leafStorageTex = this._makeLeafStorage();
485
+
486
+ }
487
+
488
+ this._computeBuilt = false;
489
+
490
+ }
491
+
492
+ /**
493
+ * Whether to write `pathtracer:instanceLeaf` (the hit's transformed TLAS leaf + 1, 0 = none).
494
+ * Off by default: it costs a full-reserve texture, and changing it rebuilds the kernel.
495
+ */
496
+ setInstanceLeafOutput( enabled ) {
497
+
498
+ enabled = !! enabled;
499
+ if ( enabled === !! this._leafStorageTex ) return;
500
+
501
+ this._computeNode?.dispose();
502
+ this._computeNode = null;
454
503
  this._computeBuilt = false;
504
+ this._dirty = true;
505
+
506
+ if ( enabled ) {
507
+
508
+ this._leafStorageTex = this._makeLeafStorage();
509
+ this._leafRT = new RenderTarget( this._rtA.width, this._rtA.height, {
510
+ type: UnsignedIntType,
511
+ format: RedIntegerFormat,
512
+ minFilter: NearestFilter,
513
+ magFilter: NearestFilter,
514
+ depthBuffer: false,
515
+ stencilBuffer: false
516
+ } );
517
+
518
+ } else {
519
+
520
+ this._leafStorageTex.dispose();
521
+ this._leafRT.dispose();
522
+ this._leafStorageTex = null;
523
+ this._leafRT = null;
524
+
525
+ }
526
+
527
+ }
528
+
529
+ _publishLeaf( context ) {
530
+
531
+ if ( this._leafRT ) context.setTexture( 'pathtracer:instanceLeaf', this._leafRT.texture );
532
+ else if ( context.getTexture( 'pathtracer:instanceLeaf' ) ) context.removeTexture( 'pathtracer:instanceLeaf' );
533
+
534
+ }
535
+
536
+ _makeLeafStorage() {
537
+
538
+ const t = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
539
+ t.type = UnsignedIntType;
540
+ t.format = RedIntegerFormat;
541
+ t.minFilter = NearestFilter;
542
+ t.magFilter = NearestFilter;
543
+ return t;
455
544
 
456
545
  }
457
546
 
@@ -461,6 +550,8 @@ export class NormalDepth extends RenderStage {
461
550
  this._outputStorageTex?.dispose();
462
551
  this._shadingStorageTex?.dispose();
463
552
  this._shadingRT?.dispose();
553
+ this._leafStorageTex?.dispose();
554
+ this._leafRT?.dispose();
464
555
  this._rtA?.dispose();
465
556
  this._rtB?.dispose();
466
557
 
@@ -303,6 +303,7 @@ export class PathTracer extends PathTracerStage {
303
303
  const auxMixes = this._updateAuxAccumulationUniforms( frameValue );
304
304
  this.frame.value = frameValue;
305
305
  this.seedFrame.value = this._seedTick ++;
306
+ this.tracedFrames ++;
306
307
 
307
308
  this._setWfDispatch();
308
309
 
@@ -155,6 +155,9 @@ export class PathTracerStage extends RenderStage {
155
155
  // Pinned back to the accumulation index in deterministic mode.
156
156
  this._seedTick = 0;
157
157
  this._pinSeedToFrame = false;
158
+ // Monotonic, unlike the seed axis; let observers tell a traced frame and a reset apart.
159
+ this.tracedFrames = 0;
160
+ this.resetCount = 0;
158
161
 
159
162
  // Denoising management state
160
163
  this.lastRenderMode = - 1;
@@ -635,6 +638,7 @@ export class PathTracerStage extends RenderStage {
635
638
  */
636
639
  reset() {
637
640
 
641
+ this.resetCount ++;
638
642
  this.frameCount = 0;
639
643
  this.frame.value = 0;
640
644
  this.hasPreviousAccumulated.value = 0;
@@ -1,5 +1,6 @@
1
- import { EventDispatcher } from 'three';
1
+ import { EventDispatcher, Matrix4 } from 'three';
2
2
  import { OIDNDenoiser } from '../Passes/OIDNDenoiser.js';
3
+ import { OIDNTemporalHistory } from '../Passes/OIDNTemporalHistory.js';
3
4
  import { AIUpscaler } from '../Passes/AIUpscaler.js';
4
5
  import { EngineEvents } from '../EngineEvents.js';
5
6
  import { createLogger } from '../utils/Logger.js';
@@ -73,6 +74,11 @@ const INTERACTION_HOPELESS_FACTOR = 3;
73
74
  // screen, so any clock would read a perfectly good frame as abandoned the moment it woke.
74
75
  const HELD_FRAME_MAX_FAILURES = 3;
75
76
 
77
+ // Still samples after which the motion history no longer helps; it fades out linearly until then.
78
+ const HISTORY_HANDOFF_SAMPLES = 16;
79
+
80
+ const historyFade = ( samples ) => Math.max( 0, 1 - samples / HISTORY_HANDOFF_SAMPLES );
81
+
76
82
  /**
77
83
  * Orchestrates all denoising, post-processing, and AI upscaling:
78
84
  * - Real-time denoiser strategy switching (ASVGF / NRD / EdgeAware / None)
@@ -151,6 +157,20 @@ export class DenoisingManager extends EventDispatcher {
151
157
  this._onPostProcessRefresh = null;
152
158
  this._onDisplayRefresh = null;
153
159
 
160
+ this.temporalHistory = DEFAULT_STATE.oidnTemporalHistory;
161
+ this._history = null;
162
+ // Stale after a scene-changing reset, or one the app did not announce (material/env edits).
163
+ this._historyDirty = true;
164
+ this._knownResetCount = 0;
165
+ this._seenTracedFrames = 0;
166
+ this._viewProj = new Matrix4();
167
+ this._historyCamera = { world: null, projInv: null, viewProj: null };
168
+ // TLAS leaf → { world, offset, prev }: placements moved since the last traced frame.
169
+ this._movedPlacements = new Map();
170
+ this._movedUpload = { count: 0, leaves: new Uint32Array( 0 ), toPrev: new Float32Array( 0 ) };
171
+ this._matA = new Matrix4();
172
+ this._matB = new Matrix4();
173
+
154
174
  // Resolution tracking — used for canvas restoration on reset
155
175
  this._lastRenderWidth = 0;
156
176
  this._lastRenderHeight = 0;
@@ -211,6 +231,7 @@ export class DenoisingManager extends EventDispatcher {
211
231
  this._holdWhileMoving = null;
212
232
  this._movingDenoiseMs.length = 0;
213
233
  this._movingHopeless = false;
234
+ this._historyDirty = true;
214
235
  // A run in flight was sized for the old resolution and setSize rebuilds the network under
215
236
  // it. Resets no longer cancel a run while its frame is on screen, so this has to.
216
237
  this.denoiser?.abort();
@@ -229,6 +250,7 @@ export class DenoisingManager extends EventDispatcher {
229
250
  }
230
251
 
231
252
  this.upscaler?.setBaseSize( width, height );
253
+ this._syncGBufferStages();
232
254
 
233
255
  }
234
256
 
@@ -273,9 +295,11 @@ export class DenoisingManager extends EventDispatcher {
273
295
  adapterInfo: null
274
296
  } ),
275
297
 
276
- getGPUTextures: () => {
298
+ getGPUTextures: ( { continuous = false } = {} ) => {
277
299
 
278
300
  if ( ! pt?.storageTextures?.readTarget ) return null;
301
+ const history = continuous ? this._historyTextures() : null;
302
+ if ( history ) return history;
279
303
  const readTextures = pt.storageTextures.getReadTextures();
280
304
  const { backend } = this.renderer;
281
305
  return {
@@ -534,8 +558,9 @@ export class DenoisingManager extends EventDispatcher {
534
558
  const mv = s.motionVector;
535
559
 
536
560
  const motionNeeded = this.requiresMotionVectors;
537
- // pathtracer:normalDepth consumed by ASVGF, NRD, EdgeFilter, BilateralFilter
538
- const normalNeeded = motionNeeded || !! ( s.edgeFilter?.enabled || s.bilateralFilter?.enabled );
561
+ // pathtracer:normalDepth consumed by ASVGF, NRD, EdgeFilter, BilateralFilter, the OIDN history
562
+ const normalNeeded = motionNeeded || this.historyWanted || !! ( s.edgeFilter?.enabled || s.bilateralFilter?.enabled );
563
+ if ( ! this.historyWanted ) this._releaseHistory();
539
564
 
540
565
  if ( nd ) {
541
566
 
@@ -543,6 +568,7 @@ export class DenoisingManager extends EventDispatcher {
543
568
  // (not the stale static fast-path) and seeds prev = current.
544
569
  if ( normalNeeded && ! nd.enabled ) nd.reset();
545
570
  nd.enabled = normalNeeded;
571
+ nd.setInstanceLeafOutput?.( this.historyWanted );
546
572
 
547
573
  }
548
574
 
@@ -630,7 +656,12 @@ export class DenoisingManager extends EventDispatcher {
630
656
  const best = this.movingCostMs;
631
657
  const seen = this._movingDenoiseMs.length;
632
658
 
633
- if ( best > INTERACTION_HOLD_BUDGET_MS * INTERACTION_HOPELESS_FACTOR ) this._movingHopeless = true;
659
+ if ( best > INTERACTION_HOLD_BUDGET_MS * INTERACTION_HOPELESS_FACTOR ) {
660
+
661
+ this._movingHopeless = true;
662
+ this._syncGBufferStages();
663
+
664
+ }
634
665
 
635
666
  // One reading is a warm-up; two that are both over budget are the answer.
636
667
  const affordable = ! this._movingHopeless && ( seen < 2 || best <= INTERACTION_HOLD_BUDGET_MS );
@@ -781,6 +812,7 @@ export class DenoisingManager extends EventDispatcher {
781
812
  setCadenceSuspended( suspended ) {
782
813
 
783
814
  this._cadenceSuspended = !! suspended;
815
+ this._historyDirty = true;
784
816
  if ( ! this._cadenceSuspended ) return;
785
817
 
786
818
  // The accumulation is what a final render shows; a held preview frame would otherwise sit
@@ -817,6 +849,221 @@ export class DenoisingManager extends EventDispatcher {
817
849
 
818
850
  }
819
851
 
852
+ // ── Motion history (OIDN live view) ──────────────────────────
853
+
854
+ /** Feeds OIDN the reprojected history of restarted frames while the view moves. */
855
+ setTemporalHistory( enabled ) {
856
+
857
+ this.temporalHistory = !! enabled;
858
+ this._syncGBufferStages();
859
+
860
+ }
861
+
862
+ // Not at a size proven too slow to denoise while moving: the raw render owns the view there.
863
+ get historyWanted() {
864
+
865
+ return this.temporalHistory && this.continuousDenoise && !! this.denoiser?.enabled && ! this._movingHopeless;
866
+
867
+ }
868
+
869
+ get historyActive() {
870
+
871
+ const pt = this._stages.pathTracer;
872
+ return this.historyWanted && ! this._cadenceSuspended && !! this._stages.normalDepth?.enabled
873
+ && ( pt?.uniforms?.get( 'cameraProjection' )?.value ?? 0 ) === 0
874
+ && ! ( pt?.visMode?.value > 0 );
875
+
876
+ }
877
+
878
+ /**
879
+ * Called by the app before each of its resets, while the accumulation still exists.
880
+ * @param {Object} [options]
881
+ * @param {boolean} [options.keepHistory] - only the view or object placements changed
882
+ */
883
+ beforeReset( { keepHistory = false } = {} ) {
884
+
885
+ const pt = this._stages.pathTracer;
886
+ if ( ! pt || ! this.historyActive ) return;
887
+
888
+ const unannounced = pt.resetCount !== this._knownResetCount;
889
+ if ( ! keepHistory || ( unannounced && pt.frameCount < 2 ) ) {
890
+
891
+ this._historyDirty = true;
892
+ return;
893
+
894
+ }
895
+
896
+ // A move that starts from a still image starts from that image, not from one sample.
897
+ if ( pt.frameCount < 2 ) return;
898
+ const src = this._historyInputs();
899
+ if ( ! src ) return;
900
+ this._ensureHistory().merge( src, pt.frameCount, this._historyCameraNow(), {
901
+ commit: true,
902
+ keepHistory: ! this._historyDirty && ! unannounced,
903
+ historyScale: historyFade( pt.frameCount ),
904
+ } );
905
+ this._historyDirty = false;
906
+
907
+ }
908
+
909
+ /** Called by the app right after its own resets, so they are not taken for unannounced ones. */
910
+ afterReset() {
911
+
912
+ this._knownResetCount = this._stages.pathTracer?.resetCount ?? 0;
913
+
914
+ }
915
+
916
+ /** Called after every render-loop tick that ran the pipeline. */
917
+ afterTrace() {
918
+
919
+ const pt = this._stages.pathTracer;
920
+ if ( ! pt || pt.tracedFrames === this._seenTracedFrames ) return;
921
+ this._seenTracedFrames = pt.tracedFrames;
922
+
923
+ if ( pt.resetCount !== this._knownResetCount ) {
924
+
925
+ this._historyDirty = true;
926
+ this._knownResetCount = pt.resetCount;
927
+
928
+ }
929
+
930
+ // Accumulating: the history holds still and is merged in when a refresh starts.
931
+ if ( ! this.historyActive || pt.frame.value !== 0 ) {
932
+
933
+ this._movedPlacements.clear();
934
+ return;
935
+
936
+ }
937
+
938
+ const src = this._historyInputs();
939
+ if ( ! src ) {
940
+
941
+ this._movedPlacements.clear();
942
+ this._historyDirty = true;
943
+ return;
944
+
945
+ }
946
+
947
+ const history = this._ensureHistory();
948
+ if ( this._historyDirty ) history.invalidate();
949
+ history.accumulate( src, this._historyCameraNow(), this._takeMovedPlacements() );
950
+ this._historyDirty = false;
951
+
952
+ }
953
+
954
+ /**
955
+ * Records a placement's transform before it changes, so the history can follow the object.
956
+ * @param {number} leaf - its TLAS leaf node
957
+ * @param {Float32Array} world - the placement matrix pool
958
+ * @param {number} offset - where this placement's matrix starts in it
959
+ */
960
+ notePlacementMoving( leaf, world, offset ) {
961
+
962
+ if ( leaf < 0 || ! this.historyActive || this._movedPlacements.has( leaf ) ) return;
963
+ this._movedPlacements.set( leaf, { world, offset, prev: world.slice( offset, offset + 16 ) } );
964
+
965
+ }
966
+
967
+ // Leaves in ascending order, each with its current→previous world matrix.
968
+ _takeMovedPlacements() {
969
+
970
+ const moved = this._movedPlacements;
971
+ const upload = this._movedUpload;
972
+ upload.count = moved.size;
973
+ if ( ! moved.size ) return upload;
974
+
975
+ if ( upload.leaves.length < moved.size ) {
976
+
977
+ upload.leaves = new Uint32Array( moved.size * 2 );
978
+ upload.toPrev = new Float32Array( moved.size * 32 );
979
+
980
+ }
981
+
982
+ const leaves = [ ...moved.keys() ].sort( ( a, b ) => a - b );
983
+ for ( let i = 0; i < leaves.length; i ++ ) {
984
+
985
+ const { world, offset, prev } = moved.get( leaves[ i ] );
986
+ this._matB.fromArray( world, offset ).invert();
987
+ this._matA.fromArray( prev ).multiply( this._matB ).toArray( upload.toPrev, i * 16 );
988
+ upload.leaves[ i ] = leaves[ i ];
989
+
990
+ }
991
+
992
+ moved.clear();
993
+ return upload;
994
+
995
+ }
996
+
997
+ // What a live refresh denoises, or null for the plain accumulation.
998
+ _historyTextures() {
999
+
1000
+ const pt = this._stages.pathTracer;
1001
+ const history = this._history;
1002
+ if ( ! history?.valid || this._historyDirty || ! this.historyActive || pt.isComplete ) return null;
1003
+ if ( pt.resetCount !== this._knownResetCount ) return null;
1004
+ if ( history.width !== this._lastRenderWidth || history.height !== this._lastRenderHeight ) return null;
1005
+ if ( pt.frameCount <= 1 ) return history.textures;
1006
+ if ( pt.frameCount >= HISTORY_HANDOFF_SAMPLES ) return null;
1007
+
1008
+ const src = this._historyInputs();
1009
+ return src ? history.merge( src, pt.frameCount, this._historyCameraNow(), { historyScale: historyFade( pt.frameCount ) } ) : null;
1010
+
1011
+ }
1012
+
1013
+ _historyInputs() {
1014
+
1015
+ const pt = this._stages.pathTracer;
1016
+ const ctx = this.pipeline?.context;
1017
+ const backend = this.renderer?.backend;
1018
+ if ( ! pt?.storageTextures?.readTarget || ! ctx || ! backend ) return null;
1019
+
1020
+ const read = pt.storageTextures.getReadTextures();
1021
+ const gpu = ( texture ) => ( texture ? backend.get( texture )?.texture : null );
1022
+ const src = {
1023
+ color: gpu( read.color ),
1024
+ albedo: gpu( read.albedo ),
1025
+ normal: gpu( read.normalDepth ),
1026
+ geo: gpu( ctx.getTexture( 'pathtracer:normalDepth' ) ),
1027
+ geoPrev: gpu( ctx.getTexture( 'pathtracer:prevNormalDepth' ) ),
1028
+ shading: gpu( ctx.getTexture( 'pathtracer:shadingNormal' ) ),
1029
+ leaf: gpu( ctx.getTexture( 'pathtracer:instanceLeaf' ) ),
1030
+ width: pt.width,
1031
+ height: pt.height,
1032
+ };
1033
+ return src.color && src.albedo && src.normal && src.geo && src.geoPrev && src.shading && src.leaf ? src : null;
1034
+
1035
+ }
1036
+
1037
+ // The camera of the frame last traced: the path tracer's uniforms, not the live camera.
1038
+ _historyCameraNow() {
1039
+
1040
+ const pt = this._stages.pathTracer;
1041
+ this._viewProj.multiplyMatrices( pt.cameraProjectionMatrix.value, pt.cameraViewMatrix.value );
1042
+ const camera = this._historyCamera;
1043
+ camera.world = pt.cameraWorldMatrix.value.elements;
1044
+ camera.projInv = pt.cameraProjectionMatrixInverse.value.elements;
1045
+ camera.viewProj = this._viewProj.elements;
1046
+ return camera;
1047
+
1048
+ }
1049
+
1050
+ _ensureHistory() {
1051
+
1052
+ this._history ??= new OIDNTemporalHistory( this.renderer.backend.device );
1053
+ this._history.settings.cleanAux = !! this.denoiser?.expectsCleanAux( this._finalQuality );
1054
+ return this._history;
1055
+
1056
+ }
1057
+
1058
+ _releaseHistory() {
1059
+
1060
+ this._history?.dispose();
1061
+ this._history = null;
1062
+ this._historyDirty = true;
1063
+ this._movedPlacements.clear();
1064
+
1065
+ }
1066
+
820
1067
  /**
821
1068
  * Selects the AI upscaler's model. 'neural' is fixed at 2x and reads the denoised picture, so it
822
1069
  * is a no-op without a denoiser — feeding it raw Monte-Carlo noise measurably loses to bilinear.
@@ -1190,6 +1437,7 @@ export class DenoisingManager extends EventDispatcher {
1190
1437
 
1191
1438
  this._unpublishOutput();
1192
1439
  this._restoreRenderDisplay();
1440
+ this._historyDirty = true;
1193
1441
 
1194
1442
  }
1195
1443
 
@@ -1203,6 +1451,7 @@ export class DenoisingManager extends EventDispatcher {
1203
1451
 
1204
1452
  // Before the denoiser destroys the picture the Compositor would otherwise still sample.
1205
1453
  this._unpublishOutput();
1454
+ this._releaseHistory();
1206
1455
 
1207
1456
  if ( this.denoiser ) {
1208
1457