import Color from "@common/utils/color.mjs";
import CanvasAnimation from "../animation/canvas-animation.mjs";
import PrimaryCanvasParticleContainer from "../primary/primary-canvas-particle-container.mjs";
import SpriteMesh from "../containers/elements/sprite-mesh.mjs";
import BatchRenderer from "../rendering/batching/batch-renderer.mjs";
import BaseSamplerShader from "../rendering/shaders/samplers/base-sampler.mjs";
import DepthSamplerShader from "../rendering/shaders/samplers/primary/depth.mjs";
import OccludableSamplerShader from "../rendering/shaders/samplers/primary/occlusion.mjs";
import PrimaryCanvasContainer from "../primary/primary-canvas-container.mjs";

/**
 * @import {
 *   ParticleGeneratorAnchor,
 *   ParticleGeneratorArea,
 *   ParticleGeneratorAreaSampleMode,
 *   ParticleGeneratorBehavior,
 *   ParticleGeneratorBehaviorId,
 *   ParticleGeneratorClipOptions,
 *   ParticleGeneratorColorFunction,
 *   ParticleGeneratorColorValue,
 *   ParticleGeneratorConfiguration,
 *   ParticleGeneratorConstraintMode,
 *   ParticleGeneratorCurvePoint,
 *   ParticleGeneratorDeathCallback,
 *   ParticleGeneratorDebugOptions,
 *   ParticleGeneratorDebugStats,
 *   ParticleGeneratorDebugTintMode,
 *   ParticleGeneratorDebugTintOptions,
 *   ParticleGeneratorFadeOptions,
 *   ParticleGeneratorFollowOptions,
 *   ParticleGeneratorLocalArea,
 *   ParticleGeneratorMode,
 *   ParticleGeneratorOrbitOptions,
 *   ParticleGeneratorParticleCallback,
 *   ParticleGeneratorPoint,
 *   ParticleGeneratorPositionTest,
 *   ParticleGeneratorRange,
 *   ParticleGeneratorRectangle,
 *   ParticleGeneratorTickCallback,
 *   ParticleGeneratorValue,
 *   ParticleGeneratorValueFunction,
 *   ParticleGeneratorVelocityFunction,
 *   ParticleGeneratorVelocityOptions,
 *   ParticleMesh
 * } from "./_types.mjs";
 */

/* -------------------------------------------- */

/**
 * A lightweight, native particle generator designed for VFX.
 *
 * ParticleGenerator manages:
 * - An internal container on the chosen canvas layer (usually `canvas.primary`)
 * - Particle pooling (reusing sprites instead of constantly allocating new ones)
 * - Lifetime, fade-in/out, basic motion, and optional constraints
 * - Two usage styles:
 *   - **ambient**: keep a steady density in the visible area (viewport-driven budget)
 *   - **effect**: spawn particles in a specific area (manual spawns or a fixed target count)
 *
 * The API is intentionally compact:
 * - Put most settings at the top level (`textures`, `blend`, `alpha`, `scale`, `count`, `lifetime`, etc.)
 * - Use `area` to define where particles spawn
 * - Use `spawnParticle()` or `spawnParticles()` to spawn particles (optionally overriding texture/area/position)
 * - Use `start({spawn: n})` when you want to immediately seed a certain number of particles on start
 * - Use `shaderClass` when you want a custom sampler shader for particles
 * - Use `onSpawn` to customize each particle after it has been positioned and configured
 * - Use `onUpdate` to apply per-particle per-frame logic after position, rotation, tint, and alpha are computed
 * - Use `onDeath` to react when particles are recycled (optional)
 *
 * ## Core concepts
 *
 * ### Mode
 * - `mode: "ambient"` maintains a stable density in the visible region.
 *   `count` is scaled by the visible area ratio, and spawning uses the padded viewport (`viewPadding`).
 * - `mode: "effect"` spawns in a defined `area`. `count` is treated as an absolute target.
 *    You can use `manual: true` and spawn at your own rate via `spawnParticle()` / `spawnParticles()`.
 *
 * ### Fade
 * `fade.in` / `fade.out` accept:
 * - milliseconds when value >= 1
 * - a fraction of the particle lifetime when 0 < value < 1
 *
 * ### Velocity
 * `velocity` can be:
 * - fixed: `{x, y}` or `new PIXI.Point(x, y)`
 * - ranged: `{x: [min, max], y: [min, max]}`
 * - polar: `{speed: value, angle: [min, max]}` (angle in degrees)
 * - function: `{fn: (particle, dt, out) => {out.x = vx; out.y = vy;}}`
 *   evaluated at spawn and during each update as the full velocity vector for that frame
 *
 * ### Attribute Values
 * `alpha`, `scale`, polar `velocity.speed`, and `rotation.speed` accept fixed values, random ranges,
 * `{min, max, curve}` objects, or `{fn}` objects.
 * `tint` accepts fixed color sources or `{curve}` / `{fn}` color objects. Curve points use color sources; `fn` returns
 * a 0xRRGGBB number. Curve time is normalized over each particle lifetime.
 * - `alpha` is multiplied by the fade envelope
 * - `scale` is applied directly to the particle
 * - `velocity.speed` updates magnitude while preserving the spawn direction
 * - `rotation.speed` updates angular velocity in degrees per second
 * - `tint` interpolates RGB channels between curve points
 *
 * ### Shader
 * Pass `shaderClass` to render particles with a custom sampler shader.
 * - Batchable shaders are recommended for anything more than modest particle counts.
 * - ParticleGenerator uses plain `SpriteMesh` instances, so the shader must be compatible with `SpriteMesh`.
 * - If your batch shader needs custom per-particle data, attach it in `onSpawn` or `onUpdate` and read it from
 *   `element.object` in the shader `_packInterleavedGeometry` override.
 *
 * ### Area
 * `area` supports:
 * - Point: `{x, y}`
 * - Rect: `{x, y, width, height}` or `new PIXI.Rectangle(x, y, w, h)`
 * - Circle: `{x, y, radius}`
 * - Ring: `{x, y, innerRadius, outerRadius}` or `{x, y, radius: [inner, outer]}`
 * - Line: `{from: {x, y}, to: {x, y}}`
 * - Points: `{points: [{x, y}, ...]}` or `{shape: "points", points: [{x, y}, ...]}`
 * - Polyline: `{path: [{x, y}, ...]}`, `{shape: "path", path: [...]}`, or
 *   `{shape: "polyline", points: [{x, y}, ...]}`
 * - Ellipse: `{x, y, radiusX, radiusY}`, `{shape: "ellipse", x, y, radiusX, radiusY}`, with optional
 *   `holeScale`, `minAngle`, `maxAngle`, `shapeRotation`, and `affectRotation`.
 * - Shape instance: {@link foundry.data.BaseShapeData}
 * - Shape source data: `{type: "circle", x, y, radius}` using the {@link foundry.data.BaseShapeData} schema.
 * Plain object areas without a `type` are inferred from their properties.
 *
 * Set `sampleMode` to `"boundary"` to spawn along the edge of area types that support boundary sampling.
 *
 * If you provide an `anchor`, object-based areas can be treated as offsets relative to that anchor.
 * To force an absolute rectangle while anchored, pass a `PIXI.Rectangle`.
 * Plain objects with a valid {@link foundry.data.BaseShapeData} `type` are shape source data, not object areas,
 * and are always interpreted in absolute scene coordinates.
 *
 * ### Debug
 * Use `debug` while tuning a generator or diagnosing why particles are not spawning as expected. It is optional and
 * should usually be disabled in final effects.
 * - `debug: true` enables spawn and recycle counters.
 * - `debug.stats: true` enables `generator.debugStats`.
 * - `debug.profile: true` also records the most recent update, spawn, and tick timings.
 * - `debug.tint: true` gives each spawned particle a random tint.
 * - `debug.tint: {mode: "palette", palette: [0xFFAA00, 0x66CCFF]}` tints particles from a palette.
 * - `debug.tint: {mode: "byTexture"}` gives each texture a stable debug color.
 * - `debug.useWhiteTexture: true` falls back to `PIXI.Texture.WHITE` when no textures are configured.
 *
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 100,
 *   spawnRate: 300,
 *   area: {x: 1200, y: 900, radius: 180},
 *   debug: {
 *     stats: true,
 *     profile: true,
 *     tint: {mode: "byTexture"},
 *     useWhiteTexture: true
 *   }
 * });
 * gen.start({spawn: 100});
 *
 * console.table(gen.debugStats);
 * ```
 *
 * ## Performance tips
 * - Preload textures before creating the generator.
 * - Prefer calling `spawnParticle()` or `spawnParticles()` with no options when you can. It avoids per-spawn object
 *   allocations.
 * - Keep `positionTest` cheap. It runs in a hot path.
 * - Prefer batchable shaders for sustained effects. Direct-render shaders are best kept to smaller particle counts.
 * - Avoid blur unless you really need it (pre-blurred textures are better).
 *
 * ## Practical limits
 * ParticleGenerator is meant for local, short-lived effects (bursts, embers, motes, small auras),
 * not for filling the entire scene with massive particle counts. If you push it into many thousands of active
 * particles, performance could degrade quickly. In practice, you'll get better results by using moderate counts,
 * short lifetimes, and small spawn areas, and by stopping the effect when it's no longer needed.
 *
 * Texture size also matters. Large particle textures (or heavy blur) increase pixel work,
 * especially with additive/screen blends and overdraw, which can become fill-rate bound on some GPUs.
 * Prefer small textures (and sprite sheets!), keep particle sizes reasonable on screen, and avoid expensive full-screen
 * coverage when you only need a local effect.
 *
 * ## Examples
 *
 * @example
 * ### 1) Ambient leaves (steady density in view)
 * Use this for soft, always-on atmospheric particles.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "ambient",
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png",
 *     "ui/particles/leaf4.png",
 *     "ui/particles/leaf5.png",
 *     "ui/particles/leaf6.png"
 *   ],
 *   blend: PIXI.BLEND_MODES.NORMAL,
 *   count: 300,
 *   viewPadding: 0.25,
 *   lifetime: [1200, 2200],
 *   fade: {in: 0.15, out: 0.25},
 *   velocity: {x: [-5, 5], y: [-10, -30]},
 *   alpha: [0.2, 0.4],
 *   scale: [0.35, 0.85]
 * });
 * gen.start();
 * ```
 *
 * ### 2) Ambient snow (padding + random age)
 * Great for snowfall that looks continuous when you pan the camera.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "ambient",
 *   textures: ["ui/particles/snow.png"],
 *   count: 400,
 *   viewPadding: 0.3,
 *   randomizeAgeInPadding: true,
 *   lifetime: [2500, 4500],
 *   fade: {in: 0.2, out: 0.2},
 *   velocity: {x: [-8, 8], y: [25, 55]},
 *   alpha: [0.12, 0.25],
 *   scale: [0.3, 0.9],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start();
 * ```
 *
 * ### 3) Manual burst at a point (effect mode)
 * Use this for clicks, impacts, or quick one-shot effects.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [450, 900],
 *   fade: {in: 0.05, out: 0.4},
 *   velocity: {speed: [80, 200], angle: [0, 360]},
 *   alpha: [0.4, 0.8],
 *   scale: [0.3, 0.8]
 * });
 * gen.start();
 *
 * // Spawn a burst at {x,y}
 * const p = {x: 1000, y: 800};
 * gen.spawnParticles(120, {position: p});
 * ```
 *
 * ### 4) Anchored emitter (leaves around a token)
 * Use this for a continuous local effect attached to a moving object.
 * ```js
 * const token = canvas.tokens.controlled[0];
 * const area = {radius: 70}; // relative to anchor, no x/y needed
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   anchor: token,
 *   anchorPoint: "center",
 *   area,
 *   count: 120,
 *   spawnRate: 90,
 *   elevation: token.document.elevation ?? 0,
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png",
 *     "ui/particles/leaf4.png",
 *     "ui/particles/leaf5.png",
 *     "ui/particles/leaf6.png"
 *   ],
 *   lifetime: [450, 900],
 *   fade: {in: 0.05, out: 0.4},
 *   velocity: {x: [-160, 160], y: [-160, 160]},
 *   rotation: {speed: 180},
 *   alpha: [0.5, 0.75],
 *   scale: [0.25, 0.75],
 *   blend: PIXI.BLEND_MODES.NORMAL
 * });
 * gen.start();
 * ```
 *
 * ### 5) Custom onTick spawning (leaves around a token)
 * Use this when custom logic decides whether the generator should spawn during a tick.
 * ```js
 * const token = canvas.tokens.controlled[0];
 * let spawnBudget = 0;
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   anchor: token,
 *   anchorPoint: "center",
 *   area: {radius: 80},
 *   count: 120,
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png"
 *   ],
 *   lifetime: [700, 1300],
 *   fade: {in: 0.1, out: 0.35},
 *   velocity: {x: [-90, 90], y: [-120, -20]},
 *   alpha: [0.35, 0.7],
 *   scale: [0.25, 0.65],
 *   onTick: (dt, generator) => {
 *     if ( !token?.visible ) return;
 *     const available = generator.adjustedMaxParticles - generator.particles.length;
 *     if ( available <= 0 ) return;
 *     spawnBudget += 45 * dt * 0.001;
 *     const n = Math.min(Math.floor(spawnBudget), available);
 *     if ( n <= 0 ) return;
 *     spawnBudget -= n;
 *     generator.spawnParticles(n);
 *   }
 * });
 * gen.start();
 * ```
 *
 * ### 6) Spawn-position direction toward a focal point
 * Use `velocity.fn` when each particle direction depends on where it spawned.
 * ```js
 * const dims = canvas.scene.dimensions;
 * const bounds = new PIXI.Rectangle(dims.sceneX, dims.sceneY, dims.sceneWidth, dims.sceneHeight);
 * const focal = {x: 1200, y: 900};
 * const focalLocal = {x: focal.x - bounds.x, y: focal.y - bounds.y};
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   bounds,
 *   textures: ["ui/particles/snow.png"],
 *   area: {x: focal.x, y: focal.y, innerRadius: 180, outerRadius: 260},
 *   lifetime: [700, 1100],
 *   fade: {in: 0.1, out: 0.3},
 *   alpha: [0.4, 0.9],
 *   scale: [0.3, 0.7],
 *   velocity: {
 *     fn: (p, dt, out) => {
 *       if ( p.time === 0 ) {
 *         let dx = focalLocal.x - p.x;
 *         let dy = focalLocal.y - p.y;
 *
 *         const distance = Math.hypot(dx, dy) || 1;
 *         dx /= distance;
 *         dy /= distance;
 *
 *         const speed = 260;
 *         p.focalVelocityX = dx * speed;
 *         p.focalVelocityY = dy * speed;
 *       }
 *
 *       out.x = p.focalVelocityX;
 *       out.y = p.focalVelocityY;
 *     }
 *   }
 * });
 * gen.start({spawn: 120});
 * ```
 *
 * ### 7) Direction evolving over lifetime (spiral)
 * Use `velocity.fn` when `particle.time` should bend the path during update.
 * ```js
 * const dims = canvas.scene.dimensions;
 * const bounds = new PIXI.Rectangle(dims.sceneX, dims.sceneY, dims.sceneWidth, dims.sceneHeight);
 * const origin = {x: 1200, y: 900};
 * const originLocal = {x: origin.x - bounds.x, y: origin.y - bounds.y};
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   bounds,
 *   textures: ["ui/particles/snow.png"],
 *   area: {x: origin.x, y: origin.y, radius: 40},
 *   lifetime: [900, 1300],
 *   fade: {in: 0.1, out: 0.35},
 *   alpha: [0.35, 0.8],
 *   scale: {min: 0.35, max: 0.7, curve: [{time: 0, value: 1}, {time: 1, value: 0.35}]},
 *   velocity: {
 *     fn: (p, dt, out) => {
 *       let dx = p.x - originLocal.x;
 *       let dy = p.y - originLocal.y;
 *
 *       const distance = Math.hypot(dx, dy) || 1;
 *       dx /= distance;
 *       dy /= distance;
 *
 *       const radial = 90 + (180 * p.time);
 *       const tangent = 240 * (1 - (0.35 * p.time));
 *       out.x = (dx * radial) + (-dy * tangent);
 *       out.y = (dy * radial) + (dx * tangent);
 *     }
 *   }
 * });
 * gen.start({spawn: 90});
 * ```
 *
 * ### 8) Keep particles inside a rectangle (wrap)
 * Use this for localized ambient effects in a region.
 * ```js
 * const zone = new PIXI.Rectangle(900, 700, 600, 400);
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 250,
 *   spawnRate: 180,
 *   area: zone,
 *   constraintMode: "wrap",
 *   constraintArea: zone,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [1200, 2200],
 *   fade: {in: 0.2, out: 0.2},
 *   velocity: {x: [-20, 20], y: [-10, 10]},
 *   alpha: [0.1, 0.25],
 *   scale: [0.4, 0.9]
 * });
 * gen.start({spawn: 250});
 * ```
 *
 * ### 9) Bounce inside bounds (arcade-style)
 * Great for “energy balls” in a box or magic motes in a bounded area.
 * ```js
 * const zone = new PIXI.Rectangle(900, 700, 600, 400);
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 80,
 *   spawnRate: 60,
 *   area: zone,
 *   constraintMode: "bounce",
 *   constraintArea: zone,
 *   restitution: 0.9,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [2000, 4000],
 *   fade: {in: 0.15, out: 0.25},
 *   velocity: {x: [-120, 120], y: [-120, 120]},
 *   alpha: [0.25, 0.6],
 *   scale: [0.5, 1.0],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start({spawn: 80});
 * ```
 *
 * ### 10) Use a clip mask (hard visual boundary)
 * Use this when you need a strict rectangle cutout.
 * ```js
 * const clipRect = new PIXI.Rectangle(900, 700, 600, 400);
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 300,
 *   spawnRate: 120,
 *   area: clipRect,
 *   clip: clipRect,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [2000, 4000],
 *   fade: {in: 0.2, out: 0.2},
 *   velocity: {x: [-10, 10], y: [-10, 10]},
 *   alpha: [0.08, 0.18],
 *   scale: [0.8, 1.6],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start({spawn: 300});
 * ```
 *
 * ### 11) Multiple textures + additive blend (magic leaves)
 * Use this for “sparkly” looks with layered variation.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 150,
 *   spawnRate: 90,
 *   area: {x: 1200, y: 900, radius: 250},
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png",
 *     "ui/particles/leaf4.png",
 *     "ui/particles/leaf5.png",
 *     "ui/particles/leaf6.png"
 *   ],
 *   blend: PIXI.BLEND_MODES.SCREEN,
 *   lifetime: [1500, 2600],
 *   fade: {in: 0.2, out: 0.35},
 *   velocity: {speed: [5, 20], angle: [0, 360]},
 *   rotation: {speed: 45},
 *   alpha: [0.15, 0.45],
 *   scale: [0.3, 0.8]
 * });
 * gen.start({spawn: 150});
 * ```
 *
 * ### 12) Force a specific texture per spawn
 * Useful when you want a rare “special” particle occasionally.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png",
 *     "ui/particles/leaf4.png",
 *     "ui/particles/leaf5.png",
 *     "ui/particles/leaf6.png"
 *   ]
 * });
 * gen.start();
 * gen.spawnParticle({texture: "ui/particles/snow.png", position: {x: 1200, y: 900}});
 * ```
 *
 * ### 13) Spawn into a temporary override area
 * Use this to reuse one generator for multiple nearby spawns without rebuilding it.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [500, 900]
 * });
 *
 * // Start the generator
 * gen.start();
 *
 * const r1 = new PIXI.Rectangle(900, 700, 200, 200);
 * const r2 = new PIXI.Rectangle(1500, 700, 200, 200);
 * gen.spawnParticles(50, {area: r1});
 * gen.spawnParticles(50, {area: r2});
 *
 * // Stop the generator (soft stop by default)
 * gen.stop()
 * ```
 *
 * ### 14) Use a custom batch shader
 * Assume `MySparkleSamplerShader` is a `BaseSamplerShader` subclass whose batch plugin was already registered.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 120,
 *   spawnRate: 120,
 *   area: {x: 1200, y: 900, radius: 180},
 *   textures: ["ui/particles/snow.png"],
 *   shaderClass: MySparkleSamplerShader,
 *   lifetime: [900, 1600],
 *   fade: {in: 0.15, out: 0.3},
 *   velocity: {speed: [10, 45], angle: [0, 360]},
 *   alpha: [0.2, 0.5],
 *   scale: [0.25, 0.7],
 *   onSpawn: p => {
 *     p.sparklePhase = Math.random() * (Math.PI * 2);
 *     p.sparkleStrength = Math.mix(0.5, 1.0, Math.random());
 *   }
 * });
 * gen.start({spawn: 120});
 * ```
 *
 * ### 15) Use a simple BaseShapeData source area (interior)
 * BaseShapeData source objects can be passed directly as areas. A simple shape, such as a circle, uses the shape's
 * optimized point sampler after construction.
 * ```js
 * const area = {type: "circle", x: 1200, y: 900, radius: 160};
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 100,
 *   spawnRate: 120,
 *   area,
 *   sampleMode: "interior",
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [800, 1400],
 *   fade: {in: 0.15, out: 0.3},
 *   velocity: {speed: [5, 25], angle: [0, 360]},
 *   alpha: [0.15, 0.4],
 *   scale: [0.3, 0.8],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start({spawn: 100});
 * ```
 *
 * ### 16) Use a complex BaseShapeData area (boundary)
 * On a gridded scene, GridShapeData can represent a union of multiple grid spaces. The resulting geometry is sampled
 * through its PolygonTree, which is useful for complex boundaries.
 * ```js
 * const origin = canvas.grid.getOffset({x: 1200, y: 900});
 * const area = new foundry.data.GridShapeData({
 *   offsets: [
 *     origin,
 *     {i: origin.i + 1, j: origin.j},
 *     {i: origin.i + 1, j: origin.j + 1},
 *     {i: origin.i + 2, j: origin.j + 1}
 *   ]
 * }, {parent: canvas.scene});
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 120,
 *   spawnRate: 120,
 *   area,
 *   sampleMode: "boundary",
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [900, 1600],
 *   fade: {in: 0.1, out: 0.35},
 *   velocity: {speed: [15, 45], angle: [0, 360]},
 *   alpha: [0.2, 0.55],
 *   scale: [0.25, 0.7],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start({spawn: 120});
 * ```
 *
 * ### 17) Continuous spray with over-life curves
 * Use `spawnRate` and over-life curves for effects that accelerate, shrink, or fade without custom callbacks.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 220,
 *   spawnRate: 180,
 *   area: {from: {x: 900, y: 700}, to: {x: 1300, y: 760}},
 *   textures: ["ui/particles/spray.png"],
 *   lifetime: [900, 1300],
 *   velocity: {
 *     angle: [70, 110],
 *     speed: {min: 525, max: 700, curve: [{time: 0, value: 1}, {time: 1, value: 0.2}]}
 *   },
 *   rotation: {
 *     speed: {min: -120, max: 120, curve: [{time: 0, value: 1}, {time: 1, value: 0.25}]}
 *   },
 *   alpha: {
 *     min: 0.45,
 *     max: 0.8,
 *     curve: [{time: 0, value: 0}, {time: 0.15, value: 1}, {time: 1, value: 0}]
 *   },
 *   scale: {min: 0.48, max: 0.8, curve: [{time: 0, value: 1.4}, {time: 1, value: 0.25}]},
 *   tint: {
 *     curve: [{time: 0, value: 0x66CCFF}, {time: 0.65, value: 0xFFFFFF}, {time: 1, value: 0x4F6DFF}]
 *   }
 * });
 * gen.start();
 * ```
 */
