rayzee 4.8.14 → 5.0.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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tracks render completion state, time limits, and sample limits.
3
+ *
4
+ * Owns: timeElapsed, lastResetTime, renderCompleteDispatched.
5
+ * Called each frame by the render loop and on reset.
6
+ */
7
+ export class CompletionTracker {
8
+
9
+ constructor() {
10
+
11
+ this.timeElapsed = 0;
12
+ this.lastResetTime = performance.now();
13
+ this.renderCompleteDispatched = false;
14
+
15
+ }
16
+
17
+ /**
18
+ * Updates elapsed time. Call each frame while rendering is active.
19
+ */
20
+ updateTime() {
21
+
22
+ this.timeElapsed = ( performance.now() - this.lastResetTime ) / 1000;
23
+
24
+ }
25
+
26
+ /**
27
+ * Checks whether the time-based render limit has been reached.
28
+ * @param {string} renderLimitMode - 'time' or 'samples'
29
+ * @param {number} renderTimeLimit - Time limit in seconds
30
+ * @returns {boolean}
31
+ */
32
+ isTimeLimitReached( renderLimitMode, renderTimeLimit ) {
33
+
34
+ return renderLimitMode === 'time' && renderTimeLimit > 0 && this.timeElapsed >= renderTimeLimit;
35
+
36
+ }
37
+
38
+ /**
39
+ * Checks whether ANY render limit (time or samples) is reached.
40
+ * @param {Object} pathTracer - The PathTracer stage
41
+ * @param {string} renderLimitMode
42
+ * @param {number} renderTimeLimit
43
+ * @returns {boolean}
44
+ */
45
+ isLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) {
46
+
47
+ if ( ! pathTracer ) return false;
48
+
49
+ if ( this.isTimeLimitReached( renderLimitMode, renderTimeLimit ) ) return true;
50
+
51
+ return pathTracer.frameCount >= pathTracer.completionThreshold;
52
+
53
+ }
54
+
55
+ /**
56
+ * Marks render as complete and returns true if this is the first time.
57
+ * @returns {boolean} true if freshly completed (should trigger denoise chain)
58
+ */
59
+ markComplete() {
60
+
61
+ if ( this.renderCompleteDispatched ) return false;
62
+ this.renderCompleteDispatched = true;
63
+ return true;
64
+
65
+ }
66
+
67
+ /**
68
+ * Resets all tracking state. Call on accumulation reset.
69
+ */
70
+ reset() {
71
+
72
+ this.timeElapsed = 0;
73
+ this.lastResetTime = performance.now();
74
+ this.renderCompleteDispatched = false;
75
+
76
+ }
77
+
78
+ /**
79
+ * Adjusts lastResetTime to account for idle time so timeElapsed
80
+ * continues from where it paused rather than including idle time.
81
+ */
82
+ resumeFromPause() {
83
+
84
+ this.renderCompleteDispatched = false;
85
+ this.lastResetTime = performance.now() - this.timeElapsed * 1000;
86
+
87
+ }
88
+
89
+ }
@@ -1,5 +1,5 @@
1
1
  import { Box3, Vector3, RectAreaLight, Color, FloatType, LinearFilter, EquirectangularReflectionMapping, LinearMipmapLinearFilter,
2
- TextureLoader, Mesh, MeshStandardMaterial, Points, PointsMaterial, LoadingManager, EventDispatcher
2
+ TextureLoader, Mesh, MeshStandardMaterial, MeshPhysicalMaterial, CircleGeometry, Points, PointsMaterial, LoadingManager, EventDispatcher
3
3
  } from 'three';
4
4
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
5
5
  import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
