/**
* Пост-эффекты: один кастомный фильтр на корне мира — переменный блюр
* (глобальный и точечный с радиусом), хроматическая аберрация, марево.
* 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);
}
}