export default class ParticleGenerator {
  /**
   * @param {ParticleGeneratorConfiguration} [config]
   */
  constructor(config={}) {
    const cfg = foundry.utils.mergeObject(this.constructor.DEFAULT_OPTIONS, config, {
      inplace: false
    });
    this.#preserveParticleValueMerge(config, cfg, "alpha");
    this.#preserveParticleValueMerge(config, cfg, "scale");
    this._migrateConfig(config, cfg);

    // Stage 1: base configuration (subclass hook)
    this._configureOptions(cfg);

    // Stage 2+: constructor-only initialization steps
    this._initializeBehaviors(cfg);
    this._initializeCachedGenerators(cfg);
  }

  /* -------------------------------------------- */
  /*  Static Defaults                             */
  /* -------------------------------------------- */

  /**
   * Default generator config.
   * @type {ParticleGeneratorConfiguration}
   */
  static DEFAULT_OPTIONS = {
    mode: "ambient",
    bounds: null,
    count: 0,
    spawnRate: 5 * PIXI.Ticker.targetFPMS * 1000,
    perFrame: 5,
    initial: 0.25,
    manual: null,
    probability: 1,
    viewPadding: 0,
    newlyVisible: true,
    randomizeAgeInPadding: true,
    area: null,
    sampleMode: "interior",
    positionTest: null,
    constraintMode: null,
    constraintArea: null,
    restitution: 1,
    clip: {
      enabled: null,
      rect: null
    },
    mask: null,
    lifetime: 1000,
    fade: {
      in: 0,
      out: 0
    },
    velocity: null,
    rotation: {},
    drift: {
      enabled: false,
      intensity: 1
    },
    textures: [],
    particleAnchor: null,
    shaderClass: null,
    blend: PIXI.BLEND_MODES.NORMAL,
    blur: null,
    alpha: {min: 1, max: 1},
    scale: {min: 1, max: 1},
    tint: 0xFFFFFF,
    elevation: 0,
    sort: 0,
    container: null,
    ticker: null,
    anchor: null,
    anchorPoint: "center",
    anchorOffset: null,
    behavior: null,
    orbit: {},
    follow: {},
    onSpawn: null,
    onUpdate: null,
    onDeath: null,
    onTick: null,
    debug: null
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The runtime mode.
   * @type {ParticleGeneratorMode}
   */
  mode;

  /**
   * The parent container which receives the internal particle container.
   * @type {PIXI.Container}
   */
  container;

  /**
   * An optional anchor used to attach areas and behaviors.
   * @type {ParticleGeneratorAnchor}
   */
  anchor;

  /**
   * Which point to use when anchoring.
   * @type {ParticleGeneratorAnchorPoint}
   */
  anchorPoint;

  /**
   * A fixed offset (scene pixels) applied to the anchor.
   * @type {{x: number, y: number}|null}
   */
  anchorOffset;

  /**
   * The configured particle textures.
   * @type {PIXI.Texture[]}
   */
  textures;

  /**
   * An optional sprite anchor override for all particles.
   * @type {{x: number, y: number}|null}
   */
  particleAnchor;

  /**
   * The shader class used to render particles.
   * @type {typeof BaseSamplerShader}
   */
  shaderClass;

  /**
   * Viewport-related behavior (used primarily in ambient mode).
   * @type {{padding: number, newlyVisible: boolean, randomizeAgeInPadding: boolean}}
   */
  viewport;

  /**
   * The target particle count.
   * @type {number}
   */
  maxParticles;

  /**
   * The initial proportion (0..1) of the computed target particle count to spawn on start.
   * @type {number}
   */
  initialBatch;

  /**
   * If true, particles are never spawned automatically.
   * @type {boolean}
   */
  manualSpawning;

  /**
   * The chance (0..1) that a spawn attempt actually creates a particle.
   * @type {number}
   */
  spawnProbability;

  /**
   * An optional spawn validator.
   * @type {ParticleGeneratorPositionTest|null}
   */
  positionTest;

  /**
   * Which part of the spawn area to sample.
   * @type {ParticleGeneratorAreaSampleMode}
   */
  sampleMode;

  /**
   * Out-of-bounds constraint configuration.
   * @type {{
   *   mode: ParticleGeneratorConstraintMode,
   *   area: "budget"|"view"|"world"|PIXI.Rectangle|null,
   *   restitution: number
   * }}
   */
  constraints;

  /**
   * Clip (mask) options for the default clip behavior.
   * @type {{enabled: boolean|null, rect: PIXI.Rectangle|null}}
   */
  clip;

  /**
   * Whether the current mask was provided externally (not managed by this generator).
   * External masks are not destroyed during cleanup.
   * @type {boolean}
   */
  #externalMask = false;

  /**
   * The particle lifetime configuration in milliseconds.
   * @type {number|number[]|{min: number, max: number}}
   */
  particleLifetime;

  /**
   * The fade-in duration in milliseconds, or a fraction of lifetime if 0 < value < 1.
   * @type {number}
   */
  fadeInDuration;

  /**
   * The fade-out duration in milliseconds, or a fraction of lifetime if 0 < value < 1.
   * @type {number}
   */
  fadeOutDuration;

  /**
   * The blend mode used to render particles.
   * @type {PIXI.BLEND_MODES}
   */
  blendMode;

  /**
   * Resolved rotation configuration for particles.
   * @type {{alignVelocity: boolean, initial: number, spread: number, speed: {min: number, max: number}}}
   */
  rotation;

  /**
   * The velocity configuration used to generate per-particle movement.
   * @type {ParticleGeneratorVelocityOptions|null}
   */
  velocity;

  /**
   * Optional random drift configuration.
   * @type {{enabled: boolean, intensity: number}}
   */
  drift;

  /**
   * Optional blur filter options applied to the internal container.
   * @type {{enabled: boolean, intensity: number, quality: number|undefined}|null}
   */
  blurOptions;

  /**
   * The elevation for the particle container.
   * @type {number}
   */
  elevation;

  /**
   * The sorting key for the particle container.
   * @type {number}
   */
  sort;

  /**
   * Orbit behavior options.
   * @type {ParticleGeneratorOrbitOptions}
   */
  orbit;

  /**
   * Follow behavior options.
   * @type {ParticleGeneratorFollowOptions}
   */
  follow;

  /**
   * An optional callback called after the particle has been placed and configured.
   * @type {ParticleGeneratorParticleCallback|null}
   */
  onSpawn;

  /**
   * An optional callback called each frame for each live particle.
   * @type {ParticleGeneratorParticleCallback|null}
   */
  onUpdate;

  /**
   * An optional callback called when a particle is recycled.
   * @type {ParticleGeneratorDeathCallback|null}
   */
  onDeath;

  /**
   * An optional callback called one time per frame (not per particle!).
   * @type {ParticleGeneratorTickCallback|null}
   */
  onTick;

  /**
   * The computed target particle count based on visible area (ambient mode) or the configured budget (effect mode).
   * @type {number}
   */
  adjustedMaxParticles = 0;

  /**
   * The currently active particle instances.
   * @type {ParticleMesh[]}
   */
  particles = [];

  /**
   * A pool of recycled particles ready to be reused.
   * @type {ParticleMesh[]}
   */
  particlePool = [];

  /* -------------------------------------------- */
  /*  Private Fields                              */
  /* -------------------------------------------- */

  /**
   * The ticker used to drive the update loop.
   * @type {PIXI.Ticker}
   */
  #ticker;

  /* -------------------------------------------- */
  /*  Internal State                              */
  /* -------------------------------------------- */

  /**
   * Generator bounds in scene coordinates.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _bounds;

  /**
   * The configured default spawn area (effect mode).
   * This area is defined in scene coordinates and may be interpreted relative to an anchor.
   * {@link foundry.data.BaseShapeData} instances and source data are always interpreted in absolute scene coordinates.
   * Shape source data is converted to a {@link foundry.data.BaseShapeData} instance.
   * Re-read each frame, so it can be replaced or its values mutated at runtime to animate the spawn region.
   * @type {ParticleGeneratorArea|null}
   */
  spawnArea = null;

  /**
   * Optional custom constraint rectangle in local coordinates.
   * @type {PIXI.Rectangle|null}
   * @protected
   */
  _constraintRect = null;

  /**
   * Whether the generator is soft-stopped.
   * @type {boolean}
   * @protected
   */
  _stopped = true;

  /**
   * Whether the update callback is attached to the ticker.
   * @type {boolean}
   * @protected
   */
  _tickerAttached = false;

  /**
   * Whether the generator has spawned its initial batch.
   * @type {boolean}
   * @protected
   */
  _initialized = false;

  /**
   * Whether an auto-spawn rate was provided by the caller.
   * @type {boolean}
   */
  #hasExplicitSpawnRate = false;

  /**
   * The maximum number of particles that may be spawned per second (auto-spawn mode).
   * @type {number}
   */
  #spawnRate = 0;

  /**
   * The legacy per-frame spawn budget derived from spawnRate.
   * @type {number}
   */
  #maxParticlesPerFrame = 0;

  /**
   * Compiled alpha value configuration.
   * @type {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}}
   */
  #alphaValue;

  /**
   * Legacy alpha range fallback for value configurations without a base range.
   * @type {{min: number, max: number}}
   */
  #alphaRange = {min: 1, max: 1};

  /**
   * Compiled scale value configuration.
   * @type {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}}
   */
  #scaleValue;

  /**
   * Legacy scale range fallback for value configurations without a base range.
   * @type {{min: number, max: number}}
   */
  #scaleRange = {min: 1, max: 1};

  /**
   * Compiled tint color configuration.
   * @type {{color: number, curve: {times: Float32Array, colors: Uint32Array}|null,
   *   fn: ParticleGeneratorColorFunction|null, dynamic: boolean}}
   */
  #tintValue;

  /**
   * Compiled polar speed value configuration.
   * @type {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}|null}
   */
  #speedValue;

  /**
   * Custom particle velocity function.
   * @type {ParticleGeneratorVelocityFunction|null}
   */
  #velocityFunction = null;

  /**
   * Compiled rotation speed value configuration, in radians per second.
   * @type {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}}
   */
  #rotationSpeedValue;

  /**
   * Fractional spawn budget preserved between ticks.
   * @type {number}
   */
  #spawnAccumulator = 0;

  /**
   * The internal container which holds all particles.
   * @type {PrimaryCanvasParticleContainer|null}
   * @protected
   */
  _particlesContainer = null;

  /**
   * The display object used to mask particle rendering.
   * @type {PIXI.DisplayObject|null}
   * @protected
   */
  _mask = null;

  /**
   * The blur filter applied to the internal container, if any.
   * @type {PIXI.Filter|null}
   * @protected
   */
  _blurFilter = null;

  /**
   * The local-space viewport rectangle without padding.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _viewRectLocal = new PIXI.Rectangle();

  /**
   * The local-space viewport rectangle with padding.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _budgetRectLocal = new PIXI.Rectangle();

  /**
   * The local-space generator bounds.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _worldRectLocal = new PIXI.Rectangle();

  /**
   * The previous-frame budget rectangle.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _oldBudgetRectLocal = new PIXI.Rectangle();

  /**
   * Whether the previous-frame budget rectangle is initialized.
   * @type {boolean}
   * @protected
   */
  _hasOldBudgetRectLocal = false;

  /**
   * A fixed pool of rectangles used to describe newly visible areas.
   * @type {PIXI.Rectangle[]}
   * @protected
   */
  _newlyVisibleAreaPool = [
    new PIXI.Rectangle(),
    new PIXI.Rectangle(),
    new PIXI.Rectangle(),
    new PIXI.Rectangle()
  ];

  /**
   * The list of newly visible areas for the current frame in local coordinates.
   * @type {PIXI.Rectangle[]}
   * @protected
   */
  _newlyVisibleAreas = [];

  /**
   * The current anchor position in scene coordinates.
   * @type {PIXI.Point}
   * @protected
   */
  _anchorScene = new PIXI.Point(0, 0);

  /**
   * The current anchor position in local coordinates.
   * @type {PIXI.Point}
   * @protected
   */
  _anchorLocal = new PIXI.Point(0, 0);

  /**
   * The active behavior implementation.
   * @type {ParticleGeneratorBehavior|null}
   * @protected
   */
  _behavior = null;

  /**
   * A cached context object passed to behavior hooks.
   * @type {object}
   * @protected
   */
  _behaviorContext;

  /**
   * Temp point used to avoid per-frame allocations.
   * @type {PIXI.Point}
   * @protected
   */
  _tlScreen = new PIXI.Point(0, 0);

  /**
   * Temp point used to avoid per-frame allocations.
   * @type {PIXI.Point}
   * @protected
   */
  _brScreen = new PIXI.Point(0, 0);

  /**
   * Temp point used to avoid per-frame allocations.
   * @type {PIXI.Point}
   * @protected
   */
  _tlLocal = new PIXI.Point(0, 0);

  /**
   * Temp point used to avoid per-frame allocations.
   * @type {PIXI.Point}
   * @protected
   */
  _brLocal = new PIXI.Point(0, 0);

  /**
   * Temp point used to sample shape-based spawn areas.
   * @type {PIXI.Point}
   * @protected
   */
  _spawnPoint = new PIXI.Point(0, 0);

  /**
   * A function which generates per-particle movement speed vectors.
   * @type {(out: PIXI.Point, particle: ParticleMesh) => void}
   * @protected
   */
  _generateMovementSpeed;

  /**
   * Normalized debug options.
   * @type {ParticleGeneratorDebugOptions|null}
   * @protected
   */
  _debug = null;

  /**
   * Debug statistics and profiling output.
   * Null when debug stats are disabled.
   * @type {ParticleGeneratorDebugStats|null}
   * @protected
   */
  _debugStats = null;

  /**
   * Whether profiling is enabled.
   * @type {boolean}
   * @protected
   */
  _debugProfile = false;

  /**
   * Cached debug tint options.
   * @type {{mode: ParticleGeneratorDebugTintMode, palette: number[]}|null}
   * @protected
   */
  _debugTint = null;

  /**
   * A mapping from textures to deterministic tint values.
   * @type {WeakMap<PIXI.Texture, number>|null}
   * @protected
   */
  _debugTintByTexture = null;

  /* -------------------------------------------- */
  /*  Public Getters                              */
  /* -------------------------------------------- */

  /**
   * The bounding rectangle of the generator in scene coordinates.
   * Used to convert between local particle coordinates and scene coordinates.
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    return this._bounds;
  }

  /* -------------------------------------------- */

  /**
   * The PIXI container that holds all particle display objects.
   * @type {PIXI.Container|null}
   */
  get particlesContainer() {
    return this._particlesContainer;
  }

  /* -------------------------------------------- */

  /**
   * The current unpadded viewport rectangle in the generator's local space.
   * @type {PIXI.Rectangle}
   */
  get viewRectLocal() {
    return this._viewRectLocal;
  }

  /* -------------------------------------------- */

  /**
   * The current padded viewport rectangle used for budget/spawning in ambient mode.
   * @type {PIXI.Rectangle}
   */
  get budgetRectLocal() {
    return this._budgetRectLocal;
  }

  /* -------------------------------------------- */

  /**
   * The maximum number of particles that may be spawned per second (auto-spawn mode).
   * @type {number}
   */
  get spawnRate() {
    return this.#spawnRate;
  }

  set spawnRate(value) {
    const targetFPS = PIXI.Ticker.targetFPMS * 1000;
    this.#spawnRate = Math.max(0, Number(value) || 0);
    this.#maxParticlesPerFrame = targetFPS > 0 ? this.#spawnRate / targetFPS : 0;
  }

  /* -------------------------------------------- */

  /**
   * The mask applied to the particle container. Set to null to remove the mask.
   * The generator does not manage the lifecycle of externally assigned masks.
   * @type {PIXI.DisplayObject|null}
   */
  get mask() {
    return this._mask;
  }

  set mask(value) {
    this.#clearMask();
    if ( !value ) return;
    this._mask = value;
    this.#externalMask = true;
    if ( this._particlesContainer ) {
      if ( !value.parent ) this.container.addChild(value);
      this._particlesContainer.mask = value;
    }
  }

  /* -------------------------------------------- */

  /**
   * Debug statistics and profiling output.
   * Returns null if {@link ParticleGeneratorDebugOptions#stats} is not enabled.
   *
   * Note: This getter returns a stable object reference and updates the live values (active/pool/target)
   * on access.
   * @type {ParticleGeneratorDebugStats|null}
   */
  get debugStats() {
    const stats = this._debugStats;
    if ( !stats ) return null;
    stats.active = this.particles.length;
    stats.pool = this.particlePool.length;
    stats.target = this.adjustedMaxParticles;
    stats.newlyVisibleAreaCount = this._newlyVisibleAreas.length;
    return stats;
  }

  /* -------------------------------------------- */
  /*  Public Methods                              */
  /* -------------------------------------------- */

  /**
   * Start the generator, create the update loop and optionally spawn an initial batch.
   * @param {object} [options]
   * @param {number} [options.spawn=0] Spawn this many particles immediately after starting.
   *                                   If {@link ParticleGenerator#manualSpawning} is false, this is capped
   *                                   to the remaining budget (target - active).
   */
  start(options) {
    this._stopped = false;
    this.#createInternalContainer();

    if ( !this._tickerAttached ) {
      this.#ticker.add(this._onTick, this);
      this._tickerAttached = true;
    }

    this._calculateGeneratorProperties();

    if ( !this.manualSpawning && !this._initialized ) this._initializeParticles();
    this._initialized = true;

    const spawn = Math.floor(Number(options?.spawn ?? 0) || 0);
    if ( spawn > 0 ) this.spawnParticles(spawn);
  }

  /* -------------------------------------------- */

  /**
   * Stop the generator.
   * @param {object} [options]
   * @param {boolean} [options.hard=false] If true, detach the update loop and destroy internal resources.
   *                                       If false, stop spawning and let existing particles expire naturally.
   */
  stop({hard=false}={}) {
    this._stopped = true;
    if ( hard === false ) return;

    if ( this._tickerAttached ) {
      this.#ticker.remove(this._onTick, this);
      this._tickerAttached = false;
    }

    if ( this._blurFilter ) {
      canvas.blurFilters.delete(this._blurFilter);
      this._blurFilter = null;
    }

    for ( const p of this.particlePool ) {
      if ( !p.destroyed ) p.destroy({texture: false, baseTexture: false});
    }
    this.particlePool.length = 0;

    const stats = this._debugStats;
    if ( stats ) stats.recycledStop += this.particles.length;

    if ( this.onDeath ) {
      for ( const p of this.particles ) this.onDeath(p, {generator: this, reason: "stop"});
    }
    this.particles.length = 0;

    this.#clearMask();
    if ( this._particlesContainer ) {
      this._particlesContainer.removeFromParent();
      this._particlesContainer.destroy({children: true, texture: false, baseTexture: false});
    }
    this._particlesContainer = null;

    this.adjustedMaxParticles = 0;
    this.#spawnAccumulator = 0;

    this._hasOldBudgetRectLocal = false;
    this._newlyVisibleAreas.length = 0;
    this._initialized = false;
  }

  /* -------------------------------------------- */

  /**
   * Spawn a single particle.
   * In "ambient" mode, the default spawn area is the current padded viewport rectangle.
   * In "effect" mode, the default spawn area is the configured {@link ParticleGeneratorArea}.
   * @param {object} [options]
   * @param {PIXI.Texture|string} [options.texture] A texture (or texture source string) to force for this particle.
   * @param {ParticleGeneratorAreaSampleMode} [options.sampleMode] Which part of the spawn area to sample.
   *   Defaults to the configured generator sample mode.
   * @param {ParticleGeneratorArea|null} [options.area] An optional spawn area override.
   *       Interpreted the same as the configured area (scene coordinates, or relative-to-anchor when anchored).
   * @param {PIXI.Point|{x: number, y: number}|null} [options.position] An optional explicit spawn position.
   *   Coordinates are in scene space.
   * @returns {ParticleMesh|null}
   */
  spawnParticle({texture, sampleMode=this.sampleMode, area=null, position=null}={}) {
    if ( this._stopped || !this._tickerAttached || !this._particlesContainer ) this.start();

    const stats = this._debugStats;
    if ( stats ) stats.spawnAttempts++;

    const usedTexture = this.#lookupTexture(texture) ?? this._getRandomTexture();
    if ( !usedTexture ) return null;

    const localArea = this.#computeSpawnAreaLocal(area, position, sampleMode);
    if ( !localArea ) {
      if ( stats ) stats.spawnRejectedNoArea++;
      return null;
    }

    return this.#spawnParticleLocal(localArea, usedTexture);
  }

  /* -------------------------------------------- */

  /**
   * Spawn multiple particles.
   * @param {number} count The number of particles to spawn.
   * @param {object} [options]
   * @param {PIXI.Texture|string} [options.texture] A texture (or texture source string) to force for this burst.
   * @param {ParticleGeneratorAreaSampleMode} [options.sampleMode] Which part of the spawn area to sample.
   *   Defaults to the configured generator sample mode.
   * @param {ParticleGeneratorArea|null} [options.area] An optional spawn area override.
   *       Interpreted the same as the configured area (scene coordinates, or relative-to-anchor when anchored).
   * @param {ParticleGeneratorPoint|null} [options.position] An optional explicit spawn position (scene coordinates).
   * @returns {number} The number of successfully spawned particles.
   */
  spawnParticles(count, {texture, sampleMode=this.sampleMode, area=null, position=null}={}) {
    if ( this._stopped || !this._tickerAttached || !this._particlesContainer ) this.start();

    const stats = this._debugStats;

    let n = Math.floor(Number(count) || 0);
    if ( n <= 0 ) return 0;

    // If we're in auto-spawn mode, cap to the remaining budget
    if ( !this.manualSpawning ) {
      const available = this.adjustedMaxParticles - this.particles.length;
      if ( available <= 0 ) return 0;
      if ( n > available ) n = available;
    }

    const textures = this.textures;
    const texCount = textures.length;

    const forcedTexture = this.#lookupTexture(texture);

    // No textures
    if ( !forcedTexture && (texCount === 0) ) return 0;

    // Compute local spawn area once when overridden or in effect mode.
    let localArea = null;

    if ( (position != null) || (area != null) || (this.mode === "effect") ) {
      localArea = this.#computeSpawnAreaLocal(area, position, sampleMode);
      if ( !localArea ) {
        if ( stats ) {
          stats.spawnAttempts += n;
          stats.spawnRejectedNoArea += n;
        }
        return 0;
      }
    }

    let created = 0;

    // Ambient default
    if ( !localArea ) {
      for ( let i = 0; i < n; i++ ) {
        if ( stats ) stats.spawnAttempts++;
        if ( this.#spawnParticleDefault() ) created++;
      }
      return created;
    }

    if ( forcedTexture ) {
      for ( let i = 0; i < n; i++ ) {
        if ( stats ) stats.spawnAttempts++;
        if ( this.#spawnParticleLocal(localArea, forcedTexture) ) created++;
      }
      return created;
    }

    for ( let i = 0; i < n; i++ ) {
      if ( stats ) stats.spawnAttempts++;
      const usedTexture = textures[(Math.random() * texCount) | 0];
      if ( this.#spawnParticleLocal(localArea, usedTexture) ) created++;
    }

    return created;
  }

  /* -------------------------------------------- */
  /*  Configuration                               */
  /* -------------------------------------------- */

  /**
   * Migrate deprecated configuration options.
   * @param {ParticleGeneratorConfiguration} config  The user-provided configuration object.
   * @param {ParticleGeneratorConfiguration} cfg     The prepared configuration object.
   * @protected
   */
  _migrateConfig(config, cfg) {
    const hasLegacyPerFrame = !!(config && Object.hasOwn(config, "perFrame"));
    const hasLegacyRotationSpeed = !!(config && Object.hasOwn(config, "rotationSpeed"));
    const hasParticleAnchor = !!(config && Object.hasOwn(config, "particleAnchor"));
    const hasSpawnRate = !!(config && Object.hasOwn(config, "spawnRate"));
    const hasExplicitSpawnRate = hasSpawnRate && (config.spawnRate != null);
    const hasExplicitLegacyPerFrame = hasLegacyPerFrame && (config.perFrame != null);
    this.#hasExplicitSpawnRate = hasExplicitSpawnRate;

    if ( hasLegacyPerFrame ) {
      foundry.utils.logCompatibilityWarning("ParticleGenerator config.perFrame is deprecated."
        + " Use config.spawnRate instead.", {since: 14, until: 16, once: true});
      if ( !hasExplicitSpawnRate && hasExplicitLegacyPerFrame ) {
        const perFrame = Number(config.perFrame) || 5;
        cfg.spawnRate = Math.max(0, perFrame) * PIXI.Ticker.targetFPMS * 1000;
      }
    }

    if ( (hasLegacyPerFrame || hasLegacyRotationSpeed) && !hasParticleAnchor ) {
      foundry.utils.logCompatibilityWarning("ParticleGenerator legacy configurations use a centered particle anchor."
        + " Set config.particleAnchor explicitly, or migrate deprecated fields to use texture.defaultAnchor.",
      {since: 14, until: 16, once: true});
      cfg.particleAnchor = {x: 0.5, y: 0.5};
    }
  }

  /* -------------------------------------------- */

  /**
   * Preserve exact particle value options where recursive default merging would otherwise inject legacy range keys.
   * @param {ParticleGeneratorConfiguration} config  The user-provided configuration object.
   * @param {ParticleGeneratorConfiguration} cfg     The prepared configuration object.
   * @param {"alpha"|"scale"} key                    The value configuration key.
   */
  #preserveParticleValueMerge(config, cfg, key) {
    const value = config?.[key];
    if ( !value || (typeof value !== "object") || Array.isArray(value) ) return;
    if ( !("curve" in value) && !("fn" in value) ) return;

    const merged = cfg[key];
    if ( !merged || (typeof merged !== "object") || Array.isArray(merged) ) return;
    if ( !("min" in value) ) delete merged.min;
    if ( !("max" in value) ) delete merged.max;
  }

  /* -------------------------------------------- */

  /**
   * Apply the configuration to the ParticleGenerator instance.
   * @param {ParticleGeneratorConfiguration} cfg   The configuration object.
   * @protected
   */
  _configureOptions(cfg) {
    this.mode = (cfg.mode === "effect") ? "effect" : "ambient";
    this.#ticker = cfg.ticker ?? CanvasAnimation.ticker;
    this.container = cfg.container ?? canvas.primary;

    // Bounds
    this._bounds = this.#normalizeRect(cfg.bounds) ?? this._getDefaultBounds();
    this._worldRectLocal.x = 0;
    this._worldRectLocal.y = 0;
    this._worldRectLocal.width = this._bounds.width;
    this._worldRectLocal.height = this._bounds.height;

    // Anchor
    this.anchor = cfg.anchor ?? null;
    this.anchorPoint = cfg.anchorPoint ?? "center";
    this.anchorOffset = this.#normalizePoint(cfg.anchorOffset);

    // View (ambient budget/culling area)
    this.viewport = {
      padding: Math.max(0, Number(cfg.viewPadding ?? 0) || 0),
      newlyVisible: (cfg.newlyVisible ?? true) !== false,
      randomizeAgeInPadding: (cfg.randomizeAgeInPadding ?? true) !== false
    };

    // Budget
    const uncapped = (cfg.count === null) || (cfg.count === undefined);
    this.maxParticles = uncapped ? Infinity : (Number(cfg.count) || 0);
    this.spawnRate = cfg.spawnRate;
    this.initialBatch = uncapped ? 0 : (Number(cfg.initial ?? 0.25) || 0);

    // Protection to ensure that uncapped generators won't exceed a dangerous steady-state particle count.
    if ( uncapped ) {
      const avgLifetime = ParticleGenerator.#computeAvgLifetime(cfg.lifetime);
      const steadyState = Math.ceil(this.spawnRate * avgLifetime / 1000);
      if ( steadyState > 20000 ) {
        throw new Error(`ParticleGenerator: uncapped count with spawnRate=${Math.round(this.spawnRate)} and`
        + ` avgLifetime=${Math.round(avgLifetime)}ms would produce ~${steadyState} particles, exceeding the 20000`
        + " maximum that is allowed for uncapped configurations. Reduce spawnRate or lifetime.");
      }
    }

    const manual = cfg.manual;
    this.manualSpawning = (manual != null) ? !!manual : ((this.mode === "effect") && !this.#hasExplicitSpawnRate);

    const prob = cfg.probability ?? 1;
    this.spawnProbability = Math.clamp(Number(prob) || 0, 0, 1);

    // Spawn
    this.positionTest = (typeof cfg.positionTest === "function") ? cfg.positionTest : null;
    this.spawnArea = cfg.area ?? null;
    this.sampleMode = (cfg.sampleMode === "boundary") ? "boundary" : "interior";

    // Constraints
    const defaultConstraintMode = (this.mode === "ambient") ? "kill" : "none";

    this.constraints = {
      mode: (cfg.constraintMode ?? null) || defaultConstraintMode,
      area: cfg.constraintArea ?? null,
      restitution: Math.clamp(Number(cfg.restitution ?? 1) || 1, 0, 1)
    };

    // Clip
    this.clip = this.#normalizeClip(cfg.clip);

    // Mask
    const cfgMask = cfg.mask ?? null;
    if ( cfgMask instanceof PIXI.DisplayObject ) {
      this._mask = cfgMask;
      this.#externalMask = true;
    } else if ( cfgMask ) {
      this._mask = this.#createMaskFromShape(cfgMask);
      this.#externalMask = false;
    }

    // Lifetime
    this.particleLifetime = cfg.lifetime;
    this.fadeInDuration = cfg.fade?.in ?? 0;
    this.fadeOutDuration = cfg.fade?.out ?? 0;

    // Motion
    this.velocity = cfg.velocity ?? null;
    this.#velocityFunction = this.#compileVelocityFunction(this.velocity);
    this.#speedValue = this.#velocityFunction ? null : this.#compileVelocitySpeedValue(this.velocity);

    const drift = cfg.drift ?? {};
    this.drift = {
      enabled: !!drift.enabled,
      intensity: Number(drift.intensity ?? 1) || 1
    };

    // Rotation - initial and speed
    const rotation = cfg.rotation ?? {};
    if ( cfg.rotationSpeed !== undefined ) {
      foundry.utils.logCompatibilityWarning("ParticleGenerator config.rotationSpeed is deprecated."
        + " Use config.rotation.speed instead.", {since: 14, until: 16, once: true});
      rotation.speed ??= cfg.rotationSpeed;
    }
    this.#rotationSpeedValue = this.#compileRotationSpeedValue(rotation.speed);
    const speedRange = this.#rotationSpeedValue.range ?? {min: 0, max: 0};
    this.rotation = {
      alignVelocity: !!rotation.alignVelocity,
      initial: Number(rotation.initial) || 0,
      spread: rotation.spread ?? Math.PI,
      speed: speedRange
    };

    // Render
    this.shaderClass = this.#validateShaderClass(cfg.shaderClass);
    this.blendMode = cfg.blend ?? PIXI.BLEND_MODES.NORMAL;
    this.blurOptions = this.#normalizeBlur(cfg.blur);
    this.#alphaValue = this.#compileParticleValue(cfg.alpha, {min: 1, max: 1});
    this.#scaleValue = this.#compileParticleValue(cfg.scale, {min: 1, max: 1});
    this.#alphaRange = this.#alphaValue.range ?? {min: 1, max: 1};
    this.#scaleRange = this.#scaleValue.range ?? {min: 1, max: 1};
    this.#tintValue = this.#compileColorValue(cfg.tint);
    this.textures = this.#loadTextures(cfg.textures);
    this.particleAnchor = this.#normalizePoint(cfg.particleAnchor);

    // Layer
    this.elevation = Number(cfg.elevation ?? 0) || 0;
    this.sort = Number(cfg.sort ?? 0) || 0;

    // Callbacks
    this.onSpawn = (typeof cfg.onSpawn === "function") ? cfg.onSpawn : null;
    this.onUpdate = (typeof cfg.onUpdate === "function") ? cfg.onUpdate : null;
    this.onDeath = (typeof cfg.onDeath === "function") ? cfg.onDeath : null;
    this.onTick = (typeof cfg.onTick === "function") ? cfg.onTick : null;

    // Debug (optional)
    this._configureDebug(cfg.debug);

    // Mode-dependent defaults
    if ( this.clip.enabled == null ) this.clip.enabled = (this.mode === "ambient");

    if ( this.constraints.area == null ) {
      if ( this.constraints.mode === "none" ) this.constraints.area = null;
      else this.constraints.area = (this.mode === "ambient") ? "budget" : "world";
    }

    // Precompute custom constraint rect (if any)
    this._constraintRect = null;
    const constraintAreaRect = this.#normalizeRect(this.constraints.area);
    if ( constraintAreaRect ) {
      this.constraints.area = constraintAreaRect;
      this._constraintRect = this.#rectToLocal(constraintAreaRect);
    }
  }

  /* -------------------------------------------- */

  /**
   * Configure optional debug helpers.
   * This feature set is fully opt-in and is designed to have near-zero overhead when disabled.
   * @param {ParticleGeneratorDebugOptions|boolean|null|undefined} debug
   * @protected
   */
  _configureDebug(debug) {
    const d = (debug === true) ? {stats: true} : debug;

    if ( !d || (typeof d !== "object") ) {
      this._debug = null;
      this._debugStats = null;
      this._debugProfile = false;
      this._debugTint = null;
      this._debugTintByTexture = null;
      return;
    }

    const useWhiteTexture = !!d.useWhiteTexture;
    const profile = !!d.profile;
    const stats = !!d.stats || profile;

    /** @type {{mode: ParticleGeneratorDebugTintMode, palette: number[]}|null} */
    let tint = null;
    if ( d.tint ) {
      if ( d.tint === true ) tint = {mode: "random", palette: []};
      else if ( typeof d.tint === "object" ) {
        const mode = (["random", "palette", "byTexture"].includes(d.tint.mode)) ? d.tint.mode : "random";
        tint = {mode, palette: this.#normalizeDebugPalette(d.tint.palette)};
      }
    }

    // Apply texture fallback before building any tint maps.
    if ( useWhiteTexture && (this.textures.length === 0) ) this.textures = [PIXI.Texture.WHITE];

    // Persist normalized debug options.
    this._debug = {
      useWhiteTexture,
      tint: tint ? {mode: tint.mode, palette: tint.palette} : null,
      stats,
      profile
    };

    this._debugProfile = profile;
    this._debugTint = tint;

    this._debugStats = stats ? this.#createDebugStats() : null;
    this._debugTintByTexture = (tint?.mode === "byTexture") ? this.#buildDebugTintByTextureMap(tint.palette) : null;
  }

  /* -------------------------------------------- */

  /**
   * Initialize behaviors from the configuration object.
   * @param {ParticleGeneratorConfiguration} cfg
   * @protected
   */
  _initializeBehaviors(cfg) {
    this.orbit = this.#normalizeOrbit(cfg.orbit);
    this.follow = this.#normalizeFollow(cfg.follow);
    this._behavior = this.#normalizeBehavior(cfg.behavior);

    this._behaviorContext = {
      generator: this,
      anchor: {
        active: false,
        scene: this._anchorScene,
        local: this._anchorLocal
      },
      dt: 0,
      ds: 0
    };

    if ( this._behavior?.initialize ) this._behavior.initialize(this);
  }

  /* -------------------------------------------- */

  /**
   * Initialize cached generators from the configuration object.
   * @param {ParticleGeneratorConfiguration} _cfg
   * @protected
   */
  _initializeCachedGenerators(_cfg) {
    this._generateMovementSpeed = this.#buildVelocityGenerator(this.velocity);
  }

  /* -------------------------------------------- */
  /*  Internal Container                          */
  /* -------------------------------------------- */

  /**
   * Create the internal container if it doesn't exist yet.
   */
  #createInternalContainer() {
    if ( this._particlesContainer ) return;

    const inPrimary = (this.container === canvas.primary) || (this.container instanceof PrimaryCanvasContainer);
    if ( inPrimary ) this._particlesContainer = new PrimaryCanvasParticleContainer();
    else {
      this._particlesContainer = new PIXI.Container();
      this._particlesContainer.eventMode = "none";
      this._particlesContainer.interactiveChildren = false;
      this._particlesContainer.sortableChildren = false;
    }
    this._particlesContainer.elevation = this.elevation;
    this._particlesContainer.sort = this.sort;
    this._particlesContainer.position.set(this._bounds.x, this._bounds.y);

    if ( this.blurOptions?.enabled && (this.blurOptions.intensity > 0) ) {
      this._blurFilter = canvas.createBlurFilter(this.blurOptions.intensity, this.blurOptions.quality);
      this._particlesContainer.filters = [this._blurFilter];
    }

    this.container.addChild(this._particlesContainer);

    // Apply configured mask (takes precedence) or default clip behavior
    if ( this._mask ) {
      if ( !this._mask.parent ) this.container.addChild(this._mask);
      this._particlesContainer.mask = this._mask;
    } else this.#refreshClip();
  }

  /* -------------------------------------------- */

  /**
   * Create or update the clip mask using the default clip behavior.
   */
  #refreshClip() {
    const enabled = this.clip.enabled;
    if ( !enabled ) {
      this.#clearMask();
      return;
    }
    this.#clearMask();

    const clipShape = this.#getClipMaskShape();
    this._mask = this.#createMaskFromShape(clipShape);
    this.#externalMask = false;
    this.container.addChild(this._mask);
    this._particlesContainer.mask = this._mask;
  }

  /* -------------------------------------------- */

  /**
   * Get the effective clip shape managed by this generator.
   * If no explicit clip rectangle is configured, a shape-based spawn area becomes the clip shape.
   * @returns {PIXI.IShape|foundry.data.BaseShapeData}
   */
  #getClipMaskShape() {
    if ( this.clip.rect instanceof PIXI.Rectangle ) return this.clip.rect;
    const hasAnchor = this.#updateAnchor();
    const local = this.#computeAreaLocal(this.spawnArea, hasAnchor ? this._anchorScene : null, this.sampleMode);
    return local && ((local.type === "shape") || (local.type === "polygonTree")) ? local.data : this._bounds;
  }

  /* -------------------------------------------- */

  /**
   * Create a PIXI.Graphics mask from a PIXI shape or shape data.
   * @param {PIXI.IShape|foundry.data.BaseShapeData} shape   The shape to draw
   * @returns {PIXI.Graphics}     The constructed mask graphics object
   */
  #createMaskFromShape(shape) {
    const graphics = new PIXI.Graphics();
    graphics.renderable = false;
    graphics.eventMode = "none";
    graphics.beginFill(0xFFFFFF);
    if ( shape instanceof foundry.data.BaseShapeData ) shape.drawShape(graphics);
    else graphics.drawShape(shape);
    graphics.endFill();
    return graphics;
  }

  /* -------------------------------------------- */

  /**
   * Remove and optionally destroy the current mask.
   * External masks are detached but not destroyed.
   */
  #clearMask() {
    if ( !this._mask ) return;
    if ( this._particlesContainer ) this._particlesContainer.mask = null;
    if ( this._mask.parent === this.container ) this._mask.removeFromParent();
    if ( !this.#externalMask ) this._mask.destroy({children: true});
    this._mask = null;
    this.#externalMask = false;
  }

  /* -------------------------------------------- */
  /*  Viewport Computation                        */
  /* -------------------------------------------- */

  /**
   * Compute the current viewport rectangles and target particle count.
   * All rectangles are in the local coordinate space of the internal container.
   * @protected
   */
  _calculateGeneratorProperties() {
    if ( this._hasOldBudgetRectLocal ) this._oldBudgetRectLocal.copyFrom(this._budgetRectLocal);

    const renderer = canvas.app.renderer;
    this._brScreen.set(renderer.width, renderer.height);

    this._particlesContainer.toLocal(this._tlScreen, undefined, this._tlLocal);
    this._particlesContainer.toLocal(this._brScreen, undefined, this._brLocal);

    const x1 = Math.min(this._tlLocal.x, this._brLocal.x);
    const y1 = Math.min(this._tlLocal.y, this._brLocal.y);
    const x2 = Math.max(this._tlLocal.x, this._brLocal.x);
    const y2 = Math.max(this._tlLocal.y, this._brLocal.y);

    const lx1 = Math.clamp(x1, 0, this._bounds.width);
    const ly1 = Math.clamp(y1, 0, this._bounds.height);
    const lx2 = Math.clamp(x2, 0, this._bounds.width);
    const ly2 = Math.clamp(y2, 0, this._bounds.height);

    const viewW = Math.max(0, lx2 - lx1);
    const viewH = Math.max(0, ly2 - ly1);

    if ( (viewW <= 0) || (viewH <= 0) ) {
      this._viewRectLocal.x = 0;
      this._viewRectLocal.y = 0;
      this._viewRectLocal.width = 0;
      this._viewRectLocal.height = 0;

      this._budgetRectLocal.x = 0;
      this._budgetRectLocal.y = 0;
      this._budgetRectLocal.width = 0;
      this._budgetRectLocal.height = 0;
      this._newlyVisibleAreas.length = 0;
      this.adjustedMaxParticles = 0;
      this._hasOldBudgetRectLocal = true;
      return;
    }

    this._viewRectLocal.x = lx1;
    this._viewRectLocal.y = ly1;
    this._viewRectLocal.width = viewW;
    this._viewRectLocal.height = viewH;

    const padX = viewW * this.viewport.padding;
    const padY = viewH * this.viewport.padding;

    const px1 = Math.clamp(lx1 - padX, 0, this._bounds.width);
    const py1 = Math.clamp(ly1 - padY, 0, this._bounds.height);
    const px2 = Math.clamp(lx2 + padX, 0, this._bounds.width);
    const py2 = Math.clamp(ly2 + padY, 0, this._bounds.height);

    const paddedW = Math.max(0, px2 - px1);
    const paddedH = Math.max(0, py2 - py1);

    this._budgetRectLocal.x = px1;
    this._budgetRectLocal.y = py1;
    this._budgetRectLocal.width = paddedW;
    this._budgetRectLocal.height = paddedH;

    if ( this.mode === "ambient" ) {
      const totalArea = this._bounds.width * this._bounds.height;
      const visibleArea = paddedW * paddedH;
      this.adjustedMaxParticles = Math.floor(this.maxParticles * Math.clamp(visibleArea / totalArea, 0, 1));
    }
    else {
      this.adjustedMaxParticles = Math.max(0, Math.floor(this.maxParticles));
    }

    this._newlyVisibleAreas.length = 0;
    if ( (this.mode === "ambient") && this.viewport.newlyVisible && this._hasOldBudgetRectLocal ) {
      this._computeNewlyVisibleAreas(this._oldBudgetRectLocal, this._budgetRectLocal);
    }

    this._hasOldBudgetRectLocal = true;
  }

  /* -------------------------------------------- */

  /**
   * Compute the portions of newRect that were not visible in oldRect.
   * This method reuses a fixed pool of rectangles to avoid per-frame allocations.
   * @param {PIXI.Rectangle} oldRect
   * @param {PIXI.Rectangle} newRect
   * @protected
   */
  _computeNewlyVisibleAreas(oldRect, newRect) {
    if ( this.#containsRect(oldRect, newRect) ) return;

    const ix = Math.max(oldRect.x, newRect.x);
    const iy = Math.max(oldRect.y, newRect.y);
    const ix2 = Math.min(oldRect.x + oldRect.width, newRect.x + newRect.width);
    const iy2 = Math.min(oldRect.y + oldRect.height, newRect.y + newRect.height);

    const iw = ix2 - ix;
    const ih = iy2 - iy;

    if ( (iw <= 0) || (ih <= 0) ) {
      const r = this._newlyVisibleAreaPool[0];
      r.x = newRect.x;
      r.y = newRect.y;
      r.width = newRect.width;
      r.height = newRect.height;
      this._newlyVisibleAreas.push(r);
      return;
    }

    let n = 0;

    const topH = iy - newRect.y;
    if ( topH > 0 ) {
      const r = this._newlyVisibleAreaPool[n++];
      r.x = newRect.x;
      r.y = newRect.y;
      r.width = newRect.width;
      r.height = topH;
      this._newlyVisibleAreas.push(r);
    }

    const bottomY = iy2;
    const bottomH = (newRect.y + newRect.height) - bottomY;
    if ( bottomH > 0 ) {
      const r = this._newlyVisibleAreaPool[n++];
      r.x = newRect.x;
      r.y = bottomY;
      r.width = newRect.width;
      r.height = bottomH;
      this._newlyVisibleAreas.push(r);
    }

    const leftW = ix - newRect.x;
    if ( (leftW > 0) && (ih > 0) ) {
      const r = this._newlyVisibleAreaPool[n++];
      r.x = newRect.x;
      r.y = iy;
      r.width = leftW;
      r.height = ih;
      this._newlyVisibleAreas.push(r);
    }

    const rightX = ix2;
    const rightW = (newRect.x + newRect.width) - rightX;
    if ( (rightW > 0) && (ih > 0) ) {
      const r = this._newlyVisibleAreaPool[n++];
      r.x = rightX;
      r.y = iy;
      r.width = rightW;
      r.height = ih;
      this._newlyVisibleAreas.push(r);
    }
  }

  /* -------------------------------------------- */
  /*  Update Loop                                 */
  /* -------------------------------------------- */

  /**
   * Spawn the initial particle batch.
   * In ambient mode, particle ages are randomized so the scene appears pre-settled.
   */
  _initializeParticles() {
    const n = Math.ceil(this.adjustedMaxParticles * Math.clamp(this.initialBatch, 0, 1));
    if ( n <= 0 ) return;
    this.spawnParticles(n);
    if ( this.mode === "ambient" ) {
      const velocityFunction = this.#velocityFunction;
      const speedValue = this.#speedValue;
      const rotationSpeedValue = this.#rotationSpeedValue;
      const scaleValue = this.#scaleValue;
      const tintValue = this.#tintValue;
      const updateSpeed = !velocityFunction && speedValue?.dynamic;
      const updateRotationSpeed = rotationSpeedValue.dynamic;
      const updateScale = scaleValue.dynamic;
      const updateTint = tintValue.dynamic;
      for ( const p of this.particles ) {
        const lifetime = p.lifetime;
        p.elapsedTime = Math.random() * lifetime;
        const normalizedAge = p.time = lifetime > 0 ? p.elapsedTime / lifetime : 1;
        if ( velocityFunction ) {
          const rotationOffset = this.rotation.alignVelocity
            ? p.rotation - Math.atan2(p.movementSpeed.y, p.movementSpeed.x)
            : null;
          this.#applyVelocityFunction(p, 0, velocityFunction);
          if ( rotationOffset !== null ) {
            p.rotation = Math.atan2(p.movementSpeed.y, p.movementSpeed.x) + rotationOffset;
          }
        }
        else if ( updateSpeed ) {
          const speed = this.#computeParticleValue(speedValue, p, p._baseSpeed, normalizedAge, 0);
          p.movementSpeed.x = (p._movementDirectionX * speed) + p._movementDriftX;
          p.movementSpeed.y = (p._movementDirectionY * speed) + p._movementDriftY;
        }
        if ( updateRotationSpeed ) p.rotationSpeed = this.#computeRotationSpeed(p, normalizedAge, 0);
        if ( updateScale ) p.scale.set(this.#computeParticleValue(scaleValue, p, p._baseScale, normalizedAge, 0));
        if ( updateTint ) p.tint = this.#computeColorValue(tintValue, p, normalizedAge, 0);
        p.alpha = this.#computeAlpha(p, normalizedAge, 0);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Ticker callback.
   * @protected
   */
  _onTick() {
    const stats = this._debugStats;
    const profile = this._debugProfile && !!stats;
    const tTickStart = profile ? performance.now() : 0;

    if ( this.container.destroyed || this._particlesContainer.destroyed ) return this.stop({hard: true});
    this._calculateGeneratorProperties();

    const dt = this.#ticker.deltaMS;
    const onTick = this.onTick;
    if ( onTick && !this._stopped ) onTick(dt, this);

    if ( profile ) {
      const t0 = performance.now();
      this._updateExistingParticles(dt);
      stats.updateMS = performance.now() - t0;
    }
    else this._updateExistingParticles(dt);

    if ( !this.manualSpawning && !this._stopped ) {
      if ( profile ) {
        const t0 = performance.now();
        this._autoSpawnParticles();
        stats.spawnMS = performance.now() - t0;
      }
      else this._autoSpawnParticles();
    }
    else if ( profile ) stats.spawnMS = 0;

    if ( profile ) stats.tickMS = performance.now() - tTickStart;
    if ( this._stopped && (this.particles.length === 0) ) this.stop({hard: true});
  }

  /* -------------------------------------------- */

  /**
   * Update all active particles.
   * @param {number} dt Delta time in milliseconds.
   * @protected
   */
  _updateExistingParticles(dt) {
    const ds = dt * 0.001;
    const particles = this.particles;

    const behavior = this._behavior;
    const bctx = behavior ? this._behaviorContext : null;
    if ( bctx ) {
      bctx.dt = dt;
      bctx.ds = ds;
      this.#updateAnchor({fallbackToBoundsCenter: true});
    }

    const mode = this.constraints.mode;
    if ( mode === "none" ) return this._updateParticlesUnconstrained(dt, ds, particles, behavior, bctx);

    const bounds = this.#getConstraintRectLocal();

    // If the constraint rect is empty, we cannot apply clamp/wrap/bounce
    // For kill mode with viewport constraints, an empty rect means everything is out of bounds
    if ( !bounds || (bounds.width <= 0) || (bounds.height <= 0) ) {
      const area = this.constraints.area;
      if ( (mode === "kill") && ((area === "budget") || (area === "view")) ) {
        const reason = "constraint";
        for ( let i = particles.length - 1; i >= 0; i-- ) this._recycleParticle(particles[i], reason);
        particles.length = 0;
        return;
      }
      return this._updateParticlesUnconstrained(dt, ds, particles, behavior, bctx);
    }

    this._updateParticlesConstrained(dt, ds, bounds, particles, behavior, bctx, mode);
  }

  /* -------------------------------------------- */

  /**
   * Update particles without constraints.
   * @param {number} dt Delta time in milliseconds.
   * @param {number} ds Delta time in seconds.
   * @param {object[]} particles
   * @param {ParticleGeneratorBehavior|null} behavior
   * @param {object|null} bctx
   * @protected
   */
  _updateParticlesUnconstrained(dt, ds, particles, behavior, bctx) {
    const velocityFunction = this.#velocityFunction;
    const speedValue = this.#speedValue;
    const rotationSpeedValue = this.#rotationSpeedValue;
    const scaleValue = this.#scaleValue;
    const tintValue = this.#tintValue;
    const updateSpeed = !velocityFunction && speedValue?.dynamic;
    const updateRotationSpeed = rotationSpeedValue.dynamic;
    const updateScale = scaleValue.dynamic;
    const updateTint = tintValue.dynamic;

    for ( let i = particles.length - 1; i >= 0; i-- ) {
      const p = particles[i];

      const t = (p.elapsedTime += dt);
      const lifetime = p.lifetime;
      const normalizedAge = p.time = (lifetime > 0) && (t < lifetime) ? t / lifetime : 1;
      if ( velocityFunction ) this.#applyVelocityFunction(p, dt, velocityFunction);
      else if ( updateSpeed ) {
        const speed = this.#computeParticleValue(speedValue, p, p._baseSpeed, normalizedAge, dt);
        p.movementSpeed.x = (p._movementDirectionX * speed) + p._movementDriftX;
        p.movementSpeed.y = (p._movementDirectionY * speed) + p._movementDriftY;
      }
      if ( updateRotationSpeed ) p.rotationSpeed = this.#computeRotationSpeed(p, normalizedAge, dt);
      p.rotation += p.rotationSpeed * ds;

      const behaviorHandled = behavior?.update ? (behavior.update(p, dt, bctx) === true) : false;
      if ( !behaviorHandled ) {
        p.x += p.movementSpeed.x * ds;
        p.y += p.movementSpeed.y * ds;
      }
      if ( updateScale ) p.scale.set(this.#computeParticleValue(scaleValue, p, p._baseScale, normalizedAge, dt));
      if ( updateTint ) p.tint = this.#computeColorValue(tintValue, p, normalizedAge, dt);
      p.alpha = this.#computeAlpha(p, normalizedAge, dt);
      if ( this.onUpdate ) this.onUpdate(p, {generator: this});

      if ( t < lifetime ) continue;

      const last = particles.pop();
      if ( i < particles.length ) particles[i] = last;
      this._recycleParticle(p, "lifetime");
    }
  }

  /* -------------------------------------------- */

  /**
   * Update particles with constraints applied.
   * @param {number} dt
   * @param {number} ds
   * @param {PIXI.Rectangle} bounds
   * @param {ParticleMesh[]} particles
   * @param {ParticleGeneratorBehavior|null} behavior
   * @param {object|null} bctx
   * @param {ParticleGeneratorConstraintMode} mode
   * @protected
   */
  _updateParticlesConstrained(dt, ds, bounds, particles, behavior, bctx, mode) {
    const minX = bounds.x;
    const minY = bounds.y;
    const maxX = bounds.x + bounds.width;
    const maxY = bounds.y + bounds.height;

    const restitution = this.constraints.restitution;
    const velocityFunction = this.#velocityFunction;
    const speedValue = this.#speedValue;
    const rotationSpeedValue = this.#rotationSpeedValue;
    const scaleValue = this.#scaleValue;
    const tintValue = this.#tintValue;
    const updateSpeed = !velocityFunction && speedValue?.dynamic;
    const updateRotationSpeed = rotationSpeedValue.dynamic;
    const updateScale = scaleValue.dynamic;
    const updateTint = tintValue.dynamic;

    for ( let i = particles.length - 1; i >= 0; i-- ) {
      const p = particles[i];

      const t = (p.elapsedTime += dt);
      const lifetime = p.lifetime;
      const normalizedAge = p.time = (lifetime > 0) && (t < lifetime) ? t / lifetime : 1;
      if ( velocityFunction ) this.#applyVelocityFunction(p, dt, velocityFunction);
      else if ( updateSpeed ) {
        const speed = this.#computeParticleValue(speedValue, p, p._baseSpeed, normalizedAge, dt);
        p.movementSpeed.x = (p._movementDirectionX * speed) + p._movementDriftX;
        p.movementSpeed.y = (p._movementDirectionY * speed) + p._movementDriftY;
      }
      if ( updateRotationSpeed ) p.rotationSpeed = this.#computeRotationSpeed(p, normalizedAge, dt);
      p.rotation += p.rotationSpeed * ds;

      const behaviorHandled = behavior?.update ? (behavior.update(p, dt, bctx) === true) : false;
      if ( !behaviorHandled ) {
        p.x += p.movementSpeed.x * ds;
        p.y += p.movementSpeed.y * ds;
      }
      if ( updateScale ) p.scale.set(this.#computeParticleValue(scaleValue, p, p._baseScale, normalizedAge, dt));
      if ( updateTint ) p.tint = this.#computeColorValue(tintValue, p, normalizedAge, dt);
      p.alpha = this.#computeAlpha(p, normalizedAge, dt);
      if ( this.onUpdate ) this.onUpdate(p, {generator: this});

      // Lifetime ends first
      if ( t >= lifetime ) {
        const last = particles.pop();
        if ( i < particles.length ) particles[i] = last;
        this._recycleParticle(p, "lifetime");
        continue;
      }

      // Constraints
      let kill = false;
      if ( mode === "kill" ) {
        kill = (p.x < minX) || (p.x > maxX) || (p.y < minY) || (p.y > maxY);
      }
      else if ( mode === "clamp" ) {
        p.x = Math.clamp(p.x, minX, maxX);
        p.y = Math.clamp(p.y, minY, maxY);
      }
      else if ( mode === "wrap" ) {
        const w = bounds.width;
        const h = bounds.height;
        if ( w > 0 ) {
          if ( p.x < minX ) p.x += w;
          else if ( p.x > maxX ) p.x -= w;
        }
        if ( h > 0 ) {
          if ( p.y < minY ) p.y += h;
          else if ( p.y > maxY ) p.y -= h;
        }
      }
      else if ( mode === "bounce" ) {
        if ( p.x < minX ) {
          p.x = minX;
          p.movementSpeed.x = -p.movementSpeed.x * restitution;
          if ( velocityFunction ) {
            p._velocityFunctionBounceX = -p._velocityFunctionBounceX;
            p._movementDriftX = -p._movementDriftX;
          }
          else if ( updateSpeed ) {
            p._movementDirectionX = -p._movementDirectionX;
            p._movementDriftX = -p._movementDriftX;
          }
        }
        else if ( p.x > maxX ) {
          p.x = maxX;
          p.movementSpeed.x = -p.movementSpeed.x * restitution;
          if ( velocityFunction ) {
            p._velocityFunctionBounceX = -p._velocityFunctionBounceX;
            p._movementDriftX = -p._movementDriftX;
          }
          else if ( updateSpeed ) {
            p._movementDirectionX = -p._movementDirectionX;
            p._movementDriftX = -p._movementDriftX;
          }
        }

        if ( p.y < minY ) {
          p.y = minY;
          p.movementSpeed.y = -p.movementSpeed.y * restitution;
          if ( velocityFunction ) {
            p._velocityFunctionBounceY = -p._velocityFunctionBounceY;
            p._movementDriftY = -p._movementDriftY;
          }
          else if ( updateSpeed ) {
            p._movementDirectionY = -p._movementDirectionY;
            p._movementDriftY = -p._movementDriftY;
          }
        }
        else if ( p.y > maxY ) {
          p.y = maxY;
          p.movementSpeed.y = -p.movementSpeed.y * restitution;
          if ( velocityFunction ) {
            p._velocityFunctionBounceY = -p._velocityFunctionBounceY;
            p._movementDriftY = -p._movementDriftY;
          }
          else if ( updateSpeed ) {
            p._movementDirectionY = -p._movementDirectionY;
            p._movementDriftY = -p._movementDriftY;
          }
        }
      }

      if ( !kill ) continue;

      const last = particles.pop();
      if ( i < particles.length ) particles[i] = last;
      this._recycleParticle(p, "constraint");
    }
  }

  /* -------------------------------------------- */

  /**
   * Spawn particles to move toward the current target count.
   * @protected
   */
  _autoSpawnParticles() {
    const target = this.adjustedMaxParticles;
    if ( target <= 0 ) return;

    const available = target - this.particles.length;
    if ( available <= 0 ) return;
    this.#spawnAccumulator += this.spawnRate * this.#ticker.deltaMS * 0.001;

    // Keep fractional budget between ticks so the average spawn rate remains stable
    const desiredParticles = Math.floor(this.#spawnAccumulator);
    if ( desiredParticles <= 0 ) return;

    this.#spawnAccumulator -= desiredParticles;
    const toCreate = Math.min(desiredParticles, available);

    const stats = this._debugStats;
    const localArea = this.mode === "effect" ? this.#computeSpawnAreaLocal(null, null) : null;

    for ( let i = 0; i < toCreate; i++ ) {
      if ( stats ) stats.spawnAttempts++;
      if ( Math.random() > this.spawnProbability ) {
        if ( stats ) stats.spawnRejectedProbability++;
        continue;
      }
      if ( localArea ) {
        const usedTexture = this._getRandomTexture();
        if ( usedTexture ) this.#spawnParticleLocal(localArea, usedTexture);
      }
      else if ( this.mode === "effect" ) {
        if ( stats ) stats.spawnRejectedNoArea++;
      }
      else this.#spawnParticleDefault();
    }
  }

  /* -------------------------------------------- */
  /*  Spawning Internals                          */
  /* -------------------------------------------- */

  /**
   * Spawn one particle using the generator defaults.
   */
  #spawnParticleDefault() {
    const stats = this._debugStats;

    const usedTexture = this._getRandomTexture();
    if ( !usedTexture ) return null;

    if ( this.mode === "ambient" ) {
      let rect = this._budgetRectLocal;
      const n = this._newlyVisibleAreas.length;
      if ( n ) rect = this._newlyVisibleAreas[Math.floor(Math.random() * n)];

      // Guard against degenerate viewport rectangles.
      if ( (rect.width <= 0) || (rect.height <= 0) ) {
        if ( stats ) stats.spawnRejectedNoArea++;
        return null;
      }

      return this.#spawnParticleLocal({type: "ambientRect", rect}, usedTexture);
    }

    const localArea = this.#computeSpawnAreaLocal(null, null);
    if ( !localArea ) {
      if ( stats ) stats.spawnRejectedNoArea++;
      return null;
    }
    return this.#spawnParticleLocal(localArea, usedTexture);
  }

  /* -------------------------------------------- */

  /**
   * Spawn a particle in local-space.
   * @param {ParticleGeneratorLocalArea} areaLocal
   * @param {PIXI.Texture} usedTexture
   * @returns {ParticleMesh|null}
   */
  #spawnParticleLocal(areaLocal, usedTexture) {
    if ( !this._particlesContainer ) return null;

    const stats = this._debugStats;

    /** @type {ParticleMesh} */
    let particle = this.particlePool.pop();
    if ( particle ) {
      particle.texture = usedTexture;
      const anchor = this.particleAnchor;
      if ( anchor ) particle.anchor.set(anchor.x, anchor.y);
      else {
        const da = usedTexture.defaultAnchor;
        particle.anchor.set(da?.x ?? 0.5, da?.y ?? 0.5);
      }
    } else {
      particle = this._createNewParticle(usedTexture);
      particle.visible = false;
      particle.renderable = false;
      this._particlesContainer.addChild(particle);
    }

    this._setupParticleBase(particle);
    if ( !this.#assignSpawnPosition(areaLocal, particle) ) {
      if ( stats ) stats.spawnRejectedNoArea++;
      this.particlePool.push(particle);
      particle.visible = false;
      particle.renderable = false;
      return null;
    }

    const velocityFunction = this.#velocityFunction;
    if ( velocityFunction ) {
      this.#applyVelocityFunction(particle, 0, velocityFunction);
      this.#initializeParticleRotation(particle);
    }

    const behavior = this._behavior;
    if ( behavior?.spawn ) {
      this.#updateAnchor({fallbackToBoundsCenter: true});
      behavior.spawn(particle, this._behaviorContext);
    }

    if ( !this.#testPosition(particle) ) {
      if ( stats ) stats.spawnRejectedPositionTest++;
      this.particlePool.push(particle);
      particle.visible = false;
      particle.renderable = false;
      return null;
    }

    if ( this.drift.enabled ) this._applyRandomDrift(particle);

    if ( this._debugTint && !this.#tintValue.dynamic ) this.#applyDebugTint(particle, usedTexture);

    if ( this.onSpawn ) this.onSpawn(particle, {generator: this});

    this.particles.push(particle);
    if ( stats ) stats.spawned++;
    return particle;
  }

  /* -------------------------------------------- */

  /**
   * Assign a spawn position (local coordinates).
   * @param {ParticleGeneratorLocalArea} areaLocal
   * @param {ParticleMesh} particle
   * @returns {boolean}
   */
  #assignSpawnPosition(areaLocal, particle) {
    let randomizeAge = false;

    switch ( areaLocal.type ) {
      case "shape":
      case "polygonTree": {
        const data = areaLocal.data;
        if ( data.isEmpty ) return false;
        const point = this._spawnPoint;
        if ( areaLocal.sampleMode === "boundary" ) data.sampleBoundary(point);
        else data.sampleInterior(point);
        particle.x = point.x - this._bounds.x;
        particle.y = point.y - this._bounds.y;
        break;
      }
      case "point":
        particle.x = areaLocal.x;
        particle.y = areaLocal.y;
        break;
      case "points": {
        const point = areaLocal.points[(Math.random() * areaLocal.points.length) | 0];
        particle.x = point.x;
        particle.y = point.y;
        break;
      }
      case "polyline": {
        if ( areaLocal.totalLength <= 0 ) {
          const point = areaLocal.fallbackPoint;
          if ( !point ) return false;
          particle.x = point.x;
          particle.y = point.y;
          break;
        }

        const target = Math.random() * areaLocal.totalLength;
        let low = 0;
        let high = areaLocal.cumulativeLengths.length - 1;
        while ( low < high ) {
          const mid = (low + high) >> 1;
          if ( target <= areaLocal.cumulativeLengths[mid] ) high = mid;
          else low = mid + 1;
        }

        const i = low;
        const segment = areaLocal.segments[i];
        const previous = i ? areaLocal.cumulativeLengths[i - 1] : 0;
        const t = (target - previous) / segment.length;
        particle.x = segment.ax + ((segment.bx - segment.ax) * t);
        particle.y = segment.ay + ((segment.by - segment.ay) * t);
        break;
      }
      case "ellipse": {
        const angle = Math.mix(areaLocal.minAngle, areaLocal.maxAngle, Math.random());
        const scale = Math.sqrt(areaLocal.holeAreaScale + (Math.random() * (1 - areaLocal.holeAreaScale)));
        let localX = areaLocal.radiusX * scale * Math.cos(angle);
        let localY = areaLocal.radiusY * scale * Math.sin(angle);

        if ( areaLocal.shapeRotation ) {
          const rotatedX = (localX * areaLocal.cosRotation) - (localY * areaLocal.sinRotation);
          const rotatedY = (localX * areaLocal.sinRotation) + (localY * areaLocal.cosRotation);
          localX = rotatedX;
          localY = rotatedY;
        }

        particle.x = areaLocal.x + localX;
        particle.y = areaLocal.y + localY;
        if ( areaLocal.affectRotation ) particle.rotation += areaLocal.shapeRotation + angle;
        break;
      }
      case "ambientRect": {
        const rect = areaLocal.rect;
        particle.x = rect.x + (Math.random() * rect.width);
        particle.y = rect.y + (Math.random() * rect.height);
        if ( (this.mode === "ambient") && this.viewport.randomizeAgeInPadding
          && !this._viewRectLocal.contains(particle.x, particle.y) ) {
          particle.elapsedTime = Math.random() * particle.lifetime;
          randomizeAge = true;
        }
        break;
      }
      case "line": {
        const t = Math.random();
        particle.x = areaLocal.ax + ((areaLocal.bx - areaLocal.ax) * t);
        particle.y = areaLocal.ay + ((areaLocal.by - areaLocal.ay) * t);
        break;
      }
      default:
        return false;
    }

    const speedValue = this.#speedValue;
    const rotationSpeedValue = this.#rotationSpeedValue;
    const scaleValue = this.#scaleValue;
    const tintValue = this.#tintValue;
    const alphaValue = this.#alphaValue;
    const updateSpeed = speedValue?.dynamic && (randomizeAge || speedValue.fn);
    const updateRotationSpeed = rotationSpeedValue.dynamic && (randomizeAge || rotationSpeedValue.fn);
    const updateScale = scaleValue.dynamic && (randomizeAge || scaleValue.fn);
    const updateTint = tintValue.dynamic && (randomizeAge || tintValue.fn);
    const updateAlpha = randomizeAge || (alphaValue.dynamic && alphaValue.fn);
    if ( !(updateSpeed || updateRotationSpeed || updateScale || updateTint || updateAlpha) ) return true;

    let normalizedAge = particle.time;
    if ( randomizeAge ) {
      const lifetime = particle.lifetime;
      normalizedAge = particle.time = lifetime > 0 ? particle.elapsedTime / lifetime : 1;
    }
    if ( updateSpeed ) {
      const speed = this.#computeParticleValue(speedValue, particle, particle._baseSpeed, normalizedAge, 0);
      particle.movementSpeed.x = (particle._movementDirectionX * speed) + particle._movementDriftX;
      particle.movementSpeed.y = (particle._movementDirectionY * speed) + particle._movementDriftY;
    }
    if ( updateRotationSpeed ) particle.rotationSpeed = this.#computeRotationSpeed(particle, normalizedAge, 0);
    if ( updateScale ) particle.scale.set(this.#computeParticleValue(scaleValue, particle, particle._baseScale,
      normalizedAge, 0));
    if ( updateTint ) particle.tint = this.#computeColorValue(tintValue, particle, normalizedAge, 0);
    if ( updateAlpha ) particle.alpha = this.#computeAlpha(particle, normalizedAge, 0);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Apply a random drift vector to a particle.
   * @param {ParticleMesh} particle
   * @protected
   */
  _applyRandomDrift(particle) {
    const angle = Math.random() * (Math.PI * 2);
    const driftMag = this.drift.intensity;
    const dx = Math.cos(angle) * driftMag;
    const dy = Math.sin(angle) * driftMag;
    particle.movementSpeed.x += dx;
    particle.movementSpeed.y += dy;
    particle._movementDriftX = dx;
    particle._movementDriftY = dy;
  }

  /* -------------------------------------------- */
  /*  Particle Internals                          */
  /* -------------------------------------------- */

  /**
   * Create a new particle instance.
   * @param {PIXI.Texture} texture
   * @returns {ParticleMesh}
   * @protected
   */
  _createNewParticle(texture) {
    const particle = /** @type {ParticleMesh} */ new SpriteMesh(texture, this.shaderClass);
    const anchor = this.particleAnchor;
    if ( anchor ) particle.anchor.set(anchor.x, anchor.y);
    else {
      const da = texture.defaultAnchor;
      particle.anchor.set(da?.x ?? 0.5, da?.y ?? 0.5);
    }
    return particle;
  }

  /* -------------------------------------------- */

  /**
   * Initialize/refresh base particle properties.
   * @param {ParticleMesh} particle
   */
  _setupParticleBase(particle) {
    particle.visible = true;
    particle.renderable = true;

    particle.alpha = 0;
    particle.generator = this;
    particle.elapsedTime = 0;
    particle.time = 0;
    particle.blendMode = this.blendMode;
    particle.lifetime = this.#computeLifetime();
    particle.fadeInDuration = this.#computeFadeDuration(this.fadeInDuration, particle.lifetime);
    particle.fadeOutDuration = this.#computeFadeDuration(this.fadeOutDuration, particle.lifetime);

    const scale = this.#sampleParticleValue(this.#scaleValue, 1);
    const maxAlpha = this.#sampleParticleValue(this.#alphaValue, 1);
    const tintValue = this.#tintValue;

    particle.scale.set(scale, scale);
    particle._baseScale = scale;
    particle._baseTint = tintValue.color;
    particle.maxAlpha = maxAlpha;
    particle._movementDriftX = 0;
    particle._movementDriftY = 0;
    particle._velocityFunctionBounceX = 1;
    particle._velocityFunctionBounceY = 1;
    particle._baseSpeed = 0;
    particle._baseRotationSpeed = this.#rotationSpeedValue.dynamic ? this.#sampleRotationSpeed() : 0;

    if ( !particle.movementSpeed ) particle.movementSpeed = new PIXI.Point(0, 0);
    if ( this.#velocityFunction ) particle.movementSpeed.set(0, 0);
    else {
      this._generateMovementSpeed(particle.movementSpeed, particle);
      this.#initializeParticleRotation(particle);
    }

    if ( this.#scaleValue.dynamic ) {
      particle.scale.set(this.#computeParticleValue(this.#scaleValue, particle, particle._baseScale, 0, 0));
    }
    particle.tint = tintValue.dynamic ? this.#computeColorValue(tintValue, particle, 0, 0) : particle._baseTint;
    particle.alpha = this.#computeAlpha(particle, 0, 0);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the particle rotation from the current movement vector.
   * @param {ParticleMesh} particle
   */
  #initializeParticleRotation(particle) {
    const {alignVelocity, initial, spread} = this.rotation;
    const base = alignVelocity ? Math.atan2(particle.movementSpeed.y, particle.movementSpeed.x) : 0;
    particle.rotation = base + initial + (((Math.random() * 2) - 1) * spread);
    if ( !this.#rotationSpeedValue.dynamic ) particle._baseRotationSpeed = this.#sampleRotationSpeed();
    particle.rotationSpeed = this.#computeRotationSpeed(particle, particle.time, 0);
  }

  /* -------------------------------------------- */

  /**
   * Recycle a particle to the pool.
   * @param {ParticleMesh} particle
   * @param {string} reason
   * @protected
   */
  _recycleParticle(particle, reason) {
    reason = (typeof reason === "string") ? reason : "unknown";

    const stats = this._debugStats;
    if ( stats ) {
      if ( reason === "lifetime" ) stats.recycledLifetime++;
      else if ( reason === "constraint" ) stats.recycledConstraint++;
    }

    if ( this.onDeath ) this.onDeath(particle, {generator: this, reason});
    particle.renderable = false;
    particle.visible = false;
    this.particlePool.push(particle);
  }

  /* -------------------------------------------- */
  /*  Alpha & Lifetime                            */
  /* -------------------------------------------- */

  /**
   * Compute alpha for a particle given its age and state.
   * @param {ParticleMesh} p
   * @param {number} [normalizedAge]
   * @param {number} [dt=0]
   * @returns {number}
   */
  #computeAlpha(p, normalizedAge, dt=0) {
    const t = p.elapsedTime;
    const lifetime = p.lifetime;

    let a = 1;
    if ( p.fadeInDuration > 0 ) a = Math.min(a, Math.clamp(t / p.fadeInDuration, 0, 1));
    if ( p.fadeOutDuration > 0 ) a = Math.min(a, Math.clamp((lifetime - t) / p.fadeOutDuration, 0, 1));
    if ( this.#alphaValue.dynamic ) {
      normalizedAge ??= (lifetime > 0) && (t < lifetime) ? t / lifetime : 1;
      return a * this.#computeParticleValue(this.#alphaValue, p, p.maxAlpha, normalizedAge, dt);
    }

    return a * p.maxAlpha;
  }

  /* -------------------------------------------- */

  /**
   * Compute rotation speed for a particle, in radians per second.
   * @param {ParticleMesh} p
   * @param {number} normalizedAge
   * @param {number} [dt=0]
   * @returns {number}
   */
  #computeRotationSpeed(p, normalizedAge, dt=0) {
    const value = this.#rotationSpeedValue;
    return value.dynamic
      ? this.#computeParticleValue(value, p, p._baseRotationSpeed, normalizedAge, dt)
      : p._baseRotationSpeed;
  }

  /* -------------------------------------------- */

  /**
   * Sample the public rotation speed range in radians per second.
   * @returns {number}
   */
  #sampleRotationSpeed() {
    const range = this.rotation.speed;
    if ( !range ) return this.#sampleParticleValue(this.#rotationSpeedValue, 0);
    const min = Number(range.min) || 0;
    const max = Number(range.max) || 0;
    return Math.mix(min, max, Math.random());
  }

  /* -------------------------------------------- */

  /**
   * Compute a per-particle lifetime.
   * @returns {number}
   */
  #computeLifetime() {
    const v = this.particleLifetime;

    if ( typeof v === "number" ) return Math.max(0, v);

    if ( Array.isArray(v) && (v.length >= 2) ) {
      const min = Number(v[0]) || 0;
      const max = Number(v[1]) || 0;
      return Math.max(0, Math.mix(min, max, Math.random()));
    }

    const min = Number(v.min ?? 0) || 0;
    const max = Number(v.max ?? min) || min;
    return Math.max(0, Math.mix(min, max, Math.random()));
  }

  /* -------------------------------------------- */

  /**
   * Compute a fade duration.
   * @param {number} value
   * @param {number} lifetime
   * @returns {number}
   */
  #computeFadeDuration(value, lifetime) {
    const v = Number(value ?? 0) || 0;
    if ( (v > 0) && (v < 1) ) return Math.clamp(lifetime * v, 0, lifetime);
    return Math.clamp(v, 0, lifetime);
  }

  /* -------------------------------------------- */
  /*  Texture Helpers                             */
  /* -------------------------------------------- */

  /**
   * Get a random texture from the configured set.
   * @returns {PIXI.Texture|null}
   * @protected
   */
  _getRandomTexture() {
    const textures = this.textures;
    if ( !textures.length ) return null;
    return textures[Math.floor(Math.random() * textures.length)];
  }

  /* -------------------------------------------- */

  /**
   * Load texture sources.
   * @param {(PIXI.Texture|string)[]} sources
   * @returns {PIXI.Texture[]}
   */
  #loadTextures(sources) {
    const out = [];
    for ( const s of sources ) {
      const t = this.#lookupTexture(s);
      if ( t ) out.push(t);
    }
    return out;
  }

  /* -------------------------------------------- */

  /**
   * Assign a texture source into a PIXI.Texture.
   * @param {PIXI.Texture|string|null|undefined} source
   * @returns {PIXI.Texture|null}
   */
  #lookupTexture(source) {
    if ( !source ) return null;
    if ( source instanceof PIXI.Texture ) return source;

    const cached = foundry.canvas.getTexture(source);
    if ( cached instanceof PIXI.Texture ) return cached;

    return PIXI.Texture.from(source);
  }

  /* -------------------------------------------- */
  /*  Spawn Validation                            */
  /* -------------------------------------------- */

  /**
   * Test whether the particle position is valid.
   * @param {ParticleMesh} particle
   * @returns {boolean}
   */
  #testPosition(particle) {
    if ( !this.positionTest ) return true;
    const x = particle.x + this._bounds.x;
    const y = particle.y + this._bounds.y;
    return this.positionTest(x, y, {generator: this, particle});
  }

  /* -------------------------------------------- */
  /*  Velocity Helpers                            */
  /* -------------------------------------------- */

  /**
   * Apply the configured custom velocity function into a particle's reusable movement point.
   * @param {ParticleMesh} particle
   * @param {number} dt
   * @param {ParticleGeneratorVelocityFunction} fn
   */
  #applyVelocityFunction(particle, dt, fn) {
    const out = particle.movementSpeed;
    out.x = 0;
    out.y = 0;

    const velocity = fn(particle, dt, out);
    if ( velocity ) {
      out.set(velocity.x, velocity.y);
    }

    out.x = (out.x * particle._velocityFunctionBounceX) + particle._movementDriftX;
    out.y = (out.y * particle._velocityFunctionBounceY) + particle._movementDriftY;
  }

  /* -------------------------------------------- */

  /**
   * Compile the custom velocity function, if one is configured.
   * @param {ParticleGeneratorVelocityOptions|null} cfg
   * @returns {ParticleGeneratorVelocityFunction|null}
   */
  #compileVelocityFunction(cfg) {
    if ( !cfg || (typeof cfg !== "object") || !("fn" in cfg) ) return null;
    if ( typeof cfg.fn !== "function" ) {
      throw new Error("ParticleGenerator: velocity.fn must be a function.");
    }

    if ( ("x" in cfg) || ("y" in cfg) || ("speed" in cfg) || ("angle" in cfg) ) {
      throw new Error("ParticleGenerator: velocity.fn cannot be combined with x, y, speed, or angle.");
    }

    return cfg.fn;
  }

