diff --git a/docs/engine/practices.md b/docs/engine/practices.md index d81458a..c885c4d 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -391,6 +391,15 @@ видят импульсы бесплатно. Вспышка «на весь экран» (`pulseAmbient`) — один слот, последний побеждает. В сценах не забыть `maxLights: MAX_LIGHTS` — иначе движковый пул (16) молча теряет источники сверх себя. +7. **Механика «тьма с прорезями»**: тьма — карта силы (RT: белый fill → + erase-спрайты), display-спрайт `multiply` с `tint = ambient`. Отсюда два + правила: (а) **bake-контейнер с erase-спрайтами не добавлять в дерево + сцены** — `destination-out` вне RT стирает мир; (б) **дорогая перерисовка + гасится квантованной подписью** `{round(x), round(y), scale·1/8, alpha·1/8}`: + bake только при её изменении, «живость» мерцания переносится в слои, + рисуемые каждый тик (тон, ambient-лерп). Смена цвета без смены геометрии + (день/ночь) — просто `tint` display-спрайта, RT не трогается. День с белым + ambient — fast-path: слой выключен целиком. ## Ситуация: добавляю визуальный эффект diff --git a/docs/engine/render.md b/docs/engine/render.md index 9265d79..02297fd 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -113,18 +113,31 @@ `apply`. Не вычисляйте проекцию через `worldRoot.position` — он обновляется при рендере и отстаёт на кадр; `toScreen` даёт позицию без лага. -## Освещение +## Освещение («тьма с прорезями») -Два компонента, оба в `renderer.lightRoot` — экранное пространство между миром и UI: +Модель: тёмный ambient-слой затемняет сцену, источники **прорезают** его +(восстанавливают яркость) ступенчато, а аддитивный тон лишь слегка подкрашивает +свет вокруг. В отличие от схемы «ambient + аддитивные пятна», спрайты героя и +мира под источником остаются читаемыми, а день с белым ambient вообще не +трогает картинку. -1. **Ambient** — fullscreen-спрайт с блендом `multiply`. Цвет и есть яркость: - `0xffffff` — свет не трогает сцену, тёмный — затемнение, цветной — тон. - Фундамент смены времени дня: день = `0xffffff`, закат = тёплый, ночь = тёмно-синий. -2. **Источники** — аддитивные спрайты со ступенчатой пиксельной текстурой - свечения (генерируется `makeGlowTexture`, кэшируется на инстансе Lighting — - модульный кэш переживал `destroy` рендерера; радиус нормируется на - `GLOW_BASE_PX`). Тонкий Pixi-адаптер — `Lighting`, чистая математика — - `lightSim` (`lightFrame`, `flickerFactor`, `lerpAmbient`, `dimColor`). +Оба компонента живут в `renderer.lightRoot` — экранное пространство между миром и UI: + +1. **Карта силы тьмы** — `RenderTexture` (480×270, resolution 1, nearest): + в неё рисуется bake-контейнер (белый fill → erase-спрайты источников); + display-спрайт RT — `multiply` с `tint = ambient`. RT хранит **силу тьмы**, + не цвет: смена ambient-цвета (лерп день/ночь каждый тик) не требует + перерисовки RT. Bake делается только при изменении квантованных подписей + источников `{round(x), round(y), scale·1/8, alpha·1/8}` — мерцание живёт + в квантовании и в тоне, который обновляется каждый тик. День + (`0xffffff`) — fast-path: слой выключен, bake пропускается. +2. **Источники** — пара спрайтов на источник: erase-прорезь в RT (ступенчатая + текстура `makeHoleTexture`, профиль `HOLE_PROFILE` из 7 ступеней — тьма + снимается полностью в центре и постепенно к краю) + аддитивный тон + (`makeToneTexture`, приглушённое ядро `TONE_PROFILE`, альфа × `TONE_GAIN` + ≈ 0.3). Тонкий Pixi-адаптер — `Lighting`, чистая математика (профили, + `stepAlphas`, `quantizeHoleAlpha`, `lightFrame`, `flickerFactor`, + `lerpAmbient`, `dimColor`) — `lightSim`. ```ts import { Lighting } from '@rpg/engine'; @@ -138,7 +151,7 @@ lighting.setLightPos('hearth', 110, 45); // runtime: move/color/enable/remove lighting.removeLight('hearth'); -lighting.update(dt); // каждый тик сцены: время, лерп ambient, мерцание +lighting.update(dt); // каждый тик сцены: время, лерп ambient, мерцание, bake по dirty lighting.frames(); // видимые кадры источников (для снапшота агента) ``` @@ -146,7 +159,12 @@ `camera.toScreen(worldToScreen-координаты точки)` (движок не знает об изометрии). Пул спрайтов фиксирован (`maxLights`, по умолчанию 16); лишние `upsertLight` игнорируются. Плавный лерп ambient (`setAmbient(color, fadeSec)`) — база для -day/night: день/ночь — кейфреймы поверх этого API. +day/night: день/ночь — кейфреймы поверх этого API. Без `renderer` (тесты в node) +движок работает в fallback: RT не создаётся, прорези no-op, кадры/API те же. + +**Грабля**: bake-контейнер с erase-спрайтами **не должен** попадать в дерево +сцены — `destination-out` вне RT стирает мир под ним. Lighting держит его +внутри себя, не добавляя в stage. Для непрерывного цикла суток в `lightSim` есть чистая кривая: `dayNightFactor(tHours, spec?)` — фактор ночи 0..1 по часам (mod 24), линейный на рассвете/закате; границы diff --git a/packages/engine/src/render/Lighting.ts b/packages/engine/src/render/Lighting.ts index 3c25e17..addf9d7 100644 --- a/packages/engine/src/render/Lighting.ts +++ b/packages/engine/src/render/Lighting.ts @@ -1,32 +1,44 @@ /** - * Слой освещения: fullscreen ambient (multiply-цвет) + аддитивные источники, - * импульсы (pulseLight/pulseAmbient), виньетка и тёмные пятна. - * Тонкий Pixi-адаптер над lightSim (чистая математика — там). + * Слой освещения «тьма с прорезями»: карта силы тьмы в RenderTexture, + * источники прорезают её erase-спрайтами, тон (мягкая аддитивная подсветка + * вокруг прорези) рисуется поверх. Плюс импульсы (pulseLight/pulseAmbient), + * виньетка и тёмные пятна. Тонкий Pixi-адаптер над lightSim (чистая математика — там). * - * Живёт в renderer.lightRoot — экранное пространство между миром и UI: - * ambient затемняет сцену целиком, источники рисуются поверх затемнения. - * Порядок детей: ambient → виньетка → тёмные пятна → источники → ambient-вспышка. + * Механика: RT хранит не цвет, а силу тьмы (белый fill → erase-спрайты); + * display-спрайт RT рисуется с blendMode 'multiply' и tint = ambient — + * смена ambient-цвета не требует перерисовки RT. Bake (отрисовка в RT) + * делается только при изменении квантованных подписей источников — + * мерцание живёт в квантовании альфы и тоне, обновляемом каждый тик. + * + * Живёт в renderer.lightRoot — экранное пространство между миром и UI. + * Порядок детей: карта тьмы → виньетка → тёмные пятна → тон → ambient-вспышка. + * Важно: bake-контейнер НЕ в дереве сцены — erase-спрайты вне RT стёрли бы мир. */ -import { Container, Graphics, Sprite, Texture } from 'pixi.js'; +import { Container, Graphics, RenderTexture, Sprite, Texture } from 'pixi.js'; import { FadeState } from '../core/fade'; import type { Renderer } from './Renderer'; import { GLOW_BASE_PX, + HOLE_PROFILE, + TONE_PROFILE, lightFrame, lerpAmbient, pulseEnvelope, pulseTotal, + quantizeHoleAlpha, + stepAlphas, type LightDef, type LightFrame, - type PulseSpec + type PulseSpec, + type RingStep } from './lightSim'; export interface LightingOptions { /** Виртуальные размеры сцены (480×270). */ width: number; height: number; - /** Живой рендерер — для генерации текстур; без него Texture.WHITE. */ + /** Живой рендерер — для генерации текстур и bake; без него Texture.WHITE. */ renderer?: Renderer; /** Размер пула спрайтов источников (по умолчанию 16). */ maxLights?: number; @@ -34,25 +46,31 @@ maxDarkSpots?: number; } +/** Сила аддитивного тона поверх прорези: тон намёк, яркость несёт сама прорезь. */ +const TONE_GAIN = 0.3; + +/** Масштаб тона относительно радиуса прорези: тон уже, чем дыра. */ +const TONE_SCALE = 0.8; + /** - * Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной - * альфой (никаких гладких градиентов) + дизер-кольцо по внешнему радиусу. - * resolution 1 + nearest — пиксельные ступени. Кэшируется вызывающим - * (Lighting держит её на инстансе — модульный кэш переживал destroy рендерера). + * Ступенчатая круговая текстура из колец (в порядке stepAlphas) — общая + * для прорези, тона и свечения. resolution 1 + nearest — пиксельные ступени. */ -export function makeGlowTexture(renderer: Renderer, steps = 4): Texture { +function makeSteppedTexture(renderer: Renderer, rings: RingStep[], dither: boolean): Texture { const g = new Graphics(); - for (let i = steps; i >= 1; i--) { - const r = (GLOW_BASE_PX * i) / steps; - g.circle(0, 0, r).fill({ color: 0xffffff, alpha: 1 / (i + 1) }); - } // Дизер-кольцо: редкие точки по внешнему радиусу — смягчают резкую ступень. - for (let i = 0; i < 32; i++) { - const a = ((i * 2) / 64) * Math.PI * 2; - g.rect(Math.cos(a) * GLOW_BASE_PX - 0.5, Math.sin(a) * GLOW_BASE_PX - 0.5, 1, 1).fill({ - color: 0xffffff, - alpha: 1 / (steps + 1) - }); + if (dither) { + const outer = rings[0].radius; + for (let i = 0; i < 32; i++) { + const a = ((i * 2) / 64) * Math.PI * 2; + g.rect(Math.cos(a) * outer - 0.5, Math.sin(a) * outer - 0.5, 1, 1).fill({ + color: 0xffffff, + alpha: rings[0].paintAlpha + }); + } + } + for (const ring of rings) { + g.circle(0, 0, ring.radius).fill({ color: 0xffffff, alpha: ring.paintAlpha }); } const tex = renderer.app.renderer.generateTexture({ target: g, @@ -65,6 +83,28 @@ } /** + * Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной + * альфой (никаких гладких градиентов) + дизер-кольцо. Используется тёмными + * пятнами. Кэшируется вызывающим (Lighting держит её на инстансе — модульный + * кэш переживал destroy рендерера). + */ +export function makeGlowTexture(renderer: Renderer, steps = 4): Texture { + const profile: number[] = []; + for (let i = 1; i <= steps; i++) profile.push(1 - 1 / (i + 1)); // накопление красок 1/(i+1) + return makeSteppedTexture(renderer, stepAlphas(profile), true); +} + +/** Текстура прорези (erase в RT): 7 ступеней из HOLE_PROFILE + дизер-кольцо. */ +export function makeHoleTexture(renderer: Renderer): Texture { + return makeSteppedTexture(renderer, stepAlphas(HOLE_PROFILE), true); +} + +/** Текстура тона (аддитивный свет вокруг прорези): приглушённое ядро. */ +export function makeToneTexture(renderer: Renderer): Texture { + return makeSteppedTexture(renderer, stepAlphas(TONE_PROFILE), false); +} + +/** * Ступенчатая виньетка: белый центр → серые кольца к тёмным углам (multiply). * Интенсивность задаётся alpha спрайта, текстура постоянна. Кэшируется * вызывающим (см. makeGlowTexture). @@ -102,7 +142,10 @@ interface LightEntry { def: LightDef; + /** Спрайт тона (аддитивный, в toneLayer). */ sprite: Sprite; + /** Erase-спрайт в bake — прорезает карту тьмы (есть только с RT). */ + hole: Sprite | null; } /** Апсейт def по полям (id — ключ карты, не меняется): без пересоздания на тике. */ @@ -139,21 +182,36 @@ } export class Lighting extends Container { - private readonly ambient: Sprite; + /** Display-спрайт карты тьмы: multiply, tint = ambient-цвет. */ + private readonly darkSprite: Sprite; private readonly vignette: Sprite; private readonly darkLayer: Container; + private readonly toneLayer: Container; private readonly pulseAmb: Sprite; - private readonly free: Sprite[] = []; + private readonly toneFree: Sprite[] = []; + private readonly holeFree: Sprite[] = []; private readonly darkFree: Sprite[] = []; private readonly lights = new Map(); private readonly darks = new Map(); private readonly pulses = new Map(); + private readonly renderer: Renderer | null; + /** Карта силы тьмы (resolution 1); null — fallback без RT (Vitest, node). */ + private readonly rt: RenderTexture | null; + /** Bake-контейнер: fill + erase-спрайты. НЕ в дереве сцены! */ + private readonly bake: Container; + private readonly bakeFill: Sprite; private readonly glow: Texture; + private readonly hole: Texture; + private readonly tone: Texture; /** Сгенерированные (не WHITE) текстуры — уничтожаются в destroy(). */ private readonly generated: Texture[] = []; private readonly maxLights: number; private readonly maxDarkSpots: number; private pulseSeq = 0; + /** Подпись набора квантованных прорезей; null — bake ещё не делался. */ + private bakeSig: string | null = null; + /** Тьма была активна на прошлом тике (для форс-bake на включении). */ + private darkOn = false; /** Переиспользуемый кадр applyFrame — без аллокации на источник/тик. */ private readonly frame: LightFrame = { id: '', x: 0, y: 0, tint: 0xffffff, alpha: 0, scale: 1 }; @@ -166,20 +224,22 @@ super(); this.maxLights = options.maxLights ?? 16; this.maxDarkSpots = options.maxDarkSpots ?? 16; - // Ambient: Texture.WHITE, растянутый на сцену; цвет — яркость (0xffffff = не трогает). - this.ambient = new Sprite(Texture.WHITE); - this.ambient.width = options.width; - this.ambient.height = options.height; - this.ambient.blendMode = 'multiply'; - this.ambient.tint = 0xffffff; + this.renderer = options.renderer ?? null; + + // Карта тьмы: Texture.WHITE без рендерера (multiply белого = не трогает). + this.darkSprite = new Sprite(Texture.WHITE); + this.darkSprite.width = options.width; + this.darkSprite.height = options.height; + this.darkSprite.blendMode = 'multiply'; + this.darkSprite.tint = 0xffffff; this.ambientFade = new FadeState(lerpAmbient, 0xffffff); - this.addChild(this.ambient); + this.addChild(this.darkSprite); // Виньетка: Texture.WHITE без рендерера (multiply белого = не трогает). - const vignetteTex = options.renderer - ? makeVignetteTexture(options.renderer, options.width, options.height) + const vignetteTex = this.renderer + ? makeVignetteTexture(this.renderer, options.width, options.height) : Texture.WHITE; - if (options.renderer) this.generated.push(vignetteTex); + if (this.renderer) this.generated.push(vignetteTex); this.vignette = new Sprite(vignetteTex); this.vignette.width = options.width; this.vignette.height = options.height; @@ -188,14 +248,13 @@ this.vignetteFade = new FadeState((a, b, k) => a + (b - a) * k, 0); this.addChild(this.vignette); - // Тёмные пятна — под источниками: свет пробивает локальную тень. + // Тёмные пятна — под тоном: свет пробивает локальную тень. this.darkLayer = new Container(); this.addChild(this.darkLayer); - this.glow = options.renderer ? makeGlowTexture(options.renderer) : Texture.WHITE; - if (options.renderer) this.generated.push(this.glow); - for (let i = 0; i < this.maxLights; i++) this.free.push(this.makeLightSprite()); - for (let i = 0; i < this.maxDarkSpots; i++) this.darkFree.push(this.makeDarkSprite()); + // Тон источников — аддитивный слой над тьмой (не в RT: цвет каждый тик). + this.toneLayer = new Container(); + this.addChild(this.toneLayer); // Ambient-вспышка: Texture.WHITE, аддитивная, alpha по огибающей. this.pulseAmb = new Sprite(Texture.WHITE); @@ -204,14 +263,49 @@ this.pulseAmb.blendMode = 'add'; this.pulseAmb.alpha = 0; this.addChild(this.pulseAmb); + + // RT карты тьмы + bake-контейнер (вне сцены): fill → erase-прорези. + this.rt = this.renderer + ? RenderTexture.create({ + width: options.width, + height: options.height, + resolution: 1, + scaleMode: 'nearest', + antialias: false + }) + : null; + this.bake = new Container(); + this.bakeFill = new Sprite(Texture.WHITE); + this.bakeFill.width = options.width; + this.bakeFill.height = options.height; + this.bake.addChild(this.bakeFill); + + this.glow = this.renderer ? makeGlowTexture(this.renderer) : Texture.WHITE; + this.hole = this.renderer ? makeHoleTexture(this.renderer) : Texture.WHITE; + this.tone = this.renderer ? makeToneTexture(this.renderer) : Texture.WHITE; + for (const tex of [this.glow, this.hole, this.tone]) { + if (tex !== Texture.WHITE) this.generated.push(tex); + } + for (let i = 0; i < this.maxLights; i++) this.toneFree.push(this.makeToneSprite()); + for (let i = 0; i < this.maxLights; i++) this.holeFree.push(this.makeHoleSprite()); + for (let i = 0; i < this.maxDarkSpots; i++) this.darkFree.push(this.makeDarkSprite()); } - private makeLightSprite(): Sprite { - const s = new Sprite(this.glow); + private makeToneSprite(): Sprite { + const s = new Sprite(this.tone); s.anchor.set(0.5); s.blendMode = 'add'; s.visible = false; - this.addChild(s); + this.toneLayer.addChild(s); + return s; + } + + private makeHoleSprite(): Sprite { + const s = new Sprite(this.hole); + s.anchor.set(0.5); + s.blendMode = 'erase'; + s.visible = false; + this.bake.addChild(s); return s; } @@ -230,8 +324,7 @@ /** Задать ambient-цвет; fadeSec > 0 — плавный лерп (день/ночь, закат). */ setAmbient(color: number, fadeSec = 0): void { this.ambientFade.to(color, Math.max(0, fadeSec)); - this.ambient.tint = this.ambientFade.value; - this.ambient.visible = this.ambientFade.value !== 0xffffff; // белый — no-op + this.applyAmbient(); } /** Целевой (заданный) ambient-цвет. */ @@ -239,6 +332,12 @@ return this.ambientFade.target; } + /** ambient на display-спрайт карты тьмы; белый — день, слой выключен. */ + private applyAmbient(): void { + this.darkSprite.tint = this.ambientFade.value; + this.darkSprite.visible = this.ambientFade.value !== 0xffffff; + } + // --- виньетка (край экрана темнеет: низкий hp, опасная зона) --- /** Виньетка 0..1 (0 — не трогает); fadeSec > 0 — плавный лерп. */ @@ -258,21 +357,34 @@ upsertLight(def: LightDef): void { let entry = this.lights.get(def.id); if (!entry) { - const sprite = this.free.pop(); + const sprite = this.toneFree.pop(); if (!sprite) return; // пул исчерпан — лишние источники игнорируются - entry = { def: { ...def }, sprite }; + entry = { def: { ...def }, sprite, hole: this.takeHole() }; this.lights.set(def.id, entry); sprite.visible = true; } copyLightDef(entry.def, def); // апсейт по полям — без пересоздания на тике } + /** Erase-слот из пула (или null без RT — прорезать нечего). */ + private takeHole(): Sprite | null { + if (!this.rt) return null; + const hole = this.holeFree.pop(); + if (hole) hole.visible = true; + return hole ?? null; + } + removeLight(id: string): void { const entry = this.lights.get(id); if (!entry) return; entry.sprite.visible = false; entry.sprite.alpha = 0; - this.free.push(entry.sprite); + this.toneFree.push(entry.sprite); + if (entry.hole) { + entry.hole.visible = false; + entry.hole.alpha = 0; + this.holeFree.push(entry.hole); + } this.lights.delete(id); } @@ -313,7 +425,7 @@ spec: PulseSpec; }): string { const id = `pulse#${this.pulseSeq++}`; - const sprite = this.free.pop(); + const sprite = this.toneFree.pop(); if (!sprite) return ''; const base = args.intensity ?? 1; const def: LightDef = { @@ -326,7 +438,7 @@ flicker: 0, seed: 0 }; - this.lights.set(id, { def, sprite }); + this.lights.set(id, { def, sprite, hole: this.takeHole() }); sprite.visible = true; this.pulses.set(id, { def, base, spec: args.spec, elapsed: 0 }); return id; @@ -387,8 +499,11 @@ update(dt: number): void { this.time += dt; this.ambientFade.update(dt); - this.ambient.tint = this.ambientFade.value; - this.ambient.visible = this.ambientFade.value !== 0xffffff; // белый multiply — пустой квад + this.applyAmbient(); + // Тьма включилась на этом тике — форс-bake, даже если подпись та же. + const on = this.ambientFade.value !== 0xffffff; + if (on && !this.darkOn) this.bakeSig = null; + this.darkOn = on; this.vignetteFade.update(dt); this.vignette.alpha = this.vignetteFade.value; this.vignette.visible = this.vignetteFade.value > 0; @@ -401,7 +516,7 @@ } p.def.intensity = p.base * pulseEnvelope(p.elapsed, p.spec); } - for (const { def, sprite } of this.lights.values()) this.applyFrame(def, sprite); + this.applyLights(); if (this.ambPulse) { this.ambPulse.elapsed += dt; if (this.ambPulse.elapsed >= pulseTotal(this.ambPulse.spec)) { @@ -416,12 +531,43 @@ } } - private applyFrame(def: LightDef, sprite: Sprite): void { - const f = lightFrame(def, this.time, this.frame); - sprite.position.set(f.x, f.y); - sprite.tint = f.tint; - sprite.alpha = Math.min(1, f.alpha); - sprite.scale.set(f.scale); + /** Кадры источников: тон каждый тик, прорези — по квантованной подписи. */ + private applyLights(): void { + let sig = ''; + let idx = 0; + for (const entry of this.lights.values()) { + const f = lightFrame(entry.def, this.time, this.frame); + // Тон: полный precision, обновляется каждый тик (живость мерцания). + const s = entry.sprite; + s.position.set(f.x, f.y); + s.tint = f.tint; + s.alpha = Math.min(1, f.alpha) * TONE_GAIN; + s.scale.set(f.scale * TONE_SCALE); + s.visible = s.alpha > 0.01; + // Прорезь: квантованные значения — сигнатура = содержимое RT. + if (entry.hole) { + const qx = Math.round(f.x); + const qy = Math.round(f.y); + const qs = Math.round(f.scale * 8) / 8; + const qa = quantizeHoleAlpha(f.alpha); + entry.hole.position.set(qx, qy); + entry.hole.scale.set(qs); + entry.hole.alpha = qa; + entry.hole.visible = qa > 0; + sig += `${idx}:${qx},${qy},${qs},${qa};`; + } + idx++; + } + if (sig !== this.bakeSig) { + this.bakeSig = sig; + this.bakeNow(); + } + } + + /** Перерисовать карту тьмы в RT; день (белый ambient) — не нужно. */ + private bakeNow(): void { + if (!this.rt || !this.renderer || !this.darkSprite.visible) return; + this.renderer.app.renderer.render({ container: this.bake, target: this.rt, clear: true }); } /** Видимые кадры источников (пост-мерцание, включая импульсы) — для снапшота. */ @@ -435,17 +581,24 @@ this.pulses.clear(); for (const id of [...this.lights.keys()]) this.removeLight(id); for (const id of [...this.darks.keys()]) this.removeDarkSpot(id); - for (const s of this.free) s.destroy(); - this.free.length = 0; + for (const s of this.toneFree) s.destroy(); + this.toneFree.length = 0; + for (const s of this.holeFree) s.destroy(); + this.holeFree.length = 0; + this.bakeFill.destroy(); + this.bake.destroy({ children: false }); + this.rt?.destroy(true); this.darks.clear(); // Тёмные спрайты — дети darkLayer: destroy явно и один раз, слой без children. for (const s of this.darkFree) s.destroy(); this.darkFree.length = 0; this.darkLayer.destroy({ children: false }); - this.ambient.destroy(); + this.darkSprite.destroy(); this.vignette.destroy(); this.pulseAmb.destroy(); - for (const tex of this.generated) tex.destroy(true); + for (const tex of this.generated) { + if (tex !== Texture.WHITE) tex.destroy(true); // стаб-рендерер отдаёт WHITE + } this.generated.length = 0; super.destroy(options); } diff --git a/packages/engine/src/render/__tests__/Lighting.test.ts b/packages/engine/src/render/__tests__/Lighting.test.ts index 5679a35..e66ebc6 100644 --- a/packages/engine/src/render/__tests__/Lighting.test.ts +++ b/packages/engine/src/render/__tests__/Lighting.test.ts @@ -1,20 +1,41 @@ import { describe, expect, it } from 'vitest'; +import { Container, Texture } from 'pixi.js'; import { Lighting } from '../Lighting'; -import { GLOW_BASE_PX, type LightFrame } from '../lightSim'; +import { GLOW_BASE_PX, HOLE_PROFILE, quantizeHoleAlpha, stepAlphas, type LightFrame } from '../lightSim'; +import type { Renderer } from '../Renderer'; function makeLighting(maxLights = 16): Lighting { - // Без renderer: glow-текстура = Texture.WHITE, пул и логика тестируются в node. + // Без renderer: RT не создаётся, glow/hole/tone = Texture.WHITE, bake no-op. return new Lighting({ width: 480, height: 270, maxLights }); } +/** + * Стаб-рендерер: generateTexture отдаёт WHITE (destroy защищён guard'ом), + * render() записывает вызовы bake — dirty-логика тестируется без GPU. + */ +function makeFakeRenderer() { + const bakes: Array<{ container: Container; clear: boolean }> = []; + const renderer = { + app: { + renderer: { + generateTexture: () => Texture.WHITE, + render: (opts: { container: Container; clear: boolean }) => { + bakes.push(opts); + } + } + } + } as unknown as Renderer; + return { renderer, bakes }; +} + describe('Lighting: ambient', () => { - it('ambient-спрайт: multiply, белый, на всю сцену', () => { + it('карта тьмы: multiply, белый (день), на всю сцену', () => { const l = makeLighting(); - const ambient = l.children[0]; - expect(ambient.blendMode).toBe('multiply'); - expect(ambient.tint).toBe(0xffffff); - expect(ambient.width).toBe(480); - expect(ambient.height).toBe(270); + const dark = l.children[0]; + expect(dark.blendMode).toBe('multiply'); + expect(dark.tint).toBe(0xffffff); + expect(dark.width).toBe(480); + expect(dark.height).toBe(270); l.destroy(); }); @@ -63,14 +84,15 @@ l.destroy(); }); - it('removeLight возвращает спрайт в пул: число детей стабильно', () => { + it('removeLight возвращает спрайты в пул: число детей стабильно', () => { const l = makeLighting(); - const before = l.children.length; + const toneLayer = l.children[3]; + const before = toneLayer.children.length; l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); l.upsertLight({ id: 'b', x: 0, y: 0, color: 0xffffff }); l.removeLight('a'); l.upsertLight({ id: 'c', x: 0, y: 0, color: 0xffffff }); - expect(l.children.length).toBe(before); + expect(toneLayer.children.length).toBe(before); expect(l.hasLight('a')).toBe(false); expect(l.hasLight('c')).toBe(true); l.destroy(); @@ -97,7 +119,6 @@ const f = l.frames()[0]; expect(f.x).toBe(7); expect(f.tint).toBe(0x112233); - expect(l.children.length).toBe(l.children.length); // пул не рос l.destroy(); }); @@ -115,7 +136,7 @@ describe('Lighting: импульсы', () => { it('pulseLight появляется в кадрах, модулируется огибающей и снимается', () => { - const l = makeLighting(2); // дети: ambient + виньетка + darkLayer + pulseAmb + 2 спрайта + const l = makeLighting(2); const id = l.pulseLight({ x: 10, y: 20, color: 0xf2b45a, intensity: 0.5, spec: { attack: 0.05, decay: 0.1 } }); expect(id).toMatch(/^pulse#/); l.update(0.01); @@ -129,7 +150,7 @@ l.update(0.1); // elapsed 0.15 = total — снят expect(l.frames().length).toBe(0); expect(l.hasLight(id)).toBe(false); - expect(l.children.length).toBe(6); // ambient+виньетка+darkLayer+pulseAmb+2 спрайта + expect(l.children.length).toBe(5); // карта тьмы+виньетка+пятна+тон+вспышка l.destroy(); }); @@ -201,7 +222,7 @@ expect(spot.y).toBe(14); l.removeDarkSpot('hazard@3,4'); expect(l.hasDarkSpot('hazard@3,4')).toBe(false); - expect(l.children.length).toBe(6); // слои на месте, спрайт в пуле + expect(l.children.length).toBe(5); // слои на месте, спрайт в пуле l.destroy(); }); @@ -217,6 +238,135 @@ }); }); +describe('Lighting: bake карты тьмы (fake-renderer)', () => { + it('bake-контейнер не в дереве сцены; erase-спрайты вне Lighting', () => { + const { renderer } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); + l.setAmbient(0x3e4658); + l.update(1 / 60); + // дети: карта тьмы, виньетка, пятна, тон, вспышка — bake среди них нет + expect(l.children.length).toBe(5); + for (const child of l.children) { + expect(child.blendMode).not.toBe('erase'); + } + l.destroy(); + }); + + it('первый update с тьмой — один bake; без изменений — без повторных', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); + l.update(1 / 60); + expect(bakes.length).toBe(1); + l.update(1 / 60); + l.update(1 / 60); + expect(bakes.length).toBe(1); // подпись не изменилась — bake пропущен + l.destroy(); + }); + + it('квантованные значения в прорези: движение на суб-пиксель — без bake', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40.7, y: 30.2, color: 0xffffff, flicker: 0 }); + l.update(1 / 60); + const first = bakes.length; + l.setLightPos('a', 41.3, 30.4); // округление то же (41, 30) + l.update(1 / 60); + expect(bakes.length).toBe(first); // округление погасило изменение + l.setLightPos('a', 44, 30); + l.update(1 / 60); + expect(bakes.length).toBe(first + 1); // сдвиг на целый px — bake + l.destroy(); + }); + + it('день (белый ambient) — fast-path: bake не вызывается', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); + l.update(1 / 60); + expect(bakes.length).toBe(0); // день — карта тьмы выключена + l.setAmbient(0x3e4658); + l.update(1 / 60); + expect(bakes.length).toBeGreaterThan(0); // тьма включилась — bake + l.setAmbient(0xffffff); + l.update(1 / 60); + const n = bakes.length; + l.upsertLight({ id: 'b', x: 10, y: 10, color: 0xffffff }); + l.update(1 / 60); + expect(bakes.length).toBe(n); // снова день — bake не нужен + l.destroy(); + }); + + it('тьма включилась после дня — форс-bake даже при той же подписи', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff, flicker: 0 }); + l.update(1 / 60); + expect(bakes.length).toBe(1); + l.setAmbient(0xffffff); + l.update(1 / 60); // день — без bake + l.setAmbient(0x3e4658); + l.update(1 / 60); // та же подпись, но тьма включилась заново + expect(bakes.length).toBe(2); + l.destroy(); + }); + + it('removeLight меняет подпись — перерисовка', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff, flicker: 0 }); + l.update(1 / 60); + const first = bakes.length; + l.removeLight('a'); + l.update(1 / 60); + expect(bakes.length).toBe(first + 1); + l.destroy(); + }); + + it('без RT (fallback) update и destroy не бросают', () => { + const l = makeLighting(); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); + expect(() => l.update(1 / 60)).not.toThrow(); + expect(l.frames().length).toBe(1); + l.destroy(); + expect(() => l.destroy()).not.toThrow(); + }); +}); + +describe('Lighting: профиль прорези (ступени)', () => { + it('paint-краски воспроизводят накопленный профиль при рисовании снаружи внутрь', () => { + const rings = stepAlphas(HOLE_PROFILE); + expect(rings.length).toBe(HOLE_PROFILE.length); + expect(rings[0].radius).toBe(GLOW_BASE_PX); // внешнее кольцо = полный радиус + // Симуляция source-over: красим снаружи внутрь, накопление к центру. + // rings[i] — кольцо k=n-i, накопленный профиль — HOLE_PROFILE[n-1-i]. + let acc = 0; + for (let i = 0; i < rings.length; i++) { + acc = 1 - (1 - rings[i].paintAlpha) * (1 - acc); + expect(acc).toBeCloseTo(HOLE_PROFILE[rings.length - 1 - i], 5); + } + // Центр прорези: тьма снята полностью. + expect(rings[rings.length - 1].paintAlpha).toBe(1); + }); + + it('quantizeHoleAlpha — сетка 1/8 с клампом', () => { + expect(quantizeHoleAlpha(0)).toBe(0); + expect(quantizeHoleAlpha(1)).toBe(1); + expect(quantizeHoleAlpha(0.1)).toBe(0.125); + expect(quantizeHoleAlpha(0.13)).toBe(0.125); + expect(quantizeHoleAlpha(2)).toBe(1); + expect(quantizeHoleAlpha(-1)).toBe(0); + expect(quantizeHoleAlpha(0.0625)).toBe(0.125); // половина шага — вверх + expect(quantizeHoleAlpha(0.06)).toBe(0); + }); +}); + describe('Lighting: destroy', () => { it('не бросает и повторный вызов безопасен', () => { const l = makeLighting(); @@ -225,4 +375,13 @@ l.destroy(); expect(() => l.destroy()).not.toThrow(); }); + + it('с fake-renderer (RT + bake) тоже безопасен', () => { + const { renderer } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); + l.setAmbient(0x3e4658); + l.update(1 / 60); + expect(() => l.destroy()).not.toThrow(); + }); }); \ No newline at end of file diff --git a/packages/engine/src/render/lightSim.ts b/packages/engine/src/render/lightSim.ts index 1067dcc..b48c312 100644 --- a/packages/engine/src/render/lightSim.ts +++ b/packages/engine/src/render/lightSim.ts @@ -101,6 +101,63 @@ return 1 - (t - attack - hold) / decay; } +// --- тьма с прорезями: ступенчатые профили текстур света --- + +/** Ступень круговой текстуры: внешнее кольцо и его краска. */ +export interface RingStep { + /** Радиус кольца в px (текстура радиусом GLOW_BASE_PX). */ + radius: number; + /** Альфа краски кольца (кольца рисуются в порядке массива, снаружи внутрь). */ + paintAlpha: number; +} + +/** + * Ступени из накопленного профиля A (изнутри наружу, A[0] — центр): + * радиус кольца k — GLOW_BASE_PX·k/n, краска p_k = 1 − (1−A_k)/(1−A_{k+1}) — + * чтобы перекрытие колец (рисование снаружи внутрь, source-over) дало в + * кольце k ровно накопленную A_k. Возвращает в порядке рисования. + */ +export function stepAlphas(profile: readonly number[]): RingStep[] { + const n = profile.length; + const out: RingStep[] = []; + let nextA = 0; // накопление за внутренним краем текущего кольца + for (let k = n; k >= 1; k--) { + const a = Math.max(0, Math.min(1, profile[k - 1])); + const paint = nextA >= 1 ? 0 : Math.max(0, 1 - (1 - a) / (1 - nextA)); + out.push({ radius: (GLOW_BASE_PX * k) / n, paintAlpha: paint }); + nextA = a; + } + return out; +} + +/** + * Профиль прорези тьмы: накопленная сила стирания тьмы изнутри наружу + * (7 ступеней: центр — тьма снята полностью, край — едва тронута). + */ +export const HOLE_PROFILE: readonly number[] = [1, 0.95, 0.82, 0.62, 0.4, 0.2, 0.08]; + +/** Профиль тона: приглушённое ядро, максимум на среднем кольце (6 ступеней). */ +export const TONE_PROFILE: readonly number[] = [0.35, 0.45, 0.4, 0.3, 0.18, 0.08]; + +/** Ступени текстуры прорези (erase-карта силы тьмы). */ +export function holeStepAlphas(): RingStep[] { + return stepAlphas(HOLE_PROFILE); +} + +/** Ступени текстуры тона (аддитивная подсветка вокруг прорези). */ +export function toneStepAlphas(): RingStep[] { + return stepAlphas(TONE_PROFILE); +} + +/** Шаг квантования силы прорези: меньше перерисовок RT, мерцание «ступеньками». */ +export const HOLE_ALPHA_STEPS = 8; + +/** Квантование альфы стирания до 1/HOLE_ALPHA_STEPS — детерминизм bake. */ +export function quantizeHoleAlpha(alpha: number): number { + const a = Math.max(0, Math.min(1, alpha)); + return Math.round(a * HOLE_ALPHA_STEPS) / HOLE_ALPHA_STEPS; +} + /** Лерп ambient-цвета по каналам: k=0 → from, k=1 → to. */ export function lerpAmbient(from: number, to: number, k: number): number { return lerpColor(from, to, k);