import { Box3, BufferGeometry, Frustum, Points, PointsMaterial } from 'three';
import { assert } from "../../../../../../core/assert.js";
import { BvhClient } from "../../../../../../core/bvh2/bvh3/BvhClient.js";
import { combine_hash } from "../../../../../../core/collection/array/combine_hash.js";
import List from "../../../../../../core/collection/list/List.js";
import { SCRATCH_UINT32_TRAVERSAL_STACK } from "../../../../../../core/collection/SCRATCH_UINT32_TRAVERSAL_STACK.js";
import { AABB3 } from "../../../../../../core/geom/3d/aabb/AABB3.js";
import { aabb3_array_combine } from "../../../../../../core/geom/3d/aabb/aabb3_array_combine.js";
import { aabb3_expand_array } from "../../../../../../core/geom/3d/aabb/aabb3_expand_array.js";
import { compose_matrix4_array } from "../../../../../../core/geom/3d/mat4/compose_matrix4_array.js";
import Quaternion from "../../../../../../core/geom/Quaternion.js";
import { v3_dot_array_array } from "../../../../../../core/geom/vec3/v3_dot_array_array.js";
import Vector3 from "../../../../../../core/geom/Vector3.js";
import { lerp } from "../../../../../../core/math/lerp.js";
import { max2 } from "../../../../../../core/math/max2.js";
import { min2 } from "../../../../../../core/math/min2.js";
import { frustum_from_camera } from "../../../../ecs/camera/frustum_from_camera.js";
import { BlendingType } from "../../../../texture/sampler/BlendingType.js";
import { ParameterSet } from "../parameter/ParameterSet.js";
import { ParticleParameter } from "../parameter/ParticleParameter.js";
import { update_parameters } from "../simulator/update_parameters.js";
import { computeEmissionFunction } from "./computeEmissionFunction.js";
import {
    PARTICLE_ATTRIBUTE_AGE,
    PARTICLE_ATTRIBUTE_BLEND,
    PARTICLE_ATTRIBUTE_DEATH_AGE,
    PARTICLE_ATTRIBUTE_LAYER_POSITION,
    PARTICLE_ATTRIBUTE_POSITION,
    PARTICLE_ATTRIBUTE_ROTATION,
    PARTICLE_ATTRIBUTE_ROTATION_SPEED,
    PARTICLE_ATTRIBUTE_SIZE,
    PARTICLE_ATTRIBUTE_SIZE_INITIAL,
    PARTICLE_ATTRIBUTE_UV,
    PARTICLE_ATTRIBUTE_VELOCITY
} from "./PARTICLE_ATTRIBUTES.js";
import { ParticleEmitterFlag } from "./ParticleEmitterFlag.js";
import { ParticleLayer } from "./ParticleLayer.js";
import { ParticleParameters } from "./ParticleParameters.js";
import { ParticlePool } from "./ParticlePool.js";
import { PARTICULAR_PARTICLE_SPECIFICATION } from "./PARTICULAR_PARTICLE_SPECIFICATION.js";
import { write_particle_patch_uv } from "./write_particle_patch_uv.js";

const EMPTY_GEOMETRY = new BufferGeometry();


const SERIALIZABLE_FLAGS = ParticleEmitterFlag.PreWarm
    | ParticleEmitterFlag.DepthSorting
    | ParticleEmitterFlag.DepthReadDisabled
    | ParticleEmitterFlag.DepthSoftDisabled
    | ParticleEmitterFlag.AlignOnVelocity
;

/**
 * Used to store AABB3 corners during bounding box calculation
 * @readonly
 * @type {Float64Array}
 */
const corners = new Float64Array(24);

const scratch_near_plane_normal = new Float32Array(3);

/**
 *
 * @type {Float32Array}
 */
const scratch_aabb3 = new Float32Array(6);

/**
 *
 * @type {number[]}
 */
const scratch_v3_array = [];

/**
 *
 * @param {ParameterSet} parameters
 */
function generateStandardParameterSet(parameters) {
    const pScale = new ParticleParameter(ParticleParameters.Scale, 1);
    pScale.setDefault([1], [0]);

    const pColor = new ParticleParameter(ParticleParameters.Color, 4);
    pColor.setDefault([1, 1, 1, 1], [0]);

    parameters.add(pScale);
    parameters.add(pColor);
}