  /* -------------------------------------------- */

  /**
   * Build a per-particle velocity generator which writes into an existing PIXI.Point.
   * @param {ParticleGeneratorVelocityOptions|null} cfg
   * @returns {(out: PIXI.Point, particle: ParticleMesh) => void}
   */
  #buildVelocityGenerator(cfg) {
    if ( cfg instanceof PIXI.Point ) {
      const x = cfg.x;
      const y = cfg.y;
      return out => out.set(x, y);
    }

    const speedValue = this.#speedValue;
    if ( cfg && (typeof cfg === "object") && ("angle" in cfg) && speedValue ) {
      const angleRange = this.#normalizeRange(cfg.angle, {min: 0, max: 0}) ?? {min: 0, max: 0};

      const angleMin = Math.toRadians(angleRange.min);
      const angleMax = Math.toRadians(angleRange.max);

      return (out, particle) => {
        const angle = Math.mix(angleMin, angleMax, Math.random());
        const dx = Math.cos(angle);
        const dy = Math.sin(angle);
        particle._baseSpeed = this.#sampleParticleValue(speedValue, 0);
        const speed = speedValue.dynamic
          ? this.#computeParticleValue(speedValue, particle, particle._baseSpeed, 0, 0)
          : particle._baseSpeed;
        if ( speedValue.dynamic ) {
          particle._movementDirectionX = dx;
          particle._movementDirectionY = dy;
        }
        out.set(dx * speed, dy * speed);
      };
    }

