diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index c41dbb7..30d1fdb 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1434,6 +1434,32 @@ Визуально: хлопок пепла у колокольчика ночью ярко-тёплый, пыль разъезда в конусе фонарика подсвечена. +## Два вида частиц (2026-09-12): излучающие и освещаемые + +Фидбек: «простые частицы не видны ночью в темноте, и тем больше видно, чем +больше на них света; излучающие светятся сами». До этого ВСЕ слои светились +одинаково при любом освещении — ручной ночной tint (PARTICLE_NIGHT) был +подменой реакции на свет. Теперь режим — свойство СЛОЯ: + +- **Движок** (`render/particles.ts`): `renderer.addParticles({lit: true})` + / `addFlatParticles({lit: true})` (по умолчанию излучающий). Общий блок + юниформ дополнен `uEnv` — env-свет сцены, рендер пересчитывает в конце + `setSun` (туда сходятся все изменения света) чистой функцией + `particleEnv(amb, sun)` = ambient·intensity + солнце·`PARTICLE_ENV_SUN` + (0.3 — у билборда нет нормали). Шейдер один, ветка `#if LIT`: + полученный свет = env + точечные источники; видимость (альфа) = + clamp(max-канал(received)·`PARTICLE_LIT_VIS` (1.6), 0, 1) — ночью в + темноте частица исчезает, у лампы проявляется; днём vis насыщается в 1 — + вид как раньше. Излучающий режим — прежняя формула (tint + источники). +- **Игра**: lit-слои — дождь и снег (`weather.ts`, метод setTint удалён — + слои сами читают день/ночь из env-света), пепел (`ash.ts`), листья/пыль + (`airFx.ts`); tint остался самосветящимся: мотыльки, дымка прудов, + блики воды (main.ts `particleTint` теперь красит только их). +- Грабли замера: тёмные грани самого кастера загрязняют маску «тени» по + яркости кадра — свет/тень делить по паре кадров (с кастером и без); + `setClock` моста принимает МИНУТЫ (720 = полдень, 1200 = 20:00 — ночь). + Замер пробы: дождь день 101 / ночь 14; мотыльки ночью светятся. + ## Погода (2026-09-12): дождь, снег, ветер, облачность — часть движка Запрос: «по погодным эффектам запариться... это должно быть частью движка, @@ -1496,6 +1522,18 @@ значение переживает обновления солнца. Грабля-замера: маску «тени» по яркости кадра загрязняют тёмные грани самого кастера — делить свет/тень надо по паре кадров (с кастером и без), исключая силуэт. +- **«Тенька сверху»** (`voxelRenderer.ts`, 2026-09-12): в облачность + свет рассеян и переотражается так, словно слабый источник висит прямо + над сценой — второй DirectionalLight в ЗЕНИТЕ (`zenithIntensity`: + `OVERCAST_TOP_LIGHT` 0.55 · cloudK · (1−0.92·nightK) · daySunScale) + с собственной тенью (1024², frustum ±(visible+2) — кастер тень вбок + не смещает). Даёт мягкую контактную тень ПОД объектом (виден размытый + ореол у основания — прямая вниз тень почти целиком скрыта объектом в + изометрии, это нормально) + чуть подсвечивает горизонтальные грани + (пасмурный «молочный» свет сверху). Мягкость общая с солнцем + (shadowSoftK); пересчёт — в setSun, как у всего света. Грабля пробы: + удаление кастера «на месте» (`mkCube().dispose()`) создавало ВТОРОЙ куб + и удаляло его — держите ссылку на первый. - **Игра** (`data/weather.ts` + `main.ts`): база погоды по областям (луга — ветерок 0.15, пруды — штиль/облачность/снег, роща — морось, разъезд — продувает с дождём, интерьеры — пусто). `activeWeatherSpec()` = база diff --git a/v2/packages/engine/src/render/__tests__/shadowIntensity.test.ts b/v2/packages/engine/src/render/__tests__/shadowIntensity.test.ts index 5c276ad..0809282 100644 --- a/v2/packages/engine/src/render/__tests__/shadowIntensity.test.ts +++ b/v2/packages/engine/src/render/__tests__/shadowIntensity.test.ts @@ -3,7 +3,7 @@ * sun.shadow.intensity (three 0.186: mix(1.0, shadow, intensity) в шейдере). */ import { describe, expect, it } from 'vitest'; -import { SHADOW_CLOUD_FADE, shadowIntensity } from '../voxelRenderer'; +import { OVERCAST_TOP_LIGHT, SHADOW_CLOUD_FADE, shadowIntensity, zenithIntensity } from '../voxelRenderer'; describe('shadowIntensity (контраст теней)', () => { it('полные тени без облачности — intensity 1', () => { @@ -31,4 +31,21 @@ expect(shadowIntensity(5, -1)).toBe(1); expect(shadowIntensity(0.5, 5)).toBeCloseTo(0.5 * (1 - SHADOW_CLOUD_FADE)); }); +}); + +describe('zenithIntensity (пасмурная тенька сверху)', () => { + it('ясный день — зенитного света нет', () => { + expect(zenithIntensity(0, 0, 1)).toBe(0); + expect(zenithIntensity(0.1, 0, 1)).toBeLessThan(OVERCAST_TOP_LIGHT / 3); + }); + + it('пасмурный полдень — максимум (OVERCAST_TOP_LIGHT)', () => { + expect(zenithIntensity(1, 0, 1)).toBeCloseTo(OVERCAST_TOP_LIGHT); + expect(zenithIntensity(5, -1, 5)).toBeCloseTo(OVERCAST_TOP_LIGHT); // клампы + }); + + it('ночь гасит вместе с небом, тёмный интерьер — тоже', () => { + expect(zenithIntensity(1, 1, 1)).toBeLessThan(OVERCAST_TOP_LIGHT / 10); + expect(zenithIntensity(1, 0, 0.2)).toBeCloseTo(OVERCAST_TOP_LIGHT * 0.2); + }); }); \ 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 397a2b9..ef97108 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -12,7 +12,7 @@ import type { PosedVoxel } from '../animation/rig'; import type { ResolvedTile } from '../models/format'; import { cloudDim, lerpColor } from './daynight'; -import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights } from './particles'; +import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, particleEnv } from './particles'; import type { ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './particles'; import { particleWindow } from './polyWindow'; @@ -122,6 +122,23 @@ return Math.max(0, s * (1 - c * SHADOW_CLOUD_FADE)); } +/** Яркость зенитного света полной облачности днём: рассеяный свет + * переотражается так, словно слабый источник висит прямо над сценой — + * минимальная тень лежит ПОД объектом, куда косые лучи солнца не достают. + * Ночь и тёмный интерьер гасят его вместе с небом. 0.55 — контактная тень + * читается в изометрии (прямая вниз тень почти целиком скрыта объектом, + * виден только размытый ореол у основания — он и нужен). */ +export const OVERCAST_TOP_LIGHT = 0.55; + +/** Интенсивность зенитного света из облачности/ночи/яркости интерьера: + * 0 в ясный день (тени ведёт солнце), максимум в пасмурный полдень. */ +export function zenithIntensity(cloudK: number, nightK: number, daySunScale: number): number { + const c = Math.min(1, Math.max(0, cloudK)); + const n = Math.min(1, Math.max(0, nightK)); + const s = Math.min(1, Math.max(0, daySunScale)); + return OVERCAST_TOP_LIGHT * c * (1 - 0.92 * n) * s; +} + /** Ручка пост-прохода: юниформы (uTime и пр. — игра анимирует сама) и отключение. */ export interface PostPassHandle { readonly uniforms: Record; @@ -141,6 +158,9 @@ readonly scene: THREE.Scene; readonly camera: THREE.OrthographicCamera; private readonly sun: THREE.DirectionalLight; + /** Зенитный свет пасмурности (вниз, с тенью): минимальная контактная + * тень прямо под объектами — рассеяный свет переотражается сверху. */ + private readonly zenith: THREE.DirectionalLight; private readonly ambient: THREE.HemisphereLight; private readonly target: THREE.Vector3; private readonly viewSize: number; @@ -205,6 +225,12 @@ this.sun.shadow.bias = -0.0005; this.scene.add(this.sun); this.scene.add(this.sun.target); + this.zenith = new THREE.DirectionalLight(0xffffff, 0); + this.zenith.castShadow = true; + this.zenith.shadow.mapSize.set(1024, 1024); // слабый свет — плотность не критична + this.zenith.shadow.bias = -0.0005; + this.scene.add(this.zenith); + this.scene.add(this.zenith.target); this.setSun(0.5); this.repositionCamera(); } @@ -375,14 +401,14 @@ * Размер частицы — в мировых юнитах, переводится в пиксели буфера один * раз (канвас рендера не меняется после создания). */ - addParticles(): ParticleLayer { + addParticles(opts: { lit?: boolean } = {}): ParticleLayer { const px = this.renderer.domElement.height / (2 * this.viewSize); - return createSpriteLayer(this.scene, 64, px, this.particleLightsU); + return createSpriteLayer(this.scene, 64, px, this.particleLightsU, opts.lit ?? false); } /** Слой плоских квадов, лежащих на земле/воде: дымка прудов, блики. */ - addFlatParticles(): ParticleLayer { - return createQuadLayer(this.scene, 64, this.particleLightsU); + addFlatParticles(opts: { lit?: boolean } = {}): ParticleLayer { + return createQuadLayer(this.scene, 64, this.particleLightsU, opts.lit ?? false); } /** @@ -477,6 +503,37 @@ // тёплый свет у горизонта (F1), нейтральный в полдень const warm = 1 - Math.sin(kk * Math.PI); this.sun.color.setRGB(1, 1 - warm * 0.25, 1 - warm * 0.45); + // «тенька сверху» пасмурности: зенитный свет с тенью — контактная + // тень прямо под объектом (косые солнечные лучи туда не достают). + // Мягкость общая с солнцем: рассеяный свет и здесь размывает край. + this.zenith.position.set( + this.target.x, + this.target.y + SHADOW_CASTER_H + this.viewSize, + this.target.z, + ); + this.zenith.target.position.copy(this.target); + this.zenith.intensity = zenithIntensity(this.cloudK, this.nightK, this.daySunScale); + // зенитный кастер не смещает тень вбок — фрустум только на видимую + // область; far — от света сверху до земли с запасом + const top = Math.ceil(visible) + 2; + const zc = this.zenith.shadow.camera; + if (zc.right !== top) { + zc.left = -top; + zc.right = top; + zc.top = top; + zc.bottom = -top; + zc.near = 0.5; + zc.far = 2 * (SHADOW_CASTER_H + this.viewSize); + zc.updateProjectionMatrix(); + } + this.zenith.shadow.radius = shadowBlurTexels(this.shadowSoftK, top, this.zenith.shadow.mapSize.x); + // env-свет освещаемых частиц: ambient + доля солнца — ночь темнит + // их без ручных tint'ов (setSun зовётся при любом изменении света) + const e = particleEnv( + { 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.particleLightsU.uEnv!.value as THREE.Vector3).set(e[0], e[1], e[2]); } /**