diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 1a23cc2..c41dbb7 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1476,16 +1476,26 @@ и сам (панель/мост), и внутри `setNight` — тик игры каждый тик перезаписывает 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. +- **Мягкость/контраст теней** (`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. + Контраст: `setShadowStrength(k)` — сила тени через `sun.shadow.intensity` + (three 0.186: `mix(1.0, shadow, intensity)` в шейдере; чистая функция + `shadowIntensity(strength, cloudK)`). Облачность НЕ перезаписывает силу, + а гасит множителем `1 − k·SHADOW_CLOUD_FADE` (0.45): рассеяный свет + освещает затенённые места со всех сторон — тень в пасмурный день светлеет + сама (замер на изоляте: контраст свет/тень 3.6 в ясный → 1.5 в пасмурный). + Пересчёт силы живёт в `setSun` (зовётся каждым тиком света) — ручное + значение переживает обновления солнца. Грабля-замера: маску «тени» + по яркости кадра загрязняют тёмные грани самого кастера — делить + свет/тень надо по паре кадров (с кастером и без), исключая силуэт. - **Игра** (`data/weather.ts` + `main.ts`): база погоды по областям (луга — ветерок 0.15, пруды — штиль/облачность/снег, роща — морось, разъезд — продувает с дождём, интерьеры — пусто). `activeWeatherSpec()` = база diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index c780c99..d51c3d4 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, SHADOW_BLUR_MAX, shadowBlurTexels } from './render/voxelRenderer'; +export { ISO_YAW, SHADOW_BLUR_MAX, SHADOW_CLOUD_FADE, shadowBlurTexels, shadowIntensity } 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__/shadowIntensity.test.ts b/v2/packages/engine/src/render/__tests__/shadowIntensity.test.ts new file mode 100644 index 0000000..5c276ad --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/shadowIntensity.test.ts @@ -0,0 +1,34 @@ +/** + * Тесты контраста теней: чистая функция перевода силы теней + облачности в + * sun.shadow.intensity (three 0.186: mix(1.0, shadow, intensity) в шейдере). + */ +import { describe, expect, it } from 'vitest'; +import { SHADOW_CLOUD_FADE, shadowIntensity } from '../voxelRenderer'; + +describe('shadowIntensity (контраст теней)', () => { + it('полные тени без облачности — intensity 1', () => { + expect(shadowIntensity(1, 0)).toBe(1); + }); + + it('облачность светлит тени: 0 → 1 − FADE, монотонно', () => { + expect(shadowIntensity(1, 1)).toBeCloseTo(1 - SHADOW_CLOUD_FADE); + let prev = shadowIntensity(1, 0); + for (let c = 0.1; c <= 1.001; c += 0.1) { + const cur = shadowIntensity(1, Math.min(c, 1)); + expect(cur).toBeLessThanOrEqual(prev); + prev = cur; + } + }); + + it('нулевая сила — теней нет при любой облачности', () => { + expect(shadowIntensity(0, 0)).toBe(0); + expect(shadowIntensity(0, 1)).toBe(0); + }); + + it('клампы аргументов 0..1, результат не ниже 0', () => { + expect(shadowIntensity(-2, 0)).toBe(0); + expect(shadowIntensity(5, 0)).toBe(1); + expect(shadowIntensity(5, -1)).toBe(1); + expect(shadowIntensity(0.5, 5)).toBeCloseTo(0.5 * (1 - SHADOW_CLOUD_FADE)); + }); +}); \ 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 cfa02e0..397a2b9 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -108,6 +108,20 @@ return Math.max(1, Math.round((k * SHADOW_BLUR_MAX) / texel)); } +/** Насколько полная облачность гасит контраст теней (0..1): рассеяный свет + * освещает затенённые места со всех сторон, тени светлеют. 0.45 — тень в + * пасмурный день видна, но заметно мягче по яркости, чем в ясный. */ +export const SHADOW_CLOUD_FADE = 0.45; + +/** Сила (плотность) тени 0..1 из силы теней и облачности: sun.shadow.intensity + * в шейдере — mix(1.0, shadow, intensity), т.е. 0.5 = тень светлее вдвое. + * Ручная сила уменьшается облачностью (рассеяный свет заполняет тени). */ +export function shadowIntensity(strength: number, cloudK: number): number { + const s = Math.min(1, Math.max(0, strength)); + const c = Math.min(1, Math.max(0, cloudK)); + return Math.max(0, s * (1 - c * SHADOW_CLOUD_FADE)); +} + /** Ручка пост-прохода: юниформы (uTime и пр. — игра анимирует сама) и отключение. */ export interface PostPassHandle { readonly uniforms: Record; @@ -142,6 +156,9 @@ private cloudK = 0; /** Мягкость теней 0..1 (0 — резкие): радиус размытия shadow map. */ private shadowSoftK = 0; + /** Сила (контрастность) теней 0..1: 1 — полные тени, 0 — исчезают. + * Рассеяный свет заполняет тени — облачность ослабляет по умолчанию. */ + private shadowStrengthK = 1; /** Полуширина текущего теневого фрустума (ставит setSun) — для пересчёта * радиуса размытия в текселях при смене мягкости/фрустума. */ private shadowFrustumHalf = 0; @@ -242,6 +259,15 @@ this.setSun(this.sunK); } + /** Сила (контрастность) теней 0..1: 1 — полные тени, 0 — тени исчезают + * (рассеяный свет со всех сторон). Облачность не перезаписывает силу + * (в отличие от мягкости), а гасит её множителем — тень в пасмурный день + * светлеет сама: shadowIntensity(k, cloudK). */ + setShadowStrength(k: number): void { + this.shadowStrengthK = Math.min(1, Math.max(0, k)); + this.sun.shadow.intensity = shadowIntensity(this.shadowStrengthK, this.cloudK); + } + /** Мягкость теней 0..1: 0 — резкие, 1 — размытие ~SHADOW_BLUR_MAX * мировых юнитов (радиус shadow map в текселях считается от текущего * фрустума). Ручное управление (например, свой стиль теней области); @@ -441,6 +467,9 @@ // пересчитывается, чтобы МИРОВОЙ размер размытия не менялся this.shadowFrustumHalf = r; this.sun.shadow.radius = shadowBlurTexels(this.shadowSoftK, r, this.sun.shadow.mapSize.x); + // контраст теней пересчитывается здесь (setSun зовётся каждым тиком + // света и из setCloud) — ручное значение переживает обновления солнца + this.sun.shadow.intensity = shadowIntensity(this.shadowStrengthK, this.cloudK); // ночь гасит солнце до остаточной засветки (луна/небо), тёмный // интерьер — и днём, облачность — в пасмурный остаток this.sun.intensity = (0.4 + 1.1 * Math.sin(kk * Math.PI)) * (1 - 0.92 * this.nightK)