@joshtol/emotive-engine 3.2.1 → 3.2.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.
@@ -20,6 +20,7 @@ import { UnrealBloomPassAlpha } from './UnrealBloomPassAlpha.js';
20
20
  import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
21
21
  import { normalizeColorLuminance } from '../utils/glowIntensityFilter.js';
22
22
  import { GlowLayer } from './effects/GlowLayer.js';
23
+ import { CameraPresetManager } from './managers/CameraPresetManager.js';
23
24
 
24
25
  export class ThreeRenderer {
25
26
  constructor(canvas, options = {}) {
@@ -52,6 +53,11 @@ export class ThreeRenderer {
52
53
  // Set clear color with full alpha transparency for CSS backgrounds
53
54
  this.renderer.setClearColor(0x000000, 0);
54
55
 
56
+ // CRITICAL: Clear the canvas immediately after renderer creation
57
+ // This ensures no garbage data is visible before the first frame renders
58
+ // Uninitialized GPU framebuffers can contain random colors (often magenta/red)
59
+ this.renderer.clear();
60
+
55
61
  // CRITICAL: Disable autoClear for transparency to work in newer Three.js versions
56
62
  this.renderer.autoClear = false;
57
63
 
@@ -191,6 +197,9 @@ export class ThreeRenderer {
191
197
  // Listen for pointer events at capture phase for earliest possible handling
192
198
  this.renderer.domElement.addEventListener('pointermove', immediateUpdate, { passive: true });
193
199
  this.renderer.domElement.addEventListener('pointerdown', immediateUpdate, { passive: true });
200
+
201
+ // Initialize camera preset manager for smooth view transitions
202
+ this.cameraPresetManager = new CameraPresetManager(this.camera, this.controls, this.cameraDistance);
194
203
  }
195
204
 
196
205
  /**
@@ -262,11 +271,14 @@ export class ThreeRenderer {
262
271
  // Guard against calls after destroy (React Strict Mode can unmount during async load)
263
272
  if (this._destroyed) return;
264
273
 
274
+ // Mark environment loading as in-progress to prevent rendering magenta flash
275
+ this._envMapLoading = true;
276
+
265
277
  // Try to load optional HDRI (.hdr format) for enhanced reflections
266
278
  // HDRI is optional - apps can place studio_1k.hdr in /hdri/ for better crystal reflections
267
279
  try {
268
- // RGBELoader is the standard HDR loader (deprecation warning is a Three.js internal issue)
269
- const { RGBELoader } = await import('three/examples/jsm/loaders/RGBELoader.js');
280
+ // HDRLoader replaces deprecated RGBELoader in Three.js r169+
281
+ const { HDRLoader } = await import('three/examples/jsm/loaders/HDRLoader.js');
270
282
 
271
283
  // Check if destroyed during import (React Strict Mode)
272
284
  if (this._destroyed) return;
@@ -275,15 +287,29 @@ export class ThreeRenderer {
275
287
  pmremGenerator.compileEquirectangularShader();
276
288
 
277
289
  try {
278
- const hdrLoader = new RGBELoader();
279
- const assetBasePath = this.options.assetBasePath || '/assets';
280
- // HDRI is in public root, not in assets folder
281
- const hdriBasePath = assetBasePath.replace('/assets', '');
282
- const texture = await hdrLoader.loadAsync(`${hdriBasePath}/hdri/studio_1k.hdr`);
290
+ const hdrLoader = new HDRLoader();
291
+
292
+ // Try multiple paths to support different server configurations:
293
+ // - /hdri/... for Next.js (serves site/public at root)
294
+ // - /site/public/hdri/... for Live Server (serves project root)
295
+ const hdrPaths = [
296
+ '/hdri/studio_1k.hdr',
297
+ '/site/public/hdri/studio_1k.hdr'
298
+ ];
299
+
300
+ let texture = null;
301
+ for (const hdrPath of hdrPaths) {
302
+ try {
303
+ texture = await hdrLoader.loadAsync(hdrPath);
304
+ if (texture && texture.image) break;
305
+ } catch (e) {
306
+ // Try next path
307
+ }
308
+ }
283
309
 
284
- // Validate texture was loaded correctly (404 can return malformed texture)
310
+ // Validate texture was loaded correctly
285
311
  if (!texture || !texture.image) {
286
- throw new Error('HDR texture loaded but image data is missing');
312
+ throw new Error('HDR texture not found at any path');
287
313
  }
288
314
 
289
315
  // Check if destroyed during load (React Strict Mode)
@@ -297,6 +323,7 @@ export class ThreeRenderer {
297
323
  this.envMap = pmremGenerator.fromEquirectangular(texture).texture;
298
324
  texture.dispose(); // CRITICAL: Dispose source texture after PMREM conversion (GPU memory leak fix)
299
325
  pmremGenerator.dispose();
326
+ this._envMapLoading = false; // HDRI loaded successfully
300
327
  console.log('[Emotive] HDRI environment map loaded');
301
328
  return;
302
329
  } catch (hdrError) {
@@ -304,7 +331,7 @@ export class ThreeRenderer {
304
331
  pmremGenerator.dispose();
305
332
  }
306
333
  } catch (error) {
307
- // RGBELoader not available - use procedural envmap
334
+ // HDRLoader not available - use procedural envmap
308
335
  }
309
336
 
310
337
  // Check if destroyed before fallback (React Strict Mode)
@@ -346,6 +373,8 @@ export class ThreeRenderer {
346
373
  this._envScene = envScene;
347
374
  this._envCubeCamera = cubeCamera;
348
375
 
376
+ // Mark environment loading as complete (using fallback)
377
+ this._envMapLoading = false;
349
378
  }
350
379
 
351
380
  /**
@@ -371,6 +400,17 @@ export class ThreeRenderer {
371
400
  });
372
401
  this.composer = new EffectComposer(this.renderer, renderTarget);
373
402
 
403
+ // CRITICAL: Clear ALL composer render targets to prevent garbage data flash
404
+ // The composer creates internal read/write buffers that contain uninitialized GPU memory
405
+ // This can show as random colors (often magenta/red) on first frame before RenderPass clears
406
+ this.renderer.setRenderTarget(renderTarget);
407
+ this.renderer.clear();
408
+ this.renderer.setRenderTarget(this.composer.readBuffer);
409
+ this.renderer.clear();
410
+ this.renderer.setRenderTarget(this.composer.writeBuffer);
411
+ this.renderer.clear();
412
+ this.renderer.setRenderTarget(null);
413
+
374
414
  // Render pass - base scene render
375
415
  const renderPass = new RenderPass(this.scene, this.camera);
376
416
  // CRITICAL: Set clear color to transparent for CSS background blending
@@ -397,6 +437,10 @@ export class ThreeRenderer {
397
437
  this.bloomPass.renderToScreen = true; // CRITICAL: Last pass must render to screen
398
438
  this.composer.addPass(this.bloomPass);
399
439
 
440
+ // CRITICAL: Clear bloom buffers immediately after creation to prevent garbage data flash
441
+ // Uninitialized GPU memory can contain random colors (often red/magenta on some drivers)
442
+ this.bloomPass.clearBloomBuffers(this.renderer);
443
+
400
444
  // === SEPARATE PARTICLE BLOOM PIPELINE ===
401
445
  // Particles get their own render target with NON-BLACK clear color
402
446
  // This prevents dark halos from blur sampling black transparent pixels
@@ -411,6 +455,11 @@ export class ThreeRenderer {
411
455
  depthBuffer: true
412
456
  });
413
457
 
458
+ // Clear particle render target
459
+ this.renderer.setRenderTarget(this.particleRenderTarget);
460
+ this.renderer.clear();
461
+ this.renderer.setRenderTarget(null);
462
+
414
463
  // Particle bloom pass (same settings but separate pipeline)
415
464
  this.particleBloomPass = new UnrealBloomPassAlpha(
416
465
  bloomResolution,
@@ -426,6 +475,9 @@ export class ThreeRenderer {
426
475
  // Skip the base copy step - we only want to add bloom on top of existing scene
427
476
  this.particleBloomPass.skipBaseCopy = true;
428
477
 
478
+ // Clear particle bloom buffers too
479
+ this.particleBloomPass.clearBloomBuffers(this.renderer);
480
+
429
481
  // === SOUL REFRACTION RENDER TARGET ===
430
482
  // Soul mesh is rendered to this texture first, then sampled by crystal shader
431
483
  // with refraction distortion to create proper lensing effect
@@ -440,6 +492,11 @@ export class ThreeRenderer {
440
492
  depthBuffer: true
441
493
  });
442
494
 
495
+ // Clear soul render target
496
+ this.renderer.setRenderTarget(this.soulRenderTarget);
497
+ this.renderer.clear();
498
+ this.renderer.setRenderTarget(null);
499
+
443
500
  // Composite shader to blend particle bloom onto main scene
444
501
  this.particleCompositeShader = {
445
502
  uniforms: {
@@ -1163,6 +1220,28 @@ export class ThreeRenderer {
1163
1220
  return;
1164
1221
  }
1165
1222
 
1223
+ // Guard against rendering before coreMesh exists (prevents magenta flash from empty scene + bloom)
1224
+ if (!this.coreMesh) {
1225
+ return;
1226
+ }
1227
+
1228
+ // Guard against rendering while environment map is loading (prevents magenta flash from missing HDR)
1229
+ // Three.js shows magenta when textures are pending/failed
1230
+ if (this._envMapLoading) {
1231
+ return;
1232
+ }
1233
+
1234
+ // CRITICAL: Skip the very first frame to allow GPU buffers to be properly initialized
1235
+ // This prevents garbage data (often magenta) from flashing before the first real render
1236
+ if (!this._firstFrameRendered) {
1237
+ this._firstFrameRendered = true;
1238
+ // Clear screen to transparent on first frame instead of rendering garbage
1239
+ this.renderer.setRenderTarget(null);
1240
+ this.renderer.setClearColor(0x000000, 0);
1241
+ this.renderer.clear();
1242
+ return;
1243
+ }
1244
+
1166
1245
  // DEBUG: Check scene for null children before render and REMOVE them
1167
1246
  // Also recursively check children of children
1168
1247
  const validateObject = (obj, path) => {
@@ -1625,6 +1704,12 @@ export class ThreeRenderer {
1625
1704
  this.glowLayer = null;
1626
1705
  }
1627
1706
 
1707
+ // Dispose camera preset manager
1708
+ if (this.cameraPresetManager) {
1709
+ this.cameraPresetManager.dispose();
1710
+ this.cameraPresetManager = null;
1711
+ }
1712
+
1628
1713
  // Dispose controls (removes DOM event listeners)
1629
1714
  if (this.controls) {
1630
1715
  this.controls.dispose();
@@ -572,8 +572,7 @@ export class CrystalSoul {
572
572
 
573
573
  /**
574
574
  * Dispose of resources
575
- * Removes mesh from scene SYNCHRONOUSLY to avoid render race conditions,
576
- * then defers geometry/material disposal to next frame for safety
575
+ * Defers scene removal AND geometry disposal to next frame to avoid Three.js render race conditions
577
576
  */
578
577
  dispose() {
579
578
  // Prevent double disposal
@@ -582,31 +581,26 @@ export class CrystalSoul {
582
581
  // Set disposed flag FIRST to prevent async callbacks from running
583
582
  this._disposed = true;
584
583
 
585
- // Mark invisible immediately
586
- if (this.mesh) {
587
- this.mesh.visible = false;
588
- }
584
+ // Mark invisible immediately (detach just sets visible=false now)
585
+ this.detach();
589
586
 
590
587
  // Store references for deferred cleanup
591
588
  const meshToDispose = this.mesh;
592
589
  const materialToDispose = this.material;
593
590
 
594
- // Remove from scene SYNCHRONOUSLY - this prevents the render loop from
595
- // trying to traverse a mesh that's being disposed. The key insight is that
596
- // scene.remove() is safe during the current frame because Three.js only
597
- // traverses children that are currently in the array when render starts.
598
- if (meshToDispose?.parent) {
599
- meshToDispose.parent.remove(meshToDispose);
600
- }
601
-
602
591
  // Clear references immediately (so nothing tries to use them)
603
592
  this.mesh = null;
604
593
  this.material = null;
605
594
  this.parentMesh = null;
606
595
 
607
- // Defer ONLY geometry/material disposal to next frame
608
- // This is safe because the mesh is already removed from the scene
596
+ // Defer BOTH scene removal AND geometry disposal to next frame
597
+ // This avoids Three.js render race conditions with projectObject traversal
609
598
  requestAnimationFrame(() => {
599
+ // Remove from scene first
600
+ if (meshToDispose?.parent) {
601
+ meshToDispose.parent.remove(meshToDispose);
602
+ }
603
+ // Then dispose resources
610
604
  if (meshToDispose?.geometry) {
611
605
  meshToDispose.geometry.dispose();
612
606
  }
@@ -635,23 +635,21 @@ export class SolarEclipse {
635
635
  const worldScale = sunScale.x; // World scale for consistent sizing
636
636
 
637
637
  // Calculate corona fade multiplier for morph transitions
638
- // Match the geometry morpher's scale curve so rays and core appear simultaneously
639
- // GeometryMorpher uses easeOutQuad for grow phase: t * (2 - t)
640
- // GeometryMorpher uses easeInQuad for shrink phase: t * t
638
+ // Entry (Anything->Sun): rays fade in with inverse cubic (slow start, fast finish - bloom effect)
639
+ // Exit (Sun->Anything): rays fade out with cubic (fast start, slow finish - rays lead)
641
640
  let coronaMorphFade = 1.0;
642
641
  if (morphProgress !== null && morphProgress > 0.5) {
643
642
  // Grow phase (entry): progress 0.5->1.0
644
- // Use easeOutQuad to match geometry scale: t * (2 - t)
645
- // This makes rays appear at the same rate as the sun core
643
+ // Inverse of exit: slow start, accelerating finish (rays bloom from sun)
646
644
  const t = (morphProgress - 0.5) * 2; // 0 to 1 as grow progresses
647
- coronaMorphFade = t * (2 - t); // easeOutQuad: matches geometry grow curve
645
+ coronaMorphFade = t * t * t; // Cubic: starts slow at 0, accelerates to 1
648
646
  } else if (morphProgress !== null && morphProgress <= 0.5) {
649
647
  // Shrink phase (exit): morphProgress goes 0->0.5
650
648
  // At start (morphProgress=0): rays should be full (1.0)
651
649
  // At end (morphProgress=0.5): rays should be gone (0.0)
652
- // Use inverse easeInQuad to match geometry shrink: 1 - t*t
650
+ // Rays fade FAST (cubic) - they lead ahead of sun's shrink
653
651
  const t = morphProgress * 2; // 0 to 1 as shrink progresses
654
- coronaMorphFade = 1.0 - (t * t); // Inverse easeInQuad: matches geometry shrink curve
652
+ coronaMorphFade = (1.0 - t) * (1.0 - t) * (1.0 - t); // Cubic: starts at 1, drops fast to 0
655
653
  }
656
654
 
657
655
  // Update time for corona animation
@@ -217,7 +217,7 @@ export function disposeMoon(moonMesh) {
217
217
  * @returns {THREE.MeshStandardMaterial}
218
218
  */
219
219
  export function createMoonMaterial(textureLoader, options = {}) {
220
- const resolution = options.resolution || '2k';
220
+ const resolution = options.resolution || '4k';
221
221
  const assetBasePath = options.assetBasePath || '/assets';
222
222
 
223
223
  // Determine texture paths based on resolution
@@ -338,7 +338,7 @@ export function createMoonFallbackMaterial(glowColor = new THREE.Color(0xffffff)
338
338
  * @returns {THREE.ShaderMaterial}
339
339
  */
340
340
  export function createMoonShadowMaterial(textureLoader, options = {}) {
341
- const resolution = options.resolution || '2k';
341
+ const resolution = options.resolution || '4k';
342
342
  const glowColor = options.glowColor || new THREE.Color(1, 1, 1);
343
343
  const glowIntensity = options.glowIntensity || 1.0;
344
344
  const shadowType = options.shadowType || 'crescent';
@@ -667,7 +667,7 @@ export function updateCrescentShadow(material, offsetX, offsetY, coverage) {
667
667
  */
668
668
  export function createMoonMultiplexerMaterial(textureLoader, options = {}) {
669
669
  const {
670
- resolution = '2k',
670
+ resolution = '4k',
671
671
  glowColor = new THREE.Color(0xffffff),
672
672
  glowIntensity = 1.0,
673
673
  assetBasePath = '/assets'
package/src/3d/index.js CHANGED
@@ -252,17 +252,21 @@ export class EmotiveMascot3D {
252
252
  this.container.appendChild(this.canvas2D);
253
253
 
254
254
  // Create WebGL canvas for 3D core (Layer 2 - front)
255
+ // CRITICAL: Canvas is NOT appended to DOM here - it will be appended after first render
256
+ // This prevents any garbage data from being visible during GPU initialization
255
257
  this.webglCanvas = document.createElement('canvas');
256
258
  this.webglCanvas.id = `${this.config.canvasId}-3d`;
257
259
  this.webglCanvas.width = this.canvas2D.width;
258
260
  this.webglCanvas.height = this.canvas2D.height;
259
- this.webglCanvas.style.position = 'absolute';
260
- this.webglCanvas.style.top = '0';
261
- this.webglCanvas.style.left = '0';
262
- this.webglCanvas.style.width = '100%';
263
- this.webglCanvas.style.height = '100%';
264
- this.webglCanvas.style.background = 'transparent';
265
- this.webglCanvas.style.zIndex = '2';
261
+ this.webglCanvas.style.cssText = `
262
+ position: absolute;
263
+ top: 0;
264
+ left: 0;
265
+ width: 100%;
266
+ height: 100%;
267
+ background: transparent;
268
+ z-index: 2;
269
+ `;
266
270
  // Only enable pointer events if controls are enabled (for orbit camera)
267
271
  // Otherwise, let events pass through to allow page scrolling
268
272
  if (this.config.enableControls) {
@@ -274,7 +278,8 @@ export class EmotiveMascot3D {
274
278
  this.webglCanvas.style.pointerEvents = 'none';
275
279
  this.webglCanvas.style.touchAction = 'auto';
276
280
  }
277
- this.container.appendChild(this.webglCanvas);
281
+ // NOTE: Canvas is NOT appended here - see animate() for deferred append after first render
282
+ this._canvasAppended = false;
278
283
  }
279
284
 
280
285
  /**
@@ -336,6 +341,14 @@ export class EmotiveMascot3D {
336
341
  // Render 3D core - check again as state may have changed
337
342
  if (this.core3D && !this._destroyed) {
338
343
  this.core3D.render(deltaTime);
344
+
345
+ // CRITICAL: Append canvas to DOM after first frame renders
346
+ // Canvas is created but NOT added to DOM during setup to prevent garbage data flash
347
+ // Only after the first successful render do we add it to the document
348
+ if (!this._canvasAppended && this.webglCanvas && this.container) {
349
+ this.container.appendChild(this.webglCanvas);
350
+ this._canvasAppended = true;
351
+ }
339
352
  }
340
353
 
341
354
  // Render 2D particles (or clear canvas if disabled)
@@ -1745,3 +1758,6 @@ export {
1745
1758
 
1746
1759
  // Export rhythm 3D adapter for advanced rhythm sync customization
1747
1760
  export { rhythm3DAdapter, Rhythm3DAdapter } from './animation/Rhythm3DAdapter.js';
1761
+
1762
+ // Export CrystalSoul for geometry preloading
1763
+ export { CrystalSoul } from './effects/CrystalSoul.js';
@@ -0,0 +1,269 @@
1
+ /**
2
+ * AnimationManager - Gesture animation orchestration for 3D mascot
3
+ *
4
+ * Manages gesture playback, virtual particle pools, and animation lifecycle.
5
+ * Extracted from Core3DManager to improve separation of concerns.
6
+ *
7
+ * @module 3d/managers/AnimationManager
8
+ */
9
+
10
+ import { getGesture } from '../../core/gestures/index.js';
11
+
12
+ /**
13
+ * Maximum number of concurrent animations to prevent memory issues
14
+ */
15
+ const MAX_ACTIVE_ANIMATIONS = 10;
16
+
17
+ /**
18
+ * Default pool size for virtual particles
19
+ */
20
+ const DEFAULT_POOL_SIZE = 5;
21
+
22
+ export class AnimationManager {
23
+ /**
24
+ * Create animation manager
25
+ * @param {ProceduralAnimator} animator - The procedural animator instance
26
+ * @param {GestureBlender} gestureBlender - The gesture blender for combining animations
27
+ */
28
+ constructor(animator, gestureBlender) {
29
+ this.animator = animator;
30
+ this.gestureBlender = gestureBlender;
31
+
32
+ // Virtual particle pool for gesture animations (prevents closure memory leaks)
33
+ this.virtualParticlePool = this._createVirtualParticlePool(DEFAULT_POOL_SIZE);
34
+ this.nextPoolIndex = 0;
35
+ }
36
+
37
+ /**
38
+ * Create reusable virtual particle object pool
39
+ * @param {number} size - Pool size
40
+ * @returns {Array} Array of reusable particle objects
41
+ * @private
42
+ */
43
+ _createVirtualParticlePool(size) {
44
+ const pool = [];
45
+ for (let i = 0; i < size; i++) {
46
+ pool.push({
47
+ x: 0,
48
+ y: 0,
49
+ vx: 0,
50
+ vy: 0,
51
+ size: 1,
52
+ baseSize: 1,
53
+ opacity: 1,
54
+ scaleFactor: 1,
55
+ gestureData: null
56
+ });
57
+ }
58
+ return pool;
59
+ }
60
+
61
+ /**
62
+ * Get next virtual particle from pool (round-robin)
63
+ * @returns {Object} Reusable virtual particle object
64
+ */
65
+ getVirtualParticleFromPool() {
66
+ const particle = this.virtualParticlePool[this.nextPoolIndex];
67
+ this.nextPoolIndex = (this.nextPoolIndex + 1) % this.virtualParticlePool.length;
68
+ // Reset particle state
69
+ particle.x = 0;
70
+ particle.y = 0;
71
+ particle.vx = 0;
72
+ particle.vy = 0;
73
+ particle.size = 1;
74
+ particle.baseSize = 1;
75
+ particle.opacity = 1;
76
+ particle.scaleFactor = 1;
77
+ particle.gestureData = null;
78
+ return particle;
79
+ }
80
+
81
+ /**
82
+ * Play gesture animation using 2D gesture data translated to 3D
83
+ * @param {string} gestureName - Name of the gesture to play
84
+ * @param {Object} callbacks - Callback functions
85
+ * @param {Function} callbacks.onUpdate - Called with (props, progress) during animation
86
+ * @param {Function} callbacks.onComplete - Called when animation completes
87
+ * @returns {boolean} True if gesture was started successfully
88
+ */
89
+ playGesture(gestureName, callbacks = {}) {
90
+ // Get the 2D gesture definition
91
+ const gesture2D = getGesture(gestureName);
92
+
93
+ if (!gesture2D) {
94
+ console.warn(`Unknown gesture: ${gestureName}`);
95
+ return false;
96
+ }
97
+
98
+ // Get reusable virtual particle from pool (prevent closure memory leaks)
99
+ const virtualParticle = this.getVirtualParticleFromPool();
100
+
101
+ // Get gesture config for duration
102
+ const config = gesture2D.config || {};
103
+ const duration = config.musicalDuration?.musical
104
+ ? (config.musicalDuration.beats || 2) * 500 // Assume 120 BPM (500ms per beat)
105
+ : (config.duration || 800);
106
+
107
+ // Start time-based animation
108
+ const startTime = this.animator.time;
109
+
110
+ // Enforce animation array size limit (prevent unbounded growth memory leak)
111
+ if (this.animator.animations.length >= MAX_ACTIVE_ANIMATIONS) {
112
+ // Remove oldest animation (FIFO cleanup)
113
+ const removed = this.animator.animations.shift();
114
+ console.warn(`Animation limit reached (${MAX_ACTIVE_ANIMATIONS}), removed oldest: ${removed.gestureName || 'unknown'}`);
115
+ }
116
+
117
+ // Create persistent gesture data object for this gesture instance
118
+ const gestureData = { initialized: false };
119
+
120
+ // Add to animator's active animations
121
+ this.animator.animations.push({
122
+ gestureName, // Store gesture name for particle system
123
+ duration,
124
+ startTime,
125
+ config, // Store config for particle system
126
+ evaluate: t => {
127
+ // Reset virtual particle to center each frame
128
+ virtualParticle.x = 0;
129
+ virtualParticle.y = 0;
130
+ virtualParticle.vx = 0;
131
+ virtualParticle.vy = 0;
132
+ virtualParticle.size = 1;
133
+ virtualParticle.opacity = 1;
134
+
135
+ // All gestures now have native 3D implementations
136
+ // Apply gesture to virtual particle if needed
137
+ if (gesture2D.apply) {
138
+ gesture2D.apply(virtualParticle, gestureData, config, t, 1.0, 0, 0);
139
+ }
140
+
141
+ // Call gesture's 3D evaluate function with particle data
142
+ const motion = {
143
+ ...config,
144
+ particle: virtualParticle,
145
+ config,
146
+ strength: config.strength || 1.0
147
+ };
148
+
149
+ // Safety check: if gesture doesn't have 3D implementation, return neutral transform
150
+ if (!gesture2D['3d'] || !gesture2D['3d'].evaluate) {
151
+ return {
152
+ position: [0, 0, 0],
153
+ rotation: [0, 0, 0],
154
+ scale: 1.0
155
+ };
156
+ }
157
+
158
+ // Call with gesture2D as context so 'this.config' works
159
+ return gesture2D['3d'].evaluate.call(gesture2D, t, motion);
160
+ },
161
+ callbacks: {
162
+ onUpdate: callbacks.onUpdate || null,
163
+ onComplete: () => {
164
+ // Clean up gesture
165
+ if (gesture2D.cleanup) {
166
+ gesture2D.cleanup(virtualParticle);
167
+ }
168
+ // Call user callback
169
+ if (callbacks.onComplete) {
170
+ callbacks.onComplete();
171
+ }
172
+ }
173
+ }
174
+ });
175
+
176
+ return true;
177
+ }
178
+
179
+ /**
180
+ * Update animations for current frame
181
+ * @param {number} deltaTime - Time since last frame in seconds
182
+ */
183
+ update(deltaTime) {
184
+ this.animator.update(deltaTime);
185
+ }
186
+
187
+ /**
188
+ * Blend all active animations
189
+ * @param {THREE.Quaternion} baseQuaternion - Base rotation quaternion
190
+ * @param {number} baseScale - Base scale value
191
+ * @param {number} baseGlowIntensity - Base glow intensity
192
+ * @returns {Object} Blended animation state
193
+ */
194
+ blend(baseQuaternion, baseScale, baseGlowIntensity) {
195
+ return this.gestureBlender.blend(
196
+ this.animator.animations,
197
+ this.animator.time,
198
+ baseQuaternion,
199
+ baseScale,
200
+ baseGlowIntensity
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Check if any animations are active
206
+ * @returns {boolean} True if animations are playing
207
+ */
208
+ hasActiveAnimations() {
209
+ return this.animator.animations.length > 0;
210
+ }
211
+
212
+ /**
213
+ * Get count of active animations
214
+ * @returns {number} Number of active animations
215
+ */
216
+ getActiveAnimationCount() {
217
+ return this.animator.animations.length;
218
+ }
219
+
220
+ /**
221
+ * Get current animator time
222
+ * @returns {number} Current animation time
223
+ */
224
+ getTime() {
225
+ return this.animator.time;
226
+ }
227
+
228
+ /**
229
+ * Get active animations array (for external systems like particle translator)
230
+ * @returns {Array} Active animations
231
+ */
232
+ getActiveAnimations() {
233
+ return this.animator.animations;
234
+ }
235
+
236
+ /**
237
+ * Stop all animations
238
+ */
239
+ stopAll() {
240
+ this.animator.stopAll();
241
+ }
242
+
243
+ /**
244
+ * Play emotion animation
245
+ * @param {string} emotion - Emotion name
246
+ */
247
+ playEmotion(emotion) {
248
+ this.animator.playEmotion(emotion);
249
+ }
250
+
251
+ /**
252
+ * Clean up and dispose resources
253
+ */
254
+ dispose() {
255
+ this.stopAll();
256
+
257
+ if (this.virtualParticlePool) {
258
+ this.virtualParticlePool.length = 0;
259
+ this.virtualParticlePool = null;
260
+ }
261
+
262
+ this.animator = null;
263
+ this.gestureBlender = null;
264
+ this.tempEuler = null;
265
+ this.gestureQuaternion = null;
266
+ }
267
+ }
268
+
269
+ export default AnimationManager;