rayzee 8.6.1 → 8.8.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.
@@ -1,12 +1,13 @@
1
1
  /**
2
- * AnimationManager — Drives GLTF skeletal/morph animations for BVH refit.
2
+ * AnimationManager — Drives animation clips for the path tracer.
3
3
  *
4
- * Owns the Three.js AnimationMixer, advances clips each frame,
5
- * extracts deformed vertex positions via CPU skinning, and returns
6
- * a per-mesh reader in the shape PathTracerApp.refitBVH() accepts.
4
+ * Deforming clips (a SkinnedMesh or morph track) return a per-mesh position reader for
5
+ * PathTracerApp.refitBVH(). Rigid clips return nothing and hand the moved meshes to
6
+ * `applyPoseCallback`, which updates placements only — a refit would bake world positions into
7
+ * triangles that other placements share.
7
8
  */
8
9
 
9
- import { AnimationMixer, EventDispatcher, Timer, Vector3, LoopRepeat, LoopOnce } from 'three';
10
+ import { AnimationMixer, EventDispatcher, Timer, Vector3, LoopRepeat, LoopOnce, PropertyBinding } from 'three';
10
11
  import { EngineEvents } from '../EngineEvents.js';
11
12
 
12
13
  export class AnimationManager extends EventDispatcher {
@@ -31,9 +32,20 @@ export class AnimationManager extends EventDispatcher {
31
32
  this._savedTimeScale = 1;
32
33
  this.onFinished = null; // callback when a non-looping clip ends
33
34
 
35
+ this._rigid = false;
36
+ this._plans = null; // per clip: { meshes: Int32Array, visibility: boolean, cameras: Camera[] }
37
+ this._allPlan = null;
38
+ this._activeClip = 0;
39
+ this._slotOf = null; // meshIndex -> row in the two caches below, or -1
40
+ this._lastWorld = null; // world matrix last handed to applyPoseCallback, 16 per row
41
+ this._lastVisible = null; // 0 / 1, or 2 before the first pose
42
+
34
43
  /** Injected by PathTracerApp — wakes the render loop after play/resume. */
35
44
  this.wakeCallback = null;
36
45
 
46
+ /** Injected by PathTracerApp — receives `{ meshIndices, visibilityChanged, cameras }`. */
47
+ this.applyPoseCallback = null;
48
+
37
49
  }
38
50
 
39
51
  /**
@@ -88,6 +100,10 @@ export class AnimationManager extends EventDispatcher {
88
100
 
89
101
  this.actions = actions;
90
102
 
103
+ this._rigid = ! meshes.some( m => m?.isSkinnedMesh ) &&
104
+ ! animations.some( clip => clip.tracks.some( t => t.name.includes( 'morphTargetInfluences' ) ) );
105
+ this._planClips( animations );
106
+
91
107
  // Listen for non-looping clip completion
92
108
  this.mixer.addEventListener( 'finished', () => {
93
109
 
@@ -121,7 +137,154 @@ export class AnimationManager extends EventDispatcher {
121
137
  this._meshPositions = [];
122
138
 
123
139
  const skinnedCount = meshes.filter( m => m.isSkinnedMesh ).length;
124
- console.debug( `[AnimationManager] Init: ${animations.length} clips, ${meshes.length} meshes (${skinnedCount} skinned), ${offset} triangles` );
140
+ console.debug( `[AnimationManager] Init: ${animations.length} clips, ${meshes.length} meshes (${skinnedCount} skinned), ${offset} triangles, ${this._rigid ? 'rigid' : 'deforming'}` );
141
+
142
+ }
143
+
144
+ /** Which meshes and cameras each clip moves, directly or through an ancestor. @private */
145
+ _planClips( animations ) {
146
+
147
+ const root = this._mixerRoot;
148
+ const byName = new Map();
149
+ // First name in pre-order wins, as in PropertyBinding.findNode.
150
+ root.traverse?.( node => {
151
+
152
+ if ( ! byName.has( node.name ) ) byName.set( node.name, node );
153
+ byName.set( node.uuid, node );
154
+
155
+ } );
156
+
157
+ this._plans = animations.map( clip => {
158
+
159
+ const animated = new Set();
160
+ let visibility = false;
161
+ for ( const track of clip.tracks ) {
162
+
163
+ const { nodeName, propertyName } = PropertyBinding.parseTrackName( track.name );
164
+ const node = byName.get( nodeName );
165
+ if ( ! node ) continue;
166
+ animated.add( node );
167
+ if ( propertyName === 'visible' ) visibility = true;
168
+
169
+ }
170
+
171
+ const meshes = [];
172
+ for ( let m = 0; m < this._meshes.length; m ++ ) {
173
+
174
+ for ( let o = this._meshes[ m ]; o; o = o.parent ) {
175
+
176
+ if ( animated.has( o ) ) {
177
+
178
+ meshes.push( m );
179
+ break;
180
+
181
+ }
182
+
183
+ }
184
+
185
+ }
186
+
187
+ const cameras = new Set();
188
+ for ( const node of animated ) node.traverse( o => o.isCamera && cameras.add( o ) );
189
+
190
+ return { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ] };
191
+
192
+ } );
193
+
194
+ this._allPlan = null;
195
+ if ( ! this._rigid ) return;
196
+
197
+ this._slotOf = new Int32Array( this._meshes.length ).fill( - 1 );
198
+ let rows = 0;
199
+ for ( const plan of this._plans ) for ( const m of plan.meshes ) if ( this._slotOf[ m ] < 0 ) this._slotOf[ m ] = rows ++;
200
+
201
+ // Unknown until the first pose, which therefore sends every animated mesh.
202
+ this._lastWorld = new Float64Array( rows * 16 ).fill( NaN );
203
+ this._lastVisible = new Uint8Array( rows ).fill( 2 );
204
+
205
+ }
206
+
207
+ /** The plan for a clip index, or the union of every clip for -1. @private */
208
+ _planFor( clipIndex ) {
209
+
210
+ if ( ! this._plans ) return null;
211
+ if ( clipIndex >= 0 ) return this._plans[ clipIndex ] ?? null;
212
+
213
+ if ( ! this._allPlan ) {
214
+
215
+ const meshes = new Set(), cameras = new Set();
216
+ let visibility = false;
217
+ for ( const plan of this._plans ) {
218
+
219
+ plan.meshes.forEach( m => meshes.add( m ) );
220
+ plan.cameras.forEach( c => cameras.add( c ) );
221
+ visibility ||= plan.visibility;
222
+
223
+ }
224
+
225
+ this._allPlan = { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ] };
226
+
227
+ }
228
+
229
+ return this._allPlan;
230
+
231
+ }
232
+
233
+ /** Send meshes whose world matrix or visibility changed since the last pose. @private */
234
+ _applyRigidPose( clipIndex ) {
235
+
236
+ const plan = this._planFor( clipIndex );
237
+ if ( ! plan ) return;
238
+
239
+ const moved = [];
240
+ let visibilityChanged = false;
241
+ const last = this._lastWorld, lastVisible = this._lastVisible;
242
+
243
+ for ( const m of plan.meshes ) {
244
+
245
+ const mesh = this._meshes[ m ];
246
+ const row = this._slotOf[ m ];
247
+ const e = mesh.matrixWorld.elements;
248
+ const o = row * 16;
249
+
250
+ for ( let i = 0; i < 16; i ++ ) {
251
+
252
+ if ( last[ o + i ] !== e[ i ] ) {
253
+
254
+ for ( let j = 0; j < 16; j ++ ) last[ o + j ] = e[ j ];
255
+ moved.push( m );
256
+ break;
257
+
258
+ }
259
+
260
+ }
261
+
262
+ if ( plan.visibility || lastVisible[ row ] === 2 ) {
263
+
264
+ let visible = 1;
265
+ for ( let n = mesh; n; n = n.parent ) if ( ! n.visible ) {
266
+
267
+ visible = 0;
268
+ break;
269
+
270
+ }
271
+
272
+ if ( lastVisible[ row ] !== visible ) {
273
+
274
+ lastVisible[ row ] = visible;
275
+ visibilityChanged = true;
276
+
277
+ }
278
+
279
+ }
280
+
281
+ }
282
+
283
+ if ( moved.length || visibilityChanged || plan.cameras.length ) {
284
+
285
+ this.applyPoseCallback?.( { meshIndices: moved, visibilityChanged, cameras: plan.cameras } );
286
+
287
+ }
125
288
 
126
289
  }
