Newer
Older
rpg / v2 / packages / engine / src / render / particles.ts
/**
 * Слои частиц рендера: лёгкий 2D-рендер поверх воксельного мира — спрайты-
 * билборды (THREE.Points, один draw call) и плоские квадраты, лежащие на
 * земле/воде (InstancedBufferGeometry). Не воксели: без теней и AO — цвет
 * и прозрачность на частицу, пересборка поз каждый кадр дёшева. Цвет пишется
 * в буфер как есть (hex-компоненты без colorspace-конверсий) — совпадает
 * с палитрой. Освещение: ambient — умножение на tint слоя (setTint, ночь
 * гасит слои вместе со сценой); точечные источники — общий блок юниформ
 * света рендера (setParticleLights): вклад лампы/фонарика в шейдере,
 * частицы подсвечиваются как мир.
 */
import * as THREE from 'three';

/** Частица: позиция, цвет, прозрачность (по умолчанию 1), размер в юнитах (1). */
export interface ParticlePoint {
    pos: [number, number, number];
    color: string;
    alpha?: number;
    size?: number;
}

/** Ручка слоя частиц: поза слоя каждый кадр (до ёмкости; рост пересоздаёт). */
export interface ParticleLayer {
    update(points: readonly ParticlePoint[]): void;
    /** Множитель цвета всего слоя (0xRRGGBB, по умолчанию белый). */
    setTint(color: number): void;
    dispose(): void;
}

/** Точечный источник для частиц — снапшот света игры (лампа, очаг, окно). */
export interface ParticleLight {
    pos: readonly [number, number, number];
    /** Цвет 0xRRGGBB (перемножается с яркостью при упаковке). */
    color: number;
    /** Яркость — «множитель игры» 0..~1.5 (как у PointLight игры до GAIN). */
    intensity: number;
    /** Радиус действия в мировых единицах. */
    radius: number;
}

/** Конус света для частиц (фонарик героя). */
export interface ParticleSpot {
    pos: readonly [number, number, number];
    /** Направление луча (нормируется при упаковке). */
    dir: readonly [number, number, number];
    color: number;
    intensity: number;
    /** Дальность в мировых единицах. */
    distance: number;
    /** Полный угол конуса (рад). */
    angle: number;
    /** Мягкость края 0..1. */
    penumbra: number;
}

/** Кап источников в шейдере частиц (совпадает с MAX_LIGHTS игры). */
export const PARTICLE_LIGHT_MAX = 24;

/** Тип общего блока юниформ света (один экземпляр на все слои рендера). */
export type ParticleLightUniforms = Record<string, THREE.IUniform>;

/** Блок юниформ света частиц: живёт на рендере, все слои (и пересозданные
 *  при росте ёмкости) ссылаются на ТЕ ЖЕ IUniform — свет не теряется. */
export function makeParticleLightUniforms(): ParticleLightUniforms {
    return {
        uLightPos: { value: new Float32Array(PARTICLE_LIGHT_MAX * 3) },
        uLightColor: { value: new Float32Array(PARTICLE_LIGHT_MAX * 3) },
        uLightRadius: { value: new Float32Array(PARTICLE_LIGHT_MAX) },
        uLightCount: { value: 0 },
        uSpotPos: { value: new Float32Array(3) },
        uSpotDir: { value: new Float32Array(3) },
        uSpotColor: { value: new Float32Array(3) },
        uSpotDist: { value: 1 },
        uSpotCos: { value: 0 }, // косинус ПОЛУугла внешнего края конуса
        uSpotSoft: { value: 0 }, // косинус внутреннего края (мягкость penumbra)
        uSpotOn: { value: 0 },
    };
}

/**
 * Упаковка снапшота света в блок юниформ: цвет × яркость считается здесь —
 * шейдер читает готовый вклад. Чистая функция — тестируется без WebGL.
 */
