Newer
Older
rpg / v2 / packages / engine / src / render / particles.ts
/**
 * Слои частиц рендера: лёгкий 2D-рендер поверх воксельного мира — спрайты-
 * билборды (THREE.Points, один draw call) и плоские квадраты, лежащие на
 * земле/воде (InstancedBufferGeometry). Не воксели: без освещения, теней и
 * AO — цвет и прозрачность на частицу, пересборка поз каждый кадр дёшева.
 * Цвет пишется в буфер как есть (hex-компоненты без colorspace-конверсий) —
 * совпадает с палитрой.
 */
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;
    dispose(): void;
}

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

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

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

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

/** Фабрика слоя спрайтов (отдельная функция — рекурсия при росте ёмкости). */
function createSpriteLayer(scene: THREE.Scene, capacity: number, pxScale: number): 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 } }, 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);
                layer.update = fresh.update;
                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;
        },
        dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); },
    };
    return layer;
}

/** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики). */
function createQuadLayer(scene: THREE.Scene, capacity: number): 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, ...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);
                layer.update = fresh.update;
                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;
        },
        dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); },
    };
    return layer;
}

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