Skip to main content

ParticleManager

Central particle routing system that dispatches particle events to specialized sub-managers.

Constructor

constructor(scene: THREE.Scene)
Parameters:
  • scene: Three.js scene to add particle meshes to
Example:
import { ParticleManager } from '@hyperscape/shared';

const particleManager = new ParticleManager(scene);

Methods

register

Register a particle emitter with discriminated union config.
register(id: string, config: ParticleConfig): void
Parameters:
  • id: Unique identifier for the emitter
  • config: Particle configuration (WaterParticleConfig | GlowParticleConfig)
Example:
// Water particles (fishing spot)
particleManager.register('fishing-spot-1', {
  type: 'water',
  position: { x: 10, y: 0, z: 20 },
  resourceId: 'fishing_spot_net'
});

// Glow particles (altar)
particleManager.register('altar-1', {
  type: 'glow',
  preset: 'altar',
  position: { x: 15, y: 1, z: 25 },
  color: 0x00ffff,
  meshRoot: altarMesh,
  modelScale: 1.0
});

unregister

Unregister a particle emitter. No type hint required (ownership map resolves automatically).
unregister(id: string): void
Parameters:
  • id: Emitter identifier to remove
Example:
particleManager.unregister('fishing-spot-1');

move

Move an existing emitter to a new position. No type hint required.
move(id: string, newPos: { x: number; y: number; z: number }): void
Parameters:
  • id: Emitter identifier to move
  • newPos: New world position
Example:
particleManager.move('fishing-spot-1', { x: 12, y: 0, z: 22 });

handleResourceEvent

Handle a resource event (e.g. RESOURCE_SPAWNED) and route to appropriate manager.
handleResourceEvent(data: ParticleResourceEvent): void
Parameters:
  • data: Event data with id, type, position
Example:
// Called by ResourceSystem
this.subscribe(EventType.RESOURCE_SPAWNED, (data) => {
  this.particleManager?.handleResourceEvent(data);
});

update

Drive all particle managers. Called once per frame.
update(dt: number, camera: THREE.Camera): void
Parameters:
  • dt: Delta time in seconds
  • camera: Three.js camera for billboard orientation
Example:
// In ResourceSystem.update()
update(dt: number): void {
  if (this.particleManager) {
    const camera = this.world.camera;
    if (camera) {
      this.particleManager.update(dt, camera);
    }
  }
}

dispose

Dispose all particle managers and clean up GPU resources.
dispose(): void
Example:
// In ResourceSystem.destroy()
if (this.particleManager) {
  this.particleManager.dispose();
  this.particleManager = undefined;
}

Types

ParticleConfig

Discriminated union of all particle configuration types.
type ParticleConfig = WaterParticleConfig | GlowParticleConfig;

WaterParticleConfig

Configuration for water-type particles (fishing spots).
interface WaterParticleConfig {
  type: 'water';
  position: { x: number; y: number; z: number };
  resourceId: string;  // e.g., 'fishing_spot_net', 'fishing_spot_fly'
}
Fishing Spot Variants:
  • fishing_spot_net: Calm, gentle ripples (4 splash, 3 bubble, 3 shimmer)
  • fishing_spot_bait: Medium activity (5 splash, 4 bubble, 4 shimmer)
  • fishing_spot_fly: Active, moving water (8 splash, 5 bubble, 5 shimmer)

GlowParticleConfig

Configuration for glow-type particles (altars, fires, torches).
interface GlowParticleConfig {
  type: 'glow';
  preset: GlowPreset;
  position: { x: number; y: number; z: number };
  color?: number | { core: number; mid: number; outer: number };
  meshRoot?: THREE.Object3D;
  modelScale?: number;
  modelYOffset?: number;
}
Parameters:
  • preset: ‘altar’ | ‘fire’ | ‘torch’
  • color: Single hex (0x00ffff) or three-tone palette
  • meshRoot: Mesh for geometry-aware spark placement (altar preset)
  • modelScale: Scale of loaded model (default 1.0)
  • modelYOffset: Vertical offset applied to model (default 0)
Preset Particle Counts:
  • altar: 12 particles (samples mesh geometry)
  • fire: 8 particles (rising with spread)
  • torch: 6 particles (tight 0.08 spread)

ParticleResourceEvent

Lightweight event shape emitted by ResourceSystem.
interface ParticleResourceEvent {
  id?: string;
  type?: string;
  position?: { x: number; y: number; z: number };
}

WaterParticleManager

GPU-instanced fishing spot effects using 4 InstancedMeshes.

Methods

registerSpot

Register a fishing spot for particle rendering.
registerSpot(config: {
  entityId: string;
  position: { x: number; y: number; z: number };
  resourceId: string;
}): void

unregisterSpot

Unregister a fishing spot and free particle slots.
unregisterSpot(entityId: string): void

moveSpot

Move a fishing spot to a new position.
moveSpot(
  entityId: string,
  newPos: { x: number; y: number; z: number }
): void

update

Per-frame particle animation update.
update(dt: number, camera: THREE.Camera): void

dispose

Dispose all GPU resources.
dispose(): void

Particle Layers

Splash Layer:
  • Parabolic arc trajectories
  • 0.6-1.2s lifetime
  • 0.05-0.35m radial spread
  • 0.12-0.32m peak height
Bubble Layer:
  • Rising with lateral wobble
  • 1.2-2.5s lifetime
  • 0.04-0.24m radial spread
  • 0.3-0.55m rise height
Shimmer Layer:
  • Surface glints with twinkle
  • 1.5-3.0s lifetime
  • 0.15-0.6m radial spread
  • GPU-computed twinkle animation
Ripple Layer:
  • Expanding ring waves
  • Continuous loop (no lifetime)
  • 0.15-1.45m scale range
  • Phase-based opacity fade

GlowParticleManager

Instanced glow billboards for altars, fires, and torches.

Methods

registerGlow

Register a glow emitter.
registerGlow(id: string, config: {
  preset: GlowPreset;
  position: { x: number; y: number; z: number };
  color?: number | { core: number; mid: number; outer: number };
  meshRoot?: THREE.Object3D;
  modelScale?: number;
  modelYOffset?: number;
}): void

unregisterGlow

Unregister a glow emitter.
unregisterGlow(id: string): void

moveGlow

Move a glow emitter to a new position.
moveGlow(id: string, newPos: { x: number; y: number; z: number }): void

update

Per-frame particle animation update.
update(dt: number, camera: THREE.Camera): void

dispose

Dispose all GPU resources.
dispose(): void

Presets

Altar Preset:
  • 12 particles sampling mesh geometry vertices
  • Mesh-aware placement (requires meshRoot)
  • Upward rise with spread
  • Color: Cyan (0x00ffff) or custom
Fire Preset:
  • 8 particles rising with spread
  • Random radial distribution
  • Parabolic rise trajectories
  • Color: Orange-red gradient or custom
Torch Preset:
  • 6 particles with tight spread (0.08)
  • Concentrated flame effect
  • Minimal lateral movement
  • Color: Orange (0xff8800) or custom

Performance

Draw Call Reduction

Before:
10 fishing spots × 15 particles each = 150 individual meshes
→ 150 draw calls
After:
4 InstancedMeshes (splash, bubble, shimmer, ripple)
→ 4 draw calls (shared across all fishing spots)

Memory Usage

Per Fishing Spot:
  • Before: ~500KB (150 meshes × ~3.3KB each)
  • After: ~5KB (particle slot data in shared buffers)
100 Fishing Spots:
  • Before: ~50MB
  • After: ~2MB (98% reduction)

FPS Impact

Reference Hardware (RTX 3060 Ti):
  • Before: 65-70 FPS with 10 fishing spots
  • After: 120 FPS with 10 fishing spots

Integration Example

Complete integration in ResourceSystem:
// packages/shared/src/systems/shared/entities/ResourceSystem.ts
export class ResourceSystem extends SystemBase {
  public particleManager?: ParticleManager;

  start(): void {
    if (!this.world.isServer) {
      const scene = this.world.stage?.scene;
      if (scene) {
        // Create particle manager
        this.particleManager = new ParticleManager(scene);
        
        // Retroactive registration
        const existingEntities = this.world.entities?.getByType?.('resource') || [];
        for (const entity of existingEntities) {
          if (entity instanceof ResourceEntity && 
              entity.config?.resourceType === 'fishing_spot') {
            entity.tryRegisterWithParticleManager();
          }
        }
      }
      
      // Listen for resource events
      this.subscribe(EventType.RESOURCE_SPAWNED, (data) => {
        this.particleManager?.handleResourceEvent(data);
      });
    }
  }

  update(dt: number): void {
    if (this.particleManager) {
      const camera = this.world.camera;
      if (camera) {
        this.particleManager.update(dt, camera);
      }
    }
  }

  destroy(): void {
    if (this.particleManager) {
      this.particleManager.dispose();
      this.particleManager = undefined;
    }
    super.destroy();
  }
}

Vertex Buffer Budget

InstancedMesh has a maximum of 8 vertex attributes: Particle Layers (7 of 8):
  1. position (built-in)
  2. uv (built-in)
  3. instanceMatrix (built-in)
  4. spotPos (custom, vec3)
  5. ageLifetime (custom, vec2)
  6. angleRadius (custom, vec2)
  7. dynamics (custom, vec4)
Ripple Layer (5 of 8):
  1. position (built-in)
  2. uv (built-in)
  3. instanceMatrix (built-in)
  4. spotPos (custom, vec3)
  5. rippleParams (custom, vec2)
Adding more custom attributes requires removing existing ones or splitting into multiple InstancedMeshes.

See Also