// 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; }>; };