rayzee 9.3.1 → 9.3.3

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.
@@ -2597,6 +2597,7 @@ export class SceneProcessor {
2597
2597
  async refitBVH( newPositions, newNormals ) {
2598
2598
 
2599
2599
  this._geometryVersion = ( this._geometryVersion ?? 0 ) + 1;
2600
+ this._tlasLeafBoxesOf = null;
2600
2601
 
2601
2602
  if ( ! this.bvh || ! this.triangles || ! this.instanceTable ) {
2602
2603
 
@@ -2648,13 +2649,19 @@ export class SceneProcessor {
2648
2649
  // can be released as soon as it is read. Callers hand over world space; triangles are
2649
2650
  // stored per instance, so each mesh comes back through its own inverse.
2650
2651
  const table = this.instanceTable;
2652
+ const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
2653
+ const triRanges = [];
2654
+ const blasRanges = [];
2655
+ let owners = 0;
2651
2656
  for ( let i = 0; i < table.count; i ++ ) {
2652
2657
 
2653
2658
  if ( ! table.isSet[ i ] || ! table.isOwner( i ) ) continue;
2659
+ owners ++;
2654
2660
 
2655
2661
  const p = positionsFor( i );
2656
2662
  if ( ! p ) continue;
2657
2663
  this._updateMeshTrianglePositions( i, p );
2664
+ blasRanges.push( table.blasOffsetOf( i ), table.blasNodeCountOf( i ) );
2658
2665
 
2659
2666
  // Smooth normals overwrite the face normals just computed, so they follow per mesh.
2660
2667
  if ( normalsFor ) {
@@ -2664,8 +2671,18 @@ export class SceneProcessor {
2664
2671
 
2665
2672
  }
2666
2673
 
2674
+ const offset = table.triOffsetOf( i ) * FPT, count = table.triCountOf( i ) * FPT;
2675
+ const last = triRanges.at( - 1 );
2676
+ if ( last && last.offset + last.count === offset ) last.count += count;
2677
+ else triRanges.push( { offset, count } );
2678
+
2667
2679
  }
2668
2680
 
2681
+ const tlasNodeCount = table.tlasNodeCount;
2682
+ const partial = tlasNodeCount > 0 && blasRanges.length / 2 < owners;
2683
+ const bvhRanges = partial ? [ this.computeTLASDirtyRange() ] : null;
2684
+ for ( let r = 0; partial && r < blasRanges.length; r += 2 ) bvhRanges.push( { offset: blasRanges[ r ] * 16, count: blasRanges[ r + 1 ] * 16 } );
2685
+
2669
2686
  return new Promise( ( resolve, reject ) => {
2670
2687
 
2671
2688
  this._refitWorker.onmessage = ( e ) => {
@@ -2673,7 +2690,7 @@ export class SceneProcessor {
2673
2690
  const msg = e.data;
2674
2691
  if ( msg.type === 'refitComplete' ) {
2675
2692
 
2676
- resolve( { refitTimeMs: msg.refitTimeMs } );
2693
+ resolve( { refitTimeMs: msg.refitTimeMs, triRanges, bvhRanges } );
2677
2694
 
2678
2695
  } else if ( msg.type === 'error' ) {
2679
2696
 
@@ -2684,7 +2701,7 @@ export class SceneProcessor {
2684
2701
  };
2685
2702
 
2686
2703
  // Signal worker — no data transfer needed, everything is in shared memory
2687
- this._refitWorker.postMessage( { type: 'refit' } );
2704
+ this._refitWorker.postMessage( { type: 'refit', blasRanges: partial ? blasRanges : null, tlasNodeCount } );
2688
2705
 
2689
2706
  } );
2690
2707
 
@@ -2834,6 +2851,8 @@ export class SceneProcessor {
2834
2851
  const start = performance.now();
2835
2852
  const table = this.instanceTable;
2836
2853
  const composed = this._transformScratch ??= new Float32Array( 16 );
2854
+ if ( this._movedPlacements?.length !== table.count ) this._movedPlacements = new Uint8Array( table.count );
2855
+ const movedMask = this._movedPlacements;
2837
2856
  let moved = 0;
2838
2857
 
2839
2858
  for ( const meshIndex of meshIndices ) {
@@ -2870,13 +2889,15 @@ export class SceneProcessor {
2870
2889
  }
2871
2890
 
2872
2891
  this._writeLeafMatrix( p );
2892
+ movedMask[ p ] = 1;
2873
2893
  moved ++;
2874
2894
 
2875
2895
  }
2876
2896
 
2877
2897
  }
2878
2898
 
2879
- this._refitTLAS();
2899
+ this._refitTLAS( movedMask );
2900
+ movedMask.fill( 0 );
2880
2901
 
2881
2902
  return { refitTimeMs: performance.now() - start, placements: moved };
2882
2903
 
@@ -3357,6 +3378,8 @@ export class SceneProcessor {
3357
3378
  /** Keep the chunked BVH and its u32 index view in step. @private */
3358
3379
  _setBVHData( data ) {
3359
3380
 
3381
+ this._tlasLeafBoxesOf = null;
3382
+
3360
3383
  const records = ! data ? null
3361
3384
  : ( data instanceof ChunkedRecords
3362
3385
  ? data
@@ -3538,9 +3561,11 @@ export class SceneProcessor {
3538
3561
  /**
3539
3562
  * Refit TLAS AABBs in-place without rebuilding the tree structure.
3540
3563
  * O(tlasNodeCount) bottom-up pass — much faster than full SAH rebuild.
3564
+ * @param {Uint8Array} [moved] - by placement: only these leaves' world boxes are recomputed, the
3565
+ * rest kept from the last pass. Every leaf when omitted, or when nothing valid was kept.
3541
3566
  * @private
3542
3567
  */
3543
- _refitTLAS() {
3568
+ _refitTLAS( moved = null ) {
3544
3569
 
3545
3570
  const tlasNodeCount = this.instanceTable.tlasNodeCount;
3546
3571
 
@@ -3548,10 +3573,12 @@ export class SceneProcessor {
3548
3573
  if ( ! this._tlasBounds || this._tlasBounds.length < tlasNodeCount * 6 ) {
3549
3574
 
3550
3575
  this._tlasBounds = new Float32Array( tlasNodeCount * 6 );
3576
+ this._tlasLeafBoxesOf = null;
3551
3577
 
3552
3578
  }
3553
3579
 
3554
3580
  const table = this.instanceTable;
3581
+ const keep = moved && this._tlasLeafBoxesOf === table;
3555
3582
 
3556
3583
  // Bottom-up pass: reverse iteration over TLAS nodes
3557
3584
  for ( let i = tlasNodeCount - 1; i >= 0; i -- ) {
@@ -3567,7 +3594,7 @@ export class SceneProcessor {
3567
3594
  // every placement of a shared geometry onto one box, so all but one copy sat
3568
3595
  // outside its own bounds and rays walked straight past it.
3569
3596
  const entryIndex = idxChunk[ o + 1 ] & TLAS_PLACEMENT_MASK;
3570
- if ( entryIndex < table.count ) {
3597
+ if ( entryIndex < table.count && ! ( keep && ! moved[ entryIndex ] ) ) {
3571
3598
 
3572
3599
  table.writeWorldAABB( entryIndex, this._tlasBounds, i * 6 );
3573
3600
 
@@ -3608,6 +3635,8 @@ export class SceneProcessor {
3608
3635
 
3609
3636
  }
3610
3637
 
3638
+ this._tlasLeafBoxesOf = table;
3639
+
3611
3640
  }
3612
3641
 
3613
3642
  /**
@@ -56,7 +56,39 @@ fn rayzee_source( x: u32, y: u32 ) -> vec4<f32> {
56
56
  }`,
57
57
  };
58
58
 
59
- const toneMapPassWGSL = ( transformWGSL, input ) => /* wgsl */ `
59
+ // RGBA bytes for a picture, or four float planes (r, g, b, a) for a network that wants the curve
60
+ // unquantized. The planes take a 2.2 power, not the sRGB curve: what the AI upscaler has always fed
61
+ // its network, so its output does not move.
62
+ const OUTPUTS = {
63
+ rgba8: /* wgsl */ `
64
+ @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
65
+
66
+ fn rayzee_store( i: u32, linear: vec4<f32>, mapped: vec3<f32> ) {
67
+ let b8 = rayzee_to_u8( rayzee_encode( mapped, params.mode ) );
68
+ var a8 = 255u;
69
+ if ( params.alpha == 1u ) { a8 = rayzee_to_u8( vec3<f32>( linear.a ) ).x; }
70
+ dst[ i ] = b8.x | ( b8.y << 8u ) | ( b8.z << 16u ) | ( a8 << 24u );
71
+ }`,
72
+ planar: /* wgsl */ `
73
+ @group(0) @binding(1) var<storage, read_write> dst: array<f32>;
74
+
75
+ fn rayzee_store( i: u32, linear: vec4<f32>, mapped: vec3<f32> ) {
76
+ var c = clamp( mapped, vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
77
+ if ( ! rayzee_output_encoded( params.mode ) ) {
78
+ let m = max( mapped, vec3<f32>( 0.0 ) );
79
+ c = select( pow( m, vec3<f32>( 1.0 / 2.2 ) ), vec3<f32>( 0.0 ), m <= vec3<f32>( 0.0 ) );
80
+ }
81
+ let n = params.width * params.height;
82
+ dst[ i ] = c.r;
83
+ dst[ n + i ] = c.g;
84
+ dst[ 2u * n + i ] = c.b;
85
+ dst[ 3u * n + i ] = linear.a;
86
+ }`,
87
+ };
88
+
89
+ const BYTES_PER_PIXEL = { rgba8: 4, planar: 16 };
90
+
91
+ const toneMapPassWGSL = ( transformWGSL, input, output ) => /* wgsl */ `
60
92
  struct Params {
61
93
  width: u32,
62
94
  height: u32,
@@ -67,11 +99,11 @@ struct Params {
67
99
  alpha: u32,
68
100
  };
69
101
 
70
- @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
71
102
  @group(0) @binding(2) var<uniform> params: Params;
72
103
  ${SOURCES[ input ]}
73
104
 
74
105
  ${transformWGSL}
106
+ ${OUTPUTS[ output ]}
75
107
 
76
108
  @compute @workgroup_size(8, 8)
77
109
  fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
@@ -81,11 +113,7 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
81
113
  if ( params.flipY == 1u ) { srcY = params.height - 1u - gid.y; }
82
114
 
83
115
  let linear = rayzee_source( gid.x, srcY );
84
- let mapped = rayzee_tone_map( linear.rgb, params.mode, params.exposure, params.saturation );
85
- let b8 = rayzee_to_u8( rayzee_encode( mapped, params.mode ) );
86
- var a8 = 255u;
87
- if ( params.alpha == 1u ) { a8 = rayzee_to_u8( vec3<f32>( linear.a ) ).x; }
88
- dst[ gid.y * params.width + gid.x ] = b8.x | ( b8.y << 8u ) | ( b8.z << 16u ) | ( a8 << 24u );
116
+ rayzee_store( gid.y * params.width + gid.x, linear, rayzee_tone_map( linear.rgb, params.mode, params.exposure, params.saturation ) );
89
117
  }
90
118
  `;
91
119
 
@@ -94,17 +122,18 @@ const PARAMS_BYTES = 32;
94
122
 
95
123
  /**
96
124
  * Tone-maps a tightly packed rgba16float buffer (two `u32` per pixel), or with `input: 'texture'` a
97
- * float texture read by pixel, into RGBA bytes.
125
+ * float texture read by pixel, into RGBA bytes — or with `output: 'planar'` into float planes.
98
126
  *
99
127
  * Bound to one device and one image size; `ensureSize()` reallocates when the size changes.
100
128
  */
101
129
  export class PackedToneMapper {
102
130
 
103
- constructor( device, label = 'rayzee:tonemap', { input = 'packed' } = {} ) {
131
+ constructor( device, label = 'rayzee:tonemap', { input = 'packed', output = 'rgba8' } = {} ) {
104
132
 
105
133
  this.device = device;
106
134
  this.label = label;
107
135
  this.input = input;
136
+ this.output = output;
108
137
  this.width = 0;
109
138
  this.height = 0;
110
139
  this._pipeline = null;
@@ -139,7 +168,7 @@ export class PackedToneMapper {
139
168
  label: this.label,
140
169
  layout: 'auto',
141
170
  compute: {
142
- module: this.device.createShaderModule( { label: this.label, code: toneMapPassWGSL( wgsl, this.input ) } ),
171
+ module: this.device.createShaderModule( { label: this.label, code: toneMapPassWGSL( wgsl, this.input, this.output ) } ),
143
172
  entryPoint: 'main',
144
173
  },
145
174
  } );
@@ -202,7 +231,7 @@ export class PackedToneMapper {
202
231
 
203
232
  this._releaseBuffers();
204
233
 
205
- const bytes = width * height * 4;
234
+ const bytes = width * height * BYTES_PER_PIXEL[ this.output ];
206
235
  this._storage = this.device.createBuffer( {
207
236
  label: `${this.label}-out`,
208
237
  size: bytes,
@@ -235,7 +264,26 @@ export class PackedToneMapper {
235
264
  * @param {boolean} [tone.preserveAlpha=false] - the source's alpha, rounded as the CPU rounds it; else 255
236
265
  * @returns {Promise<Uint8ClampedArray>} RGBA bytes, `width * height * 4`
237
266
  */
238
- async toRGBA8( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false, preserveAlpha = false } = {} ) {
267
+ async toRGBA8( src, tone ) {
268
+
269
+ return new Uint8ClampedArray( await this._run( src, tone ) );
270
+
271
+ }
272
+
273
+ /**
274
+ * The same tone map into four float planes — r, g, b, then the source's alpha — each `width * height`.
275
+ * Needs `output: 'planar'`.
276
+ * @param {GPUBuffer|GPUTexture} src - as for toRGBA8
277
+ * @param {object} tone - as for toRGBA8; `preserveAlpha` does not apply
278
+ * @returns {Promise<Float32Array>}
279
+ */
280
+ async toPlanar( src, tone ) {
281
+
282
+ return new Float32Array( await this._run( src, tone ) );
283
+
284
+ }
285
+
286
+ async _run( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false, preserveAlpha = false } = {} ) {
239
287
 
240
288
  if ( this.disposed ) throw new Error( 'PackedToneMapper: disposed' );
241
289
  if ( ! this._storage ) throw new Error( 'PackedToneMapper: call ensureSize() first' );
@@ -246,6 +294,7 @@ export class PackedToneMapper {
246
294
  this._ensureTables();
247
295
 
248
296
  const { width, height } = this;
297
+ const bytes = width * height * BYTES_PER_PIXEL[ this.output ];
249
298
 
250
299
  this._paramU32[ 0 ] = width;
251
300
  this._paramU32[ 1 ] = height;
@@ -275,13 +324,13 @@ export class PackedToneMapper {
275
324
  pass.setBindGroup( 0, group );
276
325
  pass.dispatchWorkgroups( Math.ceil( width / 8 ), Math.ceil( height / 8 ) );
277
326
  pass.end();
278
- encoder.copyBufferToBuffer( this._storage, 0, this._map, 0, width * height * 4 );
327
+ encoder.copyBufferToBuffer( this._storage, 0, this._map, 0, bytes );
279
328
  this.device.queue.submit( [ encoder.finish() ] );
280
329
 
281
330
  await this._map.mapAsync( GPUMapMode.READ );
282
- const bytes = new Uint8ClampedArray( this._map.getMappedRange().slice( 0 ) );
331
+ const out = this._map.getMappedRange().slice( 0 );
283
332
  this._map.unmap();
284
- return bytes;
333
+ return out;
285
334
 
286
335
  }
287
336
 
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Protocol:
5
5
  * 'init' → receives the shared triangle and node buffers (once per scene)
6
- * 'refit' → recomputes every node's AABB from the triangles already written there
6
+ * 'refit' → recomputes every node's AABB from the triangles already written there, or with
7
+ * `blasRanges` only those BLASes and the TLAS
7
8
  *
8
9
  * Positions never reach here: the main thread scatters each mesh into the shared triangle
9
10
  * records as it reads it, so no buffer the size of the scene exists on either side.
@@ -45,7 +46,9 @@ self.onmessage = function ( e ) {
45
46
 
46
47
  const startTime = performance.now();
47
48
 
48
- refitter.refit( bvhData, triData, nodeCount );
49
+ const { blasRanges, tlasNodeCount } = e.data;
50
+ if ( blasRanges ) refitter.refitPartial( bvhData, triData, blasRanges, tlasNodeCount );
51
+ else refitter.refit( bvhData, triData, nodeCount );
49
52
 
50
53
  self.postMessage( {
51
54
  type: 'refitComplete',
@@ -1088,26 +1088,48 @@ export class PathTracerStage extends RenderStage {
1088
1088
 
1089
1089
  }
1090
1090
 
1091
- /** Re-uploads every triangle from the CPU records, adopting `triangleData` as them when given. */
1092
- updateTriangleData( triangleData ) {
1091
+ /**
1092
+ * Re-uploads triangles from the CPU records, adopting `triangleData` as them when given.
1093
+ * @param {Object} [triangleData]
1094
+ * @param {Array<{offset:number,count:number}>} [ranges] - only these, in record lanes; every triangle when omitted
1095
+ */
1096
+ updateTriangleData( triangleData, ranges ) {
1093
1097
 
1094
1098
  if ( triangleData?.chunks?.length > 1 ) this._triangleRecords = triangleData;
1095
1099
  else if ( triangleData ) this._triangleFlat = triangleData.chunks ? triangleData.chunks[ 0 ] : triangleData;
1096
- this._uploadTriangles( 0, this._triangleRecordCount );
1100
+ if ( ranges ) this.updateBufferRanges( ranges, [] );
1101
+ else this._uploadTriangles( 0, this._triangleRecordCount );
1097
1102
 
1098
1103
  }
1099
1104
 
1100
- /** Update BVH node data in the existing GPU buffer (full). */
1101
- updateBVHData( bvhData ) {
1105
+ /**
1106
+ * Update BVH node data in the existing GPU buffer.
1107
+ * @param {Object} [bvhData]
1108
+ * @param {Array<{offset:number,count:number}>} [ranges] - only these, in floats; every node when omitted
1109
+ */
1110
+ updateBVHData( bvhData, ranges ) {
1102
1111
 
1103
1112
  if ( this._bvhRecords ) {
1104
1113
 
1105
- uploadStorageChunks( this.renderer, this.bvhStorageAttr, this._bvhRecords.chunks );
1114
+ if ( bvhData?.chunks?.length > 1 ) this._bvhRecords = bvhData;
1115
+ if ( ranges ) this.updateBufferRanges( [], ranges );
1116
+ else uploadStorageChunks( this.renderer, this.bvhStorageAttr, this._bvhRecords.chunks );
1117
+ return;
1118
+
1119
+ }
1120
+
1121
+ const flat = bvhData?.chunks ? bvhData.chunks[ 0 ] : bvhData;
1122
+ if ( ! ranges ) {
1123
+
1124
+ this._updateStorageBuffer( this.bvhStorageAttr, flat );
1106
1125
  return;
1107
1126
 
1108
1127
  }
1109
1128
 
1110
- this._updateStorageBuffer( this.bvhStorageAttr, bvhData?.chunks ? bvhData.chunks[ 0 ] : bvhData );
1129
+ // The attribute keeps its own array once the refit has moved the nodes into shared memory.
1130
+ const own = this.bvhStorageAttr?.array;
1131
+ if ( flat && own && flat !== own ) for ( const r of ranges ) own.set( flat.subarray( r.offset, r.offset + r.count ), r.offset );
1132
+ this.updateBufferRanges( [], ranges );
1111
1133
 
1112
1134
  }
1113
1135
 
@@ -10,6 +10,23 @@
10
10
  import { AnimationMixer, EventDispatcher, Timer, Vector3, LoopRepeat, LoopOnce, PropertyBinding } from 'three';
11
11
  import { EngineEvents } from '../EngineEvents.js';
12
12
 
13
+ /** The nodes of `nodes` with no ancestor in it, or null when updating each would cost more than one pass over `root`. */
14
+ function subtreeRoots( nodes, root ) {
15
+
16
+ const roots = [ ...nodes ].filter( node => {
17
+
18
+ for ( let o = node.parent; o; o = o.parent ) if ( nodes.has( o ) ) return false;
19
+ return true;
20
+
21
+ } );
22
+
23
+ let total = 0, covered = 0;
24
+ root.traverse?.( () => total ++ );
25
+ for ( const node of roots ) node.traverse?.( () => covered ++ );
26
+ return covered + roots.length / 2 < total ? roots : null;
27
+
28
+ }
29
+
13
30
  export class AnimationManager extends EventDispatcher {
14
31
 
15
32
  constructor() {
@@ -39,6 +56,8 @@ export class AnimationManager extends EventDispatcher {
39
56
  this._slotOf = null; // meshIndex -> row in the two caches below, or -1
40
57
  this._lastWorld = null; // world matrix last handed to applyPoseCallback, 16 per row
41
58
  this._lastVisible = null; // 0 / 1, or 2 before the first pose
59
+ this._lastMeshWorld = null; // deforming clips: world matrix at each mesh's last read, 16 per mesh
60
+ this._lastMorphWeights = null;
42
61
 
43
62
  /** Injected by PathTracerApp — wakes the render loop after play/resume. */
44
63
  this.wakeCallback = null;
@@ -135,6 +154,8 @@ export class AnimationManager extends EventDispatcher {
135
154
  // 9 floats × every triangle — 1,030 MB at 30M, past what a renderer can allocate.
136
155
  this._skinnedCache = [];
137
156
  this._meshPositions = [];
157
+ this._lastMeshWorld = this._rigid ? null : new Float64Array( meshes.length * 16 ).fill( NaN );
158
+ this._lastMorphWeights = [];
138
159
 
139
160
  const skinnedCount = meshes.filter( m => m.isSkinnedMesh ).length;
140
161
  console.debug( `[AnimationManager] Init: ${animations.length} clips, ${meshes.length} meshes (${skinnedCount} skinned), ${offset} triangles, ${this._rigid ? 'rigid' : 'deforming'}` );
@@ -158,11 +179,19 @@ export class AnimationManager extends EventDispatcher {
158
179
 
159
180
  const animated = new Set();
160
181
  let visibility = false;
182
+ let unresolved = false;
161
183
  for ( const track of clip.tracks ) {
162
184
 
163
185
  const { nodeName, propertyName } = PropertyBinding.parseTrackName( track.name );
164
- const node = byName.get( nodeName );
165
- if ( ! node ) continue;
186
+ // As PropertyBinding.findNode: no node name binds the root itself.
187
+ const node = nodeName === '' || nodeName === '.' ? root : byName.get( nodeName );
188
+ if ( ! node ) {
189
+
190
+ unresolved = true;
191
+ continue;
192
+
193
+ }
194
+
166
195
  animated.add( node );
167
196
  if ( propertyName === 'visible' ) visibility = true;
168
197
 
@@ -187,7 +216,7 @@ export class AnimationManager extends EventDispatcher {
187
216
  const cameras = new Set();
188
217
  for ( const node of animated ) node.traverse( o => o.isCamera && cameras.add( o ) );
189
218
 
190
- return { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ] };
219
+ return { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ], nodes: unresolved ? null : animated, roots: unresolved ? null : subtreeRoots( animated, root ) };
191
220
 
192
221
  } );
193
222
 
@@ -214,15 +243,18 @@ export class AnimationManager extends EventDispatcher {
214
243
 
215
244
  const meshes = new Set(), cameras = new Set();
216
245
  let visibility = false;
246
+ let nodes = new Set();
217
247
  for ( const plan of this._plans ) {
218
248
 
219
249
  plan.meshes.forEach( m => meshes.add( m ) );
220
250
  plan.cameras.forEach( c => cameras.add( c ) );
221
251
  visibility ||= plan.visibility;
252
+ if ( nodes && plan.nodes ) plan.nodes.forEach( n => nodes.add( n ) );
253
+ else nodes = null;
222
254
 
223
255
  }
224
256
 
225
- this._allPlan = { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ] };
257
+ this._allPlan = { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ], nodes, roots: nodes ? subtreeRoots( nodes, this._mixerRoot ) : null };
226
258
 
227
259
  }
228
260
 
@@ -487,7 +519,10 @@ export class AnimationManager extends EventDispatcher {
487
519
 
488
520
  // Bones live outside mesh subtrees so per-mesh updateMatrixWorld() misses them. Doing it
489
521
  // once here, on mixerRoot rather than the scene, keeps unrelated static objects out of it.
490
- this._mixerRoot.updateMatrixWorld( true );
522
+ // A rigid clip moves only what its tracks name, so only those subtrees are updated.
523
+ const roots = this._rigid ? this._planFor( this._activeClip )?.roots : null;
524
+ if ( roots ) for ( const node of roots ) node.updateMatrixWorld( true );
525
+ else this._mixerRoot.updateMatrixWorld( true );
491
526
 
492
527
  if ( this._rigid ) {
493
528
 
@@ -499,7 +534,52 @@ export class AnimationManager extends EventDispatcher {
499
534
  const cameras = this._planFor( this._activeClip )?.cameras;
500
535
  if ( cameras?.length ) this.applyPoseCallback?.( { meshIndices: [], visibilityChanged: false, cameras } );
501
536
 
502
- return m => this._computeMeshPositions( m );
537
+ // Decided once per reader: an expanded instanced emitter asks for one mesh several times.
538
+ const moved = new Int8Array( this._meshes.length );
539
+ return m => {
540
+
541
+ if ( moved[ m ] === 0 ) moved[ m ] = this._meshes[ m ].isSkinnedMesh || this._poseChanged( m ) ? 1 : - 1;
542
+ return moved[ m ] > 0 ? this._computeMeshPositions( m ) : null;
543
+
544
+ };
545
+
546
+ }
547
+
548
+ /** Whether a mesh's world matrix or morph weights differ from when its positions were last read. @private */
549
+ _poseChanged( m ) {
550
+
551
+ const mesh = this._meshes[ m ];
552
+ const last = this._lastMeshWorld;
553
+ const e = mesh.matrixWorld.elements;
554
+ const o = m * 16;
555
+ let changed = false;
556
+
557
+ for ( let i = 0; i < 16; i ++ ) {
558
+
559
+ if ( last[ o + i ] !== e[ i ] ) {
560
+
561
+ for ( let j = 0; j < 16; j ++ ) last[ o + j ] = e[ j ];
562
+ changed = true;
563
+ break;
564
+
565
+ }
566
+
567
+ }
568
+
569
+ const weights = mesh.morphTargetInfluences;
570
+ if ( weights?.length ) {
571
+
572
+ const lastWeights = this._lastMorphWeights[ m ];
573
+ if ( ! lastWeights || lastWeights.length !== weights.length || lastWeights.some( ( w, i ) => w !== weights[ i ] ) ) {
574
+
575
+ this._lastMorphWeights[ m ] = Float64Array.from( weights );
576
+ changed = true;
577
+
578
+ }
579
+
580
+ }
581
+
582
+ return changed;
503
583
 
504
584
  }
505
585
 
@@ -640,6 +720,8 @@ export class AnimationManager extends EventDispatcher {
640
720
  this._slotOf = null;
641
721
  this._lastWorld = null;
642
722
  this._lastVisible = null;
723
+ this._lastMeshWorld = null;
724
+ this._lastMorphWeights = null;
643
725
 
644
726
  }
645
727
 
@@ -1,4 +1,4 @@
1
- import { EventDispatcher, MathUtils, OrthographicCamera, PerspectiveCamera, Quaternion, Vector3 } from 'three';
1
+ import { EventDispatcher, MathUtils, Matrix4, OrthographicCamera, PerspectiveCamera, Quaternion, Vector3 } from 'three';
2
2
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3
3
  import { EngineEvents, } from '../EngineEvents.js';
4
4
  import { AF_DEFAULTS, CAMERA_PROJECTION_IDS } from '../EngineDefaults.js';
@@ -62,6 +62,7 @@ export class CameraManager extends EventDispatcher {
62
62
  this._smoothedFocusDistance = null;
63
63
  this._afPointDirty = false;
64
64
  this._afSuspended = false;
65
+ this._afPick = null;
65
66
 
66
67
  // Saved state for default camera when switching to model cameras
67
68
  this._defaultCameraState = null;
@@ -833,13 +834,12 @@ export class CameraManager extends EventDispatcher {
833
834
  && stage.frameCount > 0
834
835
  && ! stage.isComplete ) return;
835
836
 
836
- // AF screen point (0 to 1, y down) to NDC.
837
- const validHit = this.interactionManager.pickSurface( this.afScreenPoint.x * 2 - 1, 1 - this.afScreenPoint.y * 2 );
837
+ const hitPoint = this._pickFocusPoint( stage.resetCount );
838
838
 
839
839
  let rawDistance;
840
- if ( validHit ) {
840
+ if ( hitPoint ) {
841
841
 
842
- rawDistance = viewDepth( validHit.point, this.camera );
842
+ rawDistance = viewDepth( hitPoint, this.camera );
843
843
  this._lastValidFocusDistance = rawDistance;
844
844
 
845
845
  } else {
@@ -910,6 +910,32 @@ export class CameraManager extends EventDispatcher {
910
910
 
911
911
  }
912
912
 
913
+ /** Picks again only on a new view or AF point, or a render reset, which every scene change makes. @private */
914
+ _pickFocusPoint( resetCount ) {
915
+
916
+ const camera = this.camera;
917
+ const { x, y } = this.afScreenPoint;
918
+ const last = this._afPick ??= { point: new Vector3(), hit: false, resetCount: NaN, x: NaN, y: NaN, view: new Matrix4(), projection: new Matrix4() };
919
+
920
+ if ( this._afPointDirty || resetCount !== last.resetCount || x !== last.x || y !== last.y
921
+ || ! last.view.equals( camera.matrixWorld ) || ! last.projection.equals( camera.projectionMatrix ) ) {
922
+
923
+ // AF screen point (0 to 1, y down) to NDC.
924
+ const hit = this.interactionManager.pickSurface( x * 2 - 1, 1 - y * 2 );
925
+ last.hit = !! hit;
926
+ if ( hit ) last.point.copy( hit.point );
927
+ last.resetCount = resetCount;
928
+ last.x = x;
929
+ last.y = y;
930
+ last.view.copy( camera.matrixWorld );
931
+ last.projection.copy( camera.projectionMatrix );
932
+
933
+ }
934
+
935
+ return last.hit ? last.point : null;
936
+
937
+ }
938
+
913
939
  /**
914
940
  * Deferred dependency injection — InteractionManager needs the camera
915
941
  * in its constructor, so it can't be passed during CameraManager creation.