diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index c0d4a10..3c7eb84 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1627,7 +1627,7 @@ `update(t)` гонит огибающую мерцания `lightningEnvelope(dt)` (пик + два рестрайка, LIGHTNING_DURATION 0.55 с), `info()` — снапшот моста. Разряд — излучающий слой частиц (lit: false): `boltPoints(seed, at)` — - непрерывный зигзаг (сумма двух синусов — ограничен) 58 узлов каждые + непрерывный зигзаг (сумма двух синусов — ограничен) 59 узлов каждые 3 юнита на высоту LIGHTNING_TOP 176 (полукадр по вертикали мира — меньше не дотягивается до верха кадра; первая проба с 64 и 16 узлами дала «жидкий» пунктир у земли) + ореол (size 3.6, alpha 0.22) и две ветви. @@ -1640,15 +1640,27 @@ туман вспышку НЕ видели (их env-свет — только ambient+солнце; фидбек «молния не подсвечивает дождь») — теперь `lightningEnvLight(k, nightK)` добавляет долю вспышки (LIGHTNING_ENV_SHARE 0.2) в uEnv/uFogColor в - setSun: ночью дождь невидим и проявляется во вспышку. Мост: - `makeLightning(at?)`, - `lightningInfo()`; кнопка «⚡ Молния» в секции погоды панели. -- **Гейт weather.mjs** (+6 чеков молнии, 25 всего): вспышка активна и + setSun: ночью дождь невидим и проявляется во вспышку. Направление света + вспышки от точки удара НЕ зависит (постоянный наклон +0.25/−0.18·TOP) — + гейт проверяет тени не «перебросом стороны», а сравнением кадра с + тенями и без. Мост: `makeLightning(at?)`, + `lightningInfo()`, `setLightningShadows(on)` (диагностика гейтов); + кнопка «⚡ Молния» в секции погоды панели. `LightningSystem` ещё + `cancel()` (погасить вспышку немедленно — переходы/гейты) и `dispose()` + (гасит слой И свет рендера — иначе свет замирает навсегда). +- **Гейт weather.mjs** (+7 чеков молнии, 26 всего): вспышка активна и ярче (кадр ×1.08), гаснет за 0.55 с (кадр вернулся), ночью вспышка - освещает кадр (×1.4), кнопка панели бьёт (счётчик +1). Грабля замера: - нижняя полоса строк — 400..540 (высота кадра 540; выход за границы - декодера даёт NaN без падения). - `check:fast` — 387 ✓ (включая фикс pow-грабли lit-шейдера: степень + освещает кадр (×1.4), тени кастеров рисуются (тёмные пиксели земной + трети гаснут при setLightningShadows(false): 1258 → 1028), кнопка + панели бьёт (счётчик +1). Грабля замера: нижняя полоса строк — + 400..540 (высота кадра 540; выход за границы декодера даёт NaN без + падения). +- **VoxelRenderer.dispose()** (ревью): полное освобождение GPU-ресурсов — + пост-проход, туман, свет с shadow map, контекст GL. Облако и меши — + у владельцев. Обязателен для многоразовых экземпляров (ModelViewer + создаёт рендерер на каждое открытие — теперь зовёт dispose); для + страничного рендера демки не вызывается. + `check:fast` — 392 ✓ (включая фикс pow-грабли lit-шейдера: степень из TS в GLSL-шаблоне печаталась «2» (int) — pow не компилировался, lit-слои молча не рисовали; в шаблоны числа — через `.toFixed(1)`). diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 4999737..22bfdf1 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -1390,6 +1390,10 @@ lightning.strike(at); return { strikes: lightning.info().strikes }; }, + /** Тени света вспышки вкл/выкл (гейт: чек «тень реально рисуется»). */ + setLightningShadows(on: boolean): void { + renderer.setLightningShadows(on); + }, /** Окно видимости частиц (диагностика плотности осадков/пепла): 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 08d30c7..5ac416b 100644 --- a/v2/apps/game/tools/checks/weather.mjs +++ b/v2/apps/game/tools/checks/weather.mjs @@ -171,6 +171,34 @@ await client.call('stepTicks', 2); const nFlash = brightness(await shot(client, '/tmp/v2_lightning_night1.png')); check('молния ночью: вспышка освещает кадр', nFlash > nBefore * 1.4, { nBefore, nFlash }); + // тени вспышки: направление света постоянно (наклон от точки удара + // не зависит), так что честный чек — сравнение кадра вспышки с + // тенями и без (setLightningShadows(false)): тёмные пиксели (тень = + // один ambient) в земной трети кадра исчезают вместе с shadow map + await client.call('setWeather', { rain: 0, snow: 0, cloud: 0.4, fog: 0, windStrength: 0.2 }); + await client.call('stepTicks', 3); + const sw = await client.call('windowInfo', 0, 40); + const scx = (sw.minX + sw.maxX) / 2, scz = (sw.minZ + sw.maxZ) / 2; + // тёмные пиксели нижней (земной) трети кадра + const groundDarks = (rgba) => { + let n = 0; + for (let y = 360; y < 540; y++) for (let x = 0; x < W; x++) { + const i = (y * W + x) * 4; + if (rgba[i] * 0.3 + rgba[i + 1] * 0.6 + rgba[i + 2] * 0.1 < 40) n++; + } + return n; + }; + await client.call('makeLightning', [scx, 0, scz]); + await client.call('stepTicks', 2); + const darksOn = groundDarks(await shot(client, '/tmp/v2_lightning_shadow_on.png')); + await client.call('setLightningShadows', false); + await client.call('stepTicks', 60); // вспышка догорела + await client.call('makeLightning', [scx, 0, scz]); + await client.call('stepTicks', 2); + const darksOff = groundDarks(await shot(client, '/tmp/v2_lightning_shadow_off.png')); + await client.call('setLightningShadows', true); // вернуть для следующих чеков + check('молния: тени кастеров реально рисуются (гаснут с castShadow)', + darksOn > darksOff + 150, { darksOn, darksOff }); await client.call('setClock', 720); await client.call('setWeather', null); // снять ручную правку перед рощей diff --git a/v2/packages/engine/src/render/__tests__/lightning.test.ts b/v2/packages/engine/src/render/__tests__/lightning.test.ts index 04142f2..bd9c6dd 100644 --- a/v2/packages/engine/src/render/__tests__/lightning.test.ts +++ b/v2/packages/engine/src/render/__tests__/lightning.test.ts @@ -54,10 +54,16 @@ expect(Math.abs(p.pos[2] - at[2])).toBeLessThan(LIGHTNING_TOP * 0.12); } }); - it('есть ветви (более бледные и тонкие) и узлы полной яркости', () => { + it('есть ветви (2×5 бледных тонких сегментов вниз) и узлы полной яркости', () => { const pts = boltPoints(42, at); - expect(pts.some((p) => p.alpha < 1)).toBe(true); // ветви expect(pts.some((p) => p.alpha === 1)).toBe(true); // канал + // маркер ветви: alpha 0.7 и size 0.6 — ореол канала (0.22) не путает + const br = pts.filter((p) => p.alpha === 0.7 && p.size === 0.6); + expect(br.length).toBe(10); // две ветви по 5 сегментов + const ys = br.map((p) => p.pos[1]).sort((a, b) => b - a); + for (let i = 1; i < ys.length; i++) { + expect(ys[i]).toBeLessThan(ys[i - 1]); // обе ветви спускаются + } }); }); diff --git a/v2/packages/engine/src/render/modelViewer.ts b/v2/packages/engine/src/render/modelViewer.ts index da265c0..4f3ed77 100644 --- a/v2/packages/engine/src/render/modelViewer.ts +++ b/v2/packages/engine/src/render/modelViewer.ts @@ -184,6 +184,9 @@ dispose(): void { this.disposed = true; this.cloud?.dispose(); + this.renderer.dispose(); // рендерер многоразовый (канвас общий) — + // без этого каждое открытие вьювера оставляло GL-контексту свет + // с тремя shadow map this.canvas.removeEventListener('pointerdown', this.onDown); this.canvas.removeEventListener('pointermove', this.onMove); this.canvas.removeEventListener('pointerup', this.onUp); diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index ebe2256..e1f4323 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -649,6 +649,12 @@ return { flash: this.flashK, at: this.flashAt ? [...this.flashAt] : null }; } + /** Тени света вспышки вкл/выкл (диагностика гейтов: чек «тень реально + * рисуется» сравнивает кадр с тенями и без). */ + setLightningShadows(on: boolean): void { + this.flash.castShadow = on; + } + /** Точка фокуса камеры (цель) — опорная точка мира для систем погоды. */ focusPoint(): [number, number, number] { const t = this.target; @@ -704,6 +710,25 @@ p.rt.dispose(); } + /** Полное освобождение GPU-ресурсов рендера: контекст GL, свет с их + * shadow map, активные проходы (пост, туман). Облако и меши — на + * совести владельцев (у их ручек свои dispose). Для единственного + * страничного рендера демки не нужен; обязателен для многоразовых + * экземпляров (вьювер модели создаёт рендерер на каждое открытие). */ + dispose(): void { + this.clearPostPass(); + if (this.fogPass) { + this.fogPass.rt.dispose(); + this.fogPass.mat.dispose(); + this.fogPass = null; + } + this.sun.dispose(); + this.zenith.dispose(); + this.flash.dispose(); + this.ambient.dispose(); + this.renderer.dispose(); + } + /** Один кадр. При активном тумане сцена идёт через RT с глубиной, затем * композитный квад красит её слоями по дальности (render/fog.ts) — * в post.rt игры (пост-проход поверх тумана) или прямо на канвас. */