Overview
The Hyperscape particle system uses GPU instancing and TSL (Three.js Shading Language) shaders to render thousands of particles with minimal CPU overhead. Introduced in PR #877 (February 2026), this system provides a 97% reduction in draw calls and an 80% FPS improvement for fishing spots.
Architecture
The particle system follows a centralized routing pattern with specialized sub-managers:
ParticleManager (central router)
├── WaterParticleManager (fishing spots)
└── GlowParticleManager (altars, fires, torches)
ParticleManager
Central router that dispatches particle events to specialized sub-managers based on particle type.
Location: packages/shared/src/entities/managers/particleManager/ParticleManager.ts
Key Features:
- Single entry point for all particle systems
- Type-based routing to appropriate sub-manager
- Ownership tracking eliminates need for type hints on unregister/move
- Extensible design for future particle types (dust, smoke, blood, magic)
API:
class ParticleManager {
constructor(scene: THREE.Scene);
// Unified lifecycle
register(id: string, config: ParticleConfig): void;
unregister(id: string): void;
move(id: string, newPos: { x: number; y: number; z: number }): void;
// Event routing
handleResourceEvent(data: ParticleResourceEvent): void;
// Per-frame update
update(dt: number, camera: THREE.Camera): void;
// Cleanup
dispose(): void;
}
Particle Configuration Types:
// Discriminated union for type-safe particle configuration
type ParticleConfig = WaterParticleConfig | GlowParticleConfig;
interface WaterParticleConfig {
type: "water";
position: { x: number; y: number; z: number };
resourceId: string;
}
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;
}
WaterParticleManager
GPU-instanced water particle effects for fishing spots using InstancedMesh and TSL shaders.
Location: packages/shared/src/entities/managers/particleManager/WaterParticleManager.ts
| Metric | Before | After | Improvement |
|---|
| Draw calls per fishing spot | ~150 | 4 | 97% reduction |
| FPS (reference hardware) | 65-70 | 120 | 80% increase |
| CPU animation code | ~450 lines | 0 lines | 100% elimination |
| Per-frame CPU overhead | High (trig, quaternion, opacity) | Zero (GPU-driven) | 100% reduction |
Technical Implementation
4 InstancedMeshes:
- Splash: Water droplets with parabolic arc trajectories (MAX_SPLASH=96)
- Bubble: Rising bubbles with lateral wobble (MAX_BUBBLE=72)
- Shimmer: Surface sparkle with twinkle effect (MAX_SHIMMER=72)
- Ripple: Expanding rings with fade (MAX_RIPPLE=24)
Per-Instance Data (InstancedBufferAttributes):
| Attribute | Type | Data |
|---|
spotPos | vec3 | Fishing spot world center (x, y, z) |
ageLifetime | vec2 | Current age (x), total lifetime (y) |
angleRadius | vec2 | Polar angle (x), radial distance (y) |
dynamics | vec4 | peakHeight (x), size (y), speed (z), direction (w) |
Vertex Buffer Budget:
- Particle layers: 7 of 8 max attributes
- position(1) + uv(1) + instanceMatrix(1) + spotPos(1) + ageLifetime(1) + angleRadius(1) + dynamics(1)
- Ripple layer: 5 of 8 max attributes
- position(1) + uv(1) + instanceMatrix(1) + spotPos(1) + rippleParams(1)
Particle Animations
All animations computed on GPU via TSL shaders:
Splash Particles
Parabolic arc trajectories with pop-in fade:
// GPU shader math (TSL)
const arcY = mul(peakHeight, mul(float(4), mul(t, sub(float(1), t))));
const ox = mul(cos(angle), radius);
const oz = mul(sin(angle), radius);
particleCenter = add(spotPos, vec3(ox, add(float(0.08), arcY), oz));
// Fade animation
const fadeIn = min(mul(t, float(12)), float(1)); // Snappy pop-in (12x rate)
const fadeOut = pow(sub(float(1), t), float(1.2)); // Smooth fade-out
opacity = mul(texAlpha, mul(float(0.9), mul(fadeIn, fadeOut)));
Properties:
- Lifetime: 0.6-1.2 seconds
- Peak height: 0.12-0.2 units (burst: 0.25-0.6)
- Radial distance: 0.05-0.3 units
- Fade-in rate: 12x (snappy)
- Fade-out: Power 1.2 (smooth)
Bubble Particles
Rising with lateral wobble:
// GPU shader math (TSL)
const riseY = mul(t, peakHeight);
const wobbleFreq = mul(direction, float(4.0));
const drift = mul(sin(add(angle, mul(t, wobbleFreq))), radius);
const driftZ = mul(cos(add(angle, mul(t, float(2.5)))), mul(radius, float(0.6)));
particleCenter = add(spotPos, vec3(drift, add(float(0.03), riseY), driftZ));
Properties:
- Lifetime: 1.2-2.5 seconds
- Rise height: 0.3-0.55 units
- Wobble frequency: direction × 4.0
- Drift pattern: sin(angle + t × wobbleFreq) × radius
- Size: 0.09 units
Shimmer Particles
Surface sparkle with twinkle effect:
// GPU shader math (TSL)
const freq = mul(speed, mul(direction, float(6)));
const wanderX = mul(cos(add(angle, mul(t, freq))), radius);
const wanderZ = mul(sin(add(angle, mul(t, freq))), radius);
particleCenter = add(spotPos, vec3(wanderX, float(0.06), wanderZ));
// Twinkle effect
const twinkle = max(
float(0),
mul(
sin(add(mul(time, float(8)), mul(angle, float(5)))),
sin(add(mul(time, float(13)), mul(angle, float(3))))
)
);
Properties:
- Lifetime: 1.5-3.0 seconds
- Wander radius: 0.15-0.6 units
- Twinkle frequencies: 8 Hz and 13 Hz
- Circular wander pattern on water surface
- Size: 0.055 units
Ripple Rings
Expanding rings with fade:
// GPU shader math (TSL)
const phase = fract(add(mul(time, mul(rippleSpeed, float(0.5))), phaseOffset));
const scale = add(float(0.15), mul(phase, float(1.3)));
// Fade animation
const earlyFade = mul(div(phase, float(0.15)), float(0.55));
const lateFade = mul(
float(0.55),
pow(sub(float(1), div(sub(phase, float(0.15)), float(0.85))), float(1.5))
);
const s = step(float(0.15), phase);
const rippleOpacity = mix(earlyFade, lateFade, s);
Properties:
- Scale range: 0.15 → 1.45 (expansion)
- Early fade (0-15%): Linear ramp to 0.55 opacity
- Late fade (15-100%): Power 1.5 decay from 0.55 to 0
- Continuous expansion driven by time uniform
- Phase offset per ripple for staggered animation
Fishing Spot Variants
Three fishing spot variants with different visual characteristics:
| Variant | Splash | Bubble | Shimmer | Ripples | Burst Interval | Burst Count | Description |
|---|
| Net | 4 | 3 | 3 | 2 | 5-10s | 2 | Calm, gentle ripples (shallow water) |
| Bait | 5 | 4 | 4 | 2 | 3-7s | 3 | Medium activity (default) |
| Fly | 8 | 5 | 5 | 2 | 2-5s | 4 | Active river fishing (moving water) |
Variant Detection:
// From WaterParticleManager.ts
export function getFishingSpotVariant(resourceId: string): FishingSpotVariant {
if (resourceId.includes("net")) {
return { /* net variant config */ };
} else if (resourceId.includes("fly")) {
return { /* fly variant config */ };
}
return { /* bait variant (default) */ };
}
Burst System
Fish activity bursts fire 2-4 splash particles simultaneously to simulate fish jumping:
Burst Mechanics:
- Burst center randomized within 0.05-0.15 radius
- Burst particles cluster around center with 0.06 spread
- Burst particles have higher peak heights (0.25-0.6 vs 0.12-0.32)
- Burst timer resets to random interval after each burst
- Only fires when existing splash particles are past 60% lifetime
Burst Configuration:
interface FishingSpotVariant {
burstIntervalMin: number; // Minimum seconds between bursts
burstIntervalMax: number; // Maximum seconds between bursts
burstSplashCount: number; // Number of splash particles per burst
}
Integration with ResourceSystem
The ParticleManager is created and managed by ResourceSystem on the client:
// From packages/shared/src/systems/shared/entities/ResourceSystem.ts
// CLIENT: Create centralized particle hub for all particle effects
if (!this.world.isServer) {
const scene = this.world.stage?.scene;
if (scene) {
this.particleManager = new ParticleManager(scene as any);
// Retroactively register any fishing spot entities created before this system started
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 and route them through the particle hub
this.subscribe(
EventType.RESOURCE_SPAWNED,
(data: { id?: string; type?: string; position?: { x: number; y: number; z: number } }) => {
this.particleManager?.handleResourceEvent(data);
}
);
}
// Per-frame update: drives all particle managers on the client
update(dt: number): void {
if (this.particleManager) {
const camera = this.world.camera;
if (camera) {
this.particleManager.update(dt, camera);
}
}
}
Lazy Registration Pattern
ResourceEntity uses a lazy registration pattern to handle timing issues where entities may be created before ResourceSystem starts:
// From packages/shared/src/entities/world/ResourceEntity.ts
private _registeredWithParticleManager = false;
public tryRegisterWithParticleManager(): boolean {
if (this._registeredWithParticleManager) return true;
const pm = this.getParticleManager();
if (!pm) return false;
const pos = this.getPosition();
pm.registerSpot({
entityId: this.id,
position: { x: pos.x, y: pos.y, z: pos.z },
resourceType: this.config.resourceType || "",
resourceId: this.config.resourceId || "",
});
this._registeredWithParticleManager = true;
return true;
}
protected clientUpdate(_deltaTime: number): void {
super.clientUpdate(_deltaTime);
// Lazy registration: retry if particle manager wasn't ready during createFishingSpotVisual
if (
!this._registeredWithParticleManager &&
this.config.resourceType === "fishing_spot"
) {
if (this.tryRegisterWithParticleManager()) {
console.log(`[FishingSpot] Late registration succeeded for ${this.id}`);
}
}
// Organic glow pulse — two frequencies layered for natural breathing
if (this.glowMesh) {
const now = Date.now();
const slow = Math.sin(now * 0.0015) * 0.04;
const fast = Math.sin(now * 0.004 + 1.3) * 0.02;
const pulse = 0.18 + slow + fast;
(this.glowMesh.material as THREE.MeshBasicMaterial).opacity = pulse;
}
}
Why Lazy Registration:
- Entities may be created before ResourceSystem.start() creates the ParticleManager
- Timing/lifecycle issue where entity init runs before system initialization
- Lazy registration retries on every frame until successful
- Ensures all fishing spots eventually register with the particle manager
Usage Example
import { ParticleManager } from '@hyperscape/shared';
// ResourceSystem creates ParticleManager on client startup
const particleManager = new ParticleManager(scene);
// Register water particles (fishing spot)
particleManager.registerSpot({
entityId: 'entity-123',
position: { x: 10, y: 0, z: 20 },
resourceType: 'fishing_spot',
resourceId: 'fishing_spot_net'
});
// Move existing spot (when fishing spot relocates)
particleManager.moveSpot('entity-123', 'fishing_spot', { x: 12, y: 0, z: 22 });
// Per-frame update (called by ResourceSystem)
particleManager.update(deltaTime, camera);
// Cleanup on entity destroy
particleManager.unregisterSpot('entity-123', 'fishing_spot');
// Dispose all particle managers on system shutdown
particleManager.dispose();
Extensibility Guide
To add new particle types (fire, magic, dust, blood):
Create Sub-Manager Class
Create new file in packages/shared/src/entities/managers/particleManager/// Example: MagicParticleManager.ts
export class MagicParticleManager {
constructor(scene: THREE.Scene) { /* ... */ }
registerSpot(config: MagicSpotConfig): void { /* ... */ }
unregisterSpot(entityId: string): void { /* ... */ }
update(dt: number, camera: THREE.Camera): void { /* ... */ }
dispose(): void { /* ... */ }
}
Instantiate in ParticleManager
Add to constructorexport class ParticleManager {
private waterManager: WaterParticleManager;
private magicManager: MagicParticleManager; // Add new manager
constructor(scene: THREE.Scene) {
this.waterManager = new WaterParticleManager(scene);
this.magicManager = new MagicParticleManager(scene); // Instantiate
}
}
Add Routing Logic
Update register/unregister/move/handleEvent methodsregisterSpot(config: ParticleSpotConfig): void {
if (this.isWaterType(config.resourceType)) {
this.waterManager.registerSpot({ /* ... */ });
return;
}
if (this.isMagicType(config.resourceType)) {
this.magicManager.registerSpot({ /* ... */ });
return;
}
}
Call update() and dispose()
Add to ParticleManager lifecycle methodsupdate(dt: number, camera: THREE.Camera): void {
this.waterManager.update(dt, camera);
this.magicManager.update(dt, camera); // Add update call
}
dispose(): void {
this.waterManager.dispose();
this.magicManager.dispose(); // Add dispose call
}
TSL Shader Implementation
The particle system uses Three.js Shading Language (TSL) for GPU-computed animations:
Billboard Orientation
Particles always face the camera using right/up vectors:
const camRight = this.uCameraRight as unknown as ReturnType<typeof uniform>;
const camUp = this.uCameraUp as unknown as ReturnType<typeof uniform>;
const localXY = positionLocal.xy;
const billboardOffset = add(
mul(mul(localXY.x, size), camRight),
mul(mul(localXY.y, size), camUp)
);
material.positionNode = add(particleCenter, billboardOffset);
Parabolic Arc (Splash)
// Parabolic trajectory: y = peakHeight * 4 * t * (1-t)
const arcY = mul(peakHeight, mul(float(4), mul(t, sub(float(1), t))));
const ox = mul(cos(angle), radius);
const oz = mul(sin(angle), radius);
particleCenter = add(spotPos, vec3(ox, add(float(0.08), arcY), oz));
Wobble Pattern (Bubble)
// Lateral drift with wobble
const wobbleFreq = mul(direction, float(4.0));
const drift = mul(sin(add(angle, mul(t, wobbleFreq))), radius);
const driftZ = mul(cos(add(angle, mul(t, float(2.5)))), mul(radius, float(0.6)));
particleCenter = add(spotPos, vec3(drift, add(float(0.03), riseY), driftZ));
Twinkle Effect (Shimmer)
// Fast twinkle using global time
const twinkle = max(
float(0),
mul(
sin(add(mul(time, float(8)), mul(angle, float(5)))),
sin(add(mul(time, float(13)), mul(angle, float(3))))
)
);
const envelope = mul(
min(mul(t, float(4)), float(1)),
min(mul(sub(float(1), t), float(4)), float(1))
);
opacity = mul(texAlpha, mul(float(0.85), mul(twinkle, envelope)));
Ring Expansion (Ripple)
// Phase-based scale and fade
const phase = fract(add(mul(time, mul(rippleSpeed, float(0.5))), phaseOffset));
const scale = add(float(0.15), mul(phase, float(1.3)));
// Two-stage fade
const earlyFade = mul(div(phase, float(0.15)), float(0.55));
const lateFade = mul(
float(0.55),
pow(sub(float(1), div(sub(phase, float(0.15)), float(0.85))), float(1.5))
);
const s = step(float(0.15), phase);
const rippleOpacity = mix(earlyFade, lateFade, s);
Memory Usage
Per Fishing Spot:
- 4 InstancedMeshes (shared across all spots)
- Per-spot data: ~20 particle slots × 11 floats = ~220 floats = ~880 bytes
- Texture cache: 2 DataTextures (64×64 RGBA) = 32KB (shared)
Total Memory (10 fishing spots):
- Particle data: ~8.8KB
- Shared textures: 32KB
- Shared geometries: ~2KB
- Total: ~43KB (vs ~500KB+ for individual meshes)
Draw Call Budget
Before (per fishing spot):
- 5-8 splash particles × 1 draw call = 5-8
- 4-5 bubble particles × 1 draw call = 4-5
- 4-5 shimmer particles × 1 draw call = 4-5
- 2 ripple rings × 1 draw call = 2
- Total: ~15-20 draw calls per spot
- 10 spots: ~150-200 draw calls
After (all fishing spots):
- 1 splash InstancedMesh = 1 draw call
- 1 bubble InstancedMesh = 1 draw call
- 1 shimmer InstancedMesh = 1 draw call
- 1 ripple InstancedMesh = 1 draw call
- Total: 4 draw calls for all spots
CPU Overhead
Before:
- Per-frame updates for ~150 individual meshes
- Trigonometry (sin, cos) for each particle
- Quaternion copies for billboard orientation
- Opacity writes for fade animations
- ~450 lines of CPU animation code
After:
- Zero CPU overhead for particle animation
- All math computed on GPU via TSL shaders
- Only camera right/up vectors updated per frame (2 vec3 copies)
- Age increments in typed arrays (simple addition)
Best Practices
When to Use GPU Instancing
Use GPU instancing for particle systems when:
- You have many similar particles (10+)
- Particles share the same geometry and material
- Animation can be expressed in shader code
- Per-particle data fits in InstancedBufferAttributes
When to Use Individual Meshes
Use individual meshes when:
- Particles have unique geometries
- Complex CPU-driven behavior (physics, AI)
- Per-particle data exceeds attribute budget
- Very few particles (< 10)
Attribute Budget Management
WebGL/WebGPU has a limit of 8 vertex attributes per mesh. Plan your attribute usage:
Current Usage (Particle Layers):
- position (built-in)
- uv (built-in)
- instanceMatrix (built-in for InstancedMesh)
- spotPos (custom)
- ageLifetime (custom)
- angleRadius (custom)
- dynamics (custom)
- 1 slot remaining
Current Usage (Ripple Layer):
- position (built-in)
- uv (built-in)
- instanceMatrix (built-in for InstancedMesh)
- spotPos (custom)
- rippleParams (custom)
- 3 slots remaining
Exceeding 8 attributes will cause WebGL errors. Pack multiple values into vec4 attributes when needed.
See Also