let id_counter = 0;

export class ParticleEmitter {
    /**
     * @type {number}
     */
    id = id_counter++;

    /**
     * @private
     * @readonly
     * @type {List<ParticleLayer>}
     */
    layers = new List();

    /**
     *
     * @type {Vector3}
     */
    position = new Vector3(0, 0, 0);
    scale = new Vector3(1, 1, 1);
    rotation = new Quaternion(0, 0, 0, 1);

    /**
     *
     * @type {Vector3}
     * @readonly
     * @private
     */
    __lastSpawnPosition = new Vector3(0, 0, 0);

    /**
     * How long has this emitter been sleeping for?
     * This amount of time needs to be simulated when we wake the emitter up, but we can typically do it in a small number of large steps
     * @type {number}
     */
    sleepTime = 0;

    /**
     *
     * @type {BlendingType|number}
     */
    blendingMode = BlendingType.Normal;

    /**
     *
     * @type {ParticlePool|null}
     */
    particles = null;

    /**
     *
     * @type {Object3D|null}
     */
    mesh = null;

    /**
     * @readonly
     * @type {BvhClient}
     */
    bvh_leaf = new BvhClient();


    /**
     * Upper-bound of bounds for an individual particle
     * @readonly
     * @type {AABB3}
     */
    particleBounds = new AABB3(Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity);

    /**
     * @readonly
     * @type {AABB3}
     */
    emissionBounds = new AABB3(0, 0, 0, 0, 0, 0);


    /**
     * Bit Field of {@link ParticleEmitterFlag}
     * @type {number}
     */
    flags = ParticleEmitterFlag.DepthSorting | ParticleEmitterFlag.HashNeedUpdate | ParticleEmitterFlag.Emitting;

    /**
     *
     * @type {number}
     * @private
     */
    __hash = 0;


    /**
     * @readonly
     * @type {ParameterSet}
     */
    parameters = new ParameterSet();

    constructor() {
        generateStandardParameterSet(this.parameters);


        this.position.onChanged.add(this.updateTransform, this);
        this.scale.onChanged.add(this.updateTransform, this);
        this.rotation.onChanged.add(this.updateTransform, this);
    }

    updateTransform() {
        this.setFlag(ParticleEmitterFlag.BaseBoundsNeedUpdate);

        this.computeBoundingBox();
    }

    /**
     *
     * @param {number|ParticleEmitterFlag} flag
     * @returns {void}
     */
    setFlag(flag) {
        this.flags |= flag;
    }

    /**
     *
     * @param {number|ParticleEmitterFlag} flag
     * @returns {void}
     */
    clearFlag(flag) {
        this.flags &= ~flag;
    }

    /**
     *
     * @param {number|ParticleEmitterFlag} flag
     * @param {boolean} value
     */
    writeFlag(flag, value) {
        if (value) {
            this.setFlag(flag);
        } else {
            this.clearFlag(flag);
        }
    }

    /**
     *
     * @param {number|ParticleEmitterFlag} flag
     * @returns {boolean}
     */
    getFlag(flag) {
        return (this.flags & flag) === flag;
    }

    /**
     * @private
     */
    registerLayerParameters() {
        const layers = this.layers;
        const layer_count = layers.length;

        const parameters = this.parameters;

        parameters.setTrackCount(layer_count);

        for (let i = 0; i < layer_count; i++) {
            const layer = layers.get(i);

            parameters.setTracks(i, layer.parameterTracks);
        }
    }

    toJSON() {
        return {
            position: this.position.toJSON(),
            scale: this.scale.toJSON(),
            rotation: this.rotation.toJSON(),
            parameters: this.parameters.toJSON(),
            preWarm: this.getFlag(ParticleEmitterFlag.PreWarm),
            readDepth: !this.getFlag(ParticleEmitterFlag.DepthReadDisabled),
            softDepth: !this.getFlag(ParticleEmitterFlag.DepthSoftDisabled),
            velocityAlign: this.getFlag(ParticleEmitterFlag.AlignOnVelocity),
            depthSort: this.getFlag(ParticleEmitterFlag.DepthSorting),
            receiveLight: this.getFlag(ParticleEmitterFlag.Lit),
            blendingMode: this.blendingMode,
            layers: this.layers.toJSON()
        };
    }