export function packParticleLights(
    u: ParticleLightUniforms,
    points: readonly ParticleLight[],
    spot: ParticleSpot | null,
): void {
    const pos = u.uLightPos!.value as Float32Array;
    const col = u.uLightColor!.value as Float32Array;
    const rad = u.uLightRadius!.value as Float32Array;
    const n = Math.min(points.length, PARTICLE_LIGHT_MAX);
    for (let i = 0; i < n; i++) {
        const p = points[i]!;
        pos[i * 3] = p.pos[0];
        pos[i * 3 + 1] = p.pos[1];
        pos[i * 3 + 2] = p.pos[2];
        col[i * 3] = (((p.color >> 16) & 255) / 255) * p.intensity;
        col[i * 3 + 1] = (((p.color >> 8) & 255) / 255) * p.intensity;
        col[i * 3 + 2] = ((p.color & 255) / 255) * p.intensity;
        rad[i] = p.radius;
    }
    u.uLightCount!.value = n;
    if (spot) {
        (u.uSpotPos!.value as Float32Array).set([...spot.pos]);
        const d = Math.hypot(...spot.dir) || 1;
        (u.uSpotDir!.value as Float32Array).set(spot.dir.map((v) => v / d));
        (u.uSpotColor!.value as Float32Array).set([
            (((spot.color >> 16) & 255) / 255) * spot.intensity,
            (((spot.color >> 8) & 255) / 255) * spot.intensity,
            ((spot.color & 255) / 255) * spot.intensity,
        ]);
        u.uSpotDist!.value = spot.distance;
        u.uSpotCos!.value = Math.cos(spot.angle / 2);
        u.uSpotSoft!.value = Math.cos((spot.angle / 2) * (1 - spot.penumbra));
        u.uSpotOn!.value = 1;
    } else {
        u.uSpotOn!.value = 0;
    }
}