127
290
 
@@ -145,6 +308,7 @@ export class AnimationManager extends EventDispatcher {
145
308
 
146
309
  }
147
310
 
311
+ this._activeClip = clipIndex;
148
312
  this.timer.reset();
149
313
  this.isPlaying = true;
150
314
  this.wakeCallback?.();
@@ -188,10 +352,19 @@ export class AnimationManager extends EventDispatcher {
188
352
 
189
353
  if ( ! this.mixer ) return;
190
354
 
355
+ // Restores every animated property to its loaded value.
191
356
  this.mixer.stopAllAction();
192
357
  this.mixer.timeScale = this._savedTimeScale || 1;
193
358
  this.timer.reset();
194
359
  this.isPlaying = false;
360
+
361
+ if ( this._rigid ) {
362
+
363
+ this._mixerRoot.updateMatrixWorld( true );
364
+ this._applyRigidPose( - 1 );
365
+
366
+ }
367
+
195
368
  this.dispatchEvent( { type: EngineEvents.ANIMATION_STOPPED } );
196
369
 
197
370
  }
@@ -229,12 +402,15 @@ export class AnimationManager extends EventDispatcher {
229
402
  *
230
403
  * @param {number} time - Absolute time in seconds
231
404
  * @param {number} [clipIndex=0] - Clip to evaluate, or -1 for all active
232
- * @returns {function(number): Float32Array|null} Per-mesh reader for refitBVH, or null if no mixer
405
+ * @returns {function(number): Float32Array|null} Per-mesh reader for refitBVH, or null if no
406
+ * mixer or the clips are rigid
233
407
  */
234
408
  seekTo( time, clipIndex = 0 ) {
235
409
 
236
410
  if ( ! this.mixer || this.actions.length === 0 ) return null;
237
411
 
412
+ this._activeClip = clipIndex;
413
+
238
414
  // Ensure the target action(s) are active so setTime evaluates them.
239
415
  // Actions must NOT be paused — setTime() calls update() internally,
240
416
  // which skips paused actions entirely.
@@ -279,7 +455,8 @@ export class AnimationManager extends EventDispatcher {
279
455
  * Advance animation and prepare deformed positions.
280
456
  * Call once per frame from the animate loop.
281
457
  *
282
- * @returns {function(number): Float32Array|null} Per-mesh reader for refitBVH, or null if not playing
458
+ * @returns {function(number): Float32Array|null} Per-mesh reader for refitBVH, or null if not
459
+ * playing or the clips are rigid
283
460
  */
284
461
  update() {
285
462
 
@@ -312,6 +489,16 @@ export class AnimationManager extends EventDispatcher {
312
489
  // once here, on mixerRoot rather than the scene, keeps unrelated static objects out of it.
313
490
  this._mixerRoot.updateMatrixWorld( true );
314
491
 
492
+ if ( this._rigid ) {
493
+
494
+ this._applyRigidPose( this._activeClip );
495
+ return null;
496
+
497
+ }
498
+
499
+ const cameras = this._planFor( this._activeClip )?.cameras;
500
+ if ( cameras?.length ) this.applyPoseCallback?.( { meshIndices: [], visibilityChanged: false, cameras } );
501
+
315
502
  return m => this._computeMeshPositions( m );
316
503
 
317
504
  }
@@ -446,6 +633,13 @@ export class AnimationManager extends EventDispatcher {
446
633
  this._meshPositions = null;
447
634
  this._skinnedCache = null;
448
635
  this._clipsCache = null;
636
+ this._rigid = false;
637
+ this._plans = null;
638
+ this._allPlan = null;
639
+ this._activeClip = 0;
640
+ this._slotOf = null;
641
+ this._lastWorld = null;
642
+ this._lastVisible = null;
449
643
 
450
644
  }
451
645
 
@@ -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