diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 3d5de58..f118ef7 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1368,6 +1368,36 @@ `check:fast` — 327 тестов ✓. Визуально — зум-кропы героя/пруда, ночь с фонариком, утро/вечер без линии теней. +## Свет частиц (2026-09-12): источники подсвечивают слои частиц + +Фидбек: «для партиклов — взаимодействие со светом». Ambient-ночь уже тинтит +слои (setTint); теперь и точечные источники с фонариком: + +- **Движок** (`render/particles.ts`): общий блок юниформ света на рендере — + `makeParticleLightUniforms()` (позиции/цвета/радиусы до 24 источников + + конус фонарика), `packParticleLights(u, points, spot)` — упаковка снапшота + (цвет × яркость считается на CPU, чистая — юнит-тесты), `VoxelRenderer. + setParticleLights(points, spot?)`. Блок шарится по ССЫЛКЕ на IUniform между + всеми слоями (и пересозданными при росте ёмкости) — свет не теряется. + Шейдер (общий LIGHT_FRAG для спрайтов и квадов): вклад `lightColor·(a³)` — + умножается на цвет частицы `vColor·(uTint + light)` — красная частица у + тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку); + конус — smoothstep по dot(нормированный rel, dir) между cos-ами внешнего/ + внутреннего края (penumbra); итог ×2.0 — согласование с пулами мира + (PointLight усилен GAIN 8, прозрачной пылинке нужен множитель, иначе + вклад субпиксельный). В вершинниках появился varying vWorld (позиция + частицы в мире — для расчёта света). +- **Игра** (`lighting.ts`): dep `particleLights(points, spot)` — каждый тик + снапшот активных источников (запись handles хранит свет для частиц: + цвет/радиус/позиция + текущая яркость мерцания) и конус фонарика (тот же + луч: origin → hit, направление нормирует движок). main.ts прокидывает в + `renderer.setParticleLights`. Снапшот моста: `lightingInfo.particleLights` + (счётчик отданных источников). +- Гейт atmosphere (+2): день — источников частицам 0; ночь — отдано ровно + сколько светит (bells + лампа героя). `check:fast` — 332 теста ✓. + Визуально: хлопок пепла у колокольчика ночью ярко-тёплый, пыль разъезда + в конусе фонарика подсвечена. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/lighting.ts b/v2/apps/game/src/lighting.ts index 7ba95b9..d21abad 100644 --- a/v2/apps/game/src/lighting.ts +++ b/v2/apps/game/src/lighting.ts @@ -6,7 +6,7 @@ * детерминированный flickerFactor от времени цикла. */ import { dayNightAmbient, dayNightFactor, flickerFactor, lerpColor } from '@rpg/engine'; -import type { PointLightHandle, SpotLightHandle, VoxelRenderer } from '@rpg/engine'; +import type { ParticleLight, ParticleSpot, PointLightHandle, SpotLightHandle, VoxelRenderer } from '@rpg/engine'; import { UNIT, GROUND_Y, tileCenter } from './areas'; import type { LightingDef, LightSourceDef } from './data/lighting'; @@ -84,6 +84,9 @@ heroFacing: () => number; /** Тинт частиц слоям (ночь гасит пепел/листья/дымку/блики вместе со сценой). */ particleTint?: (color: number) => void; + /** Свет частицам: снапшот источников + фонарик (подсветка в шейдере + * частиц — пепел у очага/колокольчика светится, луч фонарика ловится). */ + particleLights?: (points: readonly ParticleLight[], spot: ParticleSpot | null) => void; } /** Снапшот для моста: фактор ночи, путь солнца + активные источники. */ @@ -93,6 +96,8 @@ sunK: number; /** Тинт частиц этого тика (день — белый 0xffffff, к ночи темнеет). */ particleTint: number; + /** Источников отдано слоям частиц (подсветка в шейдере частиц). */ + particleLights: number; sources: { id: string; x: number; y: number; intensity: number }[]; /** Фонарик: горит ли и с какой яркостью (пост-мерцание). */ flashlight: { on: boolean; intensity: number }; @@ -100,12 +105,15 @@ export class GameLighting { private readonly deps: GameLightingDeps; - /** Активные источники прошлого тика (diff: снять погасшие по флагам). */ - private readonly handles = new Map(); + /** Активные источники прошлого тика (diff: снять погасшие по флагам); + * light — снапшот для частиц (цвет/радиус/позиция + текущая яркость). */ + private readonly handles = new Map(); /** Прожектор фонарика (создаётся лениво, гаснет — dispose). */ private spot: SpotLightHandle | null = null; /** Интенсивность луча прошлого тика (снапшот — пост-мерцание). */ private spotIntensity = 0; + /** Снапшот луча для частиц (null — погашен). */ + private spotLight: ParticleSpot | null = null; constructor(deps: GameLightingDeps) { this.deps = deps; @@ -164,15 +172,23 @@ existing.handle.setIntensity(intensity); existing.handle.setPos([c.x, GROUND_Y + 2, c.z]); // лампа героя едет existing.intensity = intensity; + existing.light.intensity = intensity; + existing.light.pos = [c.x, GROUND_Y + 2, c.z]; } else { + const pos: [number, number, number] = [c.x, GROUND_Y + 2, c.z]; + const light: ParticleLight = { + pos, + color: def.color ?? 0xf2b45a, + intensity, + radius: (def.radius ?? 3) * UNIT, + }; this.handles.set(key, { handle: this.deps.renderer.addPointLight({ - pos: [c.x, GROUND_Y + 2, c.z], - color: def.color ?? 0xf2b45a, - intensity, - radius: (def.radius ?? 3) * UNIT, + pos, color: light.color, + intensity, radius: light.radius, }), intensity, + light, }); } } @@ -184,6 +200,11 @@ } } this.updateFlashlight(k); + // частицы подсвечиваются источниками как мир (шейдер частиц движка) + this.deps.particleLights?.( + [...this.handles.values()].map((e) => e.light), + this.spotLight, + ); } /** @@ -201,6 +222,7 @@ this.spot = null; } this.spotIntensity = 0; + this.spotLight = null; return; } const hero = this.deps.heroPos(); @@ -209,6 +231,13 @@ const hit: [number, number, number] = [hero.x + fx * fl.distance, GROUND_Y, hero.z + fz * fl.distance]; const flicker = flickerFactor(this.deps.timeSeconds(), seedOf('flashlight'), 0.06, 0.9); this.spotIntensity = fl.intensity * flicker; + // конус для частиц: тот же луч (направление нормирует движок) + this.spotLight = { + pos: origin, + dir: [hit[0] - origin[0], hit[1] - origin[1], hit[2] - origin[2]], + color: fl.color, intensity: this.spotIntensity, + distance: fl.distance * 1.2, angle: fl.angle, penumbra: fl.penumbra, + }; if (this.spot) { this.spot.setPos(origin); this.spot.setTarget(hit); @@ -228,6 +257,7 @@ nightFactor: this.nightFactor, sunK: Math.round(this.sunK() * 1000) / 1000, particleTint: lerpColor(0xffffff, PARTICLE_NIGHT, this.nightFactor), + particleLights: this.handles.size, flashlight: { on: this.spot !== null, intensity: Math.round(this.spotIntensity * 1000) / 1000 }, sources: [...this.handles.entries()].map(([key, entry]) => { const [id, at] = key.split('@'); diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index b119c66..5f406a6 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -510,6 +510,9 @@ ambient?.setTint(c); shimmer?.setTint(c); }, + // частицы подсвечиваются источниками в шейдере (общий блок юниформ + // рендера — пепел у очага/колокольчика, луч фонарика на пыли) + particleLights: (points, spot) => renderer.setParticleLights(points, spot), }); areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash'; if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется @@ -1278,7 +1281,7 @@ /** Снапшот освещения: ночь + активные источники (пост-мерцание). */ lightingInfo(): ReturnType { return lighting?.snapshot() ?? { - nightFactor: 0, sunK: 0, particleTint: 0xffffff, + nightFactor: 0, sunK: 0, particleTint: 0xffffff, particleLights: 0, sources: [], flashlight: { on: false, intensity: 0 }, }; }, diff --git a/v2/apps/game/tools/checks/atmosphere.mjs b/v2/apps/game/tools/checks/atmosphere.mjs index e150c14..92589d4 100644 --- a/v2/apps/game/tools/checks/atmosphere.mjs +++ b/v2/apps/game/tools/checks/atmosphere.mjs @@ -91,6 +91,7 @@ await client.call('setClock', 480); // назад в день let li = await client.call('lightingInfo'); check('день на лугах: источников нет (bells whenNight)', li.sources.length === 0, li); + check('день: частицам источников не отдано', li.particleLights === 0, li); check('фонарик днём погашен', li.flashlight.on === false, li.flashlight); // ---- Солнце едет по часам (тени следуют за временем суток) ---- @@ -110,6 +111,8 @@ li.sources.some((s) => s.id === 'bells' && s.intensity > 0), li); check('ночь: лампа героя у героя', li.sources.some((s) => s.id === 'hero_lamp'), li); check('ночь: фонарик героя светит', li.flashlight.on === true && li.flashlight.intensity > 0, li.flashlight); + // частицы в системе света: источников отдано ровно сколько светит + check('ночь: источники отданы частицам', li.particleLights === li.sources.length && li.particleLights > 0, li); // Мерцание: интенсивность bells меняется со временем (flicker 0.15) const i0 = li.sources.find((s) => s.id === 'bells')?.intensity ?? -1; diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index c80c3a9..bd1f341 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -23,11 +23,11 @@ export { meshChunk, chunkBounds } from './world/worldMesher'; export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk'; export { VoxelRenderer, worldTileAtlas } from './render/voxelRenderer'; -export { createSpriteLayer, createQuadLayer } from './render/particles'; +export { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, PARTICLE_LIGHT_MAX } from './render/particles'; export { postPass } from './render/postShader'; export type { PostOptions, PostPassSpec } from './render/postShader'; export type { PostPassHandle } from './render/voxelRenderer'; -export type { ParticlePoint, ParticleLayer } from './render/particles'; +export type { ParticlePoint, ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './render/particles'; export type { CloudTiles, WorldTileAtlas } from './render/voxelRenderer'; export { ModelViewer, modelStats, orbitPosition, modelCenter } from './render/modelViewer'; export type { ModelStats, ModelViewerOptions } from './render/modelViewer'; diff --git a/v2/packages/engine/src/render/particles.ts b/v2/packages/engine/src/render/particles.ts index f6aa415..0d749cc 100644 --- a/v2/packages/engine/src/render/particles.ts +++ b/v2/packages/engine/src/render/particles.ts @@ -1,11 +1,13 @@ /** * Слои частиц рендера: лёгкий 2D-рендер поверх воксельного мира — спрайты- * билборды (THREE.Points, один draw call) и плоские квадраты, лежащие на - * земле/воде (InstancedBufferGeometry). Не воксели: без освещения, теней и - * AO — цвет и прозрачность на частицу, пересборка поз каждый кадр дёшева. - * Цвет пишется в буфер как есть (hex-компоненты без colorspace-конверсий) — - * совпадает с палитрой. Освещение — умножение на tint слоя (setTint): - * без него частицы светятся в полной яркости и ночью (вне системы света). + * земле/воде (InstancedBufferGeometry). Не воксели: без теней и AO — цвет + * и прозрачность на частицу, пересборка поз каждый кадр дёшева. Цвет пишется + * в буфер как есть (hex-компоненты без colorspace-конверсий) — совпадает + * с палитрой. Освещение: ambient — умножение на tint слоя (setTint, ночь + * гасит слои вместе со сценой); точечные источники — общий блок юниформ + * света рендера (setParticleLights): вклад лампы/фонарика в шейдере, + * частицы подсвечиваются как мир. */ import * as THREE from 'three'; @@ -25,29 +27,161 @@ dispose(): void; } +/** Точечный источник для частиц — снапшот света игры (лампа, очаг, окно). */ +export interface ParticleLight { + pos: readonly [number, number, number]; + /** Цвет 0xRRGGBB (перемножается с яркостью при упаковке). */ + color: number; + /** Яркость — «множитель игры» 0..~1.5 (как у PointLight игры до GAIN). */ + intensity: number; + /** Радиус действия в мировых единицах. */ + radius: number; +} + +/** Конус света для частиц (фонарик героя). */ +export interface ParticleSpot { + pos: readonly [number, number, number]; + /** Направление луча (нормируется при упаковке). */ + dir: readonly [number, number, number]; + color: number; + intensity: number; + /** Дальность в мировых единицах. */ + distance: number; + /** Полный угол конуса (рад). */ + angle: number; + /** Мягкость края 0..1. */ + penumbra: number; +} + +/** Кап источников в шейдере частиц (совпадает с MAX_LIGHTS игры). */ +export const PARTICLE_LIGHT_MAX = 24; + +/** Тип общего блока юниформ света (один экземпляр на все слои рендера). */ +export type ParticleLightUniforms = Record; + +/** Блок юниформ света частиц: живёт на рендере, все слои (и пересозданные + * при росте ёмкости) ссылаются на ТЕ ЖЕ IUniform — свет не теряется. */ +export function makeParticleLightUniforms(): ParticleLightUniforms { + return { + uLightPos: { value: new Float32Array(PARTICLE_LIGHT_MAX * 3) }, + uLightColor: { value: new Float32Array(PARTICLE_LIGHT_MAX * 3) }, + uLightRadius: { value: new Float32Array(PARTICLE_LIGHT_MAX) }, + uLightCount: { value: 0 }, + uSpotPos: { value: new Float32Array(3) }, + uSpotDir: { value: new Float32Array(3) }, + uSpotColor: { value: new Float32Array(3) }, + uSpotDist: { value: 1 }, + uSpotCos: { value: 0 }, // косинус ПОЛУугла внешнего края конуса + uSpotSoft: { value: 0 }, // косинус внутреннего края (мягкость penumbra) + uSpotOn: { value: 0 }, + }; +} + +/** + * Упаковка снапшота света в блок юниформ: цвет × яркость считается здесь — + * шейдер читает готовый вклад. Чистая функция — тестируется без WebGL. + */ +export function packParticleLights( + u: ParticleLightUniforms, + points: readonly ParticleLight[], + spot: ParticleSpot | null, +): void { + const pos = u.uLightPos!.value as Float32Array; + const col = u.uLightColor!.value as Float32Array; + const rad = u.uLightRadius!.value as Float32Array; + const n = Math.min(points.length, PARTICLE_LIGHT_MAX); + for (let i = 0; i < n; i++) { + const p = points[i]!; + pos[i * 3] = p.pos[0]; + pos[i * 3 + 1] = p.pos[1]; + pos[i * 3 + 2] = p.pos[2]; + col[i * 3] = (((p.color >> 16) & 255) / 255) * p.intensity; + col[i * 3 + 1] = (((p.color >> 8) & 255) / 255) * p.intensity; + col[i * 3 + 2] = ((p.color & 255) / 255) * p.intensity; + rad[i] = p.radius; + } + u.uLightCount!.value = n; + if (spot) { + (u.uSpotPos!.value as Float32Array).set([...spot.pos]); + const d = Math.hypot(...spot.dir) || 1; + (u.uSpotDir!.value as Float32Array).set(spot.dir.map((v) => v / d)); + (u.uSpotColor!.value as Float32Array).set([ + (((spot.color >> 16) & 255) / 255) * spot.intensity, + (((spot.color >> 8) & 255) / 255) * spot.intensity, + ((spot.color & 255) / 255) * spot.intensity, + ]); + u.uSpotDist!.value = spot.distance; + u.uSpotCos!.value = Math.cos(spot.angle / 2); + u.uSpotSoft!.value = Math.cos((spot.angle / 2) * (1 - spot.penumbra)); + u.uSpotOn!.value = 1; + } else { + u.uSpotOn!.value = 0; + } +} + /** hex '#rrggbb' → компоненты 0..1. */ function rgb(hex: string): [number, number, number] { const n = parseInt(hex.slice(1), 16); return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; } +/** GLSL-фрагмент света: вклад точечных источников и фонарика в точке мира. + * Вклад умножается на цвет частицы (vColor·(tint+свет)) — красная частица + * у тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку). */ +const LIGHT_FRAG = ` +#define PLIGHTS ${PARTICLE_LIGHT_MAX} +uniform vec3 uLightPos[PLIGHTS]; +uniform vec3 uLightColor[PLIGHTS]; +uniform float uLightRadius[PLIGHTS]; +uniform int uLightCount; +uniform vec3 uSpotPos; +uniform vec3 uSpotDir; +uniform vec3 uSpotColor; +uniform float uSpotDist; +uniform float uSpotCos; +uniform float uSpotSoft; +uniform float uSpotOn; +vec3 particleLight(vec3 p) { + vec3 sum = vec3(0.0); + for (int i = 0; i < PLIGHTS; i++) { + if (i >= uLightCount) break; + float d = distance(p, uLightPos[i]); + float a = clamp(1.0 - d / uLightRadius[i], 0.0, 1.0); + sum += uLightColor[i] * (a * a * a); + } + if (uSpotOn > 0.5) { + vec3 rel = p - uSpotPos; + float d = max(length(rel), 0.0001); + float cone = smoothstep(uSpotCos, uSpotSoft, dot(rel / d, uSpotDir)); + float a = clamp(1.0 - d / uSpotDist, 0.0, 1.0); + sum += uSpotColor * (cone * a * a); + } + // согласование с пулами мира: PointLight усиливается GAIN 8, а частица — + // прозрачная «пылинка», её вклад читается только с множителем + return sum * 2.0; +}`; + const POINT_VERT = ` attribute float aAlpha; attribute float aSize; varying vec3 vColor; varying float vAlpha; +varying vec3 vWorld; uniform float uPxScale; void main() { vColor = color; vAlpha = aAlpha; + vWorld = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_PointSize = aSize * uPxScale; }`; const POINT_FRAG = ` varying vec3 vColor; varying float vAlpha; +varying vec3 vWorld; uniform vec3 uTint; -void main() { gl_FragColor = vec4(vColor * uTint, vAlpha); }`; +${LIGHT_FRAG} +void main() { gl_FragColor = vec4(vColor * (uTint + particleLight(vWorld)), vAlpha); }`; const QUAD_VERT = ` attribute vec3 iPos; @@ -56,16 +190,21 @@ attribute float iSize; varying vec3 vColor; varying float vAlpha; +varying vec3 vWorld; void main() { vColor = iColor; vAlpha = iAlpha; + vWorld = position * iSize + iPos; gl_Position = projectionMatrix * modelViewMatrix * vec4(position * iSize + iPos, 1.0); }`; const FLAGS = { transparent: true, depthWrite: false, depthTest: true }; -/** Фабрика слоя спрайтов (отдельная функция — рекурсия при росте ёмкости). */ -function createSpriteLayer(scene: THREE.Scene, capacity: number, pxScale: number): ParticleLayer { +/** Фабрика слоя спрайтов (отдельная функция — рекурсия при росте ёмкости). + * lights — общий блок юниформ света рендера (ссылки на IUniform шарятся). */ +function createSpriteLayer( + scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms, +): ParticleLayer { const mk = (itemSize: number): THREE.BufferAttribute => new THREE.BufferAttribute(new Float32Array(capacity * itemSize), itemSize).setUsage(THREE.DynamicDrawUsage); const pos = mk(3), col = mk(3), alp = mk(1), siz = mk(1); @@ -76,7 +215,7 @@ geo.setAttribute('aSize', siz); const mat = new THREE.ShaderMaterial({ vertexShader: POINT_VERT, fragmentShader: POINT_FRAG, - uniforms: { uPxScale: { value: pxScale }, uTint: { value: new THREE.Vector3(1, 1, 1) } }, + uniforms: { uPxScale: { value: pxScale }, uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights }, vertexColors: true, ...FLAGS, }); const mesh = new THREE.Points(geo, mat); @@ -86,7 +225,7 @@ update: (points) => { if (points.length > capacity) { scene.remove(mesh); geo.dispose(); mat.dispose(); - const fresh = createSpriteLayer(scene, points.length, pxScale); + const fresh = createSpriteLayer(scene, points.length, pxScale, lights); layer.update = fresh.update; layer.setTint = fresh.setTint; layer.dispose = fresh.dispose; @@ -113,7 +252,7 @@ } /** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики). */ -function createQuadLayer(scene: THREE.Scene, capacity: number): ParticleLayer { +function createQuadLayer(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms): ParticleLayer { const base = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2); // юнит-квад в XZ const geo = new THREE.InstancedBufferGeometry(); geo.index = base.index; @@ -127,7 +266,7 @@ geo.setAttribute('iSize', siz); const mat = new THREE.ShaderMaterial({ vertexShader: QUAD_VERT, fragmentShader: POINT_FRAG, - uniforms: { uTint: { value: new THREE.Vector3(1, 1, 1) } }, ...FLAGS, + uniforms: { uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights }, ...FLAGS, }); const mesh = new THREE.Mesh(geo, mat); mesh.frustumCulled = false; @@ -136,7 +275,7 @@ update: (points) => { if (points.length > capacity) { scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); - const fresh = createQuadLayer(scene, points.length); + const fresh = createQuadLayer(scene, points.length, lights); layer.update = fresh.update; layer.setTint = fresh.setTint; layer.dispose = fresh.dispose; diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index 0604995..93a99a5 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -12,8 +12,8 @@ import type { PosedVoxel } from '../animation/rig'; import type { ResolvedTile } from '../models/format'; import { lerpColor } from './daynight'; -import { createSpriteLayer, createQuadLayer } from './particles'; -import type { ParticleLayer } from './particles'; +import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights } from './particles'; +import type { ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './particles'; import { particleWindow } from './polyWindow'; /** Облако деформированных вокселей (позы рига — не выровнены по сетке). */ @@ -116,6 +116,9 @@ private readonly viewSize: number; /** Аспект кадра: полуширина видимой области = viewSize·max(aspect,1). */ private readonly viewAspect: number; + /** Общий блок юниформ света частиц: все слои ссылаются на те же IUniform — + * рост ёмкости слоя и пересборка области свет не теряют. */ + private readonly particleLightsU: ParticleLightUniforms = makeParticleLightUniforms(); private sunK = 0.5; private nightK = 0; /** Дневной базовый ambient области (интерьеры темнее улиц — как v1 setAmbient). */ @@ -288,12 +291,21 @@ */ addParticles(): ParticleLayer { const px = this.renderer.domElement.height / (2 * this.viewSize); - return createSpriteLayer(this.scene, 64, px); + return createSpriteLayer(this.scene, 64, px, this.particleLightsU); } /** Слой плоских квадов, лежащих на земле/воде: дымка прудов, блики. */ addFlatParticles(): ParticleLayer { - return createQuadLayer(this.scene, 64); + return createQuadLayer(this.scene, 64, this.particleLightsU); + } + + /** + * Свет частицам: снапшот источников игры (общий для всех слоёв рендера — + * вклад считается в шейдере частиц, ambient-ночь идёт отдельно через + * setTint слоёв). Вызывать каждый тик — мерцание источников едет с миром. + */ + setParticleLights(points: readonly ParticleLight[], spot: ParticleSpot | null = null): void { + packParticleLights(this.particleLightsU, points, spot); } /**