// HZ Particles Engine - TypeScript Declarations
///
// ─── Configuration Interfaces ────────────────────────────────────────────────
export interface ParticleSystemConfig {
name?: string;
id?: number;
maxParticles?: number;
particleCount?: number;
lifetime?: number;
emissionRate?: number;
emissionDuration?: number;
emissionDurationInfinite?: boolean;
emissionShape?: 'cube' | 'sphere' | 'square' | 'circle' | 'cylinder' | 'plain' | 'cone' | 'torus' | 'line' | 'hemisphere' | 'disc' | 'annulus' | 'capsule' | 'arc' | 'spiral' | 'frustum' | 'frustumSurface' | 'cubeSurface' | 'sphereSurface' | 'boxFrame' | 'polygon' | 'rectangle' | 'point';
particleSize?: number;
particleSpeed?: number;
particleColor?: [number, number, number];
startColor?: [number, number, number];
endColor?: [number, number, number];
fadeEnabled?: boolean;
colorTransitionEnabled?: boolean;
randomColorEnabled?: boolean;
randomColors?: number[][];
textureEnabled?: boolean;
textureType?: 'image' | 'glb';
textureImageData?: string;
glbModelEnabled?: boolean;
/** 3D motion of each mesh particle: tumble about a random axis, follow the velocity, or both (spinning about it). */
meshSpinMode?: 'none' | 'tumble' | 'align' | 'alignSpin';
/** Mesh spin speed, degrees per second (tumble / alignSpin). Default 180. */
meshSpinSpeed?: number;
/** Share of random variation of the spin speed per particle, 0..1. Default 0.5. */
meshSpinRandom?: number;
/** Velocity noise strength (m/s²); 0 = off. Smooth, animated. */
turbulence?: number;
/** Spatial frequency of the turbulence (1/m). Default 1.5. */
turbulenceFrequency?: number;
/** Vortex about the vertical axis through the system centre (rad/s²); positive = counter-clockwise from above. */
swirlStrength?: number;
glbFileName?: string | null;
glbAnimated?: boolean;
animationIndex?: number;
animationSpeed?: number;
animationLoop?: boolean;
useGlbTexture?: boolean;
glbHasTexture?: boolean;
particleShape?: 'square' | 'circle' | 'triangle' | 'diamond' | 'star' | 'hexagon' | 'ring' | 'heart' | 'cross' | 'spark' | 'leaf' | 'capsule' | 'crescent' | 'line' | 'curved-line';
particleShapeRotationX?: number;
particleShapeRotationY?: number;
particleShapeRotationZ?: number;
pulseEnabled?: boolean;
pulseAmplitude?: number;
pulseFrequency?: number;
pulsePhaseRandom?: number;
pulseOpacity?: boolean;
burstMode?: boolean;
gravityEnabled?: boolean;
gravityStrength?: number;
dampingEnabled?: boolean;
dampingStrength?: number;
attractorEnabled?: boolean;
attractorStrength?: number;
attractorReverse?: boolean;
attractorPosition?: [number, number, number];
emissionRotationX?: number;
emissionRotationY?: number;
emissionRotationZ?: number;
emissionPositionX?: number;
emissionPositionY?: number;
emissionPositionZ?: number;
overrideXVelocity?: boolean;
overrideYVelocity?: boolean;
overrideZVelocity?: boolean;
xVelocity?: number;
yVelocity?: number;
zVelocity?: number;
outerLength?: number;
innerLength?: number;
outerRadius?: number;
innerRadius?: number;
squareSize?: number;
squareInnerSize?: number;
circleOuterRadius?: number;
circleInnerRadius?: number;
rectangleWidth?: number;
rectangleHeight?: number;
circleVelocityDirection?: string;
cylinderOuterRadius?: number;
cylinderInnerRadius?: number;
cylinderHeight?: number;
cylinderVelocityDirection?: string;
bloomIntensity?: number;
rotation?: number;
/**
* Particle orientation. `fixed`/`random`/`velocity` are billboard modes; `oriented` locks the
* quad to a fixed world-space plane (Euler `orientX/Y/Z`); `cylindrical` keeps it upright while
* facing the camera horizontally (edge-on from directly above/below).
*/
rotationMode?: 'fixed' | 'random' | 'velocity' | 'oriented' | 'cylindrical';
/** Euler angles in radians for `rotationMode: 'oriented'`. */
orientX?: number;
orientY?: number;
orientZ?: number;
minRotation?: number;
maxRotation?: number;
aspectRatio?: number;
velocityStretchEnabled?: boolean;
velocityStretchFactor?: number;
blendMode?: 'normal' | 'additive';
noiseDistortEnabled?: boolean;
noiseTilingX?: number;
noiseTilingY?: number;
noiseSpeed?: number;
noiseAmplitude?: number;
randomSize?: boolean;
minSize?: number;
maxSize?: number;
fadeSizeEnabled?: boolean;
increaseSizeEnabled?: boolean;
sizeLifetimeSpeed?: number;
opacity?: number;
randomSpeed?: boolean;
minSpeed?: number;
maxSpeed?: number;
emissionTrailEnabled?: boolean;
emissionTrailDuration?: number;
emissionTrailWidth?: number;
emissionTrailMinDistance?: number;
emissionTrailMaxPoints?: number;
emissionTrailSegments?: number;
emissionTrailMode?: 'ribbon' | 'particles';
emissionTrailShape?: 'straight' | 'zigzag' | 'sine' | 'spiral';
emissionTrailShapeAmplitude?: number;
emissionTrailShapeFrequency?: number;
emissionTrailShapeSpeed?: number;
shapeStrokeEnabled?: boolean;
shapeStrokeWidth?: number;
shapeStrokeSegments?: number;
shapeStrokeShape?: 'straight' | 'zigzag' | 'sine' | 'spiral';
shapeStrokeAmplitude?: number;
shapeStrokeFrequency?: number;
shapeStrokeSpeed?: number;
shapeDisplay?: boolean;
followSystemTranslation?: boolean;
followSystemId?: number | null;
/** 1.7: what a follower reacts to: 'birth' (default, 1.6 behaviour) or 'death' (emit where the leader's particles die). */
followEvent?: 'birth' | 'death';
/** 1.7: a system that has finished is NOT respawned on its own inside a moving/pooled group (single-shot layers). */
playOnce?: boolean;
/** 1.7: emitter speed (m/s) at which `emissionRate` applies in full; slower emits proportionally less (still = none), up to 1.5x. */
emissionRateSpeedRef?: number;
/** 1.7: speed-aware emission of a MOVING emitter. `false` = off; default ON for trail groups (continuous, non-ribbon systems).
* Rate x f(speed): 0 under `deadzone`, linear up to 1 at `ref`, then (speed/ref)^power capped at `max`; speed low-passed over `smooth` s. */
emissionSpeed?: boolean | { ref?: number; min?: number; max?: number; power?: number; deadzone?: number; smooth?: number };
/** 1.7: ribbon tail colour [r,g,b] (gradient from the particle colour at the head). */
emissionTrailEndColor?: [number, number, number] | null;
/** 1.7: ribbon width falloff exponent (1 = linear, the 1.6 look). */
emissionTrailTaper?: number;
/** 1.7: ribbon opacity falloff exponent (2 = the 1.6 look). */
emissionTrailFadePower?: number;
/** 1.7: emitter speed (m/s) at which the ribbon has full width and opacity (speed-reactive ribbon). */
emissionTrailSpeedRef?: number;
hidden?: boolean;
oneOnlyMode?: boolean;
script?: string;
glbModelData?: string;
displayUnit?: 'm' | 'cm';
/** Delay in seconds before this emitter starts, for relative sequencing of emitters. */
startAt?: number;
/** Enable keyframe animation of system parameters over time (driven by the unified clock). */
keyframesEnabled?: boolean;
/** Per-parameter keyframe tracks (value + curve/easing), keyed by config field name. */
keyframes?: Record;
/** Colour/alpha over a particle's life (2..6 stops, t in 0..1). Replaces the colour mode and fade. */
colorOverLifeEnabled?: boolean;
colorOverLife?: Array<{ t: number; color: [number, number, number]; alpha?: number }> | null;
/** Size multiplier over a particle's life (2..6 stops). Replaces fadeSize/increaseSize. */
sizeOverLifeEnabled?: boolean;
sizeOverLife?: Array<{ t: number; scale: number }> | null;
/** Flipbook: animate textureImageData as a cols x rows atlas (left-to-right, top-to-bottom). Sprites only. */
flipbookEnabled?: boolean;
flipbookCols?: number;
flipbookRows?: number;
/** Frames used (default cols x rows). */
flipbookFrames?: number | null;
/** 'lifetime' = whole animation over the life, 'fps' = looped at flipbookFps, 'random' = one frame per particle. */
flipbookMode?: 'lifetime' | 'fps' | 'random';
flipbookFps?: number;
onAppearanceChange?: ((config: ParticleSystemConfig) => void) | null;
onColorChange?: ((config: ParticleSystemConfig) => void) | null;
onSizeChange?: ((config: ParticleSystemConfig) => void) | null;
onSpeedChange?: ((config: ParticleSystemConfig) => void) | null;
onPhysicsChange?: ((config: ParticleSystemConfig) => void) | null;
onBloomIntensityChange?: ((value: number) => void) | null;
onRespawn?: (() => void) | null;
getActiveSystem?: (() => ParticleSystem | null) | null;
}
export interface AnimationChannel {
targetNode: number;
targetPath: string;
interpolation: string;
timestamps: Float32Array;
values: Float32Array;
}
export interface AnimationClip {
name: string;
duration: number;
channels: AnimationChannel[];
}
export interface GLBNode {
translation: [number, number, number];
rotation: [number, number, number, number];
scale: [number, number, number];
children: number[];
name: string;
}
export interface AnimationData {
joints: number[];
inverseBindMatrices: Float32Array;
jointIndices: Uint8Array;
jointWeights: Float32Array;
nodes: GLBNode[];
animations: AnimationClip[];
}
export interface GLBParseResult {
positions: Float32Array;
normals: Float32Array;
indices: Uint16Array | Uint32Array;
texCoords: Float32Array | null;
vertexCount: number;
indexCount: number;
animationData: AnimationData | null;
hasBaseColorTexture: boolean;
}
export interface GLBTextureResult {
imageBlob: Blob;
mimeType: string;
}
export interface WebGPUContext {
device: GPUDevice;
context: GPUCanvasContext;
format: GPUTextureFormat;
canvas: HTMLCanvasElement;
}
export interface RenderTextures {
sceneTexture: GPUTexture;
bloomTexA: GPUTexture;
bloomTexB: GPUTexture;
}
export interface BindGroupLayouts {
particleBindGroupLayout: GPUBindGroupLayout;
bloomBindGroupLayout: GPUBindGroupLayout;
compositeBindGroupLayout: GPUBindGroupLayout;
object3dBindGroupLayout: GPUBindGroupLayout;
}
export interface RenderPipelines {
particlePipeline: GPURenderPipeline;
particleAdditivePipeline: GPURenderPipeline;
particleDepthWritePipeline: GPURenderPipeline;
blurPipeline: GPURenderPipeline;
compositePipeline: GPURenderPipeline;
directRenderPipeline: GPURenderPipeline;
object3dPipeline: GPURenderPipeline;
glbMeshPipeline: GPURenderPipeline;
}
export interface BindGroups {
particleBindGroup: GPUBindGroup;
horizontalBlurBindGroup: GPUBindGroup;
verticalBlurBindGroup: GPUBindGroup;
compositeBindGroup: GPUBindGroup;
directRenderBindGroup: GPUBindGroup;
}
export interface GeometryData {
vertices: Float32Array;
indices: Uint16Array;
}
export interface SystemEntry {
system: ParticleSystem;
config: ParticleSystemConfig;
}
export interface SystemInfo {
name: string;
id: number;
index: number;
isActive: boolean;
hidden: boolean;
}
export interface PhysicsReadbackResult {
particleData?: Float32Array;
particleVelocities?: Float32Array;
shouldUpdate: boolean;
activeCount?: number;
}
export interface SceneData {
systems: ParticleSystemConfig[];
activeSystemIndex: number;
}
// ─── Classes ─────────────────────────────────────────────────────────────────
export class ParticleSystem {
constructor(device: GPUDevice, config: ParticleSystemConfig);
device: GPUDevice;
config: ParticleSystemConfig;
MAX_PARTICLES: number;
particleCount: number;
activeParticles: number;
emitting: boolean;
currentEmissionTime: number;
/** true after destroy() — GPU buffers are gone, all mutating methods become no-ops. */
destroyed: boolean;
particleData: Float32Array;
particleVelocities: Float32Array;
instanceBuffer: GPUBuffer;
velocityBuffer: GPUBuffer;
appearanceUniformBuffer: GPUBuffer;
bloomIntensityBuffer: GPUBuffer;
emitter: ParticleEmitter;
physics: ParticlePhysics;
textureManager: ParticleTextureManager;
particleTexture: GPUTexture;
glbVertexBuffer: GPUBuffer | null;
glbIndexBuffer: GPUBuffer | null;
glbIndexCount: number;
glbIndexFormat: 'uint16' | 'uint32';
glbMeshData: GLBParseResult | null;
glbRawArrayBuffer: ArrayBuffer | null;
glbAnimator: GLBAnimator | null;
glbAnimated: boolean;
frameCount: number;
shouldReset: boolean;
computeReady: boolean;
initComputePipeline(device: GPUDevice): Promise;
setTexture(imageBitmap: ImageBitmap): Promise;
resetTexture(): void;
setGLBModel(arrayBuffer: ArrayBuffer): Promise;
resetGLBModel(): void;
updateAnimatedGLBBuffer(): boolean;
setSimulationTransform(transform: {
position?: [number, number, number];
rotation?: [number, number];
velocity?: [number, number, number];
}): void;
clearSimulationTransform(): void;
updateAppearanceUniform(): void;
updateBloomIntensity(): void;
spawnParticles(): void;
_newEmissions: number[];
_pendingFollowEmissions: number[];
emitParticle(): boolean;
emitFollowerParticle(px: number, py: number, pz: number): boolean;
updateParticles(deltaTime: number): void;
readbackAndProcessParticles(): Promise;
readbackForRendering(
outParticleData: Float32Array,
outVelocities: Float32Array
): Promise;
setScript(source: string | null): void;
destroy(): void;
respawnParticle(oldIndex: number, newIndex: number): void;
updateBuffers(): void;
updateParticleColors(): void;
updateParticleVelocities(): void;
setGravity(gravityValue: number): void;
setAttractor(strength: number, position: [number, number, number], reverse?: boolean): void;
}
export class ParticleSystemManager {
constructor(device: GPUDevice);
device: GPUDevice;
particleSystems: SystemEntry[];
activeSystemIndex: number;
systemCounter: number;
onSystemCreated: ((systemId: number, config: ParticleSystemConfig) => void) | null;
/** false while replaceSystems()/addSystems() rebuild GPU resources — hosts
* driving their own render loop must skip frames while !ready. */
ready: boolean;
createParticleSystem(config?: ParticleSystemConfig): number;
getSystemById(id: number): SystemEntry | null;
getActiveSystem(): ParticleSystem | null;
getActiveConfig(): ParticleSystemConfig | null;
setActiveSystem(index: number): boolean;
removeSystem(index: number): boolean;
destroy(): void;
respawnAllSystems(): void;
/** Respawn ONE group (the ids returned by addSystems) and leave every other system alone.
* The group gets its own time origin and duration: its startAt delays and keyframes are read
* from its birth, not from the shared timeline. Hosts that add groups to a LIVE overlay must
* use this - respawnAllSystems restarts every effect on screen. */
respawnSystems(ids: Iterable): void;
/** Timeline length of an arbitrary set of configs (the group-scoped getTotalDuration). */
durationOfConfigs(configs: ParticleSystemConfig[]): number;
updateAllSystems(deltaTime: number): void;
getSystemsList(): SystemInfo[];
duplicateActiveSystem(): number;
replaceSystems(sceneData: SceneData): Promise;
/** Returns the ids of the systems it created (race-proof handle), or false on error. */
addSystems(sceneData: SceneData, positionOffset?: [number, number, number]): Promise;
}
export class ParticleEmitter {
constructor(config?: ParticleSystemConfig);
config: ParticleSystemConfig;
emitParticle(particleData: Float32Array, index: number, velocities: Float32Array): void;
applyRotation(x: number, y: number, z: number): [number, number, number];
inverseRotation(x: number, y: number, z: number): [number, number, number];
emitFromCube(): [number, number, number];
emitFromSphere(): [number, number, number];
emitFromSquare(): [number, number, number];
emitFromCircle(): [number, number, number];
emitFromCylinder(): [number, number, number];
calculateVelocity(posX: number, posY: number, posZ: number, velocities: Float32Array, velIndex: number): void;
setParticleColor(particleData: Float32Array, particleIndex: number): void;
setParticleLifetime(particleData: Float32Array, particleIndex: number): void;
}
export class ParticlePhysics {
constructor(device: GPUDevice);
device: GPUDevice;
physicsSettings: {
deltaTime: number;
gravity: number;
turbulence: number;
attractorStrength: number;
attractorPosition: [number, number, number];
damping: number;
};
fixedDeltaTime: number;
physicsClock: number;
physicsAccumulator: number;
minUpdatesPerSecond: number;
lastUpdateTime: number;
computeReady: boolean;
computePipeline: GPUComputePipeline | null;
computeBindGroupLayout: GPUBindGroupLayout | null;
computeBindGroup: GPUBindGroup | null;
physicsUniformBuffer: GPUBuffer;
initComputePipeline(instanceBuffer: GPUBuffer, velocityBuffer: GPUBuffer): Promise;
createComputePipeline(instanceBuffer: GPUBuffer, velocityBuffer: GPUBuffer): Promise;
updatePhysics(
fixedDeltaTime: number,
activeParticles: number,
config: ParticleSystemConfig,
instanceBuffer: GPUBuffer,
velocityBuffer: GPUBuffer
): void;
readbackAndProcessParticles(
activeParticles: number,
particleData: Float32Array,
particleVelocities: Float32Array,
instanceBuffer: GPUBuffer,
velocityBuffer: GPUBuffer
): Promise;
readbackForRendering(
activeParticles: number,
outParticleData: Float32Array,
outVelocities: Float32Array,
instanceBuffer: GPUBuffer,
velocityBuffer: GPUBuffer
): Promise;
readbackForScript(
activeParticles: number,
outParticleData: Float32Array,
outVelocities: Float32Array,
instanceBuffer: GPUBuffer,
velocityBuffer: GPUBuffer
): Promise;
destroy(): void;
setDamping(dampingValue: number): void;
setGravity(gravityValue: number): void;
setAttractor(strength: number, position: [number, number, number], reverse?: boolean): void;
}
export class ParticleTextureManager {
constructor(device: GPUDevice);
device: GPUDevice;
defaultTexture: GPUTexture;
createDefaultTexture(): void;
loadTexture(imageBitmap: ImageBitmap): Promise;
getDefaultTexture(): GPUTexture;
destroyTexture(texture: GPUTexture): void;
}
export class GLBAnimator {
constructor(animationData: AnimationData);
joints: number[];
inverseBindMatrices: Float32Array;
jointWeights: Float32Array;
jointIndices: Uint8Array;
nodes: GLBNode[];
animations: AnimationClip[];
restPositions: Float32Array | null;
restNormals: Float32Array | null;
currentAnimIndex: number;
currentTime: number;
playing: boolean;
speed: number;
loop: boolean;
skinnedPositions: Float32Array | null;
skinnedNormals: Float32Array | null;
setRestPose(positions: Float32Array, normals: Float32Array): void;
update(deltaTime: number): { positions: Float32Array | null; normals: Float32Array | null; changed: boolean };
getCurrentAnimation(): AnimationClip;
setAnimation(index: number): void;
getAnimationNames(): string[];
}
/** Object3D is not directly importable — instances are created via Objects3DManager.addObject() */
export interface Object3D {
id: number;
type: 'cube' | 'sphere';
position: [number, number, number];
scale: [number, number, number];
color: [number, number, number, number];
rotation: [number, number, number];
uniformBuffer: GPUBuffer;
getModelMatrix(): Float32Array;
getNormalMatrix(): Float32Array;
}
export class Objects3DManager {
constructor(device: GPUDevice);
device: GPUDevice;
objects: Object3D[];
nextId: number;
onObjectAdded: ((object: Object3D) => void) | null;
onObjectRemoved: ((id: number) => void) | null;
onObjectUpdated: ((object: Object3D) => void) | null;
cubeVertexBuffer: GPUBuffer;
cubeIndexBuffer: GPUBuffer;
cubeIndexCount: number;
sphereVertexBuffer: GPUBuffer;
sphereIndexBuffer: GPUBuffer;
sphereIndexCount: number;
initializeGeometry(): void;
addObject(
type: 'cube' | 'sphere',
position?: [number, number, number],
scale?: [number, number, number],
color?: [number, number, number, number]
): Object3D;
removeObject(id: number): void;
updateObjectPosition(id: number, position: [number, number, number]): void;
updateObjectScale(id: number, scale: [number, number, number]): void;
updateObjectColor(id: number, color: [number, number, number, number]): void;
getAllObjects(): Object3D[];
getObject(id: number): Object3D | undefined;
updateObjectUniforms(object: Object3D): void;
renderObjects(
passEncoder: GPURenderPassEncoder,
pipeline: GPURenderPipeline,
bindGroupLayout: GPUBindGroupLayout,
uniformBuffer: GPUBuffer
): void;
}
export class ParticleScript {
constructor(source: string, config: ParticleSystemConfig, maxParticles: number);
compile(source: string): void;
execute(
deltaTime: number,
time: number,
frame: number,
totalFrames: number,
particleData: Float32Array,
velocityData: Float32Array,
count: number
): boolean;
snapshotConfig(config: ParticleSystemConfig): void;
restoreConfig(config: ParticleSystemConfig): void;
destroy(): void;
}
// ─── WebGPU Utilities ────────────────────────────────────────────────────────
export function initWebGPU(canvas: HTMLCanvasElement): Promise;
export function createRenderTextures(
device: GPUDevice,
format: GPUTextureFormat,
width: number,
height: number
): RenderTextures;
export function createDepthTexture(device: GPUDevice, width: number, height: number): GPUTexture;
export function createBuffer(device: GPUDevice, data: ArrayBuffer | ArrayBufferView, usage: GPUBufferUsageFlags): GPUBuffer;
// ─── Rendering ───────────────────────────────────────────────────────────────
export function createBindGroupLayouts(device: GPUDevice): BindGroupLayouts;
export function createRenderPipelines(
device: GPUDevice,
format: GPUTextureFormat,
layouts: BindGroupLayouts,
options?: { depthCompare?: string }
): RenderPipelines;
export function createBindGroups(
device: GPUDevice,
sampler: GPUSampler,
buffers: {
uniformBuffer: GPUBuffer;
appearanceUniformBuffer: GPUBuffer;
horizontalBlurUniformBuffer: GPUBuffer;
verticalBlurUniformBuffer: GPUBuffer;
bloomIntensityBuffer: GPUBuffer;
},
textures: {
sceneTexture: GPUTexture;
bloomTexA: GPUTexture;
bloomTexB: GPUTexture;
}
): BindGroups;
export function createSampler(device: GPUDevice): GPUSampler;
// ─── Shaders ─────────────────────────────────────────────────────────────────
export const particleShader: string;
export const particlePhysicsShader: string;
export const blurShader: string;
export const compositeShader: string;
export const directRenderShader: string;
export const object3dShader: string;
export const glbMeshParticleShader: string;
// ─── GLB Support ─────────────────────────────────────────────────────────────
export function parseGLB(arrayBuffer: ArrayBuffer): Promise;
export function extractGLBTexture(arrayBuffer: ArrayBuffer): Promise;
// ─── 3D Geometry ─────────────────────────────────────────────────────────────
export function createCubeGeometry(): GeometryData;
export function createSphereGeometry(latitudeBands?: number, longitudeBands?: number): GeometryData;
// ─── Utilities ───────────────────────────────────────────────────────────────
export function createLookAtMatrix(
eye: [number, number, number],
target: [number, number, number],
up: [number, number, number]
): Float32Array;
export function createProjectionMatrix(aspect: number, fov?: number): Float32Array;
export function multiplyMatrices(a: Float32Array, b: Float32Array): Float32Array;
export function hexToRgb(hex: string): [number, number, number];
export function rgbToHex(rgb: [number, number, number]): string;
// ─── Scene Management ────────────────────────────────────────────────────────
export function saveScene(particleSystemManager: ParticleSystemManager): void;
/** The current scene as SceneData (what saveScene packs), assets embedded. */
export function buildSceneData(particleSystemManager: ParticleSystemManager): SceneData;
export function loadScene(event: Event): Promise;
/** THE single source of truth for the serializable (non-binary) fields of a system config.
* saveScene, the exported standalone code, and editor undo snapshots all build their
* per-system object from this so the field set can never drift. Binary assets
* (textureImageData / glbModelData) are added by the caller. */
export function serializeSystemConfig(config: ParticleSystemConfig): Record;
// ─── Preset Fetching ─────────────────────────────────────────────────────────
export function fetchPreset(url: string): Promise;
// ─── .hzfx — self-contained binary effect package ────────────────────────────
export interface PackHZFXOptions {
/** Transcode embedded textures to WebP (browser only — needs OffscreenCanvas). */
textureFormat?: 'keep' | 'webp';
/** WebP quality 0..1 (default 0.85). */
quality?: number;
}
/** Pack a SceneData into a self-contained .hzfx binary package (config + assets). */
export function packHZFX(sceneData: SceneData, opts?: PackHZFXOptions): Promise;
/** Unpack a .hzfx package back into a SceneData the engine consumes. */
export function unpackHZFX(arrayBuffer: ArrayBuffer): SceneData;
/** True if the buffer starts with the HZFX magic. */
export function isHZFX(arrayBuffer: ArrayBuffer): boolean;
// ─── Engine-faithful FX overlay/inline renderer ──────────────────────────────
export interface HzFxEmitter {
preset: SceneData;
position?: [number, number, number];
}
export interface HzFxOverlayOptions {
/** Inline mode only — return the host scene's depth texture (or a
* depth-only view) each frame to occlude particles behind host geometry. */
getSceneDepth?: () => GPUTexture | { view: GPUTextureView; multisampled?: boolean } | null;
/** Respawn all systems when everything is dead (preview-style looping). Default true. */
autoRespawn?: boolean;
/** When true (default), the effect's saved group `loop` flag overrides autoRespawn.
* Hosts owning their own respawn timeline (the editor) pass false. */
respectEffectLoop?: boolean;
/** Share an existing ParticleSystemManager instead of creating one — lets a host
* (e.g. the hz-editor) keep all its UI/config wiring while the overlay is the single
* render path. The overlay then owns updateAllSystems/GLB-update/trail/draw each render(). */
manager?: ParticleSystemManager;
}
export interface HzFxOverlay {
manager: ParticleSystemManager;
device: GPUDevice;
context: GPUCanvasContext;
/** Column-major 4×4 matrices (e.g. three camera.projectionMatrix.elements /
* camera.matrixWorldInverse.elements) + camera world position. */
setCamera(
projectionMatrix: Float32Array | number[],
viewMatrix: Float32Array | number[],
position?: [number, number, number] | { x: number; y: number; z: number },
): void;
setCameraMVP(
mvp: Float32Array | number[],
position?: [number, number, number] | { x: number; y: number; z: number },
): void;
setEmitters(emitters: HzFxEmitter[]): Promise;
loadPreset(preset: SceneData, position?: [number, number, number]): Promise;
/** Add a preset as a STATIC group at `position` (e.g. a map FX, a splash). Returns a
* handle to remove exactly these systems. Any number coexist in the same overlay —
* prefer this over reaching into `manager.addSystems` directly. */
addEmitter(preset: SceneData, position?: [number, number, number]): Promise<{ remove(): void }>;
/** Add a preset as a MOVING emitter (e.g. a ball trail); the trail echoes follow
* the recorded path. Two ways to drive its position:
* - PULL (recommended): pass `{ getPosition }` — the overlay reads it during its
* own render(), once/frame at the right time, so the host never pushes per-frame
* (no timing race vs the host loop). Return null to pause the emitter.
* - PUSH: call the returned setPosition() each frame (for simple hosts). */
addMovingEmitter(
preset: SceneData,
opts?: {
getPosition?: () => [number, number, number] | { x: number; y: number; z: number } | null;
/** Yaw the emitter to face its horizontal movement direction (visible on asymmetric shapes). */
orientToDirection?: boolean;
/** 1.7: a TRAIL group: continuous non-ribbon systems emit in proportion to the speed (see `emissionSpeed`). */
trail?: boolean;
},
): Promise<{
setPosition(p: [number, number, number] | { x: number; y: number; z: number }): void;
setOrientToDirection(v: boolean): void;
remove(): void;
}>;
/** Simulate + render one frame (deltaTime in seconds). Inline mode: call AFTER the host scene render. */
render(deltaTime: number): void;
resize(): void;
/** Drop cached per-system bind groups. Call after a replaceSystems/addSystems that may
* reuse a system id with a new system object (stale appearance/texture otherwise). */
clearCaches(): void;
/** Track existing manager systems (by id) as a path-history group so their trails use the
* same curved `hasPathHistory` path as moving emitters — history ONLY (the host keeps
* owning position/rotation/visibility/respawn). Used by the editor's simulate-move. */
trackHistoryGroup(
ids: Iterable,
getPosition: () => [number, number, number] | { x: number; y: number; z: number } | null,
): { remove(): void };
/** 1.7 test hook: copy of the ribbon vertices built last frame, 7 floats each (x y z r g b a). */
getRibbonVertices(): Float32Array;
destroy(): void;
}
/**
* Engine-faithful FX rendering for host applications — the same render loop as
* the HZ Particles previews (library pipelines: shader shapes, per-system
* blending, GLB meshes, multi-pass bloom), driven by the host camera.
*
* Overlay mode: pass a canvas (own transparent WebGPU context, stack it above
* your scene). Inline mode: pass { device, context, canvas } from your own
* WebGPU renderer and call render() after your scene render each frame.
*/
export function initHzFxOverlay(
target: HTMLCanvasElement | { device: GPUDevice; context: GPUCanvasContext; canvas?: HTMLCanvasElement },
options?: HzFxOverlayOptions,
): Promise;
/**
* Build a `getSceneDepth` callback (for initHzFxOverlay inline mode) from a three.js
* WebGPURenderer, so the overlay occludes particles behind host geometry. Duck-typed —
* pass the renderer; no three import. Handles tone-mapping/sRGB internal framebuffers and
* returns null (no occlusion, FX stay visible) when the scene depth isn't reachable
* (e.g. a PostProcessing pass owns the output). Pass the result as `options.getSceneDepth`.
*/
export function makeThreeSceneDepth(
gl: unknown,
): () => (GPUTexture | { view: GPUTextureView; multisampled?: boolean } | null);
/** Recolours an effect: each colour keeps its lightness and takes the hue/saturation of `tint` (0..1 RGB). Returns a new scene. */
export function tintPreset(scene: SceneData, tint: [number, number, number]): SceneData;
/** One entry of a host profile's `types` (see hz-app/public/game-profiles/*.json). */
export interface FxTargetType {
gameScale?: number;
/** The effect sits on a floor: travel below its origin is hidden by depth and not counted. */
groundOccludes?: boolean;
lifetimeModel?: 'one-shot' | 'infinite-continuous';
defaultDuration?: number;
bloom?: 'forced-off' | 'avoid' | 'allowed' | 'allowed-sparingly';
concurrent?: number;
budget?: { maxLiveParticlesPerEffect?: number; maxSystems?: number };
trailCaps?: { nonHighTier?: { emissionTrailMaxPoints: number; emissionTrailSegments: number } };
visualSize?: { minParticleMeters?: number; maxParticleMeters?: number; maxExtentMeters?: number };
/** Camera distances (m) the effect is usually seen from in game. */
viewDistances?: number[];
[key: string]: unknown;
}
export interface FxSystemMetrics {
index: number;
name: string;
emission: 'burst' | 'one-only' | 'continuous-infinite' | 'continuous-finite';
/** Estimated live particles (null when scripted or following another system). */
liveParticles: number | null;
lifetime: number;
/** Seconds until the system is done (Infinity when it never stops). */
duration: number;
particleSizeMin: number;
particleSizeMax: number;
initialSpeed: number;
/** Estimated reach from the effect origin, editor units (simulated at opts.scale). */
extent: number;
/** Game meters: highest point above the birth height. */
rise: number;
/** gravityStrength when gravity is enabled, else 0 (editor units; scalePreset scales it). */
gravity: number;
/** Game meters: best-case height at half life (negative = already below its origin). */
heightAtHalfLife: number;
/** Sizes/reach use the peak of the keyframed values. */
keyframed: boolean;
/** Set by lintPreset for types with originBelowSurface: share of particles that ever rise above the surface (0..1). */
aboveSurfaceShare?: number | null;
bloom: boolean;
trail: { maxPoints: number; segments: number; mode: string } | null;
/** false when shapeDisplay is off (only the trail is drawn). */
shapeVisible: boolean;
/** GLB mesh particles (size depends on the model). */
glb: boolean;
maxParticles: number;
follows: string | number | null;
scripted: boolean;
hidden: boolean;
/** Filled by lintPreset: values in game meters after scale. */
game?: {
/** Billboard diameter range in game meters (null for GLB or hidden shapes). */
particleDiameter: [number, number] | null;
/** [smallest, largest] particle in pixels (1080 px screen) at each view distance of the type. */
particlePx: Array<{ distance: number; px: [number, number] }> | null;
particleInBalls: [number, number] | null;
/** GLB only: model units -> game meters. */
meshScale: number | null;
ribbonWidth: number | null;
extent: number;
};
}
export interface FxLintIssue {
level: 'error' | 'warn' | 'info';
code: string;
system: number | null;
message: string;
}
/** A host profile (see hz-particles/profiles/holezone.json). */
export interface FxGameProfile {
id: string;
name: string;
version: number;
units: string;
world: { ballRadius?: number; ballDiameter?: number; [key: string]: unknown };
camera: { fov: number; [key: string]: unknown };
quality: { tiers: Record; [key: string]: unknown };
types: Record;
[key: string]: unknown;
}
/** The HoleZone golf game profile: pass `holezoneProfile.types.trail` (etc.) to lintPreset. */
export const holezoneProfile: FxGameProfile;
/** Estimates per system (editor units): live count, lifetime, sizes, reach. Pure, no GPU. */
export function measurePreset(scene: SceneData, opts?: { ground?: boolean; scale?: number }): { systems: FxSystemMetrics[]; totalLive: number; totalDuration: number };
export interface FxPerf {
/** true when a scripted/following system was counted at its capacity. */
estimated: boolean;
/** 0..100 (100 = free). */
score: number;
grade: 'A' | 'B' | 'C' | 'D' | 'E';
label: string;
/** Particle-equivalents (mesh x3, 120 per system, 0.15 per trail point, 600 per kept bloom). */
cost: number;
budget: number;
/** Copies that can be on screen at once (6 players). */
concurrent: number;
parts: Array<{ system: number; name: string; what: string; cost: number }>;
}
/** A-E cost grade from measured systems (lintPreset already returns it as `perf`). */
export function perfOf(systems: FxSystemMetrics[], type: FxTargetType): FxPerf;
/** Check an effect against a host target type (sizes in game meters, budget, lifetime model, bloom). Pure, no GPU. */
export function lintPreset(
scene: SceneData,
type: FxTargetType,
opts?: { scale?: number; camera?: { fov?: number }; world?: { ballDiameter?: number }; quality?: { tiers?: Record } },
): {
ok: boolean;
issues: FxLintIssue[];
perf: FxPerf;
metrics: { scale: number; totalLiveParticles: number; liveParticlesPerTier: Record; totalDuration: number; systems: FxSystemMetrics[] };
};
type FxBriefLength = { meters: number; editorUnits: number; screen: Array<{ distance: number; px: number }> };
type FxBriefSize = { meters: number; particleSize: number; screen?: Array<{ distance: number; px: number }> };
/**
* Design sheet for a host target type: the game's dimensions (ball, cup, particle size limits,
* spread, launch speed per rise height, screen pixels at the type's camera distances, timing, budget)
* converted to EDITOR values with the same conversions as lintPreset. Pure.
*/
export function designBrief(
type: FxTargetType,
opts?: { id?: string; scale?: number; gravity?: number; camera?: { fov?: number }; world?: { ballDiameter?: number; cupRadius?: number }; quality?: { tiers?: Record } },
): {
id: string | null;
label: string | null;
scale: number;
note: string;
world: { ball: FxBriefLength; cup: FxBriefLength; originBelowSurface?: FxBriefLength };
particle: { oneBall: FxBriefSize; min: FxBriefSize | null; max: FxBriefSize | null; ribbonMax: FxBriefSize | null; /** Only with type.maxScreenPx (courseMapType): largest particle under that many px from the closest distance. */ screenFill: (FxBriefSize & { maxPx: number; distance: number }) | null; glbScalePerSize: number; note: string };
extent: FxBriefLength | null;
rise: { gravityStrength: number; rows: Array<{ meters: number; editorUnits: number; particleSpeed: number; apexSeconds: number }> };
timing: { model: string | null; cutAfter: number | null; /** pooled one-shots (surface): a copy may restart after this many seconds on rapid contacts */ restartAfter: number | null; detail: string | null };
budget: { maxLiveParticles: number | null; perTier: Record | null; maxSystems: number | null; concurrent: number | null };
bloom: string | null;
hints: string[];
};
/**
* Make an effect fit a host target type with the smallest changes that clear the fixable lint issues
* (extent: whole system scaled uniformly; sizes; finite window for one-shots; timing compressed to
* the game's cut; budget; bloom off where the game drops or forbids it; GLB shapeDisplay). Design
* issues (too many systems, below-surface, keyframe timing) come back in `remaining`. Pure; the
* input scene is not modified.
*/
export function fitPreset(
scene: SceneData,
type: FxTargetType,
opts?: { scale?: number; camera?: { fov?: number }; world?: { ballDiameter?: number }; quality?: { tiers?: Record } },
): {
scene: SceneData;
changes: Array<{ system: number | null; name: string | null; code: string; change: string }>;
remaining: FxLintIssue[];
before: ReturnType;
after: ReturnType;
};
/** Read a zip archive (stored/deflate) into { name: bytes }, with the platform DecompressionStream. */
export function readZip(data: ArrayBuffer | Uint8Array, want?: (name: string) => boolean): Promise>;
/** Copy of a zip with one entry's content replaced (stored); every other entry kept byte for byte. Zip64 refused. */
export function replaceZipEntry(data: ArrayBuffer | Uint8Array, name: string, content: Uint8Array): Uint8Array;
/** A host-game course (HoleZone .ozone): its fx.json, surfaces.json and embedded presets, unpacked. */
export interface FxCourse {
fx: { emitters?: Array<{ preset: string; pos: [number, number, number]; scale?: number; rot?: [number, number, number] }>; trail?: { preset: string; scale?: number } };
surfaces: Array<{ id?: string; client?: { enterFx?: string } }>;
/** pathInformation.json (ball route: start, path nodes, hole, variantes), null when absent. */
path?: { startPosition?: [number, number, number] | null; pathPositions?: Array<{ position: [number, number, number] }>; holePosition?: [number, number, number] | null; variantes?: Array<{ startPosition: [number, number, number]; pathPositions: Array<{ position: [number, number, number] }> }> } | null;
presets: Record;
}
/** Where a map FX placed at `pos` is seen from on this course (ball route + follow height); near >= 0.3 m. null without a route. */
export function courseSeenAt(course: FxCourse, pos: [number, number, number], profile: { camera?: { followHeight?: number } }): { near: number; far: number; routeDistance: number } | null;
/** The map type on one course: real closest/farthest camera distances as viewDistances, maxScreenPx = half the screen. */
export function courseMapType(profile: { types: Record }, near: number, far: number): FxTargetType & { maxScreenPx: number };
/** Ball routes of a course: [start, ...path nodes, hole] per start. */
export function courseRoutes(path: FxCourse['path']): Array>;
/** Distances a follow camera (route lifted by `lift` m) sees `pos` from: closest route point, farthest route node. */
export function seenFrom(pos: [number, number, number], routes: Array>, lift?: number): { near: number; far: number } | null;
/** Open a course archive (.ozone). */
export function loadCourse(data: ArrayBuffer | Uint8Array): Promise;
/**
* Check every FX use of a course the way the game plays it: map emitters at each placement scale,
* the map trail at its scale, surfaces' enterFx as surface FX; plus course totals.
*/
export function lintCourse(course: FxCourse, profile: { types: Record; camera?: object; world?: object; quality?: { tiers?: Record } }): {
status: 'ok' | 'warn' | 'error' | 'no-fx';
placements: number;
mapSystems: number;
mapLive: Record;
mapCost: number;
missing: string[];
notes: string[];
checks: Array<{
role: 'map' | 'trail' | 'surface'; preset: string; scale: number; count: number; at: Array<[number, number, number]>;
systems: number; live: number; grade: string; errors: number;
issues: Array<{ level: string; code: string; message: string }>;
/** Map placements on a course with a route: distances the camera really sees them from, px there. */
seenFrom: { near: number; far: number; closestAt: [number, number, number]; px: Array<{ name: string; at: Array<{ distance: number; px: [number, number] }> }> } | null;
}>;
};