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

/** Тип общего блока юниформ света (один экземпляр на все слои рендера). */
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 },
        uEnv: { value: new THREE.Vector3() }, // env-свет сцены (ambient + доля солнца)
    };
}

/** Доля прямого солнца в env-свете освещаемых частиц: у билборда нет
 *  нормали, солнце приближается к всенаправленному — берём часть. */
export const PARTICLE_ENV_SUN = 0.3;

/** Насколько полученный свет проявляет освещаемую частицу: видимость —
 *  clamp(max-канал(полученный свет)·GAIN, 0, 1) — множитель альфы.
 *  Ночью в темноте received ~0 — частица невидима, у лампы — видна. */
export const PARTICLE_LIT_VIS = 1.6;

/** Источник света для particleEnv: цветовые компоненты 0..1 + яркость
 *  (подходит THREE.Color/свет three — поля r/g/b/intensity). */
export interface RgbIntensity {
    readonly r: number;
    readonly g: number;
    readonly b: number;
    readonly intensity: number;
}

/** Env-свет сцены для освещаемых частиц: ambient целиком + доля прямого
 *  солнца (частица получает свет со всех сторон). Чистая функция —
 *  рендер вызывает при каждом обновлении света, тестируется без WebGL. */
export function particleEnv(amb: RgbIntensity, sun: RgbIntensity): [number, number, number] {
    return [
        amb.r * amb.intensity + sun.r * sun.intensity * PARTICLE_ENV_SUN,
        amb.g * amb.intensity + sun.g * sun.intensity * PARTICLE_ENV_SUN,
        amb.b * amb.intensity + sun.b * sun.intensity * PARTICLE_ENV_SUN,
    ];
}

/**
 * Упаковка снапшота света в блок юниформ: цвет × яркость считается здесь —
 * шейдер читает готовый вклад. Чистая функция — тестируется без 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;
        // конус для частиц шире поверхностного: тонкий луч едва пересекает
        // видимый ортокамерой слой частиц, а объёмное свечение читается
        // только широким конусом (стандартный приём volumetric-света)
        const half = Math.min((spot.angle / 2) * SPOT_PARTICLE_WIDEN, Math.PI / 2 - 0.05);
        u.uSpotCos!.value = Math.cos(half);
        u.uSpotSoft!.value = Math.cos(half * (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+свет)) — красная частица
 *  у тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку).
 *  Фоллофф точечных источников — квадратичный: кубический концентрировал
 *  вклад в внутреннем ~10% радиуса (среднее a³ по объёму сферы ≈ 1/60),
 *  и при игровой плотности частиц реакция была перцептивно нулевой. */
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);
    }
    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;
uniform vec3 uEnv;
${LIGHT_FRAG}
void main() {
    vec3 received = particleLight(vWorld);
    float vis = 1.0;
#if LIT
    // освещаемый режим: полученный свет (env + источники) правит и цветом,
    // и видимостью — ночью в темноте частица исчезает, у лампы проявляется
    received += uEnv;
    vis = clamp(max(received.r, max(received.g, received.b)) * ${PARTICLE_LIT_VIS}, 0.0, 1.0);
#endif
    gl_FragColor = vec4(vColor * (uTint + received), vAlpha * vis);
}`;

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 шарятся).
 *  lit — освещаемый режим: видимость по полученному свету (см. шапку). */
function createSpriteImpl(
    scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms,
    lit: boolean,
) {
    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 },
        defines: { LIT: lit ? 1 : 0 },
        vertexColors: true, ...FLAGS,
    });
    const mesh = new THREE.Points(geo, mat);
    mesh.frustumCulled = false; // bbox по инстансам не считается
    scene.add(mesh);
    return {
        update: (points: readonly ParticlePoint[]) => {
            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: number) => {
            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(); },
    };
}

/** Фабрика слоя спрайтов: стабильный фасад над подменяемой реализацией.
 *  Грабля «сломанная цепочка редиректа»: раньше при росте ёмкости слой
 *  перезаписывал СВОИ методы методами свежего слоя (`layer.update =
 *  fresh.update`) — держатель старого слоя (WeatherSystem) продолжал
 *  звать update старого объекта, тот уходил в мёртвое замыкание
 *  предыдущей реализации и при каждом превышении ёмкости создавал
 *  НОВЫЙ слой с однократной записью: замороженные снимки дождя копились
 *  в сцене (пелена на скрине #218). Теперь фасад один на всю жизнь слоя,
 *  при росте подменяется только `impl` — держатели всегда в актуальном
 *  меше, а первый кадр после роста рисуется сразу (impl.update(points)). */
function createSpriteLayer(
    scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms,
    lit: boolean,
): ParticleLayer {
    let cap = capacity;
    let impl = createSpriteImpl(scene, cap, pxScale, lights, lit);
    const layer: ParticleLayer = {
        update: (points) => {
            if (points.length > cap) {
                impl.dispose();
                cap = points.length;
                impl = createSpriteImpl(scene, cap, pxScale, lights, lit);
            }
            impl.update(points);
        },
        setTint: (color) => impl.setTint(color),
        dispose: () => impl.dispose(),
    };
    return layer;
}

/** Внутренняя реализация слоя квадов без роста ёмкости (рост — у фасада).
 *  lit — освещаемый режим (см. шапку). */
function createQuadImpl(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms, lit: boolean) {
    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 },
        defines: { LIT: lit ? 1 : 0 },
        ...FLAGS,
    });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.frustumCulled = false;
    scene.add(mesh);
    return {
        update: (points: readonly ParticlePoint[]) => {
            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: number) => {
            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(); },
    };
}

/** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики) —
 *  стабильный фасад, рост ёмкости подменяет impl (см. createSpriteLayer). */
function createQuadLayer(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms, lit: boolean): ParticleLayer {
    let cap = capacity;
    let impl = createQuadImpl(scene, cap, lights, lit);
    const layer: ParticleLayer = {
        update: (points) => {
            if (points.length > cap) {
                impl.dispose();
                cap = points.length;
                impl = createQuadImpl(scene, cap, lights, lit);
            }
            impl.update(points);
        },
        setTint: (color) => impl.setTint(color),
        dispose: () => impl.dispose(),
    };
    return layer;
}

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