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