diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index ac0e06a..e64cf40 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -87,6 +87,12 @@ модель без риги рисуется через dummyRig (все воксели к одному root); - `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1, dirLight + shadow map + hemisphere, низкое разрешение (480×270); + пост-обработка (трек 31): `setPostPass(fragmentShader, uniforms?)` — сцена + рендерится в WebGLRenderTarget (NearestFilter 1:1, размер из getSize), + затем полноэкранный квад (POST_VERT: `vUv = uv` + OrthographicCamera(-1,1,1,-1,0,1), + depthTest/Write false); `PostPassHandle {uniforms, dispose}` — юниформы + игры тикают сами (uTime заводится рендерером всегда); null/`clearPostPass` + — прямой рендер; `addVoxelCloud` — instanced-кубы для поз анимации (не выровнены по сетке; обновление позы без пересборки геометрии); второй аргумент `tiles` (`resolveTiles(model)`) включает текстурный слой: атлас-канвас @@ -1132,6 +1138,48 @@ - **плотность бликов**: STEP=19 при 48800 клетках воды прудов давал ~470 мерцающих точек (суета); редкие вспышки — STEP=128. +## Трек 31 (2026-09-12): пост-обработка движка + воздух области (листья/пыль) + +Фидбек: «можно добавить разных эффектов/шейдеров? и пыль и падающие листья». + +- **Движок** (`render/voxelRenderer.ts` + `render/postShader.ts`): см. + структуру выше — `VoxelRenderer.setPostPass(fragmentShader, uniforms?)` + (RT + полноэкранный квад) и сборщик `postPass({vignette?, grain?, wobble?})` + (поле !== undefined включает эффект; юниформы spec'а со стартовыми + значениями). 4 юнит-теста (сборка фрагмента, порядок, объявления). +- **Детерминизм кадр-равенств**: «зерно» — статичный хэш по координатам + пикселя (БЕЗ времени), анимируется только дисторсия через uTime; игра + привязывает active к `ashEnabled` — `setAsh(false)` замораживает uTime и + гасит дисторсию, кадр полностью детерминирован. Дисторсия «жары» — только + там, где она есть (зал Машины): проход пересобирается в setArea. +- **Игра — `src/screenShader.ts`** (`PostFx`): виньетка 0.32 днём → 0.5 к + ночи (nightFactor), зерно 0.02, дисторсия жары 0.004 в machine_hall. + Пересобирается в setArea (wobble печётся в шейдер), юниформы тикают. +- **Игра — `src/airFx.ts`** (`AirFx`, профили 'leaves'/'dust'): листья рощи + (26 шт, слой [0,26], падение ~1.1 юнит/с + покачивание, цвета кроны + + тёплый лист) и пыль разъезда (40 шт, слой [0,18], size 0.7, alpha 0.32, + дрейф-взвесь). Окно видимости как у пепла (particleWindow + маржа 16, + wrap, edgeAlpha — частицы не появляются из воздуха). Карта `AIRFX` в + `data/lighting.ts` {rust_grove: 'leaves', old_siding: 'dust'}; пересборка + в rebuildAmbient (гаснет вместе с атмосферой мостом setAsh(false)); + `ambientInfo.air` для гейта. +- **Грабли**: + - **uTime не приходит из spec'а**: `postPass()` кладёт в юниформы только + эффекты (uVignette/uGrain/uWobble), а `uTime` объявлен в шапке шейдера + всегда — заводить его обязан `setPostPass` (было: `u.uTime!.value` в + игре падало TypeError при старте страницы — гейты молча падали по + таймауту маяка). Правило: юниформа, объявленная в шейдере, обязана + существовать в материале с первого кадра. + - **объявления юниформ — ДО main** (GLSL 1): сборщик сначала decls, потом + HEAD+тело; тест «полный набор» ловит использование без объявления. + - **порог виньетки в гейте — мягкий**: зона углов кадра несёт и контент + (небо ярче земли) — чистый эффект ~-25% на углу размывается содержимым; + «углы в среднем темнее центра ×0.92» на детерминированном кадре. + - **медленная пыль не видна за 40 тиков**: дрейф-взвесь 0.3 юнит/с — + субпиксель; гейту нужен долгий шаг (400 тиков) или проверка info. + - попутно: гейт panels ждал 16 тайлей манекена (стало 19 со скинами + трека 29) — статы гейта синхронны с треком 29. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/airFx.ts b/v2/apps/game/src/airFx.ts new file mode 100644 index 0000000..31327f3 --- /dev/null +++ b/v2/apps/game/src/airFx.ts @@ -0,0 +1,88 @@ +/** + * Воздушные частицы области сверх атмосферы v1: падающие листья рощи + * (профиль 'leaves' — медленный листопад с покачиванием, редкий тёплый + * лист) и мелкая пыль разъезда ('dust' — лёгкая взвесь, еле ползёт). + * Отдельный слой частиц движка (спрайты-билборды, не воксели): позы — + * детерминированные синусы от времени цикла (гейт-стабильно); окно + * видимости камеры с перекрытием, в марже за экраном альфа гаснет — + * лист/пылинка не «появляется из воздуха». + */ +import type { ParticleLayer, VoxelRenderer } from '@rpg/engine'; +import { edgeAlpha, particleWindow, wrap } from './particlesWindow'; + +/** Профиль воздушных частиц области. */ +export type AirFxKind = 'leaves' | 'dust'; + +/** Параметры профилей: всё в юнитах мира. */ +const COUNT = 26; // листьев в кронах рощи +const LEAVES_H = 26; // слой листопада (кроны ~17-23 + запас) +const LEAVES_FALL = 1.1; // скорость падения листа (юнит/сек) +const LEAVES_COLORS = ['#7d8662', '#5e6b4a', '#c9a04a'] as const; // крона + тёплый лист +const DUST_COUNT = 40; +const DUST_H = 18; // слой пыли над разъездом +const DUST_COLOR = '#94949e'; // P6 — пыль в свете +const DUST_ALPHA = 0.32; +const MARGIN = 16; // перекрытие окна за краем кадра + +export class AirFx { + private readonly layer: ParticleLayer; + + constructor( + private readonly renderer: VoxelRenderer, + private readonly kind: AirFxKind, + ) { + this.layer = renderer.addParticles(); + } + + /** Снапшот для моста: профиль и число частиц. */ + get info(): { kind: AirFxKind; count: number } { + return { kind: this.kind, count: this.kind === 'leaves' ? COUNT : DUST_COUNT }; + } + + /** Поза слоя: время цикла (окно — из цели камеры рендерера). */ + update(t: number): void { + const points: { pos: [number, number, number]; color: string; alpha?: number; size?: number }[] = []; + if (this.kind === 'leaves') { + const win = particleWindow(this.renderer, 0, LEAVES_H, MARGIN); + for (let i = 0; i < COUNT; i++) { + const p = i * 2.399; // золотое сечение: разброс фаз + // мировой якорь (окно камеры заворачивает) + покачивание падения + const ax = wrap(i * 67.3 + Math.sin(t * 0.21 + p) * 2.5, win.minX, win.maxX - win.minX); + const az = wrap(i * 43.9 + Math.cos(t * 0.17 + p * 1.7) * 2.5, win.minZ, win.maxZ - win.minZ); + // падение: лист опускается и рождается снова в кронах + const y = LEAVES_H - ((t * (LEAVES_FALL * (0.7 + (i % 4) * 0.2)) + (i * 4.1) % LEAVES_H) % LEAVES_H); + points.push({ + pos: [ax + Math.sin(t * 1.1 + p) * 0.8, y, az + Math.cos(t * 0.9 + p) * 0.8], + color: LEAVES_COLORS[i % 7 === 6 ? 2 : i % 2]!, + alpha: edgeAlpha(win, MARGIN, ax, az), + size: 1.4, + }); + } + } else { + const win = particleWindow(this.renderer, 0, DUST_H, MARGIN); + for (let i = 0; i < DUST_COUNT; i++) { + const p = i * 2.399; + // пыль почти стоит на месте: медленный дрейф-взвесь + const ax = wrap(i * 71.9 + Math.sin(t * 0.07 + p) * 3, win.minX, win.maxX - win.minX); + const az = wrap(i * 97.3 + Math.cos(t * 0.06 + p * 1.3) * 3, win.minZ, win.maxZ - win.minZ); + const y = 2 + Math.sin(t * 0.11 + p * 2.3) * (2 + (i % 3) * 3); + points.push({ + pos: [ax, y, az], + color: DUST_COLOR, + alpha: DUST_ALPHA * edgeAlpha(win, MARGIN, ax, az), + size: 0.7, + }); + } + } + this.layer.update(points); + } + + /** Скрыть слой (мост setAsh / область без профиля). */ + hide(): void { + this.layer.update([]); + } + + dispose(): void { + this.layer.dispose(); + } +} \ No newline at end of file diff --git a/v2/apps/game/src/data/lighting.ts b/v2/apps/game/src/data/lighting.ts index 6623b30..844966f 100644 --- a/v2/apps/game/src/data/lighting.ts +++ b/v2/apps/game/src/data/lighting.ts @@ -130,4 +130,13 @@ machine_hall: 'none', house_elder: 'none', shop: 'none', +}; + +/** Профиль воздушных частиц области: листопад рощи, пыль разъезда. */ +export type AirFxProfile = 'leaves' | 'dust'; + +/** Воздух по id: где что летает сверх пепла/дымки. */ +export const AIRFX: Record = { + rust_grove: 'leaves', + old_siding: 'dust', }; \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 720e988..fbcb776 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -31,11 +31,13 @@ import { resolveCutscenes, type CutsceneAction } from './data/cutscenes'; import { FlowerQuest } from './flowers'; import { GameClock } from './clock'; -import { LIGHTING, ATMOSPHERE, type Atmosphere, type LightingDef } from './data/lighting'; +import { LIGHTING, ATMOSPHERE, AIRFX, type Atmosphere, type LightingDef } from './data/lighting'; import { GameLighting, type HeroLampDef } from './lighting'; import { AshParticles } from './ash'; import { AmbientParticles, type AmbientKind } from './ambientParticles'; import { WaterShimmer, type WaterCell } from './waterShimmer'; +import { AirFx } from './airFx'; +import { PostFx } from './screenShader'; import { createSaveManager, type GameSave } from './save'; import { GameAudio } from './audio'; import { FaunaSystem } from './fauna'; @@ -174,6 +176,10 @@ let ambient: AmbientParticles | null = null; /** Шиммер воды (блики на поверхности прудов). */ let shimmer: WaterShimmer | null = null; +/** Воздушные частицы области (листья рощи / пыль разъезда). */ +let airFx: AirFx | null = null; +/** Экранный пост-проход: виньетка/зерно + дисторсия жары (зал Машины). */ +let postFx: PostFx | null = null; const audio = new GameAudio(); /** Профиль частиц по атмосфере области: луга — мотыльки, пруды — дымка. */ @@ -193,6 +199,9 @@ ambient = amb ? new AmbientParticles(renderer, amb, waterCells) : null; shimmer?.dispose(); shimmer = ashEnabled ? new WaterShimmer(renderer, waterCells) : null; + airFx?.dispose(); + const air = AIRFX[currentArea.id]; + airFx = ashEnabled && air ? new AirFx(renderer, air) : null; } /** Позиции тайлов раскладки по id (источники `tiles`: колокольчики и пр.). */ @@ -417,6 +426,10 @@ areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash'; if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется rebuildAmbient(); // мотыльки/дымка + шиммер воды (v1 Atmosphere) + // пост-проход пересобирается по области: дисторсия жары — только в зале + postFx?.dispose(); + postFx = new PostFx(renderer, currentArea.id === 'machine_hall'); + postFx.update(lighting?.nightFactor ?? 0, elapsed, ashEnabled); fauna?.dispose(); // фауна области заново (олени по спавнам v1) fauna = faunaEnabled ? new FaunaSystem({ spawns: currentArea.fauna ?? [], @@ -946,6 +959,8 @@ if (areaAsh) ash.update(elapsed); ambient?.update(elapsed); // мотыльки/дымка области shimmer?.update(elapsed); // блики на воде прудов + airFx?.update(elapsed); // листья рощи / пыль разъезда + postFx?.update(lighting?.nightFactor ?? 0, elapsed, ashEnabled); // виньетка/жара fauna?.update(dt, elapsed); // олени бродят (в фейде/катсцене мир замер) if (fade.active) { fade.t += dt; @@ -1160,12 +1175,13 @@ lightingInfo(): ReturnType { return lighting?.snapshot() ?? { nightFactor: 0, sources: [] }; }, - /** Снапшот частиц области: пепел/мотыльки-дымка/шиммер воды (гейт). */ + /** Снапшот частиц области: пепел/мотыльки-дымка/шиммер воды/воздух (гейт). */ ambientInfo() { return { ash: areaAsh, ambient: ambient?.info ?? null, shimmer: shimmer?.info ?? null, + air: airFx?.info ?? null, }; }, /** Снапшот аудио: тема области + кольцевой буфер фактических запусков. */ diff --git a/v2/apps/game/src/screenShader.ts b/v2/apps/game/src/screenShader.ts new file mode 100644 index 0000000..6058baa --- /dev/null +++ b/v2/apps/game/src/screenShader.ts @@ -0,0 +1,42 @@ +/** + * Экранные шейдеры игры (пост-проход движка): виньетка — глубина кадра, + * усиливается к ночи; лёгкое статичное зерно — «плёнка» поверх пиксель- + * арта; дисторсия «жары» — только в духотных областях (зал Машины). + * Юниформы тикает tick: при выключенной атмосфере (мост setAsh(false) — + * режим кадр-равенств гейтов) uTime заморожен и дисторсия гаснет — кадр + * становится полностью детерминированным. + */ +import { postPass, type PostPassHandle, type VoxelRenderer } from '@rpg/engine'; + +const VIGNETTE_DAY = 0.32; // днём — лёгкая глубина кадра +const VIGNETTE_NIGHT = 0.5; // к ночи края сжимаются (свет ламп не в кадре) +const GRAIN = 0.02; // едва заметное зерно +const WOBBLE_AMP = 0.004; // амплитуда дрожания жары (доля высоты кадра) + +/** Экранный пост-проход игры: один на рендерер, юниформы меняются на тике. */ +export class PostFx { + private handle: PostPassHandle | null; + + constructor(renderer: VoxelRenderer, /** дисторсия жары включена (зал Машины) */ wobble: boolean) { + const spec = postPass({ + vignette: VIGNETTE_DAY, + grain: GRAIN, + wobble: wobble ? WOBBLE_AMP : undefined, + }); + this.handle = renderer.setPostPass(spec.fragment, spec.uniforms); + } + + /** Поза прохода: ночь усиливает виньетку; active=false — заморозка (гейты). */ + update(nightK: number, time: number, active: boolean): void { + if (!this.handle) return; + const u = this.handle.uniforms; + u.uVignette!.value = VIGNETTE_DAY + (VIGNETTE_NIGHT - VIGNETTE_DAY) * nightK; + u.uTime!.value = active ? time : 0; + if (u.uWobble) u.uWobble.value = active ? WOBBLE_AMP : 0; + } + + dispose(): void { + this.handle?.dispose(); + this.handle = null; + } +} \ No newline at end of file diff --git a/v2/apps/game/tools/checks/atmosphere.mjs b/v2/apps/game/tools/checks/atmosphere.mjs index 02e7d3c..59dfac4 100644 --- a/v2/apps/game/tools/checks/atmosphere.mjs +++ b/v2/apps/game/tools/checks/atmosphere.mjs @@ -40,6 +40,19 @@ return n; } +/** Средняя яркость прямоугольной зоны кадра (виньетка: углы против центра). */ +function zoneBrightness(px, x0, y0, x1, y1) { + let sum = 0, n = 0; + for (let y = Math.max(0, y0); y < Math.min(px.height, y1); y++) { + for (let x = Math.max(0, x0); x < Math.min(px.width, x1); x++) { + const i = (y * px.width + x) * 4; + sum += px.rgba[i] * 0.3 + px.rgba[i + 1] * 0.6 + px.rgba[i + 2] * 0.1; + n++; + } + } + return n ? sum / n : 0; +} + try { await server.waitReady; const browser = await launchBrowser(); @@ -107,6 +120,21 @@ const nightBright = brightness(decodePng(readFileSync(nightShot)).rgba); check('кадр ночи темнее кадра дня', nightBright < dayBright * 0.85, { dayBright, nightBright }); + // ---- Виньетка пост-прохода: углы кадра темнее центра ---- + const dayPx = decodePng(readFileSync(dayShot)); + const w = dayPx.width, h = dayPx.height, q = 24; // угол 24×24, центр 48×48 + const corners = [ + zoneBrightness(dayPx, 0, 0, q, q), + zoneBrightness(dayPx, w - q, 0, w, q), + zoneBrightness(dayPx, 0, h - q, q, h), + zoneBrightness(dayPx, w - q, h - q, w, h), + ]; + const center = zoneBrightness(dayPx, w / 2 - q, h / 2 - q, w / 2 + q, h / 2 + q); + const cornerMean = corners.reduce((s, v) => s + v, 0) / 4; + // зона углов несёт и контент (небо ярче земли) — порог мягкий: эффект + // ~-25% на углу плюс разброс содержимого + check('виньетка: углы в среднем темнее центра кадра', cornerMean < center * 0.92, { center, cornerMean, corners }); + // ---- Пепел: живой слой кадра ---- await client.call('setClock', 720); await client.call('setAsh', true); @@ -118,6 +146,27 @@ const ashDiff = countDiff(decodePng(readFileSync(shotNoAsh)).rgba, decodePng(readFileSync(shotAsh)).rgba); check('пепел дрейфует: кадры различаются', ashDiff > 100, { ashDiff }); + // ---- Воздух области (трек 31): листопад рощи, пыль разъезда ---- + await client.call('gotoArea', 'rust_grove'); + await client.call('stepTicks', 1); + const airL = (await client.call('ambientInfo')).air; + check('роща: листья в воздухе (leaves ×26)', airL?.kind === 'leaves' && airL.count === 26, airL); + const shotLeaves0 = await client.screenshot('/tmp/v2_atmosphere_leaves0.png'); + await client.call('stepTicks', 40); // листья падают и качаются + const shotLeaves1 = await client.screenshot('/tmp/v2_atmosphere_leaves1.png'); + const leavesDiff = countDiff(decodePng(readFileSync(shotLeaves0)).rgba, decodePng(readFileSync(shotLeaves1)).rgba); + check('листья дрейфуют: кадры различаются', leavesDiff > 100, { leavesDiff }); + + await client.call('gotoArea', 'old_siding'); + await client.call('stepTicks', 1); + const airD = (await client.call('ambientInfo')).air; + check('разъезд: пыль в воздухе (dust ×40)', airD?.kind === 'dust' && airD.count === 40, airD); + const shotDust0 = await client.screenshot('/tmp/v2_atmosphere_dust0.png'); + await client.call('stepTicks', 400); // пыль ползёт медленно — нужен долгий шаг + const shotDust1 = await client.screenshot('/tmp/v2_atmosphere_dust1.png'); + const dustDiff = countDiff(decodePng(readFileSync(shotDust0)).rgba, decodePng(readFileSync(shotDust1)).rgba); + check('пыль дрейфует: кадры различаются', dustDiff > 100, { dustDiff }); + // ---- Источники по флагам: очаг, башня ---- await client.call('setAsh', false); await client.call('gotoArea', 'house_elder'); diff --git a/v2/apps/game/tools/checks/panels.mjs b/v2/apps/game/tools/checks/panels.mjs index 78b1a82..c9cf3c4 100644 --- a/v2/apps/game/tools/checks/panels.mjs +++ b/v2/apps/game/tools/checks/panels.mjs @@ -106,8 +106,8 @@ const heroId = dev1.entries.find((e) => e.id.startsWith('char:')).id; check('вьювер открылся', await client.call('devOpenEntry', heroId) === true); const st1 = await client.call('devStats'); - check('статы манекена: 164 вокс / 5 костей / 16 тайлей', - st1.voxels === 164 && st1.bones === 5 && st1.tiles === 16, st1); + check('статы манекена: 164 вокс / 5 костей / 19 тайлей (трек 29 — скины деталей)', + st1.voxels === 164 && st1.bones === 5 && st1.tiles === 19, st1); // кадр вьювера рисует модель (зона канваса не пустая) await client.call('stepTicks', 1); diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index a9351d8..9ce2c17 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -24,6 +24,9 @@ export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk'; export { VoxelRenderer, worldTileAtlas } from './render/voxelRenderer'; export { createSpriteLayer, createQuadLayer } from './render/particles'; +export { postPass } from './render/postShader'; +export type { PostOptions, PostPassSpec } from './render/postShader'; +export type { PostPassHandle } from './render/voxelRenderer'; export type { ParticlePoint, ParticleLayer } from './render/particles'; export type { CloudTiles, WorldTileAtlas } from './render/voxelRenderer'; export { ModelViewer, modelStats, orbitPosition } from './render/modelViewer'; diff --git a/v2/packages/engine/src/render/__tests__/postShader.test.ts b/v2/packages/engine/src/render/__tests__/postShader.test.ts new file mode 100644 index 0000000..6c60926 --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/postShader.test.ts @@ -0,0 +1,43 @@ +/** + * Тесты сборщика пост-прохода: эффекты включаются точно по опциям, + * юниформы соответствуют включённому коду (GLSL не прощает лишнего). + */ +import { describe, expect, it } from 'vitest'; +import { postPass } from '../postShader'; + +describe('postPass — сборка фрагмента', () => { + it('пустые опции — прямой сэмпл без юниформ эффекта', () => { + const s = postPass(); + expect(s.fragment).toContain('texture2D(tDiffuse, uv)'); + expect(s.fragment).not.toContain('uVignette'); + expect(s.fragment).not.toContain('uGrain'); + expect(s.fragment).not.toContain('uWobble'); + expect(Object.keys(s.uniforms)).toEqual([]); + }); + + it('виньетка и зерно — код и стартовые значения', () => { + const s = postPass({ vignette: 0.35, grain: 0.03 }); + expect(s.fragment).toContain('uVignette * dot(d, d)'); + expect(s.fragment).toContain('n * uGrain'); + expect(s.fragment).not.toContain('uWobble'); + expect(s.uniforms.uVignette).toEqual({ value: 0.35 }); + expect(s.uniforms.uGrain).toEqual({ value: 0.03 }); + }); + + it('дисторсия — сдвиг uv до сэмпла, сэмпл идёт после', () => { + const s = postPass({ wobble: 0.002 }); + expect(s.fragment.indexOf('uWobble')).toBeLessThan(s.fragment.indexOf('texture2D')); + expect(s.uniforms.uWobble).toEqual({ value: 0.002 }); + }); + + it('полный набор — все юниформы объявлены и использованы', () => { + const s = postPass({ vignette: 0.4, grain: 0.02, wobble: 0.002 }); + for (const u of ['uVignette', 'uGrain', 'uWobble', 'uTime', 'tDiffuse']) { + expect(s.fragment).toContain(u); + } + // каждая использованная юниформа — объявлена (GLSL 1) + expect(s.fragment).toContain('uniform float uVignette;'); + expect(s.fragment).toContain('uniform float uGrain;'); + expect(s.fragment).toContain('uniform float uTime;'); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/render/postShader.ts b/v2/packages/engine/src/render/postShader.ts new file mode 100644 index 0000000..f1e172e --- /dev/null +++ b/v2/packages/engine/src/render/postShader.ts @@ -0,0 +1,73 @@ +/** + * Сборщик пост-прохода (движок жанронезависим): из включённых эффектов + * собирается фрагментный шейдер + стартовые юниформы для + * VoxelRenderer.setPostPass. Эффекты детерминированы: «зерно» — статичный + * хэш по координатам пикселя (без времени), анимируется только дисторсия + * через uTime — при замороженном uTime кадр-равенства гейтов не падают. + */ + +/** Включённые эффекты (поле !== undefined — эффект в шейдере) и их стартовые силы. */ +export interface PostOptions { + /** Виньетка: затемнение к углам (dot(d,d)·2; 0.3–0.5 — заметная). */ + vignette?: number; + /** Зерно: ± величина к цвету (0.02–0.05 — едва заметное). */ + grain?: number; + /** Дисторсия «жары»: горизонтальное дрожание к низу кадра (амплитуда). */ + wobble?: number; +} + +/** Готовый проход: шейдер + юниформы (uTime игра тикает сама). */ +export interface PostPassSpec { + fragment: string; + uniforms: Record; +} + +/** Шапка шейдера: uv с квада, сэмпл сцены, время (дисторсия). */ +const HEAD = ` +varying vec2 vUv; +uniform sampler2D tDiffuse; +uniform float uTime; +void main() { + vec2 uv = vUv; +`; +/** Дисторсия: сдвиг uv ДО сэмпла, сильнее к низу кадра (земля/машина). */ +const WOBBLE = ` + uv.x += sin(uv.y * 36.0 - uTime * 2.4) * uWobble * (1.2 - uv.y);`; +/** Сэмпл и выход — общий хвост. */ +const OUT = ` + vec3 c = texture2D(tDiffuse, uv).rgb; +`; +const VIGNETTE = ` + vec2 d = vUv - 0.5; + c *= 1.0 - uVignette * dot(d, d) * 2.0;`; +const GRAIN = ` + float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5; + c += n * uGrain;`; +const END = ` + gl_FragColor = vec4(c, 1.0); +}`; + +/** Собрать пост-проход: шейдер из включённых эффектов + стартовые юниформы. */ +export function postPass(o: PostOptions = {}): PostPassSpec { + const decls: string[] = []; + const body: string[] = []; + const uniforms: PostPassSpec['uniforms'] = {}; + if (o.wobble !== undefined) { + decls.push('uniform float uWobble;'); + body.push(WOBBLE); + uniforms.uWobble = { value: o.wobble }; + } + body.push(OUT); + if (o.vignette !== undefined) { + decls.push('uniform float uVignette;'); + body.push(VIGNETTE); + uniforms.uVignette = { value: o.vignette }; + } + if (o.grain !== undefined) { + decls.push('uniform float uGrain;'); + body.push(GRAIN); + uniforms.uGrain = { value: o.grain }; + } + body.push(END); + return { fragment: decls.join('\n') + '\n' + HEAD + body.join(''), uniforms }; +} \ No newline at end of file diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index d3dab93..bec8b59 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -59,6 +59,20 @@ export const ISO_YAW = Math.PI / 4; const ISO_TAN = 0.5; +/** Ручка пост-прохода: юниформы (uTime и пр. — игра анимирует сама) и отключение. */ +export interface PostPassHandle { + readonly uniforms: Record; + dispose(): void; +} + +/** Вершишник полноэкранного квада пост-прохода (PlaneGeometry даёт uv). */ +const POST_VERT = ` +varying vec2 vUv; +void main() { + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); +}`; + export class VoxelRenderer { readonly renderer: THREE.WebGLRenderer; readonly scene: THREE.Scene; @@ -73,6 +87,14 @@ private dayHemisphere = 0x94949e; // P5 /** Дневной множитель солнца (тёмный интерьер гасит и прямое солнце). */ private daySunScale = 1; + /** Активный пост-проход: RT со сценой + полноэкранный квад (null — прямой рендер). */ + private post: { + rt: THREE.WebGLRenderTarget; + scene: THREE.Scene; + camera: THREE.OrthographicCamera; + quad: THREE.Mesh; + mat: THREE.ShaderMaterial; + } | null = null; constructor(canvas: HTMLCanvasElement, opts: VoxelRendererOptions = {}) { const width = opts.width ?? 480; @@ -269,10 +291,66 @@ this.sun.color.setRGB(1, 1 - warm * 0.25, 1 - warm * 0.45); } + /** + * Пост-обработка: сцена рендерится в render target, затем на экран + * полноэкранным квадом с шейдером игры (виньетка, дисторсия...). + * null — вернуться к прямому рендеру. Юниформы прохода игрок меняет + * через ручку (uTime и пр.). Ресурсов мало, но dispose обязателен. + */ + setPostPass( + fragmentShader: string | null, + uniforms: Record = {}, + ): PostPassHandle | null { + this.clearPostPass(); + if (fragmentShader === null) return null; + const size = new THREE.Vector2(); + this.renderer.getSize(size); + const rt = new THREE.WebGLRenderTarget(size.x, size.y, { + minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, // пиксель-арт: 1:1 + }); + const mat = new THREE.ShaderMaterial({ + vertexShader: POST_VERT, + fragmentShader, + // uTime заводится всегда (пост-шейдер собирается с его объявлением), + // эффектные юниформы — из spec'а игры + uniforms: { tDiffuse: { value: rt.texture }, uTime: { value: 0 }, ...uniforms }, + 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); + this.post = { rt, scene, camera, quad, mat }; + return { + uniforms: mat.uniforms, + dispose: () => this.clearPostPass(), + }; + } + + /** Снять активный пост-проход и освободить его ресурсы. */ + private clearPostPass(): void { + const p = this.post; + if (!p) return; + this.post = null; + p.scene.remove(p.quad); + p.quad.geometry.dispose(); + p.mat.dispose(); + p.rt.dispose(); + } + /** Один кадр. */ render(): void { this.camera.updateMatrixWorld(); - this.renderer.render(this.scene, this.camera); + if (this.post) { + this.renderer.setRenderTarget(this.post.rt); + this.renderer.render(this.scene, this.camera); + this.renderer.setRenderTarget(null); + this.renderer.render(this.post.scene, this.post.camera); + } else { + this.renderer.render(this.scene, this.camera); + } } /** Ортокамера диметрии 2:1, смотрит в target с изо-направления. */