Newer
Older
rpg / packages / engine / src / render / postfx / PostFx.ts
/**
 * Пост-эффекты: один кастомный фильтр на корне мира — переменный блюр
 * (глобальный и точечный с радиусом), хроматическая аберрация, марево.
 * API по образцу Lighting: состояние set* + затухающие импульсы pulse*
 * (огибающая — pulseEnvelope из postSim). Координаты точек — виртуальные
 * экранные пиксели (текстура фильтра выровнена по вьюпорту, камера уже
 * учтена); радиусы — в тех же пикселях. blurObject — отдельный BlurFilter
 * на конкретной вьюхе (не участвует в общем фильтре).
 */
import { BlurFilter, Container, Filter, GlProgram, UniformGroup } from 'pixi.js';
import { blurKernel, pulseEnvelope, pulseTotal, type PulseSpec, type ShimmerParams } from './postSim';

const VERT = `in vec2 aPosition;
out vec2 vTextureCoord;
uniform vec4 uInputSize;
uniform vec4 uOutputFrame;
uniform vec4 uOutputTexture;
vec4 filterVertexPosition(vec2 aPosition)
{
    vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy;
    position.x = position.x * (2.0 / uOutputTexture.x) - 1.0;
    position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z;
    return vec4(position, 0.0, 1.0);
}
vec2 filterTextureCoord(vec2 aPosition)
{
    return aPosition * (uOutputFrame.zw * uInputSize.zw);
}
void main(void)
{
    gl_Position = filterVertexPosition(aPosition);
    vTextureCoord = filterTextureCoord(aPosition);
}`;

// Ядро блюра (постоянное): спираль золотого сечения из postSim, развёрнутая
// в явные сэмплы — const-массивы недопустимы в GLSL ES 1.00 (WebGL1-фолбэк).
const KERNEL = blurKernel(8)
    .map(([x, y]) => `acc += texture(uTexture, base + vec2(${x.toFixed(4)}, ${y.toFixed(4)}) * s / uViewport);`)
    .join('\n        ');

const FRAG = `in vec2 vTextureCoord;
out vec4 finalColor;
uniform sampler2D uTexture;
uniform vec2 uViewport;
uniform float uBlurStrength;
uniform vec2 uBlurCenter;
uniform float uBlurRadius;
uniform float uAberration;
uniform float uShimmerAmp;
uniform vec2 uShimmerCenter;
uniform float uShimmerRadius;
uniform float uShimmerWl;
uniform float uShimmerSpeed;
uniform float uTime;
// Спад силы от точки (гладкая ступень, как в postSim); радиус <= 0 — глобально.
float fall(vec2 px, vec2 center, float radius)
{
    if (radius <= 0.0) return 1.0;
    float t = clamp(1.0 - length(px - center) / radius, 0.0, 1.0);
    return t * t * (3.0 - 2.0 * t);
}
void main(void)
{
    vec2 px = vTextureCoord * uViewport;
    vec2 base = vTextureCoord;
    if (uShimmerAmp > 0.001) {
        float dy = uShimmerAmp * sin((px.x / uShimmerWl + uTime * uShimmerSpeed) * 6.2831853);
        base.y += (dy * fall(px, uShimmerCenter, uShimmerRadius)) / uViewport.y;
    }
    if (uAberration > 0.001) {
        // Радиальный сдвиг каналов от центра экрана: R наружу, B внутрь.
        vec2 c = uViewport * 0.5;
        vec2 d = px - c;
        vec2 dir = length(d) > 0.0001 ? normalize(d) : vec2(0.0);
        float r = texture(uTexture, base + dir * (uAberration / uViewport.x)).r;
        vec4 g = texture(uTexture, base);
        float b = texture(uTexture, base - dir * (uAberration / uViewport.x)).b;
        finalColor = vec4(r, g.g, b, g.a);
        return;
    }
    if (uBlurStrength > 0.001) {
        float s = fall(px, uBlurCenter, uBlurRadius) * uBlurStrength;
        vec4 acc = vec4(0.0);
        ${KERNEL}
        finalColor = acc / 8.0;
        return;
    }
    finalColor = texture(uTexture, base);
}`;

/** Состояние блюра: сила (px выборки), точка и радиус (px; радиус <= 0 — вся сцена). */
export interface BlurState {
    strength: number;
    center?: { x: number; y: number };
    radius?: number;
}

/** Марево: параметры из postSim + опорная зона (вне зоны амплитуда 0). */
export type ShimmerState = ShimmerParams & { center?: { x: number; y: number }; radius?: number };

interface PulseEntry {
    elapsed: number;
    spec: PulseSpec;
    blur?: BlurState;
    aberration?: number;
}

export class PostFx {
    /** Фильтр — вешается на корень мира (Renderer.worldRoot.filters). */
    readonly filter: Filter;
    private readonly worldRoot: Container;
    private readonly viewportX: number;
    private readonly viewportY: number;
    private readonly uniforms: UniformGroup;

    private time = 0;
    private blur: BlurState | null = null;
    private aberration = 0;
    private shimmer: ShimmerState | null = null;
    private readonly pulses = new Map<string, PulseEntry>();
    private pulseSeq = 0;
    private readonly objectBlurs = new Map<Container, BlurFilter>();
    private disposed = false;

