> ## Documentation Index
> Fetch the complete documentation index at: https://hyperscape-ai-mintlify-docs-update.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Particle System

> GPU-instanced particle system for high-performance visual effects

## 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:**

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
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:**

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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

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

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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:**

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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:**

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
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:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
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):

<Steps>
  <Step title="Create Sub-Manager Class">
    Create new file in `packages/shared/src/entities/managers/particleManager/`

    ```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
    // 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 { /* ... */ }
    }
    ```
  </Step>

  <Step title="Instantiate in ParticleManager">
    Add to constructor

    ```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
    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
      }
    }
    ```
  </Step>

  <Step title="Add Routing Logic">
    Update register/unregister/move/handleEvent methods

    ```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
    registerSpot(config: ParticleSpotConfig): void {
      if (this.isWaterType(config.resourceType)) {
        this.waterManager.registerSpot({ /* ... */ });
        return;
      }
      if (this.isMagicType(config.resourceType)) {
        this.magicManager.registerSpot({ /* ... */ });
        return;
      }
    }
    ```
  </Step>

  <Step title="Call update() and dispose()">
    Add to ParticleManager lifecycle methods

    ```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
    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
    }
    ```
  </Step>
</Steps>

## 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:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
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)

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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)

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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)

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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)

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// 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**

<Warning>
  Exceeding 8 attributes will cause WebGL errors. Pack multiple values into vec4 attributes when needed.
</Warning>

## See Also

* [ECS Architecture](/concepts/ecs) - Entity Component System overview
* [ResourceSystem](/api-reference/systems#resourcesystem) - Resource entity management
* [Three.js TSL](https://threejs.org/docs/#api/en/nodes/Nodes) - Three.js Shading Language
