diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 2707647..1a23cc2 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1474,13 +1474,24 @@ — солнце ×(1−0.6k), ambient/фон обесцвечиваются к серому и темнеют (`cloudDim(color, k, sat, dim)` — чистая, тестируется). Метод применяется и сам (панель/мост), и внутри `setNight` — тик игры каждый тик - перезаписывает ambient, облачность не теряется. + перезаписывает ambient, облачность не теряется. Свет рассеяный — тени + мягчеют: `setCloud` тянет мягкость теней своим k (2026-09-12). +- **Мягкость теней** (`voxelRenderer.ts`, 2026-09-12): `setShadowSoftness(k)` + — 0 резкие, 1 размытие ~`SHADOW_BLUR_MAX` (3 юнита = 0.3 тайла) мировых; + радиус `sun.shadow.radius` считается в текселях чистой функцией + `shadowBlurTexels(k, frustumHalf, mapSize)` — МИРОВОЙ размер размытия + не зависит от фрустума (зум/высота солнца меняют фрустум — `setSun` + пересчитывает тексели). Механика: PCF three (0.186) масштабирует диск + сэмплов Вогеля радиусом `shadow.radius` — мягкость без смены типа + shadow map. Грабля-урок: радиус < 1 бесполезен (диск сэмплов сжимается + в тексель); мягкость задаётся ПОСЛЕ `setCloud` — облачность перезаписывает + её своим k. - **Игра** (`data/weather.ts` + `main.ts`): база погоды по областям (луга — ветерок 0.15, пруды — штиль/облачность/снег, роща — морось, разъезд — продувает с дождём, интерьеры — пусто). `activeWeatherSpec()` = база области + ручные правки панели; `weather.set` на входе в область и в - мостах. В тике погода анимирует (в т.ч. при открытой дев-панели — мир - стоит, осадки видны: слайдеры в деле). `setAsh(false)` (гейт-режим) + мостах. В тике погода анимирует (мир при дев-панели не на паузе — правки + слайдеров видны в живой игре). `setAsh(false)` (гейт-режим) глушит и погоду — кадр-равенства старых гейтов не ломаются. - **Мост**: `weatherInfo()` (активный spec + rainDrops/snowFlakes + manual), `setWeather(spec | null)` (частичная правка поверх базы; null — база diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index c81ba36..c780c99 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -70,7 +70,7 @@ export type { Scene, FadeOverlay, SceneTransition } from './core/scene'; export { CutsceneRunner, validateCutscenes } from './core/cutscene'; export type { CutsceneStep, CutsceneSnapshot, CutsceneCamera } from './core/cutscene'; -export { ISO_YAW } from './render/voxelRenderer'; +export { ISO_YAW, SHADOW_BLUR_MAX, shadowBlurTexels } from './render/voxelRenderer'; export { validateDialogue, evalConditions } from './dialogue/graph'; export type { DialogueGraph, DialogueNode, DialogueChoice, DialogueConditions, VarCondition, diff --git a/v2/packages/engine/src/render/__tests__/shadowSoftness.test.ts b/v2/packages/engine/src/render/__tests__/shadowSoftness.test.ts new file mode 100644 index 0000000..8a67a39 --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/shadowSoftness.test.ts @@ -0,0 +1,42 @@ +/** + * Тесты мягкости теней: чистая функция перевода мягкости 0..1 в радиус + * размытия shadow map (тексели) — мировое размытие не зависит от фрустума. + */ +import { describe, expect, it } from 'vitest'; +import { SHADOW_BLUR_MAX, shadowBlurTexels } from '../voxelRenderer'; + +describe('shadowBlurTexels (мягкость теней)', () => { + it('мягкость 0 — резкая тень (1 тексель) при любом фрустуме', () => { + expect(shadowBlurTexels(0, 165, 2048)).toBe(1); + expect(shadowBlurTexels(0, 700, 2048)).toBe(1); + }); + + it('мировое размытие постоянно: рост фрустума растит радиус в текселях', () => { + // тексель = 2·frustumHalf/mapSize; SHADOW_BLUR_MAX мировых юнитов + // при фрустуме 165 — это 3/(330/2048) ≈ 18.6 → 19 текселей + const noon = shadowBlurTexels(1, 165, 2048); + expect(noon).toBe(Math.round(SHADOW_BLUR_MAX / (330 / 2048))); + // низкое солнце — фрустум шире, тексель крупнее, текселей меньше, + // а мировое размытие то же + const dawn = shadowBlurTexels(1, 676, 2048); + expect(dawn).toBeLessThan(noon); + expect(dawn).toBeGreaterThanOrEqual(1); + }); + + it('монотонность по мягкости и кламп 0..1', () => { + let prev = shadowBlurTexels(0, 165, 2048); + for (let k = 0.1; k <= 1.001; k += 0.1) { + const cur = shadowBlurTexels(Math.min(k, 1), 165, 2048); + expect(cur).toBeGreaterThanOrEqual(prev); + prev = cur; + } + // клампы: отрицательная и >1 мягкость — крайние значения + expect(shadowBlurTexels(-2, 165, 2048)).toBe(1); + expect(shadowBlurTexels(5, 165, 2048)).toBe(shadowBlurTexels(1, 165, 2048)); + }); + + it('дегенерат: нулевой фрустум не делит на ноль', () => { + expect(shadowBlurTexels(1, 0, 2048)).toBe(1); + expect(shadowBlurTexels(1, 165, 0)).toBeGreaterThanOrEqual(1); + }); +}); \ 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 cdb5464..cfa02e0 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -92,6 +92,22 @@ * теневого фрустума: тень кастера уходит от него на h/tan(elev солнца). */ const SHADOW_CASTER_H = 64; +/** Мировой радиус размытия тени при мягкости 1 (0.3 тайла): у полной + * облачности свет рассеяный — тень readability сохраняет, край плывёт. */ +export const SHADOW_BLUR_MAX = 3; + +/** Радиус размытия тени в текселях из мягкости 0..1: мировой радиус + * (0..SHADOW_BLUR_MAX) делится на тексель текущего теневого фрустума — + * размытие в МИРОВЫХ единицах не зависит от размера фрустума (зум, высота + * солнца). Минимум 1 тексель — резкая тень (радиус < 1 бесполезен: PCF + * сэмплирует диском радиуса shadowRadius текселей). */ +export function shadowBlurTexels(softness: number, frustumHalf: number, mapSize: number): number { + const k = Math.min(1, Math.max(0, softness)); + const texel = (2 * frustumHalf) / mapSize; + if (!(texel > 0)) return 1; + return Math.max(1, Math.round((k * SHADOW_BLUR_MAX) / texel)); +} + /** Ручка пост-прохода: юниформы (uTime и пр. — игра анимирует сама) и отключение. */ export interface PostPassHandle { readonly uniforms: Record; @@ -121,8 +137,14 @@ private readonly particleLightsU: ParticleLightUniforms = makeParticleLightUniforms(); private sunK = 0.5; private nightK = 0; - /** Облачность 0..1: гасит солнце и ambient, фон — к пасмурному серому. */ + /** Облачность 0..1: гасит солнце и ambient, фон — к пасмурному серому. + * По умолчанию тянет за собой мягкость теней (рассеяный свет). */ private cloudK = 0; + /** Мягкость теней 0..1 (0 — резкие): радиус размытия shadow map. */ + private shadowSoftK = 0; + /** Полуширина текущего теневого фрустума (ставит setSun) — для пересчёта + * радиуса размытия в текселях при смене мягкости/фрустума. */ + private shadowFrustumHalf = 0; /** Последний ночной tint setNight — чтобы setCloud пересчитывал сразу. */ private lastNightTint = 0x94949e; /** Дневной базовый ambient области (интерьеры темнее улиц — как v1 setAmbient). */ @@ -210,14 +232,30 @@ } /** Облачность k 0..1: солнце гаснет (~×0.4 в полную), ambient темнее - * и серее, фон — к пасмурному серому. Вызывается и сама (панель/мост), + * и серее, фон — к пасмурному серому. Свет становится рассеяным — + * тени мягчеют (мягкость = облачность). Вызывается и сама (панель/мост), * и внутри setNight (тик игры перезаписывает ambient каждый тик). */ setCloud(k: number): void { this.cloudK = Math.min(1, Math.max(0, k)); + this.shadowSoftK = this.cloudK; this.applyCloud(); this.setSun(this.sunK); } + /** Мягкость теней 0..1: 0 — резкие, 1 — размытие ~SHADOW_BLUR_MAX + * мировых юнитов (радиус shadow map в текселях считается от текущего + * фрустума). Ручное управление (например, свой стиль теней области); + * setCloud перезаписывает мягкость своим k — ручное значение задаём + * после него / на каждом тике. */ + setShadowSoftness(k: number): void { + this.shadowSoftK = Math.min(1, Math.max(0, k)); + if (this.shadowFrustumHalf > 0) { + this.sun.shadow.radius = shadowBlurTexels( + this.shadowSoftK, this.shadowFrustumHalf, this.sun.shadow.mapSize.x, + ); + } + } + /** Ambient и фон: базовый микс день/ночь, затем облачность — к серому * (обесцвечивание) и вниз по яркости. */ private applyCloud(): void { @@ -399,6 +437,10 @@ cam.far = dist + r + SHADOW_CASTER_H + this.viewSize; cam.updateProjectionMatrix(); } + // фрустум меняется с высотой солнца — радиус размытия в текселях + // пересчитывается, чтобы МИРОВОЙ размер размытия не менялся + this.shadowFrustumHalf = r; + this.sun.shadow.radius = shadowBlurTexels(this.shadowSoftK, r, this.sun.shadow.mapSize.x); // ночь гасит солнце до остаточной засветки (луна/небо), тёмный // интерьер — и днём, облачность — в пасмурный остаток this.sun.intensity = (0.4 + 1.1 * Math.sin(kk * Math.PI)) * (1 - 0.92 * this.nightK)