Newer
Older
rpg / v2 / packages / engine / src / render / fog.ts
/**
 * Туман погоды: слои по дальности от камеры. Камера ортографическая, поэтому
 * «объём» собирается в композитном проходе: сцена рендерится в RT с depth-
 * текстурой, полноэкранный квад реконструирует мировую точку каждого пикселя
 * (в орто глубина буфера линейна — без перспективного деления) и красит кадр
 * стопкой из FOG_BANDS слоёв, размеченных по дистанции вдоль оси взгляда от
 * КАМЕРЫ (не от цели — грабля невидимого тумана): k-й слой начинается на
 * своей глубине и дальше накапливается —
 * дальняя земля перекрыта всеми слоями, ближняя — первыми. Клубы каждого
 * слоя — свой fbm-шум по мировым XZ со своим дрейфом (глубокие слои медленнее
 * — параллакс объёма); высотное затухание собирает туман в низинах.
 * Полоты-квады ⟂ оси взгляда НЕ работают в изометрии: взгляд смотрит вниз
 * под ~63°, плоскость от линии пересечения с рельефом уходит под землю, и
 * стопка вырождается в пятна у камеры (грабля изолята dbg-fogiso).
 * Цвет — env-свет сцены (общий блок юниформ, рендер обновляет в конце
 * setSun): туман освещается вместе с миром. Чистые функции (fogColor,
 * fogBandStart) — тесты без WebGL.
 */
import * as THREE from 'three';
import type { RgbIntensity } from './particles';

/** Блок юниформ цвета тумана: один экземпляр на рендер, композит ссылается
 *  на ТЕ ЖЕ IUniform (как свет частиц) — рендер обновляет в конце setSun. */
export function makeFogUniforms(): Record<string, THREE.IUniform> {
    return { uFogColor: { value: new THREE.Vector3(0.8, 0.82, 0.85) } };
}

/** Доля прямого солнца в цвете тумана: воздух ловит свет со всех сторон —
 *  чуть больше, чем билборд-частица (PARTICLE_ENV_SUN 0.3). */
export const FOG_ENV_SUN = 0.45;

/** Яркость цвета тумана: env-свет сцены в полдень ~1.2 — без гашения туман
 *  выбеливает кадр; 0.75 даёт светло-серую дымку в день, тёмную ночью. */
export const FOG_BRIGHT = 0.75;

/** Цвет тумана из env-света сцены: ambient целиком + доля солнца, кламп
 *  0..1. Чистая функция — рендер вызывает при каждом обновлении света. */
export function fogColor(amb: RgbIntensity, sun: RgbIntensity): [number, number, number] {
    const ch = (a: number, s: number): number =>
        Math.min(1, Math.max(0, (a * amb.intensity + s * sun.intensity * FOG_ENV_SUN) * FOG_BRIGHT));
    return [
        ch(amb.r, sun.r),
        ch(amb.g, sun.g),
        ch(amb.b, sun.b),
    ];
}

// ---- Разметка слоёв по глубине (в долях viewSize камеры) ----
// Рамка кадра по глубине: камера стоит viewSize·4 от цели, полукадр по
// вертикали уходит вдоль взгляда на viewSize/sin(elev) ≈ 1.12·viewSize —
// ближний край кадра на глубине ~2.9·viewSize от ЦЕЛИ, дальний ~5.1.
// Слои размечены в этой полосе: первый чуть перед ближним краем (задел на
// подъём тумана), последний внутри дальнего края.

/** Число слоёв тумана. */
export const FOG_BANDS = 6;

/** Глубина старта первого слоя от цели (в viewSize). */
export const FOG_BAND_START = 2.7;

/** Шаг глубины между слоями (в viewSize). */
export const FOG_BAND_STEP = 0.44;

/** Мягкость фронта слоя (в viewSize) — резкая граница слоя видна как линия. */
export const FOG_BAND_SOFT = 0.22;

/** Вклад одного слоя в альфу (при fog = 1, до шума): дальняя земля под всей
 *  стопкой закрывается ~0.75 — густо, но силуэты читаются. */
export const FOG_BAND_ALPHA = 0.28;

/** Высотное затухание тумана: полно ниже y0, ноль выше y1 (юниты мира) —
 *  туман собирается в низинах, вершины торчат. */
export const FOG_FADE_Y0 = 1.0;
export const FOG_FADE_Y1 = 8.0;

/** Глубина старта слоя k от камеры вдоль взгляда (юниты). Чистая функция. */
export function fogBandStart(k: number, viewSize: number): number {
    return viewSize * (FOG_BAND_START + k * FOG_BAND_STEP);
}

/** Композитный шейдер тумана: сэмпл сцены + глубина → мировая точка →
 *  стопка слоёв по дистанции от цели. SRGB_OUT 1 — квад рисуется прямо
 *  на канвас (нет пост-прохода игры): нужна конверсия линейного RT. */
