diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md
index 6000b26..ee9548e 100644
--- a/v2/CLAUDE.md
+++ b/v2/CLAUDE.md
@@ -1553,8 +1553,8 @@
- **Мост**: `weatherInfo()` (активный spec + rainDrops/snowFlakes + manual),
`setWeather(spec | null)` (частичная правка поверх базы; null — база
области; правка переживает смену области), `devSetWeather` (панель
- агентом + синхронизация слайдеров). Дев-панель: секция «ПОГОДА» — 5
- слайдеров (облачность/дождь/снег/ветер/направление в градусах) + сброс,
+ агентом + синхронизация слайдеров). Дев-панель: секция «ПОГОДА» — 6
+ слайдеров (облачность/дождь/снег/ветер/направление в градусах/туман) + сброс,
инфо-строка с счётчиками слоёв и режимом (база/правка).
- **Гейт** `weather.mjs` (14): база лугов/разъезда/рощи, ливень — стена
капель (счётчики >1000 — плотность от окна, не фиксированный список) и
@@ -1565,6 +1565,58 @@
правка панели переживает смену области — в гейте снимать
`setWeather(null)` перед проверкой базы. `check:fast` — 344 ✓.
+## Туман (2026-09-12): многослойный объёмный туман + слайдер дев-панели
+
+Фидбек: «туман сделать многослойным, с претензией на объём, но через
+множество слоёв по дальности от камеры», ползунок в дев-панели.
+
+- **Движок** (`render/fog.ts`): композитный проход — сцена рендерится в RT
+ с DepthTexture (FloatType, NearestFilter), полноэкранный квад
+ реконструирует мировую точку каждого пикселя (в орто глубина буфера
+ линейна: `along = near + depth·(far−near)`; `wp = camPos + f·along +
+ r·(ndc.x·halfW) + u·(ndc.y·halfH)`) и красит кадр стопкой из
+ FOG_BANDS (6) слоёв. Слой k копится с глубины
+ `fogBandStart(k, viewSize) = viewSize·(2.7 + k·0.44)` с фронтом
+ smoothstep ±FOG_BAND_SOFT (0.22): дальняя земля под всей стопкой
+ закрывается до ~0.75 (FOG_BAND_ALPHA 0.28 × 6), ближняя — первым слоем.
+ Клубы каждого слоя — свой fbm-шум по мировым XZ с дрейфом ветра
+ (глубокие слои медленнее — параллакс объёма); высотное затухание
+ (FOG_FADE_Y0/Y1 = 1/8) собирает туман в низинах, вершины торчат.
+ Цвет — env-свет сцены: `fogColor(amb, sun)` = (ambient + солнце·
+ FOG_ENV_SUN 0.45)·FOG_BRIGHT 0.75 — туман освещается вместе с миром
+ (день светло-серый, ночь тёмный). Юниформа uFogColor — общий IUniform
+ рендера и композита (как свет частиц), обновляется в конце setSun.
+- **Полоты ⟂ оси взгляда НЕ работают в изометрии** (забракованный дизайн):
+ взгляд вниз ~63°, плоскость от линии пересечения с рельефом уходит под
+ землю — стопка вырождается в пятна у камеры без глубинного градиента.
+- **ГЛАВНАЯ ГРАБЛЯ — пространство измерения глубины**: слои размечены в
+ долях viewSize ОТ КАМЕРЫ (ближний край кадра ~2.9·viewSize, дальний
+ ~5.1 — камера стоит на 4·viewSize, полукадр вдоль взгляда ~1.12·V), и
+ `d` в шейдере обязан быть `dot(wp − uCamPos, uCamF)` — дистанцией от
+ КАМЕРЫ. Вариант `dot(wp − target, f)` (от цели) даёт для видимых
+ пикселей d ∈ [−1.12·V, +1.12·V] — все шесть smoothstep'ов на слоях
+ 2.5..5·V возвращают 0, туман полностью прозрачен при зелёном fogInfo
+ (ловится только парой кадров вкл/выкл; в изоляте камера стоит ближе —
+ там «работало», маскируя баг).
+- **Рендер** (`voxelRenderer.ts`): `setFog(strength, t, wind)` + ветка
+ render(): сцена → fogPass.rt, композитный квад → post.rt или канвас
+ (define SRGB_OUT — конверсия linear RT при прямом рендере), затем
+ пост-проход игры как обычно. fogPass собирается лениво при первом
+ кадре с туманом. fogInfo() — снапшот диагностики (сила, цвет,
+ юниформы камеры).
+- **Погода**: WeatherSpec.fog (0..1) → WeatherSystem.update гонит
+ силу/время/ветер в рендер каждый тик. База погоды: пруды fog 0.3,
+ остальные 0. Дев-панель: 6-й слайдер «туман», мост `setWeather`/
+ `devSetWeather` — как остальные поля.
+- **Гейт weather.mjs** (+6 чеков, 19 всего): кадры вкл/выкл различаются,
+ глубинный градиент (верх кадра = даль светлее низа на разнице),
+ дрейф с ветром, ночь не выбелена, 6 слайдеров панели, слайдер тумана
+ включает ручной режим. Грабля замера: нижняя полоса строк — 400..540
+ (высота кадра 540; выход за границы декодера даёт NaN без падения).
+ `check:fast` — 377 ✓ (включая фикс pow-грабли lit-шейдера: степень
+ из TS в GLSL-шаблоне печаталась «2» (int) — pow не компилировался,
+ lit-слои молча не рисовали; в шаблоны числа — через `.toFixed(1)`).
+
## Рабочие привычки
- Для задач из 3+ шагов веди список задач и иди автономно.
diff --git a/v2/apps/game/index.html b/v2/apps/game/index.html
index 4064d48..31c4b51 100644
--- a/v2/apps/game/index.html
+++ b/v2/apps/game/index.html
@@ -358,6 +358,8 @@
+
+
diff --git a/v2/apps/game/src/data/weather.ts b/v2/apps/game/src/data/weather.ts
index 37cc820..340cd35 100644
--- a/v2/apps/game/src/data/weather.ts
+++ b/v2/apps/game/src/data/weather.ts
@@ -9,8 +9,8 @@
export const AREA_WEATHER: Record> = {
// Луга: лёгкий ветерок, почти ясно.
meadows: { windDir: 0.7, windStrength: 0.15, cloud: 0.15 },
- // Пруды: штиль, облачно, редкий снег в дымке.
- ponds: { windDir: 0.3, windStrength: 0.05, cloud: 0.4, snow: 0.25 },
+ // Пруды: штиль, облачно, редкий снег, дымка над низинами.
+ ponds: { windDir: 0.3, windStrength: 0.05, cloud: 0.4, snow: 0.25, fog: 0.3 },
// Деревня: заметный ветер, облачно.
zvenets: { windDir: 2.1, windStrength: 0.3, cloud: 0.2 },
// Роща: морось в кронах.
diff --git a/v2/apps/game/src/devview.ts b/v2/apps/game/src/devview.ts
index f92eb40..7c0354c 100644
--- a/v2/apps/game/src/devview.ts
+++ b/v2/apps/game/src/devview.ts
@@ -87,6 +87,7 @@
bind('dev-w-cloud', (v) => ({ cloud: v }));
bind('dev-w-rain', (v) => ({ rain: v }));
bind('dev-w-snow', (v) => ({ snow: v }));
+ bind('dev-w-fog', (v) => ({ fog: v }));
bind('dev-w-wind', (v) => ({ windStrength: v }));
bind('dev-w-dir', (deg) => ({ windDir: (deg * Math.PI) / 180 })); // слайдер в градусах
document.getElementById('dev-w-reset')!.addEventListener('click', () => {
@@ -152,6 +153,8 @@
set('dev-w-rain-v', w.rain.toFixed(2));
input('dev-w-snow', w.snow);
set('dev-w-snow-v', w.snow.toFixed(2));
+ input('dev-w-fog', w.fog);
+ set('dev-w-fog-v', w.fog.toFixed(2));
input('dev-w-wind', w.windStrength);
set('dev-w-wind-v', w.windStrength.toFixed(2));
input('dev-w-dir', Math.round((w.windDir * 180) / Math.PI));
diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts
index a2daa41..218a9c0 100644
--- a/v2/apps/game/src/main.ts
+++ b/v2/apps/game/src/main.ts
@@ -1354,6 +1354,10 @@
manual: weatherManual,
};
},
+ /** Снапшот тумана (диагностика гейтов): сила и собран ли композит. */
+ fogInfo() {
+ return renderer.fogInfo();
+ },
/** Окно видимости частиц (диагностика плотности осадков/пепла): AABB
* следа кадра для слоя [y0,y1] + параметры камеры. */
windowInfo(y0 = 0, y1 = 40) {
diff --git a/v2/apps/game/tools/checks/weather.mjs b/v2/apps/game/tools/checks/weather.mjs
index 1ddc250..39e3c74 100644
--- a/v2/apps/game/tools/checks/weather.mjs
+++ b/v2/apps/game/tools/checks/weather.mjs
@@ -1,8 +1,8 @@
/**
* Гейт погоды v2 (трек «погода в движке»): база погоды по областям, ручной
- * режим (дождь/снег рисуются и падают, облачность темнит кадр), кадр без
- * осадков, сброс к погоде области, управление из дев-панели. Скриншоты:
- * /tmp/v2_weather_*.png.
+ * режим (дождь/снег рисуются и падают, облачность темнит кадр, туман — слои
+ * по дальности с дрейфом и глубинным градиентом), кадр без осадков, сброс к
+ * погоде области, управление из дев-панели. Скриншоты: /tmp/v2_weather_*.png.
*
* Запуск: node apps/game/tools/checks/weather.mjs [--port 5330]
*/
@@ -31,6 +31,18 @@
return sum / n;
}
+/** Средняя яркость полосы строк кадра (глубинный градиент тумана:
+ * верх кадра — даль, там туман гуще). */
+function bandBrightness(rgba, width, y0, y1) {
+ let sum = 0, n = 0;
+ for (let y = y0; y < y1; y++) for (let x = 0; x < width; x++) {
+ const i = (y * width + x) * 4;
+ sum += rgba[i] * 0.3 + rgba[i + 1] * 0.6 + rgba[i + 2] * 0.1;
+ n++;
+ }
+ return sum / n;
+}
+
/** Число различающихся пикселей двух кадров (осадки движутся). */
function countDiff(a, b) {
let n = 0;
@@ -108,6 +120,34 @@
check('облачность темнит кадр (полная — заметно темнее)',
cloudBright < clearBright * 0.93, { clearBright, cloudBright });
+ // ---- Туман: слои по дальности, дрейф, глубинный градиент ----
+ await client.call('setWeather', { rain: 0, snow: 0, cloud: 0, fog: 0, windStrength: 0.6 });
+ await client.call('stepTicks', 2);
+ const clear = await shot(client, '/tmp/v2_weather_fog0.png');
+ await client.call('setWeather', { fog: 1 });
+ await client.call('stepTicks', 3);
+ const foggy = await shot(client, '/tmp/v2_weather_fog1.png');
+ const fogDiff = countDiff(clear, foggy);
+ check('туман рисуется: кадры без/с туманом различаются', fogDiff > 20000, { fogDiff });
+ const W = decodePng(readFileSync('/tmp/v2_weather_fog0.png')).width;
+ // разница «с туманом − без» по строкам: даль (верх) должен посветлеть
+ // сильнее близи (низ) — слои копятся с глубиной
+ const gradTop = bandBrightness(foggy, W, 0, 150) - bandBrightness(clear, W, 0, 150);
+ const gradBottom = bandBrightness(foggy, W, 400, 540) - bandBrightness(clear, W, 400, 540);
+ check('туман гуще вдали: верх кадра светлее низа на разнице',
+ gradTop > gradBottom + 5, { gradTop, gradBottom });
+ await client.call('stepTicks', 60); // ветер 0.6 гонит клубы
+ const foggy2 = await shot(client, '/tmp/v2_weather_fog1b.png');
+ const drift = countDiff(foggy, foggy2);
+ check('туман дрейфует с ветром: кадры различаются', drift > 5000, { drift });
+ await client.call('setClock', 0); // ночь — туман красится env-светом
+ await client.call('stepTicks', 5);
+ const nightFog = brightness(await shot(client, '/tmp/v2_weather_fog_night.png'));
+ check('ночь: туман тёмный (env-свет), кадр не выбелен',
+ nightFog < 90 && nightFog < brightness(foggy), { nightFog });
+ await client.call('setClock', 720);
+ await client.call('setWeather', null); // снять ручную правку перед рощей
+
// ---- setAsh(false): гейт-режим глушит и осадки с облачностью ----
await client.call('setWeather', null); // ручная правка переживает смену области — снять
await client.call('gotoArea', 'rust_grove'); // база рощи — морось 0.3
@@ -131,9 +171,18 @@
await client.call('setAsh', true); // вернуть атмосферу после гейт-режима
await client.call('devOpen');
const hasSliders = await client.evaluate(() =>
- ['dev-w-cloud', 'dev-w-rain', 'dev-w-snow', 'dev-w-wind', 'dev-w-dir']
+ ['dev-w-cloud', 'dev-w-rain', 'dev-w-snow', 'dev-w-wind', 'dev-w-dir', 'dev-w-fog']
.every((id) => document.getElementById(id) !== null));
- check('дев-панель: секция погоды с 5 слайдерами', hasSliders === true);
+ check('дев-панель: секция погоды с 6 слайдерами (включая туман)', hasSliders === true);
+ // слайдер тумана → input-событие → ручная правка активна
+ await client.evaluate(() => {
+ const el = document.getElementById('dev-w-fog');
+ el.value = '0.7';
+ el.dispatchEvent(new Event('input'));
+ });
+ wi = await client.call('weatherInfo');
+ check('дев-панель: слайдер тумана включает ручной режим', wi.manual?.fog === 0.7, wi);
+ await client.call('devSetWeather', null); // сброс + синхронизация слайдеров
// слайдер дождя → input-событие → ручная правка активна
await client.evaluate(() => {
const el = document.getElementById('dev-w-rain');
diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts
index 6109a1f..4d0e8bd 100644
--- a/v2/packages/engine/src/index.ts
+++ b/v2/packages/engine/src/index.ts
@@ -24,6 +24,7 @@
export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk';
export { VoxelRenderer, worldTileAtlas } from './render/voxelRenderer';
export { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, particleEnv, litVisibility, PARTICLE_LIGHT_MAX, PARTICLE_ENV_SUN, PARTICLE_LIT_VIS, PARTICLE_LIT_GAMMA } from './render/particles';
+export { makeFogUniforms, fogColor, fogBandStart, FOG_ENV_SUN, FOG_BRIGHT, FOG_BANDS, FOG_BAND_START, FOG_BAND_STEP, FOG_BAND_SOFT, FOG_BAND_ALPHA } from './render/fog';
export { postPass } from './render/postShader';
export type { PostOptions, PostPassSpec } from './render/postShader';
export type { PostPassHandle } from './render/voxelRenderer';
diff --git a/v2/packages/engine/src/render/__tests__/fog.test.ts b/v2/packages/engine/src/render/__tests__/fog.test.ts
new file mode 100644
index 0000000..784efed
--- /dev/null
+++ b/v2/packages/engine/src/render/__tests__/fog.test.ts
@@ -0,0 +1,67 @@
+/**
+ * Тесты тумана: чистые функции цвета (fogColor из env-света) и глубин слоёв
+ * (fogBandStart — от камеры вдоль взгляда), кламп fog в WeatherSpec. Композит
+ * и шейдер тестируются пробами с WebGL — здесь только математика.
+ */
+import { describe, expect, it } from 'vitest';
+import { fogColor, fogBandStart, FOG_ENV_SUN, FOG_BRIGHT, FOG_BAND_START, FOG_BAND_STEP } from '../fog';
+import { clampWeather } from '../weather';
+
+describe('fogColor (цвет тумана из env-света)', () => {
+ it('ambient целиком + доля солнца, умноженные на FOG_BRIGHT', () => {
+ const [r, g, b] = fogColor(
+ { r: 0.5, g: 0.6, b: 0.7, intensity: 0.5 },
+ { r: 0, g: 0, b: 0, intensity: 0 },
+ );
+ expect(r).toBeCloseTo(0.25 * FOG_BRIGHT);
+ expect(g).toBeCloseTo(0.3 * FOG_BRIGHT);
+ expect(b).toBeCloseTo(0.35 * FOG_BRIGHT);
+ });
+
+ it('солнце входит с долей FOG_ENV_SUN (больше, чем у частиц)', () => {
+ const [r] = fogColor(
+ { r: 0, g: 0, b: 0, intensity: 0 },
+ { r: 1, g: 1, b: 1, intensity: 2 },
+ );
+ expect(r).toBeCloseTo(Math.min(1, 2 * FOG_ENV_SUN * FOG_BRIGHT));
+ expect(FOG_ENV_SUN).toBeGreaterThan(0.3);
+ });
+
+ it('полдень не выбеливает: кламп 0..1, ночь — тёмный туман', () => {
+ const day = fogColor(
+ { r: 0.58, g: 0.58, b: 0.62, intensity: 0.55 },
+ { r: 1, g: 0.9, b: 0.8, intensity: 2 },
+ );
+ for (const c of day) expect(c).toBeLessThanOrEqual(1);
+ const night = fogColor(
+ { r: 0.04, g: 0.04, b: 0.07, intensity: 0.55 },
+ { r: 1, g: 1, b: 1, intensity: 0.08 },
+ );
+ const luma = night[0]! * 0.3 + night[1]! * 0.6 + night[2]! * 0.1;
+ expect(luma).toBeLessThan(0.1);
+ });
+});
+
+describe('fogBandStart (глубины слоёв по дальности)', () => {
+ it('старт и шаг в долях viewSize, строго по возрастанию', () => {
+ expect(fogBandStart(0, 16)).toBeCloseTo(16 * FOG_BAND_START);
+ expect(fogBandStart(1, 16) - fogBandStart(0, 16)).toBeCloseTo(16 * FOG_BAND_STEP);
+ for (let k = 1; k < 6; k++) expect(fogBandStart(k, 16)).toBeGreaterThan(fogBandStart(k - 1, 16));
+ });
+
+ it('полоса слоёв накрывает кадр: камера на 4·viewSize от цели, полукадр ~1.12', () => {
+ // ближний край кадра ~2.9·viewSize, дальний ~5.1 — первый слой до него,
+ // последний внутри дальнего края
+ expect(fogBandStart(0, 10)).toBeLessThan(29);
+ expect(fogBandStart(5, 10)).toBeLessThan(51);
+ });
+});
+
+describe('clampWeather: fog клампится как остальные поля', () => {
+ it('недостающее поле — дефолт 0, лишние значения — кламп', () => {
+ expect(clampWeather({}).fog).toBe(0);
+ expect(clampWeather({ fog: 0.42 }).fog).toBe(0.42);
+ expect(clampWeather({ fog: 5 }).fog).toBe(1);
+ expect(clampWeather({ fog: -1 }).fog).toBe(0);
+ });
+});
\ No newline at end of file
diff --git a/v2/packages/engine/src/render/__tests__/weather.test.ts b/v2/packages/engine/src/render/__tests__/weather.test.ts
index fa6140a..e5abbe6 100644
--- a/v2/packages/engine/src/render/__tests__/weather.test.ts
+++ b/v2/packages/engine/src/render/__tests__/weather.test.ts
@@ -156,6 +156,8 @@
it('set клампит и current отдаёт нормализованную погоду', () => {
const sys = new WeatherSystem({
addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }),
+ addFog: () => ({ update: () => {}, dispose: () => {} }),
+ viewAxes: () => ({ camPos: [0, 0, 0], target: [0, 0, 0], forward: [0, 0, 1], halfW: 1, halfH: 1 }),
setCloud: () => {},
visibleWindow: () => ({ minX: 0, maxX: 0, minZ: 0, maxZ: 0 }),
} as never);
@@ -168,6 +170,7 @@
it('update считает фактические капли/снежинки от окна', () => {
const sys = new WeatherSystem({
addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }),
+ setFog: () => {},
setCloud: () => {},
visibleWindow: () => ({ minX: 0, maxX: 48, minZ: 0, maxZ: 32 }),
} as never);
diff --git a/v2/packages/engine/src/render/fog.ts b/v2/packages/engine/src/render/fog.ts
new file mode 100644
index 0000000..3ecc3ba
--- /dev/null
+++ b/v2/packages/engine/src/render/fog.ts
@@ -0,0 +1,184 @@
+/**
+ * Туман погоды: слои по дальности от камеры. Камера ортографическая, поэтому
+ * «объём» собирается в композитном проходе: сцена рендерится в 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 {
+ 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, 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 };
+}
\ No newline at end of file
diff --git a/v2/packages/engine/src/render/particles.ts b/v2/packages/engine/src/render/particles.ts
index ddca40e..b778364 100644
--- a/v2/packages/engine/src/render/particles.ts
+++ b/v2/packages/engine/src/render/particles.ts
@@ -248,7 +248,9 @@
// tint тоже гасится vis: иначе ночью цвет остаётся полным и полупрозрачные
// СЕРЫЕ точки читаются на тёмном фоне (запрос «в темноте ещё темнее»)
received += uEnv;
- vis = clamp(pow(max(received.r, max(received.g, received.b)) * ${PARTICLE_LIT_VIS}, ${PARTICLE_LIT_GAMMA}), 0.0, 1.0);
+ // грабля: число из TS в шаблоне печатается как "2" (int) — pow(int)
+ // в GLSL ES не компилируется, степень обязана быть float-литералом
+ vis = clamp(pow(max(received.r, max(received.g, received.b)) * ${PARTICLE_LIT_VIS}, ${PARTICLE_LIT_GAMMA.toFixed(1)}), 0.0, 1.0);
#endif
gl_FragColor = vec4(vColor * (uTint * vis + received), vAlpha * vis);
}`;
diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts
index ef97108..d4a1aad 100644
--- a/v2/packages/engine/src/render/voxelRenderer.ts
+++ b/v2/packages/engine/src/render/voxelRenderer.ts
@@ -14,6 +14,7 @@
import { cloudDim, lerpColor } from './daynight';
import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, particleEnv } from './particles';
import type { ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './particles';
+import { createFogComposite, makeFogUniforms, fogColor } from './fog';
import { particleWindow } from './polyWindow';
/** Облако деформированных вокселей (позы рига — не выровнены по сетке). */
@@ -169,6 +170,13 @@
/** Общий блок юниформ света частиц: все слои ссылаются на те же IUniform —
* рост ёмкости слоя и пересборка области свет не теряют. */
private readonly particleLightsU: ParticleLightUniforms = makeParticleLightUniforms();
+ /** Цвет тумана (общий блок, шарится с композитным квадом тумана). */
+ private readonly fogU: Record = makeFogUniforms();
+ /** Туман: сила/время/ветер (setFog) и лениво собранный композитный проход. */
+ private fogStrength = 0;
+ private fogTime = 0;
+ private fogWind: readonly [number, number] = [0, 0];
+ private fogPass: ReturnType | null = null;
private sunK = 0.5;
private nightK = 0;
/** Облачность 0..1: гасит солнце и ambient, фон — к пасмурному серому.
@@ -412,6 +420,42 @@
}
/**
+ * Туман погоды (render/fog.ts): сила 0..1 + время цикла + вектор ветра.
+ * Зовётся системой погоды каждый тик; рендер композитит слои по глубине
+ * в render() (сцена → RT с depth-текстурой → полноэкранный квад).
+ */
+ setFog(strength: number, t: number, wind: readonly [number, number]): void {
+ this.fogStrength = Math.min(1, Math.max(0, strength));
+ this.fogTime = t;
+ this.fogWind = wind;
+ }
+
+ /** Снапшот состояния тумана (диагностика гейтов): сила, собран ли
+ * композитный проход, цвет и юниформы композита (пространственные —
+ * сверка реконструкции с камерой). */
+ fogInfo(): {
+ strength: number; composite: boolean;
+ color: [number, number, number];
+ cam: { pos: [number, number, number]; near: number; far: number; halfW: number; halfH: number } | null;
+ } {
+ const u = this.fogPass?.mat.uniforms;
+ const c = this.fogU.uFogColor!.value as THREE.Vector3;
+ const p = u?.uCamPos!.value as THREE.Vector3 | undefined;
+ return {
+ strength: this.fogStrength,
+ composite: this.fogPass !== null,
+ color: [c.x, c.y, c.z],
+ cam: u && p ? {
+ pos: [p.x, p.y, p.z],
+ near: u.uNear!.value as number,
+ far: u.uFar!.value as number,
+ halfW: u.uHalfW!.value as number,
+ halfH: u.uHalfH!.value as number,
+ } : null,
+ };
+ }
+
+ /**
* Свет частицам: снапшот источников игры (общий для всех слоёв рендера —
* вклад считается в шейдере частиц, ambient-ночь идёт отдельно через
* setTint слоёв). Вызывать каждый тик — мерцание источников едет с миром.
@@ -534,6 +578,13 @@
{ r: this.sun.color.r, g: this.sun.color.g, b: this.sun.color.b, intensity: this.sun.intensity },
);
(this.particleLightsU.uEnv!.value as THREE.Vector3).set(e[0], e[1], e[2]);
+ // цвет тумана: тот же env-свет с яркой долей солнца — туман освещается
+ // вместе с миром (setSun зовётся при любом изменении света)
+ const fc = fogColor(
+ { r: this.ambient.color.r, g: this.ambient.color.g, b: this.ambient.color.b, intensity: this.ambient.intensity },
+ { r: this.sun.color.r, g: this.sun.color.g, b: this.sun.color.b, intensity: this.sun.intensity },
+ );
+ (this.fogU.uFogColor!.value as THREE.Vector3).set(fc[0], fc[1], fc[2]);
}
/**
@@ -585,9 +636,34 @@
p.rt.dispose();
}
- /** Один кадр. */
+ /** Один кадр. При активном тумане сцена идёт через RT с глубиной, затем
+ * композитный квад красит её слоями по дальности (render/fog.ts) —
+ * в post.rt игры (пост-проход поверх тумана) или прямо на канвас. */
render(): void {
this.camera.updateMatrixWorld();
+ if (this.fogStrength > 0.001) {
+ if (!this.fogPass) {
+ const size = new THREE.Vector2();
+ this.renderer.getSize(size);
+ this.fogPass = createFogComposite(this.fogU, { x: size.x, y: size.y });
+ }
+ this.renderer.setRenderTarget(this.fogPass.rt);
+ this.renderer.render(this.scene, this.camera);
+ this.updateFogUniforms();
+ // квад на канвасе обязан конверсию sRGB делать сам (RT линейный)
+ const srgb = this.post ? 0 : 1;
+ if ((this.fogPass.mat.defines!.SRGB_OUT as number) !== srgb) {
+ this.fogPass.mat.defines!.SRGB_OUT = srgb;
+ this.fogPass.mat.needsUpdate = true;
+ }
+ this.renderer.setRenderTarget(this.post ? this.post.rt : null);
+ this.renderer.render(this.fogPass.scene, this.fogPass.camera);
+ if (this.post) {
+ this.renderer.setRenderTarget(null);
+ this.renderer.render(this.post.scene, this.post.camera);
+ }
+ return;
+ }
if (this.post) {
this.renderer.setRenderTarget(this.post.rt);
this.renderer.render(this.scene, this.camera);
@@ -598,6 +674,24 @@
}
}
+ /** Юниформы композита тумана: базис камеры (позиция/взгляд/оси кадра),
+ * линейный диапазон глубины и текущие сила/время/ветер. */
+ private updateFogUniforms(): void {
+ const u = this.fogPass!.mat.uniforms;
+ const e = this.camera.matrixWorld.elements;
+ (u.uCamPos!.value as THREE.Vector3).copy(this.camera.position);
+ (u.uCamF!.value as THREE.Vector3).set(-e[8]!, -e[9]!, -e[10]!);
+ (u.uCamR!.value as THREE.Vector3).set(e[0]!, e[1]!, e[2]!);
+ (u.uCamU!.value as THREE.Vector3).set(e[4]!, e[5]!, e[6]!);
+ u.uNear!.value = this.camera.near;
+ u.uFar!.value = this.camera.far;
+ u.uHalfW!.value = this.camera.right;
+ u.uHalfH!.value = this.camera.top;
+ u.uStrength!.value = this.fogStrength;
+ u.uTime!.value = this.fogTime;
+ (u.uWind!.value as THREE.Vector2).set(this.fogWind[0], this.fogWind[1]);
+ }
+
/** Ортокамера диметрии 2:1, смотрит в target с изо-направления. */
private makeCamera(width: number, height: number): THREE.OrthographicCamera {
const aspect = width / height;
diff --git a/v2/packages/engine/src/render/weather.ts b/v2/packages/engine/src/render/weather.ts
index d7c9a62..90dbb49 100644
--- a/v2/packages/engine/src/render/weather.ts
+++ b/v2/packages/engine/src/render/weather.ts
@@ -34,11 +34,13 @@
rain: number;
/** Интенсивность снега 0..1. */
snow: number;
+ /** Многослойный туман 0..1 (слои по дальности от камеры, render/fog.ts). */
+ fog: number;
}
/** Дождь без ветра, ясно — нейтральная погода по умолчанию. */
export const DEFAULT_WEATHER: WeatherSpec = {
- windDir: 0.7, windStrength: 0, cloud: 0, rain: 0, snow: 0,
+ windDir: 0.7, windStrength: 0, cloud: 0, rain: 0, snow: 0, fog: 0,
};
/** Кламп спецификации: частичный ввод (мост/панель) → полная валидная. */
@@ -51,6 +53,7 @@
cloud: clamp01(num(w.cloud, DEFAULT_WEATHER.cloud)),
rain: clamp01(num(w.rain, DEFAULT_WEATHER.rain)),
snow: clamp01(num(w.snow, DEFAULT_WEATHER.snow)),
+ fog: clamp01(num(w.fog, DEFAULT_WEATHER.fog)),
};
}
@@ -242,13 +245,15 @@
return this.spec;
}
- /** Тик: позы слоёв от времени цикла (детерминированно). */
+ /** Тик: позы слоёв от времени цикла (детерминированно). Туман уходит в
+ * рендер (setFog) — слои по дальности композитятся в кадре рендера. */
update(t: number): void {
const win = weatherWindow(this.renderer, RAIN_TOP);
const drops = rainPoints(this.spec, t, win);
this.rain.update(drops.map((d) => ({ pos: d.pos, color: RAIN_COLOR, alpha: d.alpha, size: d.size })));
const flakes = snowPoints(this.spec, t, win);
this.snow.update(flakes.map((f) => ({ pos: f.pos, color: SNOW_COLOR, alpha: f.alpha, size: f.size ?? SNOW_SIZE })));
+ this.renderer.setFog(this.spec.fog, t, windVector(this.spec));
this.lastDrops = drops.length / RAIN_CHAIN; // штрих = одна капля
this.lastFlakes = flakes.length;
}