@@ -1214,7 +1214,7 @@ export class AssetLoader extends EventDispatcher {
1214
1214
 
1215
1215
  const light = new RectAreaLight(
1216
1216
  new Color( ...userData.color ),
1217
- userData.intensity / Math.PI, // Adjust intensity for better visual results
1217
+ userData.intensity * 0.1 / Math.PI, // Adjust intensity for better visual results
1218
1218
  userData.width,
1219
1219
  userData.height
1220
1220
  );
@@ -1253,6 +1253,30 @@ export class AssetLoader extends EventDispatcher {
1253
1253
  }
1254
1254
 
1255
1255
  // Utility methods
1256
+
1257
+ /**
1258
+ * Creates and adds a floor plane to the scene.
1259
+ * The floor plane is used for focus raycasting and ground contact.
1260
+ */
1261
+ createFloorPlane() {
1262
+
1263
+ this.floorPlane = new Mesh(
1264
+ new CircleGeometry(),
1265
+ new MeshPhysicalMaterial( {
1266
+ transparent: false,
1267
+ color: 0x303030,
1268
+ roughness: 1,
1269
+ metalness: 0,
1270
+ opacity: 0,
1271
+ transmission: 0,
1272
+ } )
1273
+ );
1274
+ this.floorPlane.name = "Ground";
1275
+ this.floorPlane.visible = false;
1276
+ this.scene.add( this.floorPlane );
1277
+
1278
+ }
1279
+
1256
1280
  setFloorPlane( floorPlane ) {
1257
1281
 
1258
1282
  this.floorPlane = floorPlane;
@@ -1297,6 +1297,152 @@ export class SceneProcessor {
1297
1297
 
1298
1298
  }
1299
1299
 
1300
+ /**
1301
+ * Computes the dirty buffer ranges for a set of affected mesh BLASes.
1302
+ * Used for partial GPU upload after per-mesh refit instead of full buffer copy.
1303
+ *
1304
+ * @param {number[]} affectedMeshIndices
1305
+ * @returns {{ triRanges: Array<{offset:number,count:number}>, bvhRanges: Array<{offset:number,count:number}> }}
1306
+ */
1307
+ computeBLASDirtyRanges( affectedMeshIndices ) {
1308
+
1309
+ const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
1310
+ const FPN = 16; // FLOATS_PER_NODE — 4 × vec4 per BVH node
1311
+ const triRanges = [];
1312
+ const bvhRanges = [];
1313
+
1314
+ for ( const meshIdx of affectedMeshIndices ) {
1315
+
1316
+ const entry = this.instanceTable.entries[ meshIdx ];
1317
+ if ( ! entry ) continue;
1318
+
1319
+ triRanges.push( { offset: entry.triOffset * FPT, count: entry.triCount * FPT } );
1320
+ bvhRanges.push( { offset: entry.blasOffset * FPN, count: entry.blasNodeCount * FPN } );
1321
+
1322
+ }
1323
+
1324
+ // Always include TLAS range (rebuilt on every refit)
1325
+ bvhRanges.push( { offset: 0, count: this.instanceTable.tlasNodeCount * FPN } );
1326
+
1327
+ return { triRanges, bvhRanges };
1328
+
1329
+ }
1330
+
1331
+ /**
1332
+ * Transfers all scene data (geometry, BVH, materials, textures, emissive, lights)
1333
+ * from this SceneProcessor to the PathTracer stage for GPU rendering.
1334
+ *
1335
+ * @param {import('../Stages/PathTracer.js').PathTracer} pathTracer
1336
+ * @param {import('../managers/LightManager.js').LightManager} lightManager
1337
+ * @param {import('three').Scene} meshScene
1338
+ * @param {import('three').Texture|null} environmentTexture
1339
+ * @returns {boolean} false if critical data is missing
1340
+ */
1341
+ uploadToPathTracer( pathTracer, lightManager, meshScene, environmentTexture ) {
1342
+
1343
+ if ( ! this.triangleData ) {
1344
+
1345
+ console.error( 'SceneProcessor: Failed to get triangle data' );
1346
+ return false;
1347
+
1348
+ }
1349
+
1350
+ pathTracer.setTriangleData( this.triangleData, this.triangleCount );
1351
+
1352
+ if ( ! this.bvhData ) {
1353
+
1354
+ console.error( 'SceneProcessor: Failed to get BVH data' );
1355
+ return false;
1356
+
1357
+ }
1358
+
1359
+ pathTracer.setBVHData( this.bvhData );
1360
+
1361
+ if ( this.materialData ) {
1362
+
1363
+ pathTracer.materialData.setMaterialData( this.materialData );
1364
+
1365
+ } else {
1366
+
1367
+ console.warn( 'SceneProcessor: No material data, using defaults' );
1368
+
1369
+ }
1370
+
1371
+ if ( environmentTexture ) {
1372
+
1373
+ pathTracer.environment.setEnvironmentTexture( environmentTexture );
1374
+
1375
+ }
1376
+
1377
+ pathTracer.materialData.setMaterialTextures( {
1378
+ albedoMaps: this.albedoTextures,
1379
+ normalMaps: this.normalTextures,
1380
+ bumpMaps: this.bumpTextures,
1381
+ roughnessMaps: this.roughnessTextures,
1382
+ metalnessMaps: this.metalnessTextures,
1383
+ emissiveMaps: this.emissiveTextures,
1384
+ displacementMaps: this.displacementTextures,
1385
+ } );
1386
+
1387
+ if ( this.emissiveTriangleData ) {
1388
+
1389
+ pathTracer.setEmissiveTriangleData(
1390
+ this.emissiveTriangleData,
1391
+ this.emissiveTriangleCount,
1392
+ this.emissiveTotalPower,
1393
+ );
1394
+
1395
+ }
1396
+
1397
+ if ( this.lightBVHNodeData ) {
1398
+
1399
+ pathTracer.setLightBVHData(
1400
+ this.lightBVHNodeData,
1401
+ this.lightBVHNodeCount,
1402
+ );
1403
+
1404
+ }
1405
+
1406
+ lightManager.transferSceneLights( meshScene );
1407
+ return true;
1408
+
1409
+ }
1410
+
1411
+ /**
1412
+ * Updates material emissive data and rebuilds emissive triangle sampling data.
1413
+ * Returns null if no change, or the updated emissive data for GPU upload.
1414
+ *
1415
+ * @param {number} materialIndex
1416
+ * @param {string} property - 'emissive' | 'emissiveIntensity' | 'visible'
1417
+ * @param {*} value
1418
+ * @returns {{ rawData: Float32Array, emissiveCount: number, totalPower: number }|null}
1419
+ */
1420
+ updateMaterialEmissive( materialIndex, property, value ) {
1421
+
1422
+ if ( ! this.emissiveTriangleBuilder ) return null;
1423
+
1424
+ const mat = this.materials[ materialIndex ];
1425
+ if ( ! mat ) return null;
1426
+
1427
+ if ( property === 'emissive' ) mat.emissive = value;
1428
+ else if ( property === 'emissiveIntensity' ) mat.emissiveIntensity = value;
1429
+ else if ( property === 'visible' ) mat.visible = value;
1430
+
1431
+ const changed = this.emissiveTriangleBuilder.updateMaterialEmissive(
1432
+ materialIndex, mat,
1433
+ this.triangleData, this.materials, this.triangleCount,
1434
+ );
1435
+
1436
+ if ( ! changed ) return null;
1437
+
1438
+ return {
1439
+ rawData: this.emissiveTriangleBuilder.createEmissiveRawData(),
1440
+ emissiveCount: this.emissiveTriangleBuilder.emissiveCount,
1441
+ totalPower: this.emissiveTriangleBuilder.totalEmissivePower,
1442
+ };
1443
+
1444
+ }
1445
+
1300
1446
  /**
1301
1447
  * Update triangle positions for a single mesh entry.
1302
1448
  * Iterates in BVH order for sequential writes (cache-friendly), random reads from newPositions.
@@ -116,42 +116,48 @@ export class TLASBuilder {
116
116
  let bestAxis = 0;
117
117
  let bestSplit = 0;
118
118
 
119
- for ( let axis = 0; axis < 3; axis ++ ) {
119
+ // Only attempt SAH when surface area is finite and positive
120
+ // degenerate/overflow AABBs (meshes far from origin) produce NaN costs.
121
+ if ( parentSA > 0 && isFinite( parentSA ) ) {
120
122
 
121
- // Sort indices by centroid along axis
122
- const sorted = indices.slice().sort( ( a, b ) => {
123
+ for ( let axis = 0; axis < 3; axis ++ ) {
123
124
 
124
- const aabbA = entries[ a ].worldAABB;
125
- const aabbB = entries[ b ].worldAABB;
126
- const cA = this._centroid( aabbA, axis );
127
- const cB = this._centroid( aabbB, axis );
128
- return cA - cB;
125
+ // Sort indices by centroid along axis
126
+ const sorted = indices.slice().sort( ( a, b ) => {
129
127
 
130
- } );
128
+ const aabbA = entries[ a ].worldAABB;
129
+ const aabbB = entries[ b ].worldAABB;
130
+ const cA = this._centroid( aabbA, axis );
131
+ const cB = this._centroid( aabbB, axis );
132
+ return cA - cB;
131
133
 
132
- // Evaluate SAH for each split position
133
- for ( let i = 1; i < sorted.length; i ++ ) {
134
+ } );
134
135
 
135
- const leftAABB = this._computeGroupAABB( entries, sorted, 0, i );
136
- const rightAABB = this._computeGroupAABB( entries, sorted, i, sorted.length );
136
+ // Evaluate SAH for each split position
137
+ for ( let i = 1; i < sorted.length; i ++ ) {
137
138
 
138
- const leftSA = this._surfaceArea(
139
- leftAABB.minX, leftAABB.minY, leftAABB.minZ,
140
- leftAABB.maxX, leftAABB.maxY, leftAABB.maxZ
141
- );
142
- const rightSA = this._surfaceArea(
143
- rightAABB.minX, rightAABB.minY, rightAABB.minZ,
144
- rightAABB.maxX, rightAABB.maxY, rightAABB.maxZ
145
- );
139
+ const leftAABB = this._computeGroupAABB( entries, sorted, 0, i );
140
+ const rightAABB = this._computeGroupAABB( entries, sorted, i, sorted.length );
146
141
 
147
- // SAH cost: traversal + (leftSA/parentSA * leftCount + rightSA/parentSA * rightCount)
148
- const cost = 1.0 + ( leftSA * i + rightSA * ( sorted.length - i ) ) / parentSA;
142
+ const leftSA = this._surfaceArea(
143
+ leftAABB.minX, leftAABB.minY, leftAABB.minZ,
144
+ leftAABB.maxX, leftAABB.maxY, leftAABB.maxZ
145
+ );
146
+ const rightSA = this._surfaceArea(
147
+ rightAABB.minX, rightAABB.minY, rightAABB.minZ,
148
+ rightAABB.maxX, rightAABB.maxY, rightAABB.maxZ
149
+ );
149
150
 
150
- if ( cost < bestCost ) {
151
+ // SAH cost: traversal + (leftSA/parentSA * leftCount + rightSA/parentSA * rightCount)
152
+ const cost = 1.0 + ( leftSA * i + rightSA * ( sorted.length - i ) ) / parentSA;
151
153
 
152
- bestCost = cost;
153
- bestAxis = axis;
154
- bestSplit = i;
154
+ if ( cost < bestCost ) {
155
+
156
+ bestCost = cost;
157
+ bestAxis = axis;
158
+ bestSplit = i;
159
+
160
+ }
155
161
 
156
162
  }
157
163
 
@@ -159,6 +165,19 @@ export class TLASBuilder {
159
165
 
160
166
  }
161
167
 
168
+ // Fallback to median split when SAH fails to find a valid partition
169
+ // (degenerate AABB, overflow surface area, or coincident centroids).
170
+ if ( bestSplit <= 0 || bestSplit >= indices.length ) {
171
+
172
+ bestAxis = 0;
173
+ const dx = maxX - minX, dy = maxY - minY, dz = maxZ - minZ;
174
+ if ( dy > dx && dy > dz ) bestAxis = 1;
175
+ else if ( dz > dx ) bestAxis = 2;
176
+
177
+ bestSplit = indices.length >> 1;
178
+
179
+ }
180
+
162
181
  // Sort along best axis and split
163
182
  const sorted = indices.slice().sort( ( a, b ) => {
164
183
 
@@ -301,10 +320,22 @@ export class TLASBuilder {
301
320
 
302
321
  }
303
322
 
304
- _countNodes( node ) {
323
+ _countNodes( root ) {
324
+
325
+ if ( ! root ) return 0;
326
+
327
+ let count = 0;
328
+ const stack = [ root ];
329
+ while ( stack.length > 0 ) {
330
+
331
+ const node = stack.pop();
332
+ count ++;
333
+ if ( node.leftChild ) stack.push( node.leftChild );
334
+ if ( node.rightChild ) stack.push( node.rightChild );
335
+
336
+ }
305
337
 
306
- if ( ! node ) return 0;
307
- return 1 + this._countNodes( node.leftChild ) + this._countNodes( node.rightChild );
338
+ return count;
308
339
 
309
340
  }
310
341
 
@@ -101,13 +101,91 @@ export class RenderSettings extends EventDispatcher {
101
101
 
102
102
  /**
103
103
  * Wires internal references. Called by PathTracerApp after init().
104
+ *
105
+ * @param {Object} params
106
+ * @param {Object} params.stages - Pipeline stages { pathTracer, display, autoExposure, ... }
107
+ * @param {Function} params.resetCallback - Called to reset accumulation
108
+ * @param {Function} [params.reconcileCompletion] - Called when completion limits change
104
109
  */
105
- bind( { pathTracer, resetCallback, handlers = {}, delegates = {} } ) {
110
+ bind( { stages, resetCallback, reconcileCompletion } ) {
106
111
 
107
- this._pathTracer = pathTracer;
112
+ this._pathTracer = stages.pathTracer;
108
113
  this._resetCallback = resetCallback;
109
- this._handlers = handlers;
110
- this._delegates = delegates;
114
+ this._delegates = {};
115
+ this._handlers = this._buildHandlers( stages, reconcileCompletion );
116
+
117
+ }
118
+
119
+ /**
120
+ * Builds handler functions for multi-stage settings that can't
121
+ * be routed with a simple uniform forward.
122
+ */
123
+ _buildHandlers( stages, reconcileCompletion ) {
124
+
125
+ return {
126
+
127
+ handleTransparentBackground: ( value ) => {
128
+
129
+ stages.pathTracer?.setUniform( 'transparentBackground', value );
130
+ stages.display?.setTransparentBackground( value );
131
+
132
+ },
133
+
134
+ handleExposure: ( value ) => {
135
+
136
+ if ( ! stages.autoExposure?.enabled ) {
137
+
138
+ stages.display?.setExposure( value );
139
+
140
+ }
141
+
142
+ },
143
+
144
+ handleSaturation: ( value ) => {
145
+
146
+ stages.display?.setSaturation( value );
147
+
148
+ },
149
+
150
+ handleRenderLimitMode: ( value ) => {
151
+
152
+ stages.pathTracer?.setRenderLimitMode?.( value );
153
+
154
+ },
155
+
156
+ handleMaxSamples: ( value ) => {
157
+
158
+ stages.pathTracer?.setUniform( 'maxSamples', value );
159
+ stages.pathTracer?.updateCompletionThreshold();
160
+ reconcileCompletion?.();
161
+
162
+ },
163
+
164
+ handleRenderTimeLimit: () => {
165
+
166
+ reconcileCompletion?.();
167
+
168
+ },
169
+
170
+ handleRenderMode: ( value ) => {
171
+
172
+ stages.pathTracer?.setUniform( 'renderMode', parseInt( value ) );
173
+
174
+ },
175
+
176
+ handleEnvironmentRotation: ( value ) => {
177
+
178
+ stages.pathTracer?.environment.setEnvironmentRotation( value );
179
+
180
+ },
181
+
182
+ handleInteractionModeEnabled: ( value ) => {
183
+
184
+ stages.pathTracer?.setInteractionModeEnabled( value );
185
+
186
+ },
187
+
188
+ };
111
189
 
112
190
  }
113
191
 
@@ -145,7 +145,7 @@ export const sampleRectAreaLight = Fn( ( [ light, rayOrigin, ruv, lightSelection
145
145
  const cosAngle = dot( direction.negate(), lightNormal ).toVar();
146
146
 
147
147
  ls_lightType.assign( int( LIGHT_TYPE_AREA ) );
148
- ls_emission.assign( light.color.mul( light.intensity ).div( PI ) );
148
+ ls_emission.assign( light.color.mul( light.intensity ) );
149
149
  ls_distance.assign( dist );
150
150
  ls_direction.assign( direction );
151
151
  // Guard division: ensure denominator is never zero
@@ -205,7 +205,7 @@ export const sampleCircAreaLight = Fn( ( [ light, rayOrigin, ruv, lightSelection
205
205
  const cosAngle = dot( direction.negate(), lightNormal ).toVar();
206
206
 
207
207
  ls_lightType.assign( int( LIGHT_TYPE_AREA ) );
208
- ls_emission.assign( light.color.mul( light.intensity ).div( PI ) );
208
+ ls_emission.assign( light.color.mul( light.intensity ) );
209
209
  ls_distance.assign( dist );
210
210
  ls_direction.assign( direction );
211
211
  // Guard division
@@ -1062,15 +1062,14 @@ export const calculateDirectLightingUnified = Fn( ( [
1062
1062
  const lightPdfWeighted = lightSample.pdf.mul( lightWeight ).toVar();
1063
1063
  const brdfPdfWeighted = bPdf.mul( brdfWeight ).toVar();
1064
1064
 
1065
- // Apply power heuristic for area lights and primary directional lights
1065
+ // Apply power heuristic only for area lights the BRDF path can
1066
+ // intersect area lights, so both strategies contribute and MIS is valid.
1067
+ // Point/spot/directional lights are delta or non-intersectable by the
1068
+ // BRDF path, so MIS would only reduce energy without compensation.
1066
1069
  If( lightSample.lightType.equal( int( LIGHT_TYPE_AREA ) ), () => {
1067
1070
 
1068
1071
  misW.assign( powerHeuristic( { pdf1: lightPdfWeighted, pdf2: brdfPdfWeighted } ) );
1069
1072
 
1070
- } ).ElseIf( bounceIndex.equal( int( 0 ) ).and( lightSample.lightType.equal( int( LIGHT_TYPE_DIRECTIONAL ) ) ), () => {
1071
-
1072
- misW.assign( powerHeuristic( { pdf1: lightPdfWeighted, pdf2: brdfPdfWeighted } ) );
1073
-
1074
1073
  } );
1075
1074
 
1076
1075
  } );
package/src/index.js CHANGED
@@ -53,15 +53,5 @@ export { TransformManager } from './managers/TransformManager.js';
53
53
  // Video rendering
54
54
  export { VideoRenderManager } from './managers/VideoRenderManager.js';
55
55
 
56
- // Sub-API facades (namespaced access via engine.camera, engine.lights, etc.)
57
- export {
58
- OutputAPI,
59
- LightsAPI,
60
- AnimationAPI,
61
- SelectionAPI,
62
- TransformAPI,
63
- CameraAPI,
64
- EnvironmentAPI,
65
- MaterialsAPI,
66
- DenoisingAPI,
67
- } from './api/index.js';
56
+ // Interaction
57
+ export { InteractionManager } from './managers/InteractionManager.js';
@@ -6,29 +6,35 @@
6
6
  * them in the format expected by PathTracerApp.refitBVH().
7
7
  */
8
8
 
9
- import { AnimationMixer, Timer, Vector3, LoopRepeat, LoopOnce } from 'three';
9
+ import { AnimationMixer, EventDispatcher, Timer, Vector3, LoopRepeat, LoopOnce } from 'three';
10
+ import { EngineEvents } from '../EngineEvents.js';
10
11
 
11
- export class AnimationManager {
12
+ export class AnimationManager extends EventDispatcher {
12
13
 
13
14
  constructor() {
14
15
 
16
+ super();
17
+
15
18
  this.mixer = null;
16
19
  this.timer = new Timer();
17
20
  this.actions = [];
18
21
  this.isPlaying = false;
19
22
 
20
- this._scene = null; // scene root (for matrixWorld updates)
21
- this._mixerRoot = null; // mixer target (GLTF model root for track resolution)
23
+ this._scene = null; // scene root (for matrixWorld updates)
24
+ this._mixerRoot = null; // mixer target (GLTF model root for track resolution)
22
25
  this._meshes = null;
23
26
  this._meshTriRanges = null; // { start, count, uniqueVerts, indices }[]
24
- this._posBuffer = null; // Float32Array(triCount * 9) — reused each frame
27
+ this._posBuffer = null; // Float32Array(triCount * 9) — reused each frame
25
28
  this._tempVec = new Vector3();
26
- this._skinnedCache = null; // per-mesh Float32Array for skinned vertex positions
29
+ this._skinnedCache = null; // per-mesh Float32Array for skinned vertex positions
27
30
  this._totalTriangleCount = 0;
28
31
  this._clipsCache = null;
29
32
  this._savedTimeScale = 1;
30
33
  this.onFinished = null; // callback when a non-looping clip ends
31
34
 
35
+ /** Injected by PathTracerApp — wakes the render loop after play/resume. */
36
+ this.wakeCallback = null;
37
+
32
38
  }
33
39
 
34
40
  /**
@@ -144,6 +150,8 @@ export class AnimationManager {
144
150
 
145
151
  this.timer.reset();
146
152
  this.isPlaying = true;
153
+ this.wakeCallback?.();
154
+ this.dispatchEvent( { type: EngineEvents.ANIMATION_STARTED } );
147
155
 
148
156
  }
149
157
 
@@ -157,6 +165,7 @@ export class AnimationManager {
157
165
  this.mixer.timeScale = 0;
158
166
  this.timer.reset();
159
167
  this.isPlaying = false;
168
+ this.dispatchEvent( { type: EngineEvents.ANIMATION_PAUSED } );
160
169
 
161
170
  }
162
171
 
@@ -170,6 +179,8 @@ export class AnimationManager {
170
179
  this.mixer.timeScale = this._savedTimeScale || 1;
171
180
  this.timer.reset();
172
181
  this.isPlaying = true;
182
+ this.wakeCallback?.();
183
+ this.dispatchEvent( { type: EngineEvents.ANIMATION_STARTED } );
173
184
 
174
185
  }
175
186
 
@@ -184,6 +195,7 @@ export class AnimationManager {
184
195
  this.mixer.timeScale = this._savedTimeScale || 1;
185
196
  this.timer.reset();
186
197
  this.isPlaying = false;
198
+ this.dispatchEvent( { type: EngineEvents.ANIMATION_STOPPED } );
187
199
 
188
200
  }
189
201