export const FOG_FRAG = `
varying vec2 vUv;
uniform sampler2D tDiffuse;
uniform sampler2D tDepth;
uniform vec3 uCamPos;
uniform vec3 uCamR;
uniform vec3 uCamU;
uniform vec3 uCamF;
uniform float uNear;
uniform float uFar;
uniform float uHalfW;
uniform float uHalfH;
uniform vec3 uFogColor;
uniform float uStrength;
uniform float uTime;
uniform vec2 uWind;
float ahash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float anoise(vec2 p) {
    vec2 i = floor(p), f = fract(p);
    vec2 q = f * f * (3.0 - 2.0 * f);
    return mix(mix(ahash(i), ahash(i + vec2(1.0, 0.0)), q.x),
               mix(ahash(i + vec2(0.0, 1.0)), ahash(i + vec2(1.0, 1.0)), q.x), q.y);
}
float afbm(vec2 p) {
    float s = 0.0, a = 0.5;
    for (int k = 0; k < 3; k++) { s += a * anoise(p); p = p * 2.03 + vec2(17.3); a *= 0.5; }
    return s;
}
void main() {
    vec3 scene = texture2D(tDiffuse, vUv).rgb;
    // ортокамера: глубина буфера линейна — дистанция вдоль взгляда без деления
    float depth = texture2D(tDepth, vUv).x;
    float along = uNear + depth * (uFar - uNear);
    vec2 ndc = vUv * 2.0 - 1.0;
    vec3 wp = uCamPos + uCamF * along + uCamR * (ndc.x * uHalfW) + uCamU * (ndc.y * uHalfH);
    // грабля: глубину пикселя меряем ОТ КАМЕРЫ, не от цели — слои размечены
    // в долях viewSize вдоль взгляда от камеры (ближний край кадра ~2.9·V,
    // дальний ~5.1·V; от цели тот же кадр — это −1.12·V..+1.12·V, и слои на
    // 2.5..5·V от цели дали бы band = 0 всюду — туман невидим)
    float d = dot(wp - uCamPos, uCamF);
    float fog = 0.0;
    for (int k = 0; k < ${FOG_BANDS}; k++) {
        float fk = float(k);
        // k-й слой копится с его глубины: дальние пиксели под большей стопкой
        float band = smoothstep(
            uHalfH * ${(FOG_BAND_START - FOG_BAND_SOFT).toFixed(2)} + fk * uHalfH * ${FOG_BAND_STEP.toFixed(2)},
            uHalfH * ${(FOG_BAND_START + FOG_BAND_SOFT).toFixed(2)} + fk * uHalfH * ${FOG_BAND_STEP.toFixed(2)}, d);
        // клубы слоя: fbm по мировым XZ, свой дрейф; глубокие слои медленнее
        // (параллакс объёма) и с собственным сдвигом узора
        vec2 p = wp.xz * 0.09;
        vec2 drift = uWind * uTime * (0.3 + fk * 0.06) + fk * vec2(13.7, 7.9);
        float n = 0.6 * afbm(p + drift)
                + 0.4 * afbm(p * 1.7 + drift * 0.6 + (fk + 1.0) * uTime * vec2(0.021, -0.017));
        float billow = clamp(0.5 + (n - 0.5) * 1.9, 0.0, 1.2);
        float h = 1.0 - smoothstep(${FOG_FADE_Y0.toFixed(1)}, ${FOG_FADE_Y1.toFixed(1)}, wp.y);
        fog += band * billow * h;
    }
    float a = clamp(fog * uStrength * ${FOG_BAND_ALPHA.toFixed(2)}, 0.0, 1.0);
    vec3 c = mix(scene, uFogColor, a);
#if SRGB_OUT
    c = linearToOutputTexel(vec4(c, 1.0)).rgb;
#endif
    gl_FragColor = vec4(c, 1.0);
}`;

/** Композитный квад тумана: полноэкранная сцена для внутреннего прохода
 *  рендера (сцена → RT с глубиной → этот квад → post.rt или канвас). */
export function createFogComposite(
    fogU: Record<string, THREE.IUniform>, size: { x: number; y: number },
): { rt: THREE.WebGLRenderTarget; scene: THREE.Scene; camera: THREE.OrthographicCamera; mat: THREE.ShaderMaterial } {
    const depthTexture = new THREE.DepthTexture(size.x, size.y);
    depthTexture.type = THREE.FloatType; // 8-битная глубина полосит на градиенте
    depthTexture.minFilter = THREE.NearestFilter;
    depthTexture.magFilter = THREE.NearestFilter;
    const rt = new THREE.WebGLRenderTarget(size.x, size.y, { depthTexture, depthBuffer: true });
    const mat = new THREE.ShaderMaterial({
        vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
        fragmentShader: FOG_FRAG,
        uniforms: {
            ...fogU, // uFogColor — общий IUniform с рендером
            tDiffuse: { value: rt.texture },
            tDepth: { value: depthTexture },
            uCamPos: { value: new THREE.Vector3() },
            uCamR: { value: new THREE.Vector3() },
            uCamU: { value: new THREE.Vector3() },
            uCamF: { value: new THREE.Vector3() },
            uNear: { value: 0.1 }, uFar: { value: 1 },
            uHalfW: { value: 1 }, uHalfH: { value: 1 },
            uStrength: { value: 0 }, uTime: { value: 0 },
            uWind: { value: new THREE.Vector2() },
        },
        defines: { SRGB_OUT: 0 },
        depthTest: false, depthWrite: false,
    });
    const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
    quad.frustumCulled = false;
    const scene = new THREE.Scene();
    scene.add(quad);
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
    return { rt, scene, camera, mat };
}