modifiers/ColorRamp.js

import { Particle } from '../core/Particle.js';

import { Modifier } from './Modifier.js';

/**
 * @import { ModifierConfig } from './Modifier.js';
 */

/**
 * ColorRamp configuration options.
 * Includes all properties from {@link ModifierConfig}.
 * @typedef {object} ColorRampConfig
 * @property {Array<Array<number>>} [colors=[[255, 255, 255], [0, 0, 0]]] Array of RGB color arrays.
 */

/**
 * Particle modifier that blends a particle's color over time by interpolating through an arbitrary number of colors.
 * Colors are distributed evenly along the timeline.
 * @class
 * @extends Modifier
 */
export class ColorRamp extends Modifier {
    /**
     * Array of color stop objects; keys and their relative positions along the timeline.
     * @type {Array<{pos: number, r: number, g: number, b: number}>}
     */
    #colorStops = [];

    /**
     * Initializes a color ramp modifier with evenly distributed color stops.
     * @constructor
     * @param {ColorRampConfig} [config={}] ColorRampConfig configuration options.
     */
    constructor(config = {}) {
        super(config);

        const colors = config.colors ?? [[255, 255, 255], [0, 0, 0]];

        const count = colors.length;
        for (let i = 0; i < count; i++) {
            const relativePosition = count === 1 ? 0 : i / (count - 1);  // [0.0, 1.0]

            this.#colorStops.push({
                pos: relativePosition,
                r: colors[i][0],
                g: colors[i][1],
                b: colors[i][2],
            });
        }
    }

    /**
     * Blends a particle's color channels based on its normalized age.
     * @override
     * @param {Particle} particle Particle instance to affect.
     * @param {number} normalizedAge Normalized age of the particle (0.0 = emitted, 1.0 = dead).
     * @returns {void}
     */
    update(particle, normalizedAge) {
        const particleAge = Math.max(0, Math.min(1, normalizedAge));
        const totalColorStopCount = this.#colorStops.length;

        // Map the normalized particle age onto the array indices of the color stops.
        // E.g., if there are 5 stops, the max index is 4. An age of 0.5 results in 2.0.
        const fractionalIndex = particleAge * (totalColorStopCount - 1);

        // The integer part of the index represents the lower color stop flanking the particle's age
        const lowerColorStopIndex = Math.floor(fractionalIndex);

        // The next integer represents the upper color stop, capped to stay inside array bounds
        const upperColorStopIndex = Math.min(lowerColorStopIndex + 1, totalColorStopCount - 1);

        const lowerColorStop = this.#colorStops[lowerColorStopIndex];
        const upperColorStop = this.#colorStops[upperColorStopIndex];

        // The decimal part is the particle's local age (progress factor) within this segment
        const interpolationFactor = fractionalIndex - lowerColorStopIndex;

        // Linear interpolation on each RGB channel between the lower and upper colors of this segment
        // based on the particle's local progress
        particle.color.r = lowerColorStop.r + (upperColorStop.r - lowerColorStop.r) * interpolationFactor;
        particle.color.g = lowerColorStop.g + (upperColorStop.g - lowerColorStop.g) * interpolationFactor;
        particle.color.b = lowerColorStop.b + (upperColorStop.b - lowerColorStop.b) * interpolationFactor;
    }
}