    fromJSON(json) {
        if (typeof json.blendingMode === "number") {
            this.blendingMode = json.blendingMode;
        } else {
            this.blendingMode = BlendingType.Normal;
        }

        if (json.position !== undefined) {
            this.position.fromJSON(json.position);
        }

        if (json.scale !== undefined) {
            this.scale.fromJSON(json.scale);
        }

        if (json.rotation !== undefined) {
            this.rotation.fromJSON(json.rotation);
        }

        if (json.parameters !== undefined) {
            this.parameters.fromJSON(json.parameters);
        }

        if (typeof json.preWarm === "boolean") {
            this.writeFlag(ParticleEmitterFlag.PreWarm, json.preWarm);
        } else {
            this.writeFlag(ParticleEmitterFlag.PreWarm, false);
        }

        if (typeof json.readDepth === 'boolean') {
            this.writeFlag(ParticleEmitterFlag.DepthReadDisabled, !json.readDepth);
        } else {
            this.writeFlag(ParticleEmitterFlag.DepthReadDisabled, false);
        }

        if (typeof json.softDepth === 'boolean') {
            this.writeFlag(ParticleEmitterFlag.DepthSoftDisabled, !json.softDepth);
        } else {
            this.writeFlag(ParticleEmitterFlag.DepthSoftDisabled, false);
        }

        if (typeof json.velocityAlign === "boolean") {
            this.writeFlag(ParticleEmitterFlag.AlignOnVelocity, json.velocityAlign);
        } else {
            this.writeFlag(ParticleEmitterFlag.AlignOnVelocity, false);
        }

        if (typeof json.depthSort === "boolean") {
            this.writeFlag(ParticleEmitterFlag.DepthSorting, json.depthSort);
        } else {
            this.writeFlag(ParticleEmitterFlag.DepthSorting, true);
        }

        if (typeof json.receiveLight === "boolean") {
            this.writeFlag(ParticleEmitterFlag.Lit, json.receiveLight);
        } else {
            this.clearFlag(ParticleEmitterFlag.Lit);
        }

        this.layers.fromJSON(json.layers, ParticleLayer);

        this.writeFlag(ParticleEmitterFlag.Built, false);
        this.setFlag(ParticleEmitterFlag.Emitting);

        //register loaded layers
        this.registerLayerParameters();
    }


    /**
     *
     * @param {ParticleLayer} layer
     */
    addLayer(layer) {
        this.layers.add(layer);

        const numLayers = this.layers.length;
        this.parameters.setTrackCount(numLayers);

        this.parameters.setTracks(numLayers - 1, layer.parameterTracks);
    }

    /**
     *
     * @param {function(layer:ParticleLayer,index:number)} visitor
     * @param {*} [thisArg]
     */
    traverseLayers(visitor, thisArg) {
        const layers = this.layers;

        const l = layers.length;

        for (let i = 0; i < l; i++) {
            const layer = layers.get(i);

            visitor.call(thisArg, layer, i);
        }
    }

    /**
     * Causes all active particles from a given layer to be destroyed immediately
     * @param {ParticleLayer} layer
     * @return {number} number of particles destroyed
     */
    destroyParticlesFromLayer(layer) {
        const layers = this.layers;

        const targetLayerIndex = layers.indexOf(layer);

        if (targetLayerIndex === -1) {
            //layer doesn't exist
            throw new Error('Layer not found');
        }

        const particles = this.particles;
        const occupancy = particles.occupancy;

        let removeCount = 0;

        for (
            let i = occupancy.nextSetBit(0);
            i !== -1;
            i = occupancy.nextSetBit(i + 1)
        ) {

            const layerPosition = particles.readAttributeScalar(i, PARTICLE_ATTRIBUTE_LAYER_POSITION);

            if (layerPosition !== targetLayerIndex) {
                // not the layer we want
                continue;
            }

            //destroy the particle
            occupancy.set(i, false);

            removeCount++;

        }

        if (removeCount > 0) {
            //remove dead particles from the pool
            particles.compact();
        }

        return removeCount;
    }

