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 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:
Particle Configuration Types:

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

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): 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:
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:
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:
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:
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 Detection:

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:

Integration with ResourceSystem

The ParticleManager is created and managed by ResourceSystem on the client:

Lazy Registration Pattern

ResourceEntity uses a lazy registration pattern to handle timing issues where entities may be created before ResourceSystem starts:
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

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/
2

Instantiate in ParticleManager

Add to constructor
3

Add Routing Logic

Update register/unregister/move/handleEvent methods
4

Call update() and dispose()

Add to ParticleManager lifecycle methods

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:

Parabolic Arc (Splash)

Wobble Pattern (Bubble)

Twinkle Effect (Shimmer)

Ring Expansion (Ripple)

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