/**
* Туман погоды: слои по дальности от камеры. Камера ортографическая, поэтому
* «объём» собирается в композитном проходе: сцена рендерится в 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 };
}