    constructor(worldRoot: Container, viewportX: number, viewportY: number) {
        this.worldRoot = worldRoot;
        this.viewportX = viewportX;
        this.viewportY = viewportY;
        this.uniforms = new UniformGroup({
            uViewport: { value: new Float32Array([viewportX, viewportY]), type: 'vec2<f32>' },
            uBlurStrength: { value: 0, type: 'f32' },
            uBlurCenter: { value: new Float32Array([viewportX / 2, viewportY / 2]), type: 'vec2<f32>' },
            uBlurRadius: { value: 0, type: 'f32' },
            uAberration: { value: 0, type: 'f32' },
            uShimmerAmp: { value: 0, type: 'f32' },
            uShimmerCenter: { value: new Float32Array([viewportX / 2, viewportY / 2]), type: 'vec2<f32>' },
            uShimmerRadius: { value: 0, type: 'f32' },
            uShimmerWl: { value: 40, type: 'f32' },
            uShimmerSpeed: { value: 1, type: 'f32' },
            uTime: { value: 0, type: 'f32' }
        });
        this.filter = new Filter({
            glProgram: GlProgram.from({ vertex: VERT, fragment: FRAG }),
            resources: { postFxUniforms: this.uniforms },
            // inherit: текстура фильтра в разрешении рендера — пиксель-арт не мылится.
            resolution: 'inherit',
            padding: 0
        });
        worldRoot.filters = [this.filter];
    }

    /** Блюр сцены/точки: null — выключить. Импульсы поверх (см. pulseBlur). */
    setBlur(args: BlurState | null): void {
        this.blur = args ? { ...args } : null;
    }

    /** Импульс блюра с огибающей (attack/hold/decay, сек) — удар, резонанс. */
    pulseBlur(args: BlurState, spec: PulseSpec): void {
        const id = `blur#${this.pulseSeq++}`;
        this.pulses.set(id, { elapsed: 0, spec, blur: { ...args } });
    }

    /** Хроматическая аберрация, px сдвига каналов (0 — выкл). */
    setAberration(px: number): void {
        this.aberration = px;
    }

    /** Импульс аберрации — урон, вспышки. */
    pulseAberration(px: number, spec: PulseSpec): void {
        const id = `aberr#${this.pulseSeq++}`;
        this.pulses.set(id, { elapsed: 0, spec, aberration: px });
    }

    /** Марево (горячий воздух); null — выключить. */
    setShimmer(args: ShimmerState | null): void {
        this.shimmer = args ? { ...args } : null;
    }

    /** Блюр конкретной вьюхи (отдельный BlurFilter, не общий фильтр). */
    blurObject(view: Container, strength = 3): void {
        const existing = this.objectBlurs.get(view);
        if (existing) {
            existing.strength = strength;
            return;
        }
        const blur = new BlurFilter({ strength, quality: 2, kernelSize: 5 });
        this.objectBlurs.set(view, blur);
        view.filters = [...(view.filters ?? []), blur];
    }

    /** Снять объектный блюр. */
    clearBlurObject(view: Container): void {
        const blur = this.objectBlurs.get(view);
        if (!blur) return;
        this.objectBlurs.delete(view);
        view.filters = (view.filters ?? []).filter((f) => f !== blur);
    }

    /** Сброс состояний и импульсов (объектный блюр не трогает). */
    clear(): void {
        this.blur = null;
        this.aberration = 0;
        this.shimmer = null;
        this.pulses.clear();
    }

    /** Тик: время марева, затухание импульсов, перенос состояния в шейдер. */
    update(dt: number): void {
        if (this.disposed) return;
        this.time += dt;
        for (const [id, p] of [...this.pulses]) {
            p.elapsed += dt;
            if (p.elapsed >= pulseTotal(p.spec)) this.pulses.delete(id);
        }
        // Эффективные значения: импульс перекрывает установленное состояние.
        let blur = this.blur;
        let aberration = this.aberration;
        for (const p of this.pulses.values()) {
            const env = pulseEnvelope(p.elapsed, p.spec);
            if (p.blur && env > 0) blur = { ...p.blur, strength: p.blur.strength * env };
            if (p.aberration !== undefined && env > 0) aberration = Math.max(aberration, p.aberration * env);
        }
        // uniforms-карта v8 даёт сами значения (не обёртки {value}).
        const u = this.uniforms.uniforms;
        u.uTime = this.time;
        u.uBlurStrength = blur ? blur.strength : 0;
        (u.uBlurCenter as Float32Array).set([
            blur?.center?.x ?? this.viewportX / 2,
            blur?.center?.y ?? this.viewportY / 2
        ]);
        u.uBlurRadius = blur?.radius ?? 0;
        u.uAberration = aberration;
        u.uShimmerAmp = this.shimmer ? this.shimmer.amp : 0;
        (u.uShimmerCenter as Float32Array).set([
            this.shimmer?.center?.x ?? this.viewportX / 2,
            this.shimmer?.center?.y ?? this.viewportY / 2
        ]);
        u.uShimmerRadius = this.shimmer?.radius ?? 0;
        u.uShimmerWl = this.shimmer?.wavelength ?? 40;
        u.uShimmerSpeed = this.shimmer?.speed ?? 1;
        this.filter.enabled =
            (blur !== null && blur.strength > 0.001) ||
            aberration > 0.001 ||
            (this.shimmer !== null && this.shimmer.amp > 0.001) ||
            this.pulses.size > 0;
    }

    /** Снять фильтр с корня мира и объектные блюры. */
    dispose(): void {
        this.disposed = true;
        this.clear();
        for (const view of [...this.objectBlurs.keys()]) this.clearBlurObject(view);
        this.worldRoot.filters = (this.worldRoot.filters ?? []).filter((f) => f !== this.filter);
    }
}