/** hex '#rrggbb' → компоненты 0..1. */
function rgb(hex: string): [number, number, number] {
    const n = parseInt(hex.slice(1), 16);
    return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

/** GLSL-фрагмент света: вклад точечных источников и фонарика в точке мира.
 *  Вклад умножается на цвет частицы (vColor·(tint+свет)) — красная частица
 *  у тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку). */
const LIGHT_FRAG = `
#define PLIGHTS ${PARTICLE_LIGHT_MAX}
uniform vec3 uLightPos[PLIGHTS];
uniform vec3 uLightColor[PLIGHTS];
uniform float uLightRadius[PLIGHTS];
uniform int uLightCount;
uniform vec3 uSpotPos;
uniform vec3 uSpotDir;
uniform vec3 uSpotColor;
uniform float uSpotDist;
uniform float uSpotCos;
uniform float uSpotSoft;
uniform float uSpotOn;
vec3 particleLight(vec3 p) {
    vec3 sum = vec3(0.0);
    for (int i = 0; i < PLIGHTS; i++) {
        if (i >= uLightCount) break;
        float d = distance(p, uLightPos[i]);
        float a = clamp(1.0 - d / uLightRadius[i], 0.0, 1.0);
        sum += uLightColor[i] * (a * a * a);
    }
    if (uSpotOn > 0.5) {
        vec3 rel = p - uSpotPos;
        float d = max(length(rel), 0.0001);
        float cone = smoothstep(uSpotCos, uSpotSoft, dot(rel / d, uSpotDir));
        float a = clamp(1.0 - d / uSpotDist, 0.0, 1.0);
        sum += uSpotColor * (cone * a * a);
    }
    // согласование с пулами мира: PointLight усиливается GAIN 8, а частица —
    // прозрачная «пылинка», её вклад читается только с множителем
    return sum * 2.0;
}`;

const POINT_VERT = `
attribute float aAlpha;
attribute float aSize;
varying vec3 vColor;
varying float vAlpha;
varying vec3 vWorld;
uniform float uPxScale;
void main() {
    vColor = color;
    vAlpha = aAlpha;
    vWorld = position;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    gl_PointSize = aSize * uPxScale;
}`;
const POINT_FRAG = `
varying vec3 vColor;
varying float vAlpha;
varying vec3 vWorld;
uniform vec3 uTint;
${LIGHT_FRAG}
void main() { gl_FragColor = vec4(vColor * (uTint + particleLight(vWorld)), vAlpha); }`;

const QUAD_VERT = `
attribute vec3 iPos;
attribute vec3 iColor;
attribute float iAlpha;
attribute float iSize;
varying vec3 vColor;
varying float vAlpha;
varying vec3 vWorld;
void main() {
    vColor = iColor;
    vAlpha = iAlpha;
    vWorld = position * iSize + iPos;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position * iSize + iPos, 1.0);
}`;

const FLAGS = { transparent: true, depthWrite: false, depthTest: true };

/** Фабрика слоя спрайтов (отдельная функция — рекурсия при росте ёмкости).
 *  lights — общий блок юниформ света рендера (ссылки на IUniform шарятся). */
function createSpriteLayer(
    scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms,
): ParticleLayer {
    const mk = (itemSize: number): THREE.BufferAttribute =>
        new THREE.BufferAttribute(new Float32Array(capacity * itemSize), itemSize).setUsage(THREE.DynamicDrawUsage);
    const pos = mk(3), col = mk(3), alp = mk(1), siz = mk(1);
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', pos);
    geo.setAttribute('color', col);
    geo.setAttribute('aAlpha', alp);
    geo.setAttribute('aSize', siz);
    const mat = new THREE.ShaderMaterial({
        vertexShader: POINT_VERT, fragmentShader: POINT_FRAG,
        uniforms: { uPxScale: { value: pxScale }, uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights },
        vertexColors: true, ...FLAGS,
    });
    const mesh = new THREE.Points(geo, mat);
    mesh.frustumCulled = false; // bbox по инстансам не считается
    scene.add(mesh);
    const layer: ParticleLayer = {
        update: (points) => {
            if (points.length > capacity) {
                scene.remove(mesh); geo.dispose(); mat.dispose();
                const fresh = createSpriteLayer(scene, points.length, pxScale, lights);
                layer.update = fresh.update;
                layer.setTint = fresh.setTint;
                layer.dispose = fresh.dispose;
                fresh.update(points); // грабля: редирект без позы терял первый кадр
                return;
            }
            points.forEach((p, i) => {
                const [r, g, b] = rgb(p.color);
                pos.setXYZ(i, p.pos[0], p.pos[1], p.pos[2]);
                col.setXYZ(i, r, g, b);
                alp.setX(i, p.alpha ?? 1);
                siz.setX(i, p.size ?? 1);
            });
            geo.setDrawRange(0, points.length);
            pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true;
        },
        setTint: (color) => {
            const v = mat.uniforms.uTint.value as THREE.Vector3;
            v.set(((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255);
        },
        dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); },
    };
    return layer;
}

/** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики). */
function createQuadLayer(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms): ParticleLayer {
    const base = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2); // юнит-квад в XZ
    const geo = new THREE.InstancedBufferGeometry();
    geo.index = base.index;
    geo.setAttribute('position', base.getAttribute('position'));
    const mkI = (itemSize: number): THREE.InstancedBufferAttribute =>
        new THREE.InstancedBufferAttribute(new Float32Array(capacity * itemSize), itemSize).setUsage(THREE.DynamicDrawUsage);
    const pos = mkI(3), col = mkI(3), alp = mkI(1), siz = mkI(1);
    geo.setAttribute('iPos', pos);
    geo.setAttribute('iColor', col);
    geo.setAttribute('iAlpha', alp);
    geo.setAttribute('iSize', siz);
    const mat = new THREE.ShaderMaterial({
        vertexShader: QUAD_VERT, fragmentShader: POINT_FRAG,
        uniforms: { uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights }, ...FLAGS,
    });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.frustumCulled = false;
    scene.add(mesh);
    const layer: ParticleLayer = {
        update: (points) => {
            if (points.length > capacity) {
                scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose();
                const fresh = createQuadLayer(scene, points.length, lights);
                layer.update = fresh.update;
                layer.setTint = fresh.setTint;
                layer.dispose = fresh.dispose;
                fresh.update(points); // грабля: редирект без позы терял первый кадр
                return;
            }
            points.forEach((p, i) => {
                const [r, g, b] = rgb(p.color);
                pos.setXYZ(i, p.pos[0], p.pos[1], p.pos[2]);
                col.setXYZ(i, r, g, b);
                alp.setX(i, p.alpha ?? 1);
                siz.setX(i, p.size ?? 1);
            });
            geo.instanceCount = points.length;
            pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true;
        },
        setTint: (color) => {
            const v = mat.uniforms.uTint.value as THREE.Vector3;
            v.set(((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255);
        },
        dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); },
    };
    return layer;
}

/** Публичные фабрики: стартовая ёмкость и px-масштаб спрайтов задаёт рендер. */
export { createSpriteLayer, createQuadLayer };