    if ( cfg && (typeof cfg === "object") && ("x" in cfg) && ("y" in cfg) ) {
      const x = this.#normalizeRange(cfg.x, {min: 0, max: 0}) ?? {min: 0, max: 0};
      const y = this.#normalizeRange(cfg.y, {min: 0, max: 0}) ?? {min: 0, max: 0};
      return out => {
        const vx = Math.mix(x.min, x.max, Math.random());
        const vy = Math.mix(y.min, y.max, Math.random());
        out.set(vx, vy);
      };
    }

    return out => out.set(0, 0);
  }

  /* -------------------------------------------- */
  /*  Rectangle Helpers                           */
  /* -------------------------------------------- */

  /**
   * Check whether rectA fully contains rectB.
   * @param {PIXI.Rectangle} rectA
   * @param {PIXI.Rectangle} rectB
   * @returns {boolean}
   */
  #containsRect(rectA, rectB) {
    return (
      (rectB.x >= rectA.x)
      && (rectB.y >= rectA.y)
      && ((rectB.x + rectB.width) <= (rectA.x + rectA.width))
      && ((rectB.y + rectB.height) <= (rectA.y + rectA.height))
    );
  }

  /* -------------------------------------------- */
  /*  Ranges                                      */
  /* -------------------------------------------- */

  /**
   * Compile the polar velocity speed value, if one is configured.
   * @param {ParticleGeneratorVelocityOptions|null} cfg
   * @returns {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}|null}
   */
  #compileVelocitySpeedValue(cfg) {
    if ( !cfg || (typeof cfg !== "object") || !("angle" in cfg) || !("speed" in cfg) ) return null;
    return this.#compileParticleValue(cfg.speed, {min: 0, max: 0});
  }

  /* -------------------------------------------- */

  /**
   * Compile the rotation speed value into radians per second.
   * The config API accepts degrees per second. Curves paired with a range remain unitless multipliers.
   * @param {ParticleGeneratorValue|null|undefined} value
   * @returns {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}}
   */
  #compileRotationSpeedValue(value) {
    const compiled = this.#compileParticleValue(value, {min: 0, max: 0});

    const range = compiled.range;
    if ( range ) {
      range.min = Math.toRadians(range.min);
      range.max = Math.toRadians(range.max);
    }

    if ( compiled.curve && !range ) {
      const values = compiled.curve.values;
      for ( let i = 0; i < values.length; i++ ) values[i] = Math.toRadians(values[i]);
    }

    if ( compiled.fn ) {
      const fn = compiled.fn;
      compiled.fn = (particle, dt) => Math.toRadians(fn(particle, dt));
    }

    return compiled;
  }

  /* -------------------------------------------- */

  /**
   * Compile a particle value for spawn-time sampling and update-time evaluation.
   * Curves are linearly interpolated by default, or evaluated in discrete steps if the `step` flag is true.
   * @param {ParticleGeneratorValue|null|undefined} value
   * @param {{min: number, max: number}} fallback
   * @returns {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean, step: boolean}}
   */
  #compileParticleValue(value, fallback) {
    const hasOptions = value && (typeof value === "object") && !Array.isArray(value);
    if ( hasOptions && ("fn" in value) ) {
      const fn = this.#validateValueFunction(value, "value", ["min", "max", "curve", "step"],
        "min, max, curve, or step");
      return {range: null, curve: null, fn, dynamic: true, step: false};
    }
    const hasCurve = hasOptions && ("curve" in value);
    const hasRange = hasOptions && (("min" in value) || ("max" in value));
    if ( hasOptions && !hasCurve && !hasRange ) {
      throw new Error("ParticleGenerator: value object must define min, max, curve, or fn.");
    }
    const curve = hasCurve ? this.#compileValueCurve(value.curve) : null;
    let range = null;
    if ( hasCurve ) {
      if ( ("min" in value) || ("max" in value) ) range = this.#normalizeValueRange(value, null);
    } else range = this.#normalizeValueRange(value, fallback);
    if ( !range && !curve ) range = fallback;
    return {range, curve, fn: null, dynamic: !!curve, step: !!(curve && value.step)};
  }

  /* -------------------------------------------- */

  /**
   * Validate a custom particle value function.
   * @param {object} value
   * @param {string} field
   * @param {string[]} conflicts
   * @param {string} conflictLabel
   * @returns {ParticleGeneratorValueFunction|ParticleGeneratorColorFunction}
   */
  #validateValueFunction(value, field, conflicts, conflictLabel) {
    if ( typeof value.fn !== "function" ) {
      throw new Error(`ParticleGenerator: ${field}.fn must be a function.`);
    }
    for ( const conflict of conflicts ) {
      if ( conflict in value ) {
        throw new Error(`ParticleGenerator: ${field}.fn cannot be combined with ${conflictLabel}.`);
      }
    }
    return value.fn;
  }

  /* -------------------------------------------- */

  /**
   * Validate a raw curve source.
   * @param {any} raw
   * @param {string} field
   * @returns {number}
   */
  #validateCurveSource(raw, field) {
    if ( !Array.isArray(raw) ) throw new Error(`ParticleGenerator: ${field}.curve must be an array.`);
    if ( raw.length < 2 ) {
      throw new Error(`ParticleGenerator: ${field}.curve must contain at least two points.`);
    }
    return raw.length;
  }

  /* -------------------------------------------- */

  /**
   * Validate a raw curve point.
   * @param {any} point
   * @param {number} i
   * @param {number} n
   * @param {number} previousTime
   * @param {string} field
   * @returns {number}
   */
  #validateCurvePoint(point, i, n, previousTime, field) {
    if ( !point || (typeof point !== "object") ) {
      throw new Error(`ParticleGenerator: ${field}.curve[${i}] must be an object.`);
    }
    const time = point.time;
    if ( (typeof time !== "number") || !Number.isFinite(time) ) {
      throw new Error(`ParticleGenerator: ${field}.curve[${i}].time must be a finite number.`);
    }
    if ( (time < 0) || (time > 1) ) {
      throw new Error(`ParticleGenerator: ${field}.curve[${i}].time must be between 0 and 1.`);
    }
    if ( (i === 0) && (time !== 0) ) {
      throw new Error(`ParticleGenerator: ${field}.curve must start with time 0.`);
    }
    if ( (i === (n - 1)) && (time !== 1) ) {
      throw new Error(`ParticleGenerator: ${field}.curve must end with time 1.`);
    }
    if ( time <= previousTime ) {
      throw new Error(`ParticleGenerator: ${field}.curve times must be strictly increasing.`);
    }
    return time;
  }

  /* -------------------------------------------- */

  /**
   * Validate and compile curve points for update-time evaluation.
   * @param {ParticleGeneratorCurvePoint[]} raw
   * @param {string} [field="value"]  Field name used in error messages.
   * @returns {{times: Float32Array, values: Float32Array}}
   */
  #compileValueCurve(raw, field="value") {
    const n = this.#validateCurveSource(raw, field);
    const times = new Float32Array(n);
    const values = new Float32Array(n);
    let previousTime = -Infinity;
    for ( let i = 0; i < n; i++ ) {
      const point = raw[i];
      const time = this.#validateCurvePoint(point, i, n, previousTime, field);
      const value = point.value;
      if ( (typeof value !== "number") || !Number.isFinite(value) ) {
        throw new Error(`ParticleGenerator: ${field}.curve[${i}].value must be a finite number.`);
      }
      times[i] = time;
      values[i] = value;
      previousTime = time;
    }
    return {times, values};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate a compiled value curve at a normalized age.
   * @param {{times: Float32Array, values: Float32Array}} curve
   * @param {number} t                  Normalized time [0, 1]
   * @param {boolean} [step=false]      Evaluate in discrete steps rather than interpolation
   * @returns {number}                  The evaluated value
   */
  #evaluateValueCurve(curve, t, step=false) {
    const times = curve.times;
    const values = curve.values;
    const last = times.length - 1;

    if ( t <= times[0] ) return values[0];
    if ( t >= times[last] ) return values[last];

    for ( let i = 1; i <= last; i++ ) {
      const t1 = times[i];
      if ( t > t1 ) continue;
      if ( step ) return values[i - 1];
      const t0 = times[i - 1];
      const f = (t - t0) / (t1 - t0);
      return Math.mix(values[i - 1], values[i], f);
    }

    return values[last];
  }

  /* -------------------------------------------- */

  /**
   * Sample a particle value's base range.
   * @param {{range: {min: number, max: number}|null}} value
   * @param {number} fallback
   * @returns {number}
   */
  #sampleParticleValue(value, fallback) {
    const range = value.range;
    if ( !range ) return fallback;
    return Math.mix(range.min, range.max, Math.random());
  }

  /* -------------------------------------------- */

  /**
   * Compute a particle value at the provided lifetime ratio.
   * @param {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, step: boolean}} value
   * @param {ParticleMesh} particle
   * @param {number} baseValue
   * @param {number} normalizedAge
   * @param {number} [dt=0]
   * @returns {number}
   */
  #computeParticleValue(value, particle, baseValue, normalizedAge, dt=0) {
    if ( value.fn ) {
      return value.fn(particle, dt);
    }

    if ( !value.curve ) return baseValue;

    const curveValue = this.#evaluateValueCurve(value.curve, normalizedAge, value.step);
    return value.range ? baseValue * curveValue : curveValue;
  }

  /* -------------------------------------------- */

  /**
   * Compile a particle tint value for spawn-time sampling and update-time evaluation.
   * @param {ParticleGeneratorColorValue|null|undefined} value
   * @returns {{color: number, curve: {times: Float32Array, colors: Uint32Array}|null,
   *   fn: ParticleGeneratorColorFunction|null, dynamic: boolean}}
   */
  #compileColorValue(value) {
    if ( value == null ) return {color: 0xFFFFFF, curve: null, fn: null, dynamic: false};
    const hasOptions = value && (typeof value === "object") && !Array.isArray(value) && !(value instanceof Color)
      && (("fn" in value) || ("curve" in value));
    if ( !hasOptions ) return {color: this.#normalizeColor(value, "tint"), curve: null, fn: null, dynamic: false};
    if ( "fn" in value ) {
      const fn = this.#validateValueFunction(value, "tint", ["curve"], "curve");
      return {color: 0xFFFFFF, curve: null, fn, dynamic: true};
    }
    if ( "curve" in value ) {
      return {color: 0xFFFFFF, curve: this.#compileColorCurve(value.curve), fn: null, dynamic: true};
    }
    throw new Error("ParticleGenerator: tint must be a color source or an object with curve/fn.");
  }

  /* -------------------------------------------- */

  /**
   * Validate and compile color curve points for update-time evaluation.
   * @param {object[]} raw
   * @returns {{times: Float32Array, colors: Uint32Array}}
   */
  #compileColorCurve(raw) {
    const n = this.#validateCurveSource(raw, "tint");
    const times = new Float32Array(n);
    const colors = new Uint32Array(n);
    let previousTime = -Infinity;
    for ( let i = 0; i < n; i++ ) {
      const point = raw[i];
      const time = this.#validateCurvePoint(point, i, n, previousTime, "tint");
      times[i] = time;
      colors[i] = this.#normalizeColor(point.value, `tint.curve[${i}].value`);
      previousTime = time;
    }
    return {times, colors};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate a compiled color curve.
   * @param {{times: Float32Array, colors: Uint32Array}} curve
   * @param {number} t
   * @returns {number}
   */
  #evaluateColorCurve(curve, t) {
    const times = curve.times;
    const colors = curve.colors;
    const last = times.length - 1;
    if ( t <= times[0] ) return colors[0];
    if ( t >= times[last] ) return colors[last];
    for ( let i = 1; i <= last; i++ ) {
      const t1 = times[i];
      if ( t > t1 ) continue;
      const t0 = times[i - 1];
      const f = (t - t0) / (t1 - t0);
      return Color.mix(colors[i - 1], colors[i], f);
    }
    return colors[last];
  }

  /* -------------------------------------------- */

  /**
   * Compute a particle tint at the provided lifetime ratio.
   * @param {{color: number, curve: {times: Float32Array, colors: Uint32Array}|null,
   *   fn: ParticleGeneratorColorFunction|null}} value
   * @param {ParticleMesh} particle
   * @param {number} normalizedAge
   * @param {number} [dt=0]
   * @returns {number}
   */
  #computeColorValue(value, particle, normalizedAge, dt=0) {
    if ( value.fn ) {
      return Math.clamp(Math.round(value.fn(particle, dt)), 0, 0xFFFFFF);
    }
    if ( !value.curve ) return value.color;
    return this.#evaluateColorCurve(value.curve, normalizedAge);
  }

  /* -------------------------------------------- */

  /**
   * Normalize a color source into a 0xRRGGBB color number.
   * @param {any} value
   * @param {string} field
   * @returns {number}
   */
  #normalizeColor(value, field) {
    const color = Color.from(value);
    if ( !color.valid ) {
      throw new Error(`ParticleGenerator: ${field} must be a valid color source.`);
    }
    return color.valueOf();
  }

  /* -------------------------------------------- */

  /**
   * Normalize a particle value's base range.
   * @param {any} value
   * @param {{min: number, max: number}|null} fallback
   * @returns {{min: number, max: number}|null}
   */
  #normalizeValueRange(value, fallback) {
    if ( value == null ) return fallback;
    if ( typeof value === "number" ) return {min: value, max: value};
    if ( Array.isArray(value) && (value.length >= 2) ) return {min: value[0], max: value[1]};
    if ( !value || (typeof value !== "object") ) return fallback;

    const hasMin = "min" in value;
    const hasMax = "max" in value;
    if ( hasMin && hasMax ) return {min: Number(value.min) || 0, max: Number(value.max) || 0};
    if ( hasMin ) {
      const n = Number(value.min) || 0;
      return {min: n, max: n};
    }
    if ( hasMax ) {
      const n = Number(value.max) || 0;
      return {min: n, max: n};
    }
    return fallback;
  }

  /* -------------------------------------------- */

  /**
   * Normalize an input range value to {min,max}.
   * @param {any} value
   * @param {{min: number, max: number}|null} fallback
   * @returns {{min: number, max: number}|null}
   */
  #normalizeRange(value, fallback) {
    if ( value == null ) return fallback;
    if ( typeof value === "number" ) return {min: value, max: value};
    if ( Array.isArray(value) && (value.length >= 2) ) return {min: value[0], max: value[1]};
    return {min: value.min ?? 0, max: value.max ?? value.min ?? 0};
  }

  /* -------------------------------------------- */
  /*  Bounds and Coordinate Helpers               */
  /* -------------------------------------------- */

  /**
   * Normalize a rectangle input to PIXI.Rectangle.
   * @param {any} value
   * @returns {PIXI.Rectangle|null}
   */
  #normalizeRect(value) {
    if ( !value ) return null;
    if ( value instanceof PIXI.Rectangle ) return value.clone();

    if ( (typeof value === "object") && ("x" in value) && ("y" in value) && ("width" in value) && ("height" in value) ) {
      const x = Number(value.x) || 0;
      const y = Number(value.y) || 0;
      const w = Number(value.width) || 0;
      const h = Number(value.height) || 0;
      return new PIXI.Rectangle(x, y, w, h);
    }

    return null;
  }

  /* -------------------------------------------- */

  /**
   * Convert a scene-space rectangle into local coordinates.
   * @param {PIXI.Rectangle} rect
   * @returns {PIXI.Rectangle}
   */
  #rectToLocal(rect) {
    return new PIXI.Rectangle(
      rect.x - this._bounds.x,
      rect.y - this._bounds.y,
      rect.width,
      rect.height
    );
  }

  /* -------------------------------------------- */

  /**
   * Normalize the effective clip configuration.
   * @param {ParticleGeneratorClipOptions|ParticleGeneratorRectangle|boolean} clip
   * @returns {{enabled: boolean|null, rect: PIXI.Rectangle|null}}
   */
  #normalizeClip(clip) {
    if ( typeof clip === "boolean" ) return {enabled: clip, rect: null};
    if ( !clip ) return {enabled: null, rect: null};

    // Shorthand: provide a rectangle directly.
    if ( clip instanceof PIXI.Rectangle ) return {enabled: true, rect: clip.clone()};
    if ( (typeof clip === "object") && !("enabled" in clip) && !("rect" in clip) ) {
      const rect = this.#normalizeRect(clip);
      if ( rect ) return {enabled: true, rect};
    }

    const enabled = clip.enabled ?? null;
    const rect = this.#normalizeRect(clip.rect);
    return {enabled, rect};
  }

  /* -------------------------------------------- */

  /**
   * Normalize blur options.
   * @param {number|{enabled?: boolean, intensity: number, quality?: number}|
   *   {intensity: number, quality?: number}|null|undefined} blur
   * @returns {{enabled: boolean, intensity: number, quality: number|undefined}|null}
   */
  #normalizeBlur(blur) {
    if ( blur == null ) return null;

    if ( typeof blur === "number" ) {
      const intensity = Number(blur) || 0;
      if ( intensity <= 0 ) return null;
      return {enabled: true, intensity, quality: undefined};
    }

    if ( typeof blur === "object" ) {
      if ( blur.enabled === false ) return null;
      const intensity = Number(blur.intensity ?? 0) || 0;
      if ( intensity <= 0 ) return null;
      return {enabled: true, intensity, quality: blur.quality};
    }

    return null;
  }

  /* -------------------------------------------- */
  /*  Shaders                                     */
  /* -------------------------------------------- */

  /**
   * Validate the effective particle shader class.
   * @param {typeof BaseSamplerShader|null|undefined} shaderClass
   * @returns {typeof BaseSamplerShader}
   */
  #validateShaderClass(shaderClass) {
    shaderClass ??= BaseSamplerShader;
    if ( !foundry.utils.isSubclass(shaderClass, BaseSamplerShader) ) {
      throw new Error("ParticleGenerator shaderClass must inherit from BaseSamplerShader.");
    }

    if ( foundry.utils.isSubclass(shaderClass, OccludableSamplerShader)
      || foundry.utils.isSubclass(shaderClass, DepthSamplerShader) ) {
      throw new Error("ParticleGenerator shaderClass must be compatible with SpriteMesh."
        + " Shaders that require occlusion or depth data are not supported.");
    }

    const pluginName = shaderClass.classPluginName;
    if ( pluginName && !BatchRenderer.hasPlugin(pluginName) ) {
      throw new Error(`ParticleGenerator shaderClass ${shaderClass.name} requires the "${pluginName}" batch plugin,`
        + " but it has not been registered.");
    }

    return shaderClass;
  }

  /* -------------------------------------------- */
  /*  Behaviors                                   */
  /* -------------------------------------------- */

  /**
   * Normalize orbit behavior options.
   * @param {ParticleGeneratorOrbitOptions|undefined|null} cfg
   * @returns {ParticleGeneratorOrbitOptions}
   */
  #normalizeOrbit(cfg) {
    const o = cfg ?? {};

    const radius = (o.radius == null) ? null : this.#normalizeRange(o.radius, null);
    const angularSpeed = this.#normalizeRange(o.angularSpeed ?? 120, {min: 120, max: 120}) ?? {min: 120, max: 120};
    const phase = this.#normalizeRange(o.phase ?? [0, 360], {min: 0, max: 360}) ?? {min: 0, max: 360};
    const radialSpeed = this.#normalizeRange(o.radialSpeed ?? 0, {min: 0, max: 0}) ?? {min: 0, max: 0};

    const direction = ([1, -1, "random"].includes(o.direction)) ? o.direction : 1;
    const rotation = (["none", "tangent", "radial"].includes(o.rotation)) ? o.rotation : "none";

    return {radius, angularSpeed, phase, radialSpeed, direction, rotation};
  }

  /* -------------------------------------------- */

  /**
   * Normalize follow behavior options.
   * @param {ParticleGeneratorFollowOptions|undefined|null} cfg
   * @returns {ParticleGeneratorFollowOptions}
   */
  #normalizeFollow(cfg) {
    const f = cfg ?? {};
    const offset = this.#normalizePoint(f.offset);
    const stiffness = Math.clamp(Number(f.stiffness ?? 1) || 0, 0, 1);
    return {offset, stiffness};
  }

  /* -------------------------------------------- */

  /**
   * Normalize a behavior definition.
   * @param {ParticleGeneratorBehaviorId|ParticleGeneratorBehavior|null|undefined} behavior
   * @returns {ParticleGeneratorBehavior|null}
   */
  #normalizeBehavior(behavior) {
    if ( !behavior || (behavior === "default") ) return null;

    if ( typeof behavior === "object" ) return behavior;

    if ( behavior === "orbit" ) {
      return {
        spawn: this.#orbitSpawn.bind(this),
        update: this.#orbitUpdate.bind(this)
      };
    }

    if ( behavior === "follow" ) {
      return {
        spawn: this.#followSpawn.bind(this),
        update: this.#followUpdate.bind(this)
      };
    }

    return null;
  }

  /* -------------------------------------------- */

  /**
   * Orbit behavior spawn hook.
   * @param {ParticleMesh} particle
   * @param {object} ctx
   */
  #orbitSpawn(particle, ctx) {
    const anchor = ctx.anchor?.local;
    if ( !anchor ) return;

    const orbit = this.orbit;

    let angle = 0;
    let radius = 0;

    if ( orbit.radius ) {
      radius = Math.max(0, Math.mix(orbit.radius.min, orbit.radius.max, Math.random()));
      angle = Math.toRadians(Math.mix(orbit.phase.min, orbit.phase.max, Math.random()));
    }
    else {
      const dx = particle.x - anchor.x;
      const dy = particle.y - anchor.y;
      radius = Math.hypot(dx, dy);
      angle = (radius > 0) ? Math.atan2(dy, dx) : (Math.random() * (Math.PI * 2));
    }

    const wDeg = Math.mix(orbit.angularSpeed.min, orbit.angularSpeed.max, Math.random());

    let dir = orbit.direction;
    if ( dir === "random" ) dir = (Math.random() < 0.5) ? -1 : 1;
    if ( (dir !== 1) && (dir !== -1) ) dir = 1;

    const omega = Math.toRadians(wDeg) * dir;
    const radialSpeed = Math.mix(orbit.radialSpeed.min, orbit.radialSpeed.max, Math.random());

    particle._orbitAngle = angle;
    particle._orbitRadius = radius;
    particle._orbitOmega = omega;
    particle._orbitRadialSpeed = radialSpeed;
    particle._orbitRotation = orbit.rotation;

    particle.x = anchor.x + (Math.cos(angle) * radius);
    particle.y = anchor.y + (Math.sin(angle) * radius);

    if ( orbit.rotation === "tangent" ) {
      const s = (omega < 0) ? -1 : 1;
      particle.rotation = angle + (s * (Math.PI * 0.5));
    }
    else if ( orbit.rotation === "radial" ) particle.rotation = angle;
  }

  /* -------------------------------------------- */

  /**
   * Orbit behavior update hook.
   * @param {ParticleMesh} particle
   * @param {number} dt
   * @param {object} ctx
   * @returns {boolean}
   */
  #orbitUpdate(particle, dt, ctx) {
    const anchor = ctx.anchor?.local;
    if ( !anchor ) return false;

    const ds = Number(ctx.ds ?? (dt * 0.001)) || 0;

    let angle = Number(particle._orbitAngle ?? 0) || 0;
    let radius = Math.max(0, Number(particle._orbitRadius ?? 0) || 0);
    const omega = Number(particle._orbitOmega ?? 0) || 0;
    const radialSpeed = Number(particle._orbitRadialSpeed ?? 0) || 0;

    angle += omega * ds;
    radius = Math.max(0, radius + (radialSpeed * ds));

    particle._orbitAngle = angle;
    particle._orbitRadius = radius;

    particle.x = anchor.x + (Math.cos(angle) * radius);
    particle.y = anchor.y + (Math.sin(angle) * radius);

    const rot = particle._orbitRotation;
    if ( rot === "tangent" ) {
      const s = (omega < 0) ? -1 : 1;
      particle.rotation = angle + (s * (Math.PI * 0.5));
    }
    else if ( rot === "radial" ) particle.rotation = angle;

    return true;
  }

  /* -------------------------------------------- */

  /**
   * Follow behavior spawn hook.
   * @param {ParticleMesh} particle
   * @param {object} ctx
   */
  #followSpawn(particle, ctx) {
    const anchor = ctx.anchor?.local;
    if ( !anchor ) return;

    const follow = this.follow;

    let ox = 0;
    let oy = 0;

    if ( follow.offset ) {
      ox = follow.offset.x;
      oy = follow.offset.y;
    }
    else {
      ox = particle.x - anchor.x;
      oy = particle.y - anchor.y;
    }

    particle._followOffsetX = ox;
    particle._followOffsetY = oy;
  }

  /* -------------------------------------------- */

  /**
   * Follow behavior update hook.
   * @param {ParticleMesh} particle
   * @param {number} dt
   * @param {object} ctx
   * @returns {boolean}
   */
  #followUpdate(particle, dt, ctx) {
    const anchor = ctx.anchor?.local;
    if ( !anchor ) return false;

    const ox = Number(particle._followOffsetX ?? 0) || 0;
    const oy = Number(particle._followOffsetY ?? 0) || 0;

    const tx = anchor.x + ox;
    const ty = anchor.y + oy;

    const k = this.follow.stiffness ?? 1;

    if ( k >= 1 ) {
      particle.x = tx;
      particle.y = ty;
    }
    else if ( k > 0 ) {
      particle.x = Math.mix(particle.x, tx, k);
      particle.y = Math.mix(particle.y, ty, k);
    }

    return true;
  }

  /* -------------------------------------------- */

  /**
   * Update the cached anchor points.
   * @param {object} [options]
   * @param {boolean} [options.fallbackToBoundsCenter=false] Use the bounds center when no anchor is configured?
   * @returns {boolean} True if an anchor point was resolved.
   */
  #updateAnchor({fallbackToBoundsCenter=false}={}) {
    const ctxAnchor = this._behaviorContext?.anchor;
    let p = null;
    const anchor = this.anchor;

    if ( anchor ) {
      if ( typeof anchor === "function" ) p = anchor();
      else {
        const mode = this.anchorPoint ?? "center";
        if ( typeof mode === "function" ) p = mode(anchor);
        else if ( mode === "position" ) p = anchor.position ?? anchor;
        else p = anchor.center ?? anchor.position ?? anchor;
      }
    }
    else if ( fallbackToBoundsCenter ) {
      this._anchorScene.set(
        this._bounds.x + (this._bounds.width * 0.5),
        this._bounds.y + (this._bounds.height * 0.5)
      );
      this._anchorLocal.set(this._anchorScene.x - this._bounds.x, this._anchorScene.y - this._bounds.y);
      if ( ctxAnchor ) ctxAnchor.active = true;
      return true;
    }
    else {
      if ( ctxAnchor ) ctxAnchor.active = false;
      return false;
    }

    const pt = this.#normalizePoint(p);
    if ( !pt ) {
      if ( ctxAnchor ) ctxAnchor.active = false;
      return false;
    }

    let x = pt.x;
    let y = pt.y;

    const offset = this.anchorOffset;
    if ( offset ) {
      x += offset.x;
      y += offset.y;
    }

    this._anchorScene.set(x, y);
    this._anchorLocal.set(x - this._bounds.x, y - this._bounds.y);
    if ( ctxAnchor ) ctxAnchor.active = true;
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Compute the spawn area to use for one spawn request.
   * @param {ParticleGeneratorArea|null} area
   * @param {PIXI.Point|{x: number, y: number}|null} position
   * @param {ParticleGeneratorAreaSampleMode} [sampleMode]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computeSpawnAreaLocal(area, position, sampleMode=this.sampleMode) {
    if ( position ) {
      const p = this.#normalizePoint(position);
      if ( !p ) return null;
      return {type: "point", x: p.x - this._bounds.x, y: p.y - this._bounds.y};
    }

    const hasAnchor = this.#updateAnchor();
    const anchorScene = hasAnchor ? this._anchorScene : null;
    const local = this.#computeAreaLocal(area, anchorScene, sampleMode);
    if ( local ) return local;
    if ( this.#isExplicitArea(area) ) return null;

    if ( this.mode === "ambient" ) {
      if ( (this._budgetRectLocal.width <= 0) || (this._budgetRectLocal.height <= 0) ) return null;
      return {type: "ambientRect", rect: this._budgetRectLocal};
    }

    const configured = this.#computeAreaLocal(this.spawnArea, anchorScene, sampleMode);
    if ( configured ) return configured;
    if ( this.#isExplicitArea(this.spawnArea) ) return null;

    if ( hasAnchor ) return {type: "point", x: this._anchorLocal.x, y: this._anchorLocal.y};

    // Default: a point at the center of the generator bounds.
    return {type: "point", x: this._bounds.width * 0.5, y: this._bounds.height * 0.5};
  }

  /* -------------------------------------------- */

  /**
   * Convert a spawn area definition into a local spawn area.
   * Managed object areas are resolved against the anchor before they are converted to shape data.
   * @param {ParticleGeneratorArea|null} area
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @param {ParticleGeneratorAreaSampleMode} [sampleMode]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computeAreaLocal(area, anchorScene=null, sampleMode=this.sampleMode) {
    if ( !area ) return null;
    sampleMode = sampleMode === "boundary" ? "boundary" : "interior";

    if ( area instanceof foundry.data.BaseShapeData ) {
      return this.#shapeDataAreaLocal(area, sampleMode, {checkScene: true});
    }

    if ( area instanceof foundry.data.PolygonTree ) {
      if ( area.isEmpty ) return null;
      return {type: "polygonTree", data: area, sampleMode};
    }

    if ( area instanceof PIXI.Rectangle ) {
      if ( (area.width <= 0) || (area.height <= 0) ) return null;
      const data = this.#createShapeData("rectangle", {
        x: area.x,
        y: area.y,
        width: area.width,
        height: area.height
      });
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    if ( typeof area !== "object" ) return null;

    if ( foundry.utils.isPlainObject(area) && Object.hasOwn(area, "type") ) {
      const data = this.#createShapeData(area.type, area, {parent: canvas.scene});
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    const hasManagedShape = foundry.utils.isPlainObject(area) && (typeof area.shape === "string");
    const managedArea = this.#computeManagedAreaLocal(area, anchorScene, sampleMode);
    if ( managedArea ) return managedArea;
    if ( hasManagedShape && !["point", "rect", "circle", "ring", "line"].includes(area.shape) ) return null;

    // Rectangles map cleanly to RectangleShapeData once the anchor has been applied.
    if ( ("width" in area) && ("height" in area) ) {
      if ( !anchorScene && (!(("x" in area) && ("y" in area))) ) return null;
      const width = Number(area.width) || 0;
      const height = Number(area.height) || 0;
      if ( (width <= 0) || (height <= 0) ) return null;
      const data = this.#createShapeData("rectangle", {
        x: (anchorScene ? anchorScene.x : 0) + (Number(area.x) || 0),
        y: (anchorScene ? anchorScene.y : 0) + (Number(area.y) || 0),
        width,
        height
      });
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    // The managed line shortcut samples a centerline, not the thick LineShapeData contour.
    const a = this.#normalizePoint(area.from ?? area.a);
    const b = this.#normalizePoint(area.to ?? area.b);
    if ( a && b ) {
      const ax = anchorScene ? (anchorScene.x + a.x) : a.x;
      const ay = anchorScene ? (anchorScene.y + a.y) : a.y;
      const bx = anchorScene ? (anchorScene.x + b.x) : b.x;
      const by = anchorScene ? (anchorScene.y + b.y) : b.y;
      return {
        type: "line",
        ax: ax - this._bounds.x,
        ay: ay - this._bounds.y,
        bx: bx - this._bounds.x,
        by: by - this._bounds.y
      };
    }

    let x;
    let y;
    if ( anchorScene ) {
      x = anchorScene.x + (Number(area.x) || 0);
      y = anchorScene.y + (Number(area.y) || 0);
    } else {
      const center = this.#normalizePoint(area);
      if ( !center ) return null;
      ({x, y} = center);
    }

    // Ring shortcut: {innerRadius, outerRadius} or {radius: [inner, outer]}.
    if ( ("innerRadius" in area) || ("outerRadius" in area) || Array.isArray(area.radius) ) {
      let innerRadius;
      let outerRadius;
      if ( Array.isArray(area.radius) ) {
        innerRadius = Number(area.radius[0]) || 0;
        outerRadius = Number(area.radius[1]) || 0;
      }
      else {
        innerRadius = Number(area.innerRadius ?? 0) || 0;
        outerRadius = Number(area.outerRadius ?? 0) || 0;
      }

      innerRadius = Math.max(0, innerRadius);
      outerRadius = Math.max(innerRadius, outerRadius);
      if ( outerRadius <= innerRadius ) {
        if ( outerRadius <= 0 ) return {type: "point", x: x - this._bounds.x, y: y - this._bounds.y};
        const data = this.#createShapeData("circle", {x, y, radius: outerRadius});
        return this.#shapeDataAreaLocal(data, "boundary");
      }

      const radius = (innerRadius + outerRadius) / 2;
      const data = this.#createShapeData("ring", {
        x,
        y,
        radius,
        innerWidth: radius - innerRadius,
        outerWidth: outerRadius - radius
      });
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    if ( "radius" in area ) {
      const radius = Math.max(0, Number(area.radius) || 0);
      if ( radius <= 0 ) return {type: "point", x: x - this._bounds.x, y: y - this._bounds.y};
      const data = this.#createShapeData("circle", {x, y, radius});
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    if ( ("x" in area) && ("y" in area) ) return {type: "point", x: x - this._bounds.x, y: y - this._bounds.y};
    if ( anchorScene ) return {type: "point", x: x - this._bounds.x, y: y - this._bounds.y};
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Convert a ParticleGenerator-managed spawn shape definition into a local spawn area.
   * @param {object} area
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @param {ParticleGeneratorAreaSampleMode} [sampleMode]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computeManagedAreaLocal(area, anchorScene=null, sampleMode=this.sampleMode) {
    const shape = typeof area.shape === "string" ? area.shape : "";

    if ( (shape === "points") || (!shape && Array.isArray(area.points)) ) {
      const points = area.points;
      if ( !Array.isArray(points) ) return null;
      const localPoints = [];
      for ( const point of points ) {
        const p = this.#normalizeAreaPointLocal(point, anchorScene);
        if ( p ) localPoints.push(p);
      }
      return localPoints.length ? {type: "points", points: localPoints} : null;
    }

    if ( (shape === "path") || (shape === "polyline") || (!shape && Array.isArray(area.path)) ) {
      return this.#computePolylineAreaLocal(area.path ?? area.points, anchorScene);
    }

    if ( (shape === "ellipse") || (!shape && (("radiusX" in area) || ("radiusY" in area))) ) {
      return this.#computeEllipseAreaLocal(area, anchorScene, sampleMode);
    }

    return null;
  }

  /* -------------------------------------------- */

  /**
   * Convert a polyline definition into a local area.
   * @param {ParticleGeneratorPolylineDefinition} path
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computePolylineAreaLocal(path, anchorScene=null) {
    if ( !Array.isArray(path) || !path.length ) return null;

    const segments = [];
    const cumulativeLengths = [];
    let fallbackPoint = null;
    let totalLength = 0;

    const addPath = points => {
      let last = null;
      for ( const point of points ) {
        const p = this.#normalizeAreaPointLocal(point, anchorScene);
        if ( !p ) continue;

        fallbackPoint ??= {x: p.x, y: p.y};
        if ( last ) {
          const dx = p.x - last.x;
          const dy = p.y - last.y;
          const length = Math.hypot(dx, dy);
          if ( length > 0 ) {
            totalLength += length;
            segments.push({ax: last.x, ay: last.y, bx: p.x, by: p.y, length});
            cumulativeLengths.push(totalLength);
          }
        }

        last = p;
      }
    };

    if ( this.#normalizePoint(path[0]) ) addPath(path);
    else {
      for ( const points of path ) {
        if ( Array.isArray(points) ) addPath(points);
      }
    }

    if ( !segments.length && !fallbackPoint ) return null;
    return {type: "polyline", segments, cumulativeLengths, totalLength, fallbackPoint};
  }

  /* -------------------------------------------- */

  /**
   * Normalize a point-like value into generator local coordinates.
   * @param {any} value
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @returns {{x: number, y: number}|null}
   */
  #normalizeAreaPointLocal(value, anchorScene=null) {
    const point = this.#normalizePoint(value);
    if ( !point ) return null;
    const x = anchorScene ? (anchorScene.x + point.x) : point.x;
    const y = anchorScene ? (anchorScene.y + point.y) : point.y;
    return {x: x - this._bounds.x, y: y - this._bounds.y};
  }

  /* -------------------------------------------- */

  /**
   * Convert a ParticleGenerator-managed ellipse definition into a local area.
   * Simple ellipses are delegated to BaseShapeData. Extended ellipse options are sampled by ParticleGenerator.
   * @param {object} area
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @param {ParticleGeneratorAreaSampleMode} [sampleMode]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computeEllipseAreaLocal(area, anchorScene=null, sampleMode=this.sampleMode) {
    const centerScene = anchorScene ? {
      x: anchorScene.x + (Number(area.x) || 0),
      y: anchorScene.y + (Number(area.y) || 0)
    } : this.#normalizePoint(area);
    if ( !centerScene ) return null;

    const radiusX = Math.max(0, Number(area.radiusX) || 0);
    const radiusY = Math.max(0, Number(area.radiusY) || 0);
    if ( (radiusX <= 0) || (radiusY <= 0) ) return null;

    const rawMinAngle = Number(area.minAngle ?? 0);
    const rawMaxAngle = Number(area.maxAngle ?? 360);
    const minAngle = Number.isFinite(rawMinAngle) ? rawMinAngle : 0;
    const maxAngle = Number.isFinite(rawMaxAngle) ? rawMaxAngle : 360;
    const shapeRotation = Math.toRadians(Number(area.shapeRotation ?? area.rotation ?? 0) || 0);
    const holeScale = Math.clamp(Number(area.holeScale ?? 0) || 0, 0, 1);

    if ( (holeScale === 0) && (minAngle === 0) && (maxAngle === 360) && !area.affectRotation ) {
      const data = this.#createShapeData("ellipse", {
        x: centerScene.x,
        y: centerScene.y,
        radiusX,
        radiusY,
        rotation: Math.toDegrees(shapeRotation)
      });
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    return {
      type: "ellipse",
      x: centerScene.x - this._bounds.x,
      y: centerScene.y - this._bounds.y,
      radiusX,
      radiusY,
      minAngle: Math.toRadians(minAngle),
      maxAngle: Math.toRadians(maxAngle),
      shapeRotation,
      affectRotation: !!area.affectRotation,
      cosRotation: Math.cos(shapeRotation),
      sinRotation: Math.sin(shapeRotation),
      holeAreaScale: holeScale * holeScale
    };
  }

  /* -------------------------------------------- */

  /**
   * Is an area an explicit absolute shape/rectangle input?
   * @param {any} area
   * @returns {boolean}
   */
  #isExplicitArea(area) {
    if ( !area ) return false;
    return (area instanceof PIXI.Rectangle) || (area instanceof foundry.data.PolygonTree)
      || (area instanceof foundry.data.BaseShapeData) || (foundry.utils.isPlainObject(area)
        && (Object.hasOwn(area, "type") || Object.hasOwn(area, "shape")));
  }

  /* -------------------------------------------- */

  /**
   * Create a shape data instance for a ParticleGenerator area.
   * @param {string} type
   * @param {object} source
   * @param {object} [options]
   * @param {foundry.abstract.DataModel} [options.parent]
   * @returns {foundry.data.BaseShapeData}
   */
  #createShapeData(type, source, {parent}={}) {
    const ShapeData = foundry.data.BaseShapeData.TYPES[type];
    if ( !ShapeData ) throw new Error(`Invalid ParticleGenerator shape data type "${type}".`);
    const data = Object.hasOwn(source, "type") ? source : {...source, type};
    return new ShapeData(data, parent ? {parent} : {});
  }

  /* -------------------------------------------- */

  /**
   * Create a local area from shape data.
   * @param {foundry.data.BaseShapeData} data
   * @param {ParticleGeneratorAreaSampleMode} sampleMode
   * @param {object} [options]
   * @param {boolean} [options.checkScene=false]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #shapeDataAreaLocal(data, sampleMode, {checkScene=false}={}) {
    if ( data.isEmpty ) return null;
    if ( checkScene && data.scene && (data.scene !== canvas.scene) ) return null;
    return {type: "shape", data, sampleMode};
  }

  /* -------------------------------------------- */

  /**
   * Normalize a point-like value.
   * @param {any} value
   * @returns {{x: number, y: number}|null}
   */
  #normalizePoint(value) {
    if ( !value ) return null;
    if ( value instanceof PIXI.Point ) return {x: value.x, y: value.y};
    if ( (typeof value === "object") && ("x" in value) && ("y" in value) ) {
      return {x: Number(value.x) || 0, y: Number(value.y) || 0};
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Get the active constraint rectangle in local coordinates.
   * @returns {PIXI.Rectangle|null}
   */
  #getConstraintRectLocal() {
    const area = this.constraints.area;

    if ( area === "budget" ) return this._budgetRectLocal;
    if ( area === "view" ) return this._viewRectLocal;
    if ( area === "world" ) return this._worldRectLocal;
    if ( this._constraintRect ) return this._constraintRect;
    return null;
  }

  /* -------------------------------------------- */
  /*  Default Bounds                              */
  /* -------------------------------------------- */

  /**
   * Get default bounds from the current scene dimensions.
   * @returns {PIXI.Rectangle}
   * @protected
   */
  _getDefaultBounds() {
    const d = canvas.scene.dimensions;
    return new PIXI.Rectangle(d.sceneX, d.sceneY, d.sceneWidth, d.sceneHeight);
  }

  /* -------------------------------------------- */
  /*  Debug Helpers                               */
  /* -------------------------------------------- */

  /**
   * Normalize a debug tint palette.
   * @param {any} palette
   * @returns {number[]}
   */
  #normalizeDebugPalette(palette) {
    if ( !Array.isArray(palette) ) return [];
    const out = [];
    for ( const c of palette ) {
      const n = Number(c);
      if ( !Number.isFinite(n) ) continue;
      out.push(Math.clamp(Math.floor(n), 0, 0xFFFFFF));
    }
    return out;
  }

  /* -------------------------------------------- */

  /**
   * Create a debug stats object.
   * @returns {ParticleGeneratorDebugStats}
   */
  #createDebugStats() {
    return {
      active: 0,
      pool: 0,
      target: 0,
      spawnAttempts: 0,
      spawned: 0,
      spawnRejectedProbability: 0,
      spawnRejectedPositionTest: 0,
      spawnRejectedNoArea: 0,
      recycledLifetime: 0,
      recycledConstraint: 0,
      recycledStop: 0,
      newlyVisibleAreaCount: 0,
      updateMS: 0,
      spawnMS: 0,
      tickMS: 0
    };
  }

  /* -------------------------------------------- */

  /**
   * Build a lookup table for deterministic debug tinting.
   * @param {number[]} palette
   * @returns {WeakMap<PIXI.Texture, number>}
   */
  #buildDebugTintByTextureMap(palette) {
    const textures = this.textures;
    const usePalette = Array.isArray(palette) && (palette.length > 0);
    const map = new WeakMap();

    for ( let i = 0, n = textures.length; i < n; i++ ) {
      const t = textures[i];
      const tint = usePalette ? palette[i % palette.length] : ParticleGenerator.#debugColorForIndex(i);
      map.set(t, tint);
    }

    return map;
  }

  /* -------------------------------------------- */

  /**
   * Apply debug tinting to a particle.
   * @param {ParticleMesh} particle
   * @param {PIXI.Texture} texture
   */
  #applyDebugTint(particle, texture) {
    const cfg = this._debugTint;
    if ( !cfg ) return;

    const mode = cfg.mode;
    const palette = cfg.palette;

    let tint = 0xFFFFFF;

    if ( mode === "random" ) tint = (Math.random() * 0xFFFFFF) | 0;
    else if ( mode === "palette" ) {
      tint = palette.length
        ? palette[(Math.random() * palette.length) | 0]
        : ((Math.random() * 0xFFFFFF) | 0);
    }
    else if ( mode === "byTexture" ) {
      tint = this._debugTintByTexture?.get(texture) ?? 0xFFFFFF;
    }

    particle.tint = tint;
  }

  /**
   * Compute the average particle lifetime from a raw lifetime config value.
   * Used at construction time for steady-state budget validation.
   * @param {number|number[]|{min: number, max: number}} v
   * @returns {number}
   */
  static #computeAvgLifetime(v) {
    if ( typeof v === "number" ) return Math.max(0, v);
    if ( Array.isArray(v) && (v.length >= 2) ) return Math.max(0, ((Number(v[0]) || 0) + (Number(v[1]) || 0)) / 2);
    const min = Number(v?.min ?? 0) || 0;
    const max = Number(v?.max ?? min) || min;
    return Math.max(0, (min + max) / 2);
  }

  /* -------------------------------------------- */

  /**
   * Generate a deterministic, reasonably bright debug color for an index
   * @param {number} i
   * @returns {number}
   */
  static #debugColorForIndex(i) {
    // Use a multiplicative hash and bias toward brighter values
    const x = Math.imul((i + 1), 0x9E3779B1) >>> 0;
    return (((x >>> 8) & 0xFFFFFF) | 0x404040) >>> 0;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get maxParticlesPerFrame() {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#maxParticlesPerFrame is deprecated."
      + " Use ParticleGenerator#spawnRate instead.", {since: 14, until: 16, once: true});
    return this.#maxParticlesPerFrame;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  set maxParticlesPerFrame(value) {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#maxParticlesPerFrame is deprecated."
      + " Use ParticleGenerator#spawnRate instead.", {since: 14, until: 16, once: true});
    const targetFPS = PIXI.Ticker.targetFPMS * 1000;
    const maxParticlesPerFrame = Math.max(0, Number(value) || 0);
    this.#maxParticlesPerFrame = maxParticlesPerFrame;
    this.#spawnRate = maxParticlesPerFrame * targetFPS;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  get alphaRange() {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#alphaRange is deprecated."
      + " Use ParticleGeneratorConfiguration#alpha instead.", {since: 14, until: 16, once: true});
    return this.#alphaValue?.range ?? this.#alphaRange;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  set alphaRange(value) {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#alphaRange is deprecated."
      + " Use ParticleGeneratorConfiguration#alpha instead.", {since: 14, until: 16, once: true});
    const range = this.#normalizeValueRange(value, {min: 1, max: 1}) ?? {min: 1, max: 1};
    this.#alphaRange = range;
    if ( this.#alphaValue ) this.#alphaValue.range = range;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  get scaleRange() {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#scaleRange is deprecated."
      + " Use ParticleGeneratorConfiguration#scale instead.", {since: 14, until: 16, once: true});
    return this.#scaleValue?.range ?? this.#scaleRange;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  set scaleRange(value) {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#scaleRange is deprecated."
      + " Use ParticleGeneratorConfiguration#scale instead.", {since: 14, until: 16, once: true});
    const range = this.#normalizeValueRange(value, {min: 1, max: 1}) ?? {min: 1, max: 1};
    this.#scaleRange = range;
    if ( this.#scaleValue ) this.#scaleValue.range = range;
  }
}