    computeEmissionBounds() {

        let bounds_x0 = Infinity;
        let bounds_y0 = Infinity;
        let bounds_z0 = Infinity;

        let bounds_x1 = -Infinity;
        let bounds_y1 = -Infinity;
        let bounds_z1 = -Infinity;

        const layers = this.layers;
        const numLayers = layers.length;

        const position = this.position;
        const rotation = this.rotation;
        const scale = this.scale;

        compose_matrix4_array(matrix4, position, rotation, scale);

        for (let i = 0; i < numLayers; i++) {
            const particleLayer = layers.get(i);

            particleLayer.computeBoundsAttributes();

            const s_2 = particleLayer.scaledSpriteHalfSize;

            const baseBoundingBox = particleLayer.baseBoundingBox;
            baseBoundingBox.getCorners(corners);

            for (let j = 0; j < 24; j += 3) {

                const x = corners[j];
                const y = corners[j + 1];
                const z = corners[j + 2];

                //apply matrix transform
                const _x = matrix4[0] * x + matrix4[4] * y + matrix4[8] * z + matrix4[12];
                const _y = matrix4[1] * x + matrix4[5] * y + matrix4[9] * z + matrix4[13];
                const _z = matrix4[2] * x + matrix4[6] * y + matrix4[10] * z + matrix4[14];


                //update bounds
                bounds_x0 = min2(bounds_x0, _x - s_2);
                bounds_y0 = min2(bounds_y0, _y - s_2);
                bounds_z0 = min2(bounds_z0, _z - s_2);

                bounds_x1 = max2(bounds_x1, _x + s_2);
                bounds_y1 = max2(bounds_y1, _y + s_2);
                bounds_z1 = max2(bounds_z1, _z + s_2);
            }
        }

        this.emissionBounds.setBounds(
            bounds_x0, bounds_y0, bounds_z0,
            bounds_x1, bounds_y1, bounds_z1
        );

        this.clearFlag(ParticleEmitterFlag.BaseBoundsNeedUpdate);
    }

    computeParticleBounds() {
        //retire dead particles
        const particles = this.particles;

        particles.computeAttributeVector3AxisAlignedBoundingBox(PARTICLE_ATTRIBUTE_POSITION, scratch_aabb3);

        //expand bounds by maximum sprite size
        const extents = this.computeSpriteMaxHalfSize();

        aabb3_expand_array(scratch_aabb3, scratch_aabb3, extents);

        //write updated bonds
        const bb = this.particleBounds;

        bb.readFromArray(scratch_aabb3);

        this.updateGeometryBounds();

        this.clearFlag(ParticleEmitterFlag.ParticleBoundsNeedUpdate);
    }

    computeBoundingBox() {

        if (this.getFlag(ParticleEmitterFlag.BaseBoundsNeedUpdate)) {
            //emission bounds need update
            this.computeEmissionBounds();
        }

        if (this.getFlag(ParticleEmitterFlag.ParticleBoundsNeedUpdate)) {
            //particle bounds need update
            this.computeParticleBounds();
        }

        const ebb = this.emissionBounds;
        const pbb = this.particleBounds;

        aabb3_array_combine(this.bvh_leaf.bounds, 0, ebb, 0, pbb, 0);

        this.bvh_leaf.write_bounds();

    }

    updateGeometryBounds() {

        const particles = this.particles;

        if (particles === null) {
            return;
        }

        const geometry = particles.geometry.getValue();

        if (geometry !== null) {
            const bvh = this.particleBounds;

            const bb = geometry.boundingBox;

            bb.min.set(bvh.x0, bvh.y0, bvh.z0);
            bb.max.set(bvh.x1, bvh.y1, bvh.z1);
        }
    }

    /**
     * @param {number} limit longest maximum life per layer, layers that exceed this value are ignored. Measured in seconds
     * @returns {number} in seconds
     */
    computeMaxEmittingParticleLife(limit = 60) {
        const layers = this.layers;

        const numLayers = layers.length;

        let i;
        let result = 0;

        for (i = 0; i < numLayers; i++) {
            const layer = layers.get(i);

            if (layer.emissionRate <= 0 && layer.emissionImmediate <= 0) {
                //skip layers with no emission
                continue;
            }

            const maxLifeTime = layer.particleLife.max;

            if (maxLifeTime > limit) {
                //ignore layer, it's above limit
                continue;
            }

            if (maxLifeTime > result) {
                result = maxLifeTime;
            }
        }

        return result;
    }

