# hz-particles

**Prompt-generated, real-time WebGPU FX for Three.js and React Three Fiber.**

Design particle effects in the HZ editor — by hand or **from a prompt** — export them as
`JSON` / `.hzfx` presets, and render them **faithfully** in your app with the *same* WebGPU
engine the editor uses. What you design is exactly what renders.

[![npm version](https://img.shields.io/npm/v/hz-particles.svg)](https://www.npmjs.com/package/hz-particles)
[![license](https://img.shields.io/npm/l/hz-particles.svg)](https://github.com/jguyet/particle-system/blob/main/LICENSE)
[![WebGPU](https://img.shields.io/badge/WebGPU-required-blue.svg)](https://caniuse.com/webgpu)

```tsx
import { HZFaithfulFX } from 'hz-particles/r3f';
import firePreset from './fire.json';

export function Scene() {
  return <HZFaithfulFX preset={firePreset} position={[0, 1, 0]} />;
}
```

- **Prompt FX generator / editor:** https://particles.hole.zone/editor
- **Gallery (live WebGPU previews):** https://particles.hole.zone/discover
- **Docs:** https://particles.hole.zone/docs
- **NPM:** https://www.npmjs.com/package/hz-particles

## Why hz-particles?

Most AI/VFX tools generate **videos** or **spritesheets**. hz-particles generates **real-time,
editable FX presets** that run inside your Three.js / R3F scene.

- **Not a video** — effects are simulated in real time, on the GPU, every frame.
- **Not a screenshot** — presets are editable `JSON` / `.hzfx` files you can re-tune or re-prompt.
- **Not a separate renderer** — the R3F component renders the *same engine* as the editor, so
  there's no "looked great in the editor, renders wrong in-game" gap.
- **Game-ready** — moving emitters, trails, depth occlusion, bloom, GLB-mesh particles.

> **Text-to-realtime-FX, not text-to-video.** The HZ editor turns a prompt into an editable
> preset; this package is the runtime that renders it in your app.

The full pipeline:

```
HZ editor (manual or prompt)  →  preset (JSON / .hzfx)  →  faithful WebGPU runtime  →  Three.js / R3F
```

## Features

- **WebGPU Compute Shaders** — GPU-accelerated particle physics simulation
- **GPU Instancing** — efficient rendering of thousands of particles
- **Engine-faithful R3F component** — `HZFaithfulFX` runs the real overlay inline on three's
  `WebGPURenderer`: shader shapes, noise, per-system blending, multi-pass bloom, faithful trails,
  depth occlusion. Editor parity by construction.
- **Faithful trails & moving emitters** — comet trails that follow an arbitrary path (ball trails)
- **GLB model support** — use 3D models as particle shapes, with automatic texture extraction
- **Skeletal animations** — animated GLB models with full animation control
- **20+ emitter shapes** — volumes & surfaces: sphere, cube, cylinder, cone, torus, capsule,
  frustum, `frustumSurface`, hemisphere, disc, annulus, arc, spiral, polygon, `cubeSurface`,
  `sphereSurface`, `boxFrame`, and more
- **Shape contour strokes** — render a textured ribbon along the full contour of planar emitters
  (`circle`, `disc`, `annulus`, `arc`, `square`, `rectangle`, `plain`, `line`, `polygon`)
- **Real-time physics** — gravity, attractors, drag, velocity, lifetime
- **Reverse attractors** — the same attractor API can repel away from a point (`reverse=true`)
- **Particle orientation modes** — `billboard` (fixed/random/velocity-aligned), `oriented` (a fixed
  world angle via Euler `orientX/Y/Z`), and `cylindrical` (upright, faces the camera horizontally)
- **Keyframe animation** — animate per-system parameters over time with curves + easing
  (`keyframes` + `keyframesEnabled`), driven by the unified clock; per-emitter `startAt` sequencing
- **Serialized presets** — self-contained `.hzfx` binary package (textures + GLB inlined) or plain JSON
- **3D object support** — static 3D objects alongside particle systems

## Requirements

**A WebGPU-capable browser is required:**
- Chrome / Edge 113+ (stable)
- Firefox Nightly (experimental)
- Safari Technology Preview / Safari 18+ (experimental)

Check browser support: [caniuse.com/webgpu](https://caniuse.com/webgpu)

## Installation

```bash
npm install hz-particles
```

```javascript
// React Three Fiber (recommended)
import { HZFaithfulFX } from 'hz-particles/r3f';

// Standalone WebGPU / overlay
import { initHzFxOverlay, ParticleSystemManager } from 'hz-particles';
```

For R3F usage you also need the peers: `react`, `three` (with its WebGPU backend), and
`@react-three/fiber`.

## Usage modes

Pick the entry point that matches how much control you need:

### 1. Drop-in R3F FX — `<HZFaithfulFX />`
The fastest path. Give it a preset, drop it in your scene, done. Engine-faithful by construction.

### 2. Overlay / inline renderer — `initHzFxOverlay()`
For non-React apps, custom render loops, post-processing pipelines, or when you want to host many
coexisting effects and drive the camera yourself. Same faithful engine, full control.

### 3. Low-level particle engine — `ParticleSystemManager` / `ParticleSystem`
Build your own engine on top of the raw compute + render pipelines.

## Quick Start (React Three Fiber)

`<HZFaithfulFX>` runs the **real engine** inline on three's `WebGPURenderer`: shader-drawn shapes,
noise, per-system blending, multi-pass bloom, faithful trails, and depth occlusion. Pass a
`position` for a static FX, or a `positionRef` for a moving one (e.g. a ball trail) — the overlay
pulls the ref each frame.

Your R3F `Canvas` must use a **WebGPU** renderer. With `@react-three/fiber` v9 and `three/webgpu`:

```tsx
import { Canvas } from '@react-three/fiber';
import * as THREE from 'three/webgpu';
import { useRef } from 'react';
import { HZFaithfulFX } from 'hz-particles/r3f';
import firePreset from './fire.json';
import trailPreset from './trail.json';

export function App() {
  const ballRef = useRef<THREE.Vector3>(null); // updated by your app each frame

  return (
    <Canvas
      // Provide a WebGPURenderer instead of the default WebGLRenderer:
      gl={async (props) => {
        const renderer = new THREE.WebGPURenderer(props as any);
        await renderer.init();
        return renderer;
      }}
      camera={{ position: [0, 2, 6], fov: 50 }}
    >
      <HZFaithfulFX preset={firePreset} position={[0, 1, 0]} />        {/* static FX */}
      <HZFaithfulFX preset={trailPreset} positionRef={ballRef} scale={1.5} /> {/* ball trail */}
    </Canvas>
  );
}
```

> **Important integration note —** `<HZFaithfulFX>` **drives rendering**: its `useFrame` calls
> `gl.render` itself, so R3F stops auto-rendering. This is what guarantees the FX composites on
> top of your scene with correct depth occlusion.
>
> - Use `<HZFaithfulFX>` when it can own the frame (the common case).
> - If your host owns its own render loop (custom RAF, a post-processing / EffectComposer
>   pipeline), use [`initHzFxOverlay()`](#engine-faithful-overlay) directly instead so *you* stay
>   in control of when the scene and the FX render.
>
> Re-trigger an effect by remounting it (change its `key`).

### Loading `.hzfx` presets

The `preset` prop is a plain `SceneData` object. JSON presets can be imported directly (above).
For the binary `.hzfx` package, fetch it with `fetchPreset` (auto-detects `.hzfx` vs JSON):

```tsx
import { useEffect, useState } from 'react';
import { fetchPreset } from 'hz-particles/r3f';
import { HZFaithfulFX } from 'hz-particles/r3f';

function Fire() {
  const [preset, setPreset] = useState(null);
  useEffect(() => { fetchPreset('/fx/fire.hzfx').then(setPreset); }, []);
  return preset ? <HZFaithfulFX preset={preset} position={[0, 1, 0]} /> : null;
}
```

### Props reference

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `preset` | `SceneData` | — | Particle preset (JSON object exported from the editor) |
| `position` | `[number, number, number]` | `[0, 0, 0]` | Static emitter world position (when `positionRef` is omitted) |
| `positionRef` | `RefObject<Vector3 \| null>` | — | Moving emitter (e.g. a ball trail): the overlay reads this ref every frame |
| `scale` | `number` | `1` | Uniform scale applied to the preset (sizes, speeds, emission, trail) |
| `active` | `boolean` | `true` | When false, a moving emitter pauses (no new trail) |
| `renderPriority` | `number` | `1` | `useFrame` priority. This component drives rendering, so keep it > 0 |
| `noOcclusion` | `boolean` | `false` | Skip scene-depth occlusion (particles never hidden behind geometry) |

The `hz-particles/r3f` entry also exports `HZTrailRibbon` (a lightweight ribbon-trail mesh driven
by a `positionRef`) and the helpers `scalePreset`, `computeParticleSize`, `computeParticleOpacity`.

## Engine-faithful overlay

`initHzFxOverlay()` is **the same render loop the HZ previews and editor use** — shader-drawn
shapes, per-system blending, noise distortion, GLB-mesh particles + animation, multi-pass bloom,
and faithful trails. It's the recommended way to drop HZ effects into any app (vanilla JS,
TypeScript, or a React app with its own render loop — one core, no per-framework reimplementation).

```javascript
import { initHzFxOverlay, makeThreeSceneDepth } from 'hz-particles';

// OVERLAY mode: pass a canvas — the overlay owns its WebGPU context and renders on a
// transparent background. Stack it above your scene with pointer-events: none.
const fx = await initHzFxOverlay(canvas);
await fx.loadPreset(preset);                       // or fx.setEmitters([{ preset, position }])

function frame(dt) {
  fx.setCamera(proj, view, [cx, cy, cz]);          // column-major matrices + camera world pos
  fx.render(dt);
}

// INLINE mode (share your own WebGPU renderer, e.g. three's WebGPURenderer):
const fx2 = await initHzFxOverlay(
  { device, context, canvas },
  { getSceneDepth: makeThreeSceneDepth(renderer) } // one-line occlusion behind your geometry
);
// each frame, AFTER renderer.render(scene, camera): fx2.setCamera(...); fx2.render(dt);
```

**Coexisting groups & moving emitters** — one overlay hosts many FX at once. `addEmitter(preset, pos)`
adds a static group; `addMovingEmitter(preset, { getPosition })` adds a moving one (e.g. a ball trail)
whose comet trail follows the path you supply (the overlay *pulls* `getPosition()` each frame; return
`null` to pause). Both return `{ remove() }`.

| Method | Description |
| --- | --- |
| `setCamera(proj, view, pos)` / `setCameraMVP(mvp, pos)` | Drive the camera (column-major). |
| `loadPreset(preset, pos?)` / `setEmitters([...])` | (Re)build emitters. |
| `addEmitter(preset, pos?)` → `{ remove }` | Add a STATIC group that coexists with others. |
| `addMovingEmitter(preset, { getPosition })` → `{ setPosition, remove }` | Add a MOVING group (ball trail). |
| `render(dt)` | Simulate + render one frame (inline: call after your scene render). |
| `resize()` | Recreate render textures (also auto-detected from the canvas). |
| `clearCaches()` | Drop cached bind groups after a `replaceSystems`/`addSystems` that reuses ids. |
| `trackHistoryGroup(ids, getPosition)` → `{ remove }` | Give existing manager systems a moving trail (history only). |
| `destroy()` | Release GPU resources. |

Options: `{ getSceneDepth, autoRespawn, manager }`. Pass `manager` to **share an existing
`ParticleSystemManager`** so your app keeps owning it while the overlay is the single render path.

## Advanced: standalone WebGPU engine

If you want to build your own renderer on the raw engine (no overlay, no R3F), drive the
`ParticleSystemManager` / `ParticleSystem` directly:

```javascript
import { initWebGPU, ParticleSystemManager } from 'hz-particles';

// 1. Setup WebGPU context
const canvas = document.getElementById('webgpu-canvas');
canvas.width = 800;
canvas.height = 600;
const { device, context, format } = await initWebGPU(canvas);

// 2. Create manager + a particle system
const manager = new ParticleSystemManager(device);
manager.createParticleSystem({ maxParticles: 10000, particleCount: 1000 });

// 3. Initialize the compute pipeline
const system = manager.getActiveSystem();
await system.initComputePipeline(device);

// 4. Render loop
let lastTime = performance.now();
function render() {
  const now = performance.now();
  const dt = (now - lastTime) / 1000;
  lastTime = now;

  manager.updateAllSystems(dt);

  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      clearValue: { r: 0, g: 0, b: 0, a: 1 },
      loadOp: 'clear',
      storeOp: 'store',
    }],
  });
  system.render(pass);
  pass.end();
  device.queue.submit([encoder.finish()]);

  requestAnimationFrame(render);
}
render();
```

## Preset configuration

Presets are plain `SceneData`. A few key per-system fields:

| Field | Type | Description |
|-------|------|-------------|
| `particleCount` | `number` | Number of active particles |
| `lifetime` | `number` | Particle lifetime in seconds |
| `emissionRate` | `number` | Particles emitted per second |
| `emissionShape` | `string` | Emitter shape (default `cube`). Volumes: `cube`/`box`, `sphere`, `cylinder`, `cone`, `torus`, `capsule`, `frustum`, `hemisphere`, `spiral`. Flat: `circle`, `square`, `rectangle`, `disc`, `annulus`, `arc`, `polygon`, `plain`, `line`. Surfaces: `frustumSurface`, `cubeSurface`, `sphereSurface`, `boxFrame` |
| `particleSize` | `number` | Base size of each particle |
| `colors` | `string[]` | Hex colors interpolated over lifetime |
| `fadeIn` | `number` | Opacity fade-in duration (0–1, fraction of lifetime) |
| `fadeOut` | `number` | Opacity fade-out start (0–1, fraction of lifetime) |
| `bloom` | `boolean` | Enable bloom glow on particles |
| `gravity` | `number` | Gravity strength applied to particles |
| `damping` | `number` | Velocity damping factor (0 = no drag, 1 = full stop) |
| `rotationMode` | `string` | Particle orientation: `fixed` / `random` / `velocity` (billboard), `oriented` (fixed world angle), `cylindrical` (upright, camera-facing) |
| `orientX` / `orientY` / `orientZ` | `number` | Euler angles (radians) for `rotationMode: 'oriented'` |
| `keyframesEnabled` | `boolean` | Enable keyframe animation of system parameters over time |
| `keyframes` | `object` | Per-parameter keyframe tracks (value + curve/easing) applied via the clock |
| `startAt` | `number` | Delay (seconds) before this emitter starts, for relative sequencing |
| `emissionTrailEnabled` | `boolean` | Enable particle trail rendering |
| `emissionTrailDuration` | `number` | How long trail segments persist (seconds) |
| `emissionTrailWidth` | `number` | Width of the emission trail |
| `shapeStrokeEnabled` | `boolean` | Draw a contour ribbon along supported emitter shapes |
| `shapeStrokeWidth` | `number` | World-space width of the contour ribbon |
| `shapeStrokeSegments` | `number` | Sampling resolution of the contour path |
| `shapeStrokeShape` | `string` | Stroke deformation: `straight`, `zigzag`, `sine`, `spiral` |
| `shapeStrokeAmplitude` / `shapeStrokeFrequency` / `shapeStrokeSpeed` | `number` | Stroke deformation amplitude, tiling frequency, and animation speed. In `straight`, `shapeStrokeSpeed` scrolls the stroke along the contour |

`serializeSystemConfig(config)` is the single source of truth for the full set of serializable
fields (used by `saveScene` and the editor's code export).

## API reference

### `initWebGPU(canvas)`

Convenience helper to initialize a WebGPU context and device.

```typescript
async function initWebGPU(canvas: HTMLCanvasElement): Promise<{
  device: GPUDevice,
  context: GPUCanvasContext,
  format: GPUTextureFormat,
  canvas: HTMLCanvasElement
}>
```

Set `canvas.width` / `canvas.height` before calling. Throws if WebGPU is unsupported.

### `ParticleSystem`

Core particle system class managing simulation and rendering.

```typescript
class ParticleSystem {
  constructor(device: GPUDevice, config?: object)
}
```

**Config:** `maxParticles` (default `10000`), `particleCount` (default `100`).

| Method | Description |
|--------|-------------|
| `initComputePipeline(device)` | Initialize GPU compute and render pipelines. Call before rendering. |
| `setTexture(imageBitmap)` | Set particle texture from an `ImageBitmap`. |
| `setGLBModel(arrayBuffer)` | Use GLB model geometry as particle shape (auto-extracts textures). |
| `updateParticles(deltaTime)` | Execute a physics simulation step on the GPU. |
| `spawnParticles()` | Emit new particles according to emitter configuration. |
| `setGravity(value)` | Set gravity strength (default `9.8`). |
| `setAttractor(strength, position, reverse?)` | Set an attractor or repulsor point `[x, y, z]`. |
| `render(renderPass)` | Render particles to the current render pass. |

### `ParticleSystemManager`

Manages multiple particle systems within a single scene.

```typescript
class ParticleSystemManager {
  constructor(device: GPUDevice)
}
```

| Method | Description |
|--------|-------------|
| `createParticleSystem(config?)` | Create a new particle system. Returns system ID. |
| `getActiveSystem()` | Get the currently active `ParticleSystem`. |
| `getActiveConfig()` | Get the active system configuration object. |
| `setActiveSystem(index)` | Switch active system by index. |
| `removeSystem(index)` | Remove a system by index. |
| `updateAllSystems(deltaTime)` | Update physics for all systems. |
| `getSystemsList()` | List all systems (`name`, `id`, `index`, `isActive`). |
| `duplicateActiveSystem()` | Clone the active system. Returns new system ID. |
| `replaceSystems(sceneData)` | Load a scene from serialized data. |

### `parseGLB(arrayBuffer)`

Parse GLB binary and extract geometry.

```typescript
async function parseGLB(arrayBuffer: ArrayBuffer): Promise<{
  positions: Float32Array,
  normals: Float32Array,
  indices: Uint16Array | Uint32Array,
  texCoords: Float32Array | null,
  vertexCount: number,
  indexCount: number,
  animationData: object | null,
  hasBaseColorTexture: boolean
}>
```

### `GLBAnimator`

Skeletal animation playback for animated GLB models.

```typescript
class GLBAnimator {
  constructor(animationData: object)
  currentTime: number
  playing: boolean
  speed: number   // default 1.0
  loop: boolean   // default true
}
```

| Method | Description |
|--------|-------------|
| `setRestPose(positions, normals)` | Set the T-pose/bind pose. |
| `update(deltaTime)` | Advance animation. Returns `{ positions, normals, changed }`. |
| `setAnimation(index)` | Switch to animation clip by index. |
| `getAnimationNames()` | List available animation clip names. |

## GLB models & animation

```javascript
import { parseGLB, GLBAnimator } from 'hz-particles';

const arrayBuffer = await (await fetch('model.glb')).arrayBuffer();
const glbData = await parseGLB(arrayBuffer);

await system.setGLBModel(arrayBuffer); // use as particle shape

if (glbData.animationData) {
  const animator = new GLBAnimator(glbData.animationData);
  animator.setRestPose(glbData.positions, glbData.normals);
  animator.playing = true;

  // in the render loop:
  const { positions, normals, changed } = animator.update(deltaTime);
  if (changed) { /* push updated geometry to the system */ }
}
```

## Scene save / load

```javascript
import { saveScene, loadScene } from 'hz-particles';

// Save (downloads a self-contained .hzfx package; Alt-click → JSON):
saveButton.addEventListener('click', () => saveScene(manager));

// Load from a file input (.hzfx or JSON, auto-detected):
fileInput.addEventListener('change', async (e) => {
  if (await loadScene(e, manager)) console.log('Scene loaded');
});
```

## TypeScript

Type declarations **are shipped** — the package's `types` entry points at
`dist-lib/hz-particles.d.ts` (and `dist-lib/hz-particles-r3f.d.ts` for the `hz-particles/r3f`
subpath), so imports are fully typed out of the box:

```typescript
import { initHzFxOverlay, ParticleSystemManager, serializeSystemConfig } from 'hz-particles';
import { HZFaithfulFX } from 'hz-particles/r3f';
```

## Secondary exports

Also exported for advanced usage:

- **`fetchPreset(url)`** — load a preset from a URL (`.hzfx` or JSON, auto-detected)
- **`packHZFX` / `unpackHZFX` / `isHZFX`** — build/read the `.hzfx` binary package format
- **`serializeSystemConfig(config)`** — single source of truth for a system's serializable fields
- **`saveScene(manager)` / `loadScene(event)`** — export/import a scene (`.hzfx` or JSON)
- **`ParticleEmitter`** — emission-shape configuration
- **`ParticlePhysics`** — physics parameter management
- **`ParticleTextureManager`** — texture loading utilities
- **`Objects3DManager`** — 3D object scene management
- **`extractGLBTexture(arrayBuffer)`** — extract base color texture from a GLB
- **Shader / geometry / pipeline helpers** — low-level WGSL and pipeline construction

## Online editor

Generate effects from a prompt — or build them by hand — in the HZ editor, then export a preset and
render it with this package.

- Hosted editor & prompt FX generator: https://particles.hole.zone/editor
- Browse the community gallery (live previews): https://particles.hole.zone/discover

The editor provides real-time configuration, emitter-shape tuning, GLB import + animation control, a
preset library, and `JSON` / `.hzfx` export.

## Contributing

1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes
4. Push and open a Pull Request

## License

MIT License — see [LICENSE](LICENSE).

Copyright (c) 2025-2026 HZ
