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; }