    build() {

        this.particles = new ParticlePool(PARTICULAR_PARTICLE_SPECIFICATION);

        //make overallocation a bit more aggressive to prevent frequent resizing
        this.particles.shrinkFactor = 0.5;

        this.particles.build();

        const points = new Points(EMPTY_GEOMETRY, DEFAULT_POINT_MATERIAL);
        points.frustumCulled = false;
        points.matrixAutoUpdate = false;

        //back link
        points.__meep_ecs_component = this;

        this.mesh = points;

        //subscribe to geometry changes
        this.particles.geometry.process((geometry) => {
            points.geometry = geometry;

            //set bounding box
            geometry.boundingBox = new Box3();

            this.updateGeometryBounds();
        });

        //build parameters
        this.parameters.build();

        //mark bounds for update
        this.writeFlag(ParticleEmitterFlag.BaseBoundsNeedUpdate, true);

        //update bounding box
        this.computeBoundingBox();

        // mark as built
        this.setFlag(ParticleEmitterFlag.Built | ParticleEmitterFlag.HashNeedUpdate);
    }

    initialize() {
        //set buffered position to current position
        this.__lastSpawnPosition.copy(this.position);

        //emit immediate
        let i;
        const numLayers = this.layers.length;

        if (this.getFlag(ParticleEmitterFlag.PreWarm)) {
            //pre-warm the emitter
            for (i = 0; i < numLayers; i++) {
                const layer = this.layers.get(i);
                if (layer.emissionRate > 0 && layer.isEmitting) {
                    const averageLifetime = (layer.particleLife.max + layer.particleLife.min) / 2;
                    this.spawnLayerParticlesContinuous(i, averageLifetime);
                }
            }
        }

        for (i = 0; i < numLayers; i++) {
            //immediate emission
            const layer = this.layers.get(i);
            if (layer.emissionImmediate > 0 && layer.isEmitting) {
                this.spawnLayerParticlesImmediate(i, layer.emissionImmediate);
            }
        }

        // Mark as initialized
        this.setFlag(ParticleEmitterFlag.Initialized);
    }

    /**
     * @returns {number}
     */
    #computeHash() {
        const parameters_hash = this.parameters.hash();
        const layers_hash = this.layers.hash();

        /**
         * Extract relevant flags
         * @type {number}
         */
        const flags = this.flags & (SERIALIZABLE_FLAGS);

        return combine_hash(
            parameters_hash,
            layers_hash,
            this.blendingMode,
            flags
        );
    }

    hash() {
        if (this.getFlag(ParticleEmitterFlag.HashNeedUpdate)) {
            this.__hash = this.#computeHash();
            this.clearFlag(ParticleEmitterFlag.HashNeedUpdate);
        }

        return this.__hash;
    }

    /**
     *
     * @param {ParticleEmitter} other
     * @returns {boolean}
     */
    equals(other) {
        return this.blendingMode === other.blendingMode
            && this.parameters.equals(other.parameters)
            && this.layers.equals(other.layers)
            && this.getFlag(ParticleEmitterFlag.DepthSorting) === other.getFlag(ParticleEmitterFlag.DepthSorting)
            && this.getFlag(ParticleEmitterFlag.PreWarm) === other.getFlag(ParticleEmitterFlag.PreWarm);
    }

    /**
     * PRECONDITION: no dead particles may exist in the pool. Make sure to call {@link ParticlePool#compact} before sorting
     * @protected
     * @param {Camera} camera THREE.js camera object
     */
    sort(camera) {
        assert.defined(camera, 'camera');
        assert.notNull(camera, 'camera');

        //sort particles by distance from camera to ensure proper rendering order
        // NOTE we don't use full distance calculation, but DOT product instead, as the final component is always the same and we only care about relative distance for comparison

        /**
         *
         * @type {ParticlePool}
         */
        const particles = this.particles;

        const particleCount = particles.size();

        //test pre-condition: NO DEAD PARTICLES IN THE POOL
        //assert.notOk(this.particles.hasHoles(), 'Broken pre-condition: particle pool must not have holes. Make sure to call "compact" before sorting');

        //get font plane of camera
        frustum_from_camera(camera, scratch_frustum);
        const nearPlane = scratch_frustum.planes[0];
        const nearPlaneNormal = nearPlane.normal;
        nearPlaneNormal.toArray(scratch_near_plane_normal);

        //Bind attribute array directly for faster access
        const positionAttribute = particles.attributes[PARTICLE_ATTRIBUTE_POSITION];
        const position_array = positionAttribute.array;


        if (particleCount <= 1) {
            //nothing to sort
            return;
        }

        //Stack-based implementation, avoiding recursion for performance improvement
        const stack = SCRATCH_UINT32_TRAVERSAL_STACK;

        const stack_top = stack.pointer;
        let stackPointer = stack_top;

        stack[stackPointer++] = 0;
        stack[stackPointer++] = particleCount - 1;

        let i, j;

        while (stackPointer > stack_top) {
            stackPointer -= 2;

            const right = stack[stackPointer + 1];
            const left = stack[stackPointer];

            i = left;
            j = right;

            const pivot_index = (left + right) >> 1;

            const pivot = v3_dot_array_array(position_array, pivot_index * 3, scratch_near_plane_normal, 0);

            /* partition */
            while (i <= j) {

                while (v3_dot_array_array(position_array, i * 3, scratch_near_plane_normal, 0) > pivot) {
                    i++;
                }

                while (v3_dot_array_array(position_array, j * 3, scratch_near_plane_normal, 0) < pivot) {
                    j--;
                }

                if (i <= j) {

                    if (i !== j) {
                        //do swap
                        particles.swap(i, j);
                    }

                    i++;
                    j--;
                }
            }

            /* recursion */
            if (left < j) {
                stack[stackPointer++] = left;
                stack[stackPointer++] = j;
            }
            if (i < right) {
                stack[stackPointer++] = i;
                stack[stackPointer++] = right;
            }
        }
    }

    update() {
        this.particles.update();

        //check if sprites need updating
        if (this.getFlag(ParticleEmitterFlag.SpritesNeedUpdate)) {
            this.#updateSprites();
        }
    }

    /**
     *
     * @return {number}
     */
    computeSpriteMaxHalfSize() {
        const layers = this.layers;
        const n = layers.length;

        let result = 0;

        for (let i = 0; i < n; i++) {
            const particleLayer = layers.get(i);

            result = max2(result, particleLayer.computeScaledSpriteHalfSize());
        }

        return result;
    }

    /**
     *
     * @param {number} timeDelta
     */
    advance(timeDelta) {
        this.emit(timeDelta);

        this.setFlag(ParticleEmitterFlag.ParticleBoundsNeedUpdate);
    }

    /**
     * Write sprite UVs
     */
    #updateSprites() {
        const particles = this.particles;

        const occupancy = particles.occupancy;

        const layers = this.layers;

        let missingFlag = false;

        //cycle through each particle
        for (let i = occupancy.nextSetBit(0); i !== -1; i = occupancy.nextSetBit(i + 1)) {
            const layerIndex = particles.readAttributeScalar(i, PARTICLE_ATTRIBUTE_LAYER_POSITION);

            const layer = layers.get(layerIndex);

            const atlasPatch = layer.__atlasPatch;

            if (atlasPatch !== null) {

                write_particle_patch_uv(atlasPatch, particles, i);
            } else {
                missingFlag = true;
            }
        }

        this.writeFlag(ParticleEmitterFlag.SpritesNeedUpdate, missingFlag);
    }

    /**
     * @private
     * @param {int} layerIndex
     * @param {number} timeDelta period over which particles are being spawned, this will ensure that spawned particles have different initial age
     */
    spawnLayerParticlesContinuous(layerIndex, timeDelta) {
        const layer = this.layers.get(layerIndex);

        const random = Math.random;

        /**
         *
         * @type {ParticlePool}
         */
        const particles = this.particles;


        const emissionFunction = computeEmissionFunction(layer.emissionFrom, layer.emissionShape);

        const emissionPeriod = 1 / layer.emissionRate;

        //compute transform matrix of the emitter
        const emitter_position = this.position;
        const last_spawn_position = this.__lastSpawnPosition;

        compose_matrix4_array(matrix4, emitter_position, this.rotation, this.scale);

        let time = -layer.timeSinceLastEmission;

        while (time + emissionPeriod < timeDelta) {
            time += emissionPeriod;

            const ref = particles.create();

            // randomize position across the supplied time interval
            const f = time / timeDelta;

            //compute position for particle
            emissionFunction(random, scratch_v3_array, 0);

            v3position.fromArray(scratch_v3_array, 0);

            //apply layer transform
            v3position.multiply(layer.scale);
            v3position.add(layer.position);

            if (this.getFlag(ParticleEmitterFlag.PositionChanged)) {
                //compute position in between
                matrix4[12] = lerp(emitter_position.x, last_spawn_position.x, f);
                matrix4[13] = lerp(emitter_position.y, last_spawn_position.y, f);
                matrix4[14] = lerp(emitter_position.z, last_spawn_position.z, f);
            }

            //apply emitter transform
            v3position.applyMatrix4(matrix4);


            //randomize initial age across time delta
            const initialAge = f * timeDelta;

            //write age
            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_AGE, initialAge);

            //write rotation
            const rotation = layer.particleRotation.sampleRandom(random);
            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_ROTATION, rotation);

            //write rotation speed
            const rotationSpeed = layer.particleRotationSpeed.sampleRandom(random);
            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_ROTATION_SPEED, rotationSpeed);

            //initialize velocity
            layer.particleVelocityDirection.sampleRandomDirection(random, v3velocity);
            const speed = layer.particleSpeed.sampleRandom(random);

            v3velocity.multiplyScalar(speed);

            particles.writeAttributeVector3(ref, PARTICLE_ATTRIBUTE_VELOCITY, v3velocity.x, v3velocity.y, v3velocity.z);

            //write position
            const p_x = v3position.x + v3velocity.x * initialAge;
            const p_y = v3position.y + v3velocity.y * initialAge;
            const p_z = v3position.z + v3velocity.z * initialAge;

            particles.writeAttributeVector3(ref, PARTICLE_ATTRIBUTE_POSITION, p_x, p_y, p_z);

            //write death age
            const deathAge = layer.particleLife.sampleRandom(random);

            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_DEATH_AGE, deathAge);

            //write particle size
            const size = layer.particleSize.sampleRandom(random);

            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_SIZE_INITIAL, size);

            //write UV
            const atlasPatch = layer.__atlasPatch;
            if (atlasPatch !== null) {
                //patch exists, write it
                write_particle_patch_uv(atlasPatch, particles, ref);
            } else {
                this.setFlag(ParticleEmitterFlag.SpritesNeedUpdate);
            }

            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_LAYER_POSITION, layerIndex);

            update_parameters(ref, particles, layer, initialAge / deathAge);

            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_BLEND, this.blendingMode);
        }

        //update time buffer
        layer.timeSinceLastEmission = timeDelta - time;

        this.setFlag(ParticleEmitterFlag.ParticleBoundsNeedUpdate);
    }

    /**
     * @private
     * @param {int} layerIndex
     * @param {number} count how many particles to spawn
     */
    spawnLayerParticlesImmediate(layerIndex, count) {
        if (count <= 0) {
            return;
        }

        const layers = this.layers;

        const layer = layers.get(layerIndex);

        const random = Math.random;

        /**
         *
         * @type {ParticlePool}
         */
        const particles = this.particles;

        const emissionFunction = computeEmissionFunction(layer.emissionFrom, layer.emissionShape);

        //compute transform matrix of the emitter
        compose_matrix4_array(matrix4, this.position, this.rotation, this.scale);

        //pre-grow particle pool
        particles.growCapacity(particles.capacity + count);

        let spritesNeedUpdate = false;

        for (let j = 0; j < count; j++) {
            const ref = particles.create();

            //compute position for particle
            emissionFunction(random, scratch_v3_array, 0);

            v3position.fromArray(scratch_v3_array, 0);

            //apply layer transform
            v3position.multiply(layer.scale);
            v3position.add(layer.position);

            //apply emitter transform
            v3position.applyMatrix4(matrix4);


            //randomize initial age across time delta
            const initialAge = 0;

            //write age
            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_AGE, initialAge);

            //write rotation
            const rotation = layer.particleRotation.sampleRandom(random);
            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_ROTATION, rotation);

            //write rotation speed
            const rotationSpeed = layer.particleRotationSpeed.sampleRandom(random);
            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_ROTATION_SPEED, rotationSpeed);

            //initialize velocity
            layer.particleVelocityDirection.sampleRandomDirection(random, v3velocity);
            const speed = layer.particleSpeed.sampleRandom(random);

            v3velocity.multiplyScalar(speed);

            particles.writeAttributeVector3(ref, PARTICLE_ATTRIBUTE_VELOCITY, v3velocity.x, v3velocity.y, v3velocity.z);

            //write position
            const p_x = v3position.x + v3velocity.x * initialAge;
            const p_y = v3position.y + v3velocity.y * initialAge;
            const p_z = v3position.z + v3velocity.z * initialAge;

            particles.writeAttributeVector3(ref, PARTICLE_ATTRIBUTE_POSITION,
                p_x,
                p_y,
                p_z
            );

            //write death age
            const deathAge = layer.particleLife.sampleRandom(random);

            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_DEATH_AGE, deathAge);

            //write size
            const size = layer.particleSize.sampleRandom(random);

            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_SIZE_INITIAL, size);

            //write UV
            const atlasPatch = layer.__atlasPatch;

            if (atlasPatch !== null) {
                //patch exists, write it
                write_particle_patch_uv(atlasPatch, particles, ref);
            } else {
                //patch is not loaded yet, request update
                spritesNeedUpdate = true;
            }

            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_LAYER_POSITION, layerIndex);

            update_parameters(ref, particles, layer, initialAge / deathAge);

            particles.writeAttributeScalar(ref, PARTICLE_ATTRIBUTE_BLEND, this.blendingMode);
        }

        if (spritesNeedUpdate) {
            this.setFlag(ParticleEmitterFlag.SpritesNeedUpdate);
        }

        this.setFlag(ParticleEmitterFlag.ParticleBoundsNeedUpdate);
    }

    /**
     * @private
     * @param {number} timeDelta
     */
    emit(timeDelta) {
        if (!this.getFlag(ParticleEmitterFlag.Emitting)) {
            return;
        }

        const layers = this.layers;
        const numLayers = layers.length;

        if (!this.position.equals(this.__lastSpawnPosition)) {
            this.setFlag(ParticleEmitterFlag.PositionChanged);
        }

        //emit new  particles
        for (let i = 0; i < numLayers; i++) {
            /**
             * @type {ParticleLayer}
             */
            const layer = layers.get(i);

            if (!layer.isEmitting) {
                //this layer is not spawning any particles, ignore it
                continue;
            }

            const particleLife = layer.particleLife;

            const averageLifetime = (particleLife.max + particleLife.min) / 2;

            //emitting more than averageLifetime lifetime is unproductive, so we'll crop the emission
            let emissionDelta;
            if (timeDelta > averageLifetime) {
                emissionDelta = averageLifetime;
                layer.timeSinceLastEmission = 0;
            } else {
                emissionDelta = timeDelta;
            }

            this.spawnLayerParticlesContinuous(i, emissionDelta);
        }

        if (this.getFlag(ParticleEmitterFlag.PositionChanged)) {
            this.__lastSpawnPosition.copy(this.position);
            this.clearFlag(ParticleEmitterFlag.PositionChanged);
        }
    }


    /**
     * Cleanup used resources
     */
    dispose() {
        if (this.particles !== null) {
            this.particles.dispose();
        }
    }

    /**
     *
     * @param json
     * @returns {ParticleEmitter}
     */
    static fromJSON(json) {
        const result = new ParticleEmitter();
        result.fromJSON(json);
        return result
    }
}

ParticleEmitter.typeName = "ParticleEmitter";

ParticleEmitter.Attributes = {
    PARTICLE_ATTRIBUTE_AGE,
    PARTICLE_ATTRIBUTE_DEATH_AGE,
    PARTICLE_ATTRIBUTE_LAYER_POSITION,
    PARTICLE_ATTRIBUTE_POSITION,
    PARTICLE_ATTRIBUTE_ROTATION,
    PARTICLE_ATTRIBUTE_ROTATION_SPEED,
    PARTICLE_ATTRIBUTE_SIZE,
    PARTICLE_ATTRIBUTE_UV,
    PARTICLE_ATTRIBUTE_VELOCITY
};

ParticleEmitter.SERIALIZABLE_FLAGS = SERIALIZABLE_FLAGS;


const matrix4 = new Float32Array(16);

const DEFAULT_POINT_MATERIAL = new PointsMaterial({ color: 0xFFFFFF });

const scratch_frustum = new Frustum();

const v3position = new Vector3();
const v3velocity = new Vector3();
