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

/** Блок юниформ цвета тумана: один экземпляр на рендер, композит ссылается
 *  на ТЕ ЖЕ 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;

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

// ---- Разметка слоёв в перспективных режимах ----
// Пирамида не даёт фиксированной полосы глубины: след кадра по земле зависит
// от pitch и высоты камеры. Слои размечаем по наземному следу фрустума:
// нижняя грань кадра (pitch+fov/2 ниже горизонта) бьёт землю на nearG,
// верхняя (pitch−fov/2, кламп 0.05 — взгляд выше горизонта не бьёт землю
// вблизи) — на farG = eyeH/tan(...); стопка растягивается на этот след.

/** Cap дальнего края стопки (в долях viewSize): в 1st person у горизонта
 *  farG ушёл бы в бесконечность — стопка размазывается в телеграфный столб. */
export const PERSP_FOG_FAR_K = 6;

/** Разметка слоёв тумана под камеру. Чистая функция: iso — легаси-масштаб
 *  viewSize·констант (регрессионный замок изо-кадра), persp — след земли
 *  фрустума: start = nearG·0.9 (задел на подъём), шаг до farG. Вырожденные
 *  случаи (взгляд в небо: обе грани выше горизонта) дают конечную стопку —
 *  start/step клампятся, NaN невозможен. */
export function fogBandLayout(cam: {
    mode: CameraMode;
    /** Размер кадра (полувысота орто / калибровка капа перспективы). */
    viewSize: number;
    /** Перспектива: pitch орбиты (рад, >0 — камера смотрит вниз) и высота
     *  камеры над плоскостью цели. */
    pitch: number;
    eyeH: number;
    /** Вертикальный fov (рад). */
    fov: number;
}): { start: number; step: number; nearG: number; farG: number } {
    if (cam.mode === 'iso') {
        return {
            start: cam.viewSize * FOG_BAND_START,
            step: cam.viewSize * FOG_BAND_STEP,
            nearG: cam.viewSize * FOG_BAND_START,
            farG: cam.viewSize * (FOG_BAND_START + (FOG_BANDS - 1) * FOG_BAND_STEP),
        };
    }
    const bottom = Math.min(Math.max(cam.pitch + cam.fov / 2, 0.05), 1.5);
    const nearG = cam.eyeH / Math.tan(bottom);
    const top = Math.max(cam.pitch - cam.fov / 2, 0.05);
    const farG = Math.min(cam.eyeH / Math.tan(top), cam.viewSize * PERSP_FOG_FAR_K);
    const start = Math.min(nearG, farG) * 0.9;
    const step = Math.max((farG - start) / (FOG_BANDS - 1), cam.viewSize * 0.001);
    return { start, step, nearG, farG };
}

/** Композитный шейдер тумана: сэмпл сцены + глубина → мировая точка →
 *  стопка слоёв по дистанции от камеры. 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 float uBandStart;
uniform float uBandStep;
uniform float uTanHalf;
uniform float uAspect;
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;
#ifdef PERSP
    // перспектива: глубина буфера нелинейна — восстанавливаем дистанцию
    // вдоль взгляда (along = near·far/(far − (far−near)·depth)), полуразмеры
    // кадра растут с along по тангенсу fov
    float along = (uNear * uFar) / (uFar - (uFar - uNear) * depth);
    float halfH = uTanHalf * along;
    float halfW = halfH * uAspect;
#else
    // ортокамера: глубина буфера линейна — дистанция вдоль взгляда без деления
    float along = uNear + depth * (uFar - uNear);
    float halfH = uHalfH;
    float halfW = uHalfW;
#endif
    vec2 ndc = vUv * 2.0 - 1.0;
    vec3 wp = uCamPos + uCamF * along + uCamR * (ndc.x * halfW) + uCamU * (ndc.y * halfH);
    // грабля: глубину пикселя меряем ОТ КАМЕРЫ, не от цели — слои размечены
    // вдоль взгляда от камеры (см. fogBandLayout: изо — доли viewSize,
    // перспектива — след земли фрустума)
    float d = dot(wp - uCamPos, uCamF);
    float fog = 0.0;
    for (int k = 0; k < ${FOG_BANDS}; k++) {
        float fk = float(k);
#ifdef PERSP
        // k-й слой копится с его глубины: разметка юниформами (fogBandLayout),
        // мягкость фронта = полшага (та же пропорция FOG_BAND_SOFT/STEP)
        float band = smoothstep(
            uBandStart - uBandStep * 0.5 + fk * uBandStep,
            uBandStart + uBandStep * 0.5 + fk * uBandStep, d);
#else
        // 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);
#endif
        // клубы слоя: fbm по мировым XZ, свой дрейф; глубокие слои медленнее
        // (параллакс объёма) и с собственным сдвигом узора
        vec2 p = wp.xz * 0.09;
        vec2 drift = uWind * uTime * (0.42 - fk * 0.05) + 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);
        fog += band * billow;
    }
    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 },
            uBandStart: { value: 1 }, uBandStep: { value: 1 },
            uTanHalf: { value: 0.5 }, uAspect: { 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 };
}