Skip to main content

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

Performance Metrics

MetricBeforeAfterImprovement
Draw calls per fishing spot~150497% reduction
FPS (reference hardware)65-7012080% increase
CPU animation code~450 lines0 lines100% elimination
Per-frame CPU overheadHigh (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):
AttributeTypeData
spotPosvec3Fishing spot world center (x, y, z)
ageLifetimevec2Current age (x), total lifetime (y)
angleRadiusvec2Polar angle (x), radial distance (y)
dynamicsvec4peakHeight (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:
VariantSplashBubbleShimmerRipplesBurst IntervalBurst CountDescription
Net43325-10s2Calm, gentle ripples (shallow water)
Bait54423-7s3Medium activity (default)
Fly85522-5s4Active 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):
1

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 { /* ... */ }
}
2

Instantiate in ParticleManager

Add to constructor
export 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
  }
}
3

Add Routing Logic

Update register/unregister/move/handleEvent methods
registerSpot(config: ParticleSpotConfig): void {
  if (this.isWaterType(config.resourceType)) {
    this.waterManager.registerSpot({ /* ... */ });
    return;
  }
  if (this.isMagicType(config.resourceType)) {
    this.magicManager.registerSpot({ /* ... */ });
    return;
  }
}
4

Call update() and dispose()

Add to ParticleManager lifecycle methods
update(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);

Performance Considerations

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):
  1. position (built-in)
  2. uv (built-in)
  3. instanceMatrix (built-in for InstancedMesh)
  4. spotPos (custom)
  5. ageLifetime (custom)
  6. angleRadius (custom)
  7. dynamics (custom)
  8. 1 slot remaining
Current Usage (Ripple Layer):
  1. position (built-in)
  2. uv (built-in)
  3. instanceMatrix (built-in for InstancedMesh)
  4. spotPos (custom)
  5. rippleParams (custom)
  6. 3 slots remaining
Exceeding 8 attributes will cause WebGL errors. Pack multiple values into vec4 attributes when needed.

See Also