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.
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.
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.
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
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):
position (built-in)
uv (built-in)
instanceMatrix (built-in)
spotPos (custom, vec3)
ageLifetime (custom, vec2)
angleRadius (custom, vec2)
dynamics (custom, vec4)
Ripple Layer (5 of 8):
position (built-in)
uv (built-in)
instanceMatrix (built-in)
spotPos (custom, vec3)
rippleParams (custom, vec2)
Adding more custom attributes requires removing existing ones or splitting into multiple InstancedMeshes.
See Also