space-game/src/environment/asteroids/explosionManager.ts
Michael Mainguy b46f44e32d
All checks were successful
Build / build (push) Successful in 1m45s
Performance fixes and debug features
- Fix duplicate render loops causing 50% FPS drop (70→40)
  - Add stopRenderLoop() before runRenderLoop() in level1.ts and levelSelectedHandler.ts
- Add ?loglevel=debug|info|warn|error query parameter
- Add Y button to toggle inspector in XR
- Throttle scoreboard updates to every 10 frames
- Throttle game-end condition checks to every 30 frames
- Remove per-frame logging from explosion animations
- Reduce background stars from 5000 to 2500
- Freeze asteroid material after loading
- Reduce physics substeps from 5 to 2
- Disable autoClear for Quest 2 performance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 07:58:15 -06:00

355 lines
13 KiB
TypeScript

import {
AbstractMesh, AudioEngineV2, Color3, InstancedMesh,
Mesh, MeshBuilder,
MeshExploder,
Scene, SoundState, StandardMaterial, StaticSound,
TransformNode,
Vector3
} from "@babylonjs/core";
import {DefaultScene} from "../../core/defaultScene";
import log from '../../core/logger';
/**
* Configuration for explosion effects
*/
interface ExplosionConfig {
/** Duration of explosion in milliseconds */
duration?: number;
/** Maximum explosion force (how far pieces spread) */
explosionForce?: number;
/** Frame rate for explosion animation */
frameRate?: number;
}
/**
* Manages mesh explosion effects using BabylonJS MeshExploder
*/
export class ExplosionManager {
private scene: Scene;
private config: Required<ExplosionConfig>;
private _debrisBaseMesh: Mesh;
private audioEngine: AudioEngineV2 | null = null;
private explosionSounds: StaticSound[] = [];
private soundPoolSize: number = 5;
// Default configuration
private static readonly DEFAULT_CONFIG: Required<ExplosionConfig> = {
duration: 2000,
explosionForce: 10,
frameRate: 60
};
constructor(scene: Scene, config?: ExplosionConfig) {
this.scene = scene;
this.config = { ...ExplosionManager.DEFAULT_CONFIG, ...config };
log.debug(this.config);
this._debrisBaseMesh = MeshBuilder.CreateIcoSphere(
'debrisBase',
{
radius: 1,
subdivisions: 2
}, DefaultScene.MainScene
);
const debrisMaterial = new StandardMaterial('debrisMaterial', DefaultScene.MainScene);
debrisMaterial.emissiveColor = new Color3(1,1,0);
this._debrisBaseMesh.material = debrisMaterial;
this._debrisBaseMesh.setEnabled(false);
}
/**
* Initialize the explosion manager (no longer needed for MeshExploder, but kept for API compatibility)
*/
public async initialize(): Promise<void> {
log.debug("ExplosionManager initialized with MeshExploder");
}
/**
* Initialize audio for explosions (called after audio engine is unlocked)
*/
public async initAudio(audioEngine: AudioEngineV2): Promise<void> {
this.audioEngine = audioEngine;
log.debug(`ExplosionManager: Initializing audio with pool size ${this.soundPoolSize}`);
// Create sound pool for concurrent explosions
for (let i = 0; i < this.soundPoolSize; i++) {
const sound = await audioEngine.createSoundAsync(
`explosionSound_${i}`,
"/assets/themes/default/audio/explosion.mp3",
{
loop: false,
volume: 1.0,
spatialEnabled: true,
spatialDistanceModel: "linear",
spatialMaxDistance: 500,
spatialMinUpdateTime: 0.5,
spatialRolloffFactor: 1
}
);
this.explosionSounds.push(sound);
}
log.debug(`ExplosionManager: Loaded ${this.explosionSounds.length} explosion sounds`);
}
/**
* Get an available sound from the pool
*/
private getAvailableSound(): StaticSound | null {
// Find a sound that's not currently playing
for (const sound of this.explosionSounds) {
if (sound.state !== SoundState.Started && sound.state !== SoundState.Starting) {
return sound;
}
}
// If all sounds are playing, reuse the first one (will cut off the oldest)
log.debug("ExplosionManager: All sounds in pool are playing, reusing sound 0");
return this.explosionSounds[0] || null;
}
/**
* Play explosion audio at a specific position
*/
private playExplosionAudio(position: Vector3): void {
if (!this.audioEngine) {
// Audio not initialized, skip silently
return;
}
const sound = this.getAvailableSound();
if (!sound) {
log.debug("ExplosionManager: No sound available in pool");
return;
}
// Create lightweight TransformNode for spatial audio positioning
const explosionNode = new TransformNode(`explosionAudio_${Date.now()}`, this.scene);
explosionNode.position = position.clone();
try {
// Attach spatial sound to the node
sound.spatial.attach(explosionNode);
sound.play();
sound.onEndedObservable.addOnce(() => {
//Cleanup after sound ends.
sound.spatial.detach();
explosionNode.dispose();
})
} catch (error) {
log.debug("ExplosionManager: Error playing explosion audio", error);
explosionNode.dispose();
}
}
/**
* Create sphere debris pieces for explosion
* MeshExploder requires an array of separate meshes
* @param mesh The mesh to explode (used for position/scale)
* @param pieces Number of pieces to create
* @returns Array of sphere mesh objects
*/
private splitIntoSeparateMeshes(position: Vector3, pieces: number = 32): InstancedMesh[] {
log.debug(`[ExplosionManager] Creating ${pieces} sphere debris pieces`);
const meshPieces: InstancedMesh[] = [];
// Create material for debris
for (let i = 0; i < pieces; i++) {
try {
// Create a small sphere for debris
const sphere = new InstancedMesh(
`debris_${i}`,
this._debrisBaseMesh);
// Position spheres in a small cluster around the original position
const offsetRadius = 1;
const angle1 = (i / pieces) * Math.PI * 2;
const angle2 = Math.random() * Math.PI;
sphere.position = new Vector3(
position.x + Math.sin(angle2) * Math.cos(angle1) * offsetRadius,
position.y + Math.sin(angle2) * Math.sin(angle1) * offsetRadius,
position.z + Math.cos(angle2) * offsetRadius
);
sphere.isVisible = true;
sphere.setEnabled(true);
meshPieces.push(sphere);
} catch (error) {
log.error(`[ExplosionManager] ERROR creating debris piece ${i}:`, error);
}
}
log.debug(`[ExplosionManager] Successfully created ${meshPieces.length}/${pieces} sphere debris pieces`);
if (meshPieces.length > 0) {
log.debug('[ExplosionManager] First piece sample:', {
name: meshPieces[0].name,
position: meshPieces[0].position.toString(),
isVisible: meshPieces[0].isVisible,
isEnabled: meshPieces[0].isEnabled()
});
}
return meshPieces;
}
/**
* Explode a mesh by breaking it into pieces and animating them outward
* @param mesh The mesh to explode (will be cloned internally)
*/
public playExplosion(mesh: AbstractMesh): void {
log.debug('[ExplosionManager] playExplosion called');
log.debug('[ExplosionManager] Input mesh:', {
name: mesh.name,
id: mesh.id,
isInstancedMesh: !!(mesh as any).sourceMesh,
position: mesh.position.toString(),
scaling: mesh.scaling.toString()
});
// Play explosion audio at the mesh's position
const explosionPosition = mesh.getAbsolutePosition();
this.playExplosionAudio(explosionPosition);
// Get the source mesh if this is an instanced mesh
let sourceMesh: Mesh;
if ((mesh as any).sourceMesh) {
sourceMesh = (mesh as any).sourceMesh as Mesh;
log.debug('[ExplosionManager] Using source mesh from instance:', sourceMesh.name);
} else {
sourceMesh = mesh as Mesh;
log.debug('[ExplosionManager] Using mesh directly (not instanced)');
}
// Clone the source mesh so we don't affect the original
log.debug('[ExplosionManager] Cloning mesh...');
mesh.computeWorldMatrix(true);
// Apply the instance's transformation to the cloned mesh
const position = mesh.getAbsolutePosition().clone();
// Force world matrix computation
// Check if mesh has proper geometry
if (!mesh.getTotalVertices || mesh.getTotalVertices() === 0) {
log.error('[ExplosionManager] ERROR: Mesh has no vertices, cannot explode');
mesh.dispose();
return;
}
// Split the mesh into separate mesh objects (MeshExploder requirement)
log.debug('[ExplosionManager] Splitting mesh into pieces...');
const meshPieces = this.splitIntoSeparateMeshes(position, 12);
if (meshPieces.length === 0) {
log.error('[ExplosionManager] ERROR: Failed to split mesh into pieces');
mesh.dispose();
return;
}
// Original mesh is no longer needed - the pieces replace it
log.debug('[ExplosionManager] Disposing original cloned mesh');
mesh.dispose();
// Create the exploder with the array of separate meshes
// The second parameter is optional - it's the center mesh to explode from
// If not provided, MeshExploder will auto-calculate the center
log.debug('[ExplosionManager] Creating MeshExploder...');
try {
const exploder = new MeshExploder((meshPieces as unknown) as Mesh[]);
log.debug('[ExplosionManager] MeshExploder created successfully');
log.debug(`[ExplosionManager] Starting explosion animation:`, {
pieceCount: meshPieces.length,
duration: this.config.duration,
maxForce: this.config.explosionForce
});
// Animate the explosion using Babylon's render loop instead of requestAnimationFrame
const startTime = Date.now();
const animationDuration = this.config.duration;
const maxForce = this.config.explosionForce;
let frameCount = 0;
const animationObserver = this.scene.onBeforeRenderObservable.add(() => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / animationDuration, 1.0);
// Calculate current explosion value (0 to maxForce)
const currentValue = progress * maxForce;
try {
exploder.explode(currentValue);
} catch (error) {
log.error('[ExplosionManager] ERROR in explode():', error);
}
// Animate debris size to zero (1.0 to 0.0)
const scale = 1.0 - progress;
meshPieces.forEach(piece => {
if (piece && !piece.isDisposed()) {
piece.scaling.set(scale, scale, scale);
}
});
frameCount++;
// Continue animation if not complete
if (progress >= 1.0) {
// Animation complete - remove observer and clean up
log.debug(`[ExplosionManager] Animation complete after ${frameCount} frames, cleaning up`);
this.scene.onBeforeRenderObservable.remove(animationObserver);
this.cleanupExplosion(meshPieces);
}
});
// Log that animation loop is registered
log.debug('[ExplosionManager] Starting animation loop...');
} catch (error) {
log.error('[ExplosionManager] ERROR creating MeshExploder:', error);
// Clean up pieces if exploder failed
meshPieces.forEach(piece => {
if (piece && !piece.isDisposed()) {
piece.dispose();
}
});
}
}
/**
* Clean up explosion meshes
*/
private cleanupExplosion(meshPieces: InstancedMesh[]): void {
log.debug('[ExplosionManager] Starting cleanup of explosion meshes...');
let disposedCount = 0;
// Dispose all the mesh pieces
meshPieces.forEach((mesh, index) => {
if (mesh && !mesh.isDisposed()) {
try {
mesh.dispose();
disposedCount++;
} catch (error) {
log.error(`[ExplosionManager] ERROR disposing piece ${index}:`, error);
}
}
});
log.debug(`[ExplosionManager] Cleanup complete - disposed ${disposedCount}/${meshPieces.length} pieces`);
}
/**
* Dispose of the explosion manager
*/
public dispose(): void {
this._debrisBaseMesh.dispose(false, true);
// Nothing to dispose with MeshExploder approach
log.debug("ExplosionManager disposed");
}
}