@joshtol/emotive-engine 3.2.2 → 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.
- package/dist/emotive-mascot-3d.js +1 -1
- package/dist/emotive-mascot-3d.js.map +1 -1
- package/dist/emotive-mascot-3d.umd.js +1 -1
- package/dist/emotive-mascot-3d.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/3d/Core3DManager.js +111 -308
- package/src/3d/ThreeRenderer.js +91 -6
- package/src/3d/effects/CrystalSoul.js +10 -16
- package/src/3d/effects/SolarEclipse.js +6 -8
- package/src/3d/geometries/Moon.js +3 -3
- package/src/3d/index.js +24 -8
- package/src/3d/managers/AnimationManager.js +269 -0
- package/src/3d/managers/BehaviorController.js +248 -0
- package/src/3d/managers/BreathingPhaseManager.js +163 -0
- package/src/3d/managers/CameraPresetManager.js +182 -0
- package/src/3d/managers/EffectManager.js +385 -0
- package/src/3d/utils/MaterialFactory.js +6 -5
- package/types/index.d.ts +207 -11
package/src/3d/ThreeRenderer.js
CHANGED
|
@@ -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,6 +271,9 @@ 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 {
|
|
@@ -276,14 +288,28 @@ export class ThreeRenderer {
|
|
|
276
288
|
|
|
277
289
|
try {
|
|
278
290
|
const hdrLoader = new HDRLoader();
|
|
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`);
|
|
283
291
|
|
|
284
|
-
//
|
|
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
|
+
}
|
|
309
|
+
|
|
310
|
+
// Validate texture was loaded correctly
|
|
285
311
|
if (!texture || !texture.image) {
|
|
286
|
-
throw new Error('HDR texture
|
|
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) {
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
|
608
|
-
// This
|
|
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
|
-
//
|
|
639
|
-
//
|
|
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
|
-
//
|
|
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 *
|
|
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
|
-
//
|
|
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); //
|
|
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 || '
|
|
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 || '
|
|
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 = '
|
|
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.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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;
|