diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 9dd3be5..ac0e06a 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1079,6 +1079,59 @@ - Грабля: у утопленной панели откос обязателен — одна глубина проёма без заполненного откоса даёт «дырку» мимо панели при взгляде по диагонали. +## Трек 30 (2026-09-11): слой частиц движка (не воксели) + окно видимости + +Фидбек: «частицы — тоже воксели, давай отдельную систему, допустимо 2D; +должна покрывать видимую область с перекрытием и без появления из воздуха». + +- **Движок** (`packages/engine/src/render/particles.ts`): `ParticlePoint + {pos, color, alpha?, size?}` + `ParticleLayer {update, dispose}`; фабрики + `createSpriteLayer` (спрайты-билборды: THREE.Points + ShaderMaterial, + один draw call, gl_PointSize = size·uPxScale, uPxScale = высота канваса + /(2·viewSize) — размер в юнитах мира) и `createQuadLayer` (плоские + квадраты в XZ: InstancedBufferGeometry + iPos/iColor/iAlpha/iSize). + Рендерер: `VoxelRenderer.addParticles()/addFlatParticles()` (стартовая + ёмкость 64, рост — пересоздание), цвет пишется в буфер как есть (hex без + colorspace-конверсий). Тесты: 4 (+313 всего). +- **Окно видимости** (`render/polyWindow.ts` + `VoxelRenderer.visibleWindow + (y0, y1)`): точный AABB сляба ортокамеры, пересечённого с горизонтальным + слоем [y0,y1], — перебор вершин полиэдра (8 полуплоскостей, 56 троек, + Крамер). Видно δ от цели ⇔ |r·δ|≤a, |u·δ|≤h, k0≤f·δ≤k1, δ.y∈[dy0,dy1]. + Окно шире наземного следа кадра: высокую частицу видно дальше от цели + (сдвиг края ≈ 0.35·Δy), сляб уходит в глубину до дальней плоскости. +- **Механика «без появления из воздуха»** (игра, `src/particlesWindow.ts`): + окно частиц = visibleWindow + маржа (16 юнитов) за край кадра; частицы + живут в МИРОВЫХ координатах и заворачиваются в окно (`wrap`), а в марже + альфа гаснет до нуля (`edgeAlpha`) — респавн с другой стороны происходит + уже за экраном. Миграция: пепел (`ash.ts`, 128 спрайтов, окно слоя + [0,44]), мотыльки (`ambientParticles.ts`, 16, слой [5,13]), дымка прудов + и блики воды — плоские квады НА воде (якоря — клетки поверхности). + Гейты кадр-равенств гасят всё мостом `setAsh(false)` как раньше. +- **Грабли** (каждую ловили реально): + - **знаки полуплоскостей**: все ограничения — в форме n·δ ≤ b, нижние + границы — с ПЕРЕВОРОТОМ нормали ([−r, a], а не [r, −a]); глубинные — + [−f, −k0], [f, k1]. Ошибка знака даёт мусорное/пустое окно при + зелёных снапшотах. + - **AABB — в дельтах от цели камеры**: `visibleWindow` обязан прибавить + target.x/z, иначе окно живёт вокруг origin, а частицы в мире получают + alpha 0 «на ровном месте». + - **окно обязано ехать за камерой**: никакой кэша окна — первый запрос + раньше позиционирования камеры замораживал окно вокруг origin + навсегда; пересборка полиэдра дёшева на каждый тик. + - **плоские квады на воде тонут**: вода — слой y=2, верхняя грань y=3; + квад на y=2.55 ниже поверхности и отсекается depth-тестом (воксель- + кубы старого шиммера торчали верхней гранью — потому работали). + Блики/дымка — на y=3.03/3.05. + - **рост ёмкости слоя**: после редиректа на свежий слой обязателен + `fresh.update(points)` — иначе первый кадр после роста пуст. + - **скорость дрейфа — по v1**: v1 задаёт px/s при 32 px/юнит; пепел v1 + 2..7 px/s ≈ 1.2 юнита/с v2. Медленный дрейф за 40 тиков гейта — + субпиксельный, гейт «пепел дрейфует» не видит движения. + - **`setAsh(true)` обязан ПЕРЕСОБИРАТЬ частицы области** (rebuildAmbient): + простой `null` убивал мотыльков/дымку/шиммер до следующего setArea. + - **плотность бликов**: STEP=19 при 48800 клетках воды прудов давал + ~470 мерцающих точек (суета); редкие вспышки — STEP=128. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/ambientParticles.ts b/v2/apps/game/src/ambientParticles.ts index 28d2fb0..05a472e 100644 --- a/v2/apps/game/src/ambientParticles.ts +++ b/v2/apps/game/src/ambientParticles.ts @@ -1,70 +1,66 @@ /** * Атмосферные частицы (v1 Atmosphere): тёплые мотыльки над лугами - * (светятся цветом, дрейф по синусу) и дымка прудов (серые клочья, - * якорятся на клетки воды и медленно бродят на месте). Voxel-облако, - * позы — детерминированные синусы от времени цикла, гейт-стабильно. + * (светятся цветом, дрейф по синусу) и дымка прудов (серые полупрозрачные + * клочья, лежащие на воде). Слои частиц движка (не воксели): мотыльки — + * спрайты-билборды с окном видимости камеры (с перекрытием и альфой в + * марже — заворачивание за экраном), дымка — плоские квадраты НА воде, + * якорящиеся на клетки и медленно бродящие на месте. Позы — детермини- + * рованные синусы от времени цикла, гейт-стабильно. */ -import type { VoxelCloud, VoxelRenderer } from '@rpg/engine'; +import type { ParticleLayer, VoxelRenderer } from '@rpg/engine'; +import { edgeAlpha, particleWindow, wrap } from './particlesWindow'; import type { WaterCell } from './waterShimmer'; /** Профиль частиц области. */ export type AmbientKind = 'moths' | 'fog'; -const MOTH_COUNT = 10; +const MOTH_COUNT = 16; // окно — весь кадр: чуть больше, чтобы стая читалась const MOTH_COLORS = ['#f0d878', '#d8b050']; // B-тёплые: только «жизнь» const FOG_COUNT = 22; const FOG_COLOR = '#a7b3bd'; // светлее воды — клочья читаются туманом -const RADIUS = 24; // половина окна мотыльков вокруг камеры (мировые якоря) -/** Клок тумана: квадрат 2×2 вокселя ЛЕЖИТ на воде (как полупрозрачные - * пятна v1 — непрозрачный куб в воздухе читался бы «ящиком»). */ -const FOG_TILE: readonly (readonly [number, number])[] = [[0, 0], [1, 0], [0, 1], [1, 1]]; -/** Сдвиг клочьев из плоскости куба воды — против z-fighting копланарных - * граней на кромках (как у бликов шиммера, EPS 0.03). */ -const FOG_EPS = 0.03; - -/** Заворачивание значения в окно [min, min+size) — как у пепла. */ -const wrap = (v: number, min: number, size: number): number => { - let r = (v - min) % size; - if (r < 0) r += size; - return min + r; -}; +const FOG_ALPHA = 0.55; // полупрозрачные пятна — как в v1 +const FOG_Y = 3.05; // чуть над поверхностью воды (вода — слой y=2, верх y=3) +const MARGIN = 16; // перекрытие окна мотыльков за краем кадра export class AmbientParticles { - private readonly cloud: VoxelCloud; + private readonly layer: ParticleLayer; constructor( - renderer: VoxelRenderer, + private readonly renderer: VoxelRenderer, private readonly kind: AmbientKind, /** Клетки поверхности воды — якоря клочьев дымки (только для fog). */ private readonly cells: readonly WaterCell[] = [], ) { - this.cloud = renderer.addVoxelCloud([]); - this.cloud.mesh.castShadow = false; // частицы не бросают теней + this.layer = kind === 'fog' ? renderer.addFlatParticles() : renderer.addParticles(); } /** Снапшот для моста: профиль и число частиц (у мотыльков всегда видимы). */ get info(): { kind: AmbientKind; count: number } { return { kind: this.kind, - count: this.kind === 'moths' ? MOTH_COUNT : this.cells.length ? FOG_COUNT * FOG_TILE.length : 0, + count: this.kind === 'moths' ? MOTH_COUNT : this.cells.length ? FOG_COUNT : 0, }; } - /** Поза облака: цель камеры и время цикла (детерминизм гейтов). */ - update(t: number, target: readonly [number, number, number]): void { - const voxels: { pos: [number, number, number]; color: string }[] = []; + /** Поза слоя: время цикла (окно мотыльков — из цели камеры рендерера). */ + update(t: number): void { + const points: { pos: [number, number, number]; color: string; alpha?: number; size?: number }[] = []; if (this.kind === 'moths') { + const win = particleWindow(this.renderer, 5, 13, MARGIN); for (let i = 0; i < MOTH_COUNT; i++) { const p = i * 2.399; // золотое сечение: разброс фаз // мировой якорь (окно камеры заворачивает) + кружение НА месте: // мотылёк не следует за героем — герой летит сквозь стаю - const ax = wrap(i * 53.1 + Math.sin(t * 0.13 + p) * 4, target[0] - RADIUS, RADIUS * 2); - const az = wrap(i * 89.7 + Math.cos(t * 0.11 + p * 1.7) * 4, target[2] - RADIUS, RADIUS * 2); - // полёт: плавная волна над травой + трепет (герой ~22 вокс) + const ax = wrap(i * 53.1 + Math.sin(t * 0.13 + p) * 4, win.minX, win.maxX - win.minX); + const az = wrap(i * 89.7 + Math.cos(t * 0.11 + p * 1.7) * 4, win.minZ, win.maxZ - win.minZ); + // полёт: плавная волна над травой + трепет (герой ~16 юнитов) const y = 9 + Math.sin(t * 0.4 + p * 2) * 4.4; - voxels.push({ - pos: [ax + Math.sin(t * 1.7 + p) * 0.6, y, az + Math.cos(t * 1.5 + p) * 0.6], + const x = ax + Math.sin(t * 1.7 + p) * 0.6; + const z = az + Math.cos(t * 1.5 + p) * 0.6; + points.push({ + pos: [x, y, z], color: MOTH_COLORS[i % MOTH_COLORS.length]!, + alpha: edgeAlpha(win, MARGIN, x, z), }); } } else { @@ -74,23 +70,23 @@ for (let i = 0; i < FOG_COUNT && n > 0; i++) { const p = i * 2.399; const a = this.cells[(i * 131) % n]!; // 131 взаимно прост с длиной — разброс - const x = a.x + Math.sin(t * 0.05 + p) * 2.5; - const z = a.z + Math.cos(t * 0.04 + p * 1.3) * 2.5; - for (const [dx, dz] of FOG_TILE) { - voxels.push({ pos: [x + dx + FOG_EPS, 2.56, z + dz + FOG_EPS], color: FOG_COLOR }); - } + points.push({ + pos: [a.x + Math.sin(t * 0.05 + p) * 2.5, FOG_Y, a.z + Math.cos(t * 0.04 + p * 1.3) * 2.5], + color: FOG_COLOR, + alpha: FOG_ALPHA, + size: 2, // клок 2×2, лежит на воде + }); } } - this.cloud.update(voxels); - this.cloud.mesh.castShadow = false; // пересоздание облака возвращает тень + this.layer.update(points); } - /** Скрыть облако (мост setAsh / смена профиля). */ + /** Скрыть слой (мост setAsh / смена профиля). */ hide(): void { - this.cloud.update([]); + this.layer.update([]); } dispose(): void { - this.cloud.dispose(); + this.layer.dispose(); } } \ No newline at end of file diff --git a/v2/apps/game/src/ash.ts b/v2/apps/game/src/ash.ts index 0ba81c6..b41b83d 100644 --- a/v2/apps/game/src/ash.ts +++ b/v2/apps/game/src/ash.ts @@ -1,64 +1,62 @@ /** * Атмосфера области (v1 Atmosphere, вариант 'ash'): медленные хлопья пепла - * в воздухе. Voxel-облако (instanced-кубы рендера): поза каждого хлопка — - * детерминированные синусы от времени цикла (гейт-стабильно). Хлопки живут - * в МИРОВЫХ координатах (не «приклеены» к камере): каждый дрейфует по ветру - * от своего якоря, а окно видимости вокруг цели камеры их заворачивает — - * ушедший из окна хлопок появляется с противоположной стороны. + * в воздухе. Отдельный слой частиц движка (спрайты-билборды, не воксели): + * позы — детерминированные синусы от времени цикла (гейт-стабильно). + * Хлопки живут в МИРОВЫХ координатах (не «приклеены» к камере): каждый + * дрейфует по ветру от своего якоря, а окно видимости камеры их заворачивает. + * Окно покрывает весь кадр с перекрытием (маржа за экраном), и в марже + * альфа гаснет — ушедший хлопок появляется с противоположной стороны + * уже за экраном, без «появления из воздуха». */ -import type { VoxelCloud, VoxelRenderer } from '@rpg/engine'; +import type { ParticleLayer, VoxelRenderer } from '@rpg/engine'; +import { edgeAlpha, particleWindow, wrap } from './particlesWindow'; -/** Параметры пепла (масштаб мира v2: радиус в вокселях). */ -const COUNT = 48; // хлопков в облаке (v1 ~40 частиц) -const RADIUS = 60; // половина окна вокруг камеры (кадр 80×45 вокс) +/** Параметры пепла (окно — след кадра + маржа, в мировых юнитах). */ +const COUNT = 128; // хлопков в облаке (окно выросло до всего кадра) const TOP = 44; // высота верхнего слоя пепла над землёй -const SPEED = 0.35; // дрейф по ветру (вокс/сек) +const SPEED = 1.2; // дрейф по ветру (юнит/сек; v1: 2..7 px/s при 32 px/юнит) +const MARGIN = 16; // перекрытие за краем кадра /** Цвет пепла (палитра сцены: P3 — светлый пепел). */ const ASH_COLOR = '#3e3e48'; -/** Заворачивание значения в окно [min, min+size) — респавн с другой стороны. */ -const wrap = (v: number, min: number, size: number): number => { - let r = (v - min) % size; - if (r < 0) r += size; - return min + r; -}; - export class AshParticles { - private readonly cloud: VoxelCloud; + private readonly layer: ParticleLayer; - constructor(renderer: VoxelRenderer) { - this.cloud = renderer.addVoxelCloud([]); + constructor(private readonly renderer: VoxelRenderer) { + this.layer = renderer.addParticles(); } - /** Поза облака: цель камеры и время цикла (детерминизм гейтов). */ - update(t: number, target: readonly [number, number, number]): void { - const voxels: { pos: [number, number, number]; color: string }[] = []; + /** Поза облака: время цикла (окно — из цели камеры рендерера). */ + update(t: number): void { + const win = particleWindow(this.renderer, 0, TOP, MARGIN); + const points: { pos: [number, number, number]; color: string; alpha: number }[] = []; for (let i = 0; i < COUNT; i++) { - // сид хлопка — от индекса; фазы кружения разнесены - const p = i * 2.399; // золотое сечение: равномерный разброс фаз + // сид хлопка — от индекса; фазы кружения разнесены золотым сечением + const p = i * 2.399; // мировой якорь + дрейф по ветру; окно камеры заворачивает const x = wrap( i * 61.7 + t * SPEED + Math.sin(t * 0.7 + p) * 1.5, - target[0] - RADIUS, RADIUS * 2, + win.minX, win.maxX - win.minX, ); const z = wrap( i * 47.3 + Math.cos(t * 0.5 + p) * 1.5, - target[2] - RADIUS, RADIUS * 2, + win.minZ, win.maxZ - win.minZ, ); - // вертикаль: медленное оседание - const fall = ((t * (0.25 + (i % 5) * 0.06) + (i * 3.7) % TOP) % TOP); - voxels.push({ pos: [x, TOP - fall, z], color: ASH_COLOR }); + // вертикаль: оседание 0.5..1.1 юнит/с (v1: 1..4 px/s) + const fall = ((t * (0.5 + (i % 5) * 0.15) + (i * 3.7) % TOP) % TOP); + const y = TOP - fall; + points.push({ pos: [x, y, z], color: ASH_COLOR, alpha: edgeAlpha(win, MARGIN, x, z) }); } - this.cloud.update(voxels); + this.layer.update(points); } /** Скрыть облако (область без атмосферы). */ hide(): void { - this.cloud.update([]); + this.layer.update([]); } dispose(): void { - this.cloud.dispose(); + this.layer.dispose(); } } \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 6f5ea21..720e988 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -183,6 +183,18 @@ return null; } +/** Пересборка частиц области (setArea и мост setAsh(true)): мотыльки/дымка + * по профилю области + шиммер на клетках воды. Грабля: setAsh(true) без + * пересборки оставлял ambient/shimmer = null до следующего setArea. */ +function rebuildAmbient(): void { + ambient?.dispose(); + const amb = ashEnabled ? ambientOf(ATMOSPHERE[currentArea.id]) : null; + const waterCells = ashEnabled ? waterSurfaceCells(layout) : []; // якоря дымки/блики + ambient = amb ? new AmbientParticles(renderer, amb, waterCells) : null; + shimmer?.dispose(); + shimmer = ashEnabled ? new WaterShimmer(renderer, waterCells) : null; +} + /** Позиции тайлов раскладки по id (источники `tiles`: колокольчики и пр.). */ function layerTilePos(): Map { const l = layout; @@ -404,14 +416,7 @@ }); areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash'; if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется - // Мотыльки лугов / дымка прудов (v1 Atmosphere): облако на профиль области. - ambient?.dispose(); - const amb = ashEnabled ? ambientOf(ATMOSPHERE[currentArea.id]) : null; - const waterCells = ashEnabled ? waterSurfaceCells(layout) : []; // якоря дымки/блики - ambient = amb ? new AmbientParticles(renderer, amb, waterCells) : null; - // Шиммер воды: блики на поверхности прудов (слой y=2 — вода) - shimmer?.dispose(); - shimmer = ashEnabled ? new WaterShimmer(renderer, waterCells) : null; + rebuildAmbient(); // мотыльки/дымка + шиммер воды (v1 Atmosphere) fauna?.dispose(); // фауна области заново (олени по спавнам v1) fauna = faunaEnabled ? new FaunaSystem({ spawns: currentArea.fauna ?? [], @@ -938,8 +943,8 @@ clock.advance(dt); elapsed += dt; lighting?.update(); - if (areaAsh) ash.update(elapsed, renderer.currentTarget()); - ambient?.update(elapsed, renderer.currentTarget()); // мотыльки/дымка области + if (areaAsh) ash.update(elapsed); + ambient?.update(elapsed); // мотыльки/дымка области shimmer?.update(elapsed); // блики на воде прудов fauna?.update(dt, elapsed); // олени бродят (в фейде/катсцене мир замер) if (fade.active) { @@ -1175,11 +1180,7 @@ ashEnabled = on; areaAsh = on && ATMOSPHERE[currentArea.id] === 'ash'; if (!areaAsh) ash.hide(); - // частицы области гасятся тем же мостом (кадр-равенства) - ambient?.hide(); - ambient = null; - shimmer?.hide(); - shimmer = null; + rebuildAmbient(); // вкл: пересобрать частицы; выкл: null (тик их не рисует) }, /** Фауна вкл/выкл (гейт: кадр-равенство — олени бродят в реальном времени). */ setFauna(on: boolean): void { diff --git a/v2/apps/game/src/particlesWindow.ts b/v2/apps/game/src/particlesWindow.ts new file mode 100644 index 0000000..938a05c --- /dev/null +++ b/v2/apps/game/src/particlesWindow.ts @@ -0,0 +1,33 @@ +/** + * Окно частиц (общее для пепла и мотыльков): окно видимости движка, + * расширенное маржой за край кадра. В марже альфа частицы гаснет до нуля — + * заворачивание окна происходит уже за экраном, без «появления из воздуха»; + * у видимой границы частица полностью непрозрачна (перекрытие кадра). + */ +import type { VoxelRenderer } from '@rpg/engine'; + +/** Прямоугольное окно в мировых координатах (по x/z). */ +export interface Window2 { + minX: number; maxX: number; minZ: number; maxZ: number; +} + +/** Окно видимости слоя [y0,y1], расширенное маржой за край кадра. */ +export function particleWindow( + renderer: VoxelRenderer, y0: number, y1: number, margin: number, +): Window2 { + const b = renderer.visibleWindow(y0, y1); + return { minX: b.minX - margin, maxX: b.maxX + margin, minZ: b.minZ - margin, maxZ: b.maxZ + margin }; +} + +/** Заворачивание значения в окно [min, min+size) — респавн с другой стороны. */ +export function wrap(v: number, min: number, size: number): number { + let r = (v - min) % size; + if (r < 0) r += size; + return min + r; +} + +/** Альфа у края окна: 0 в марже (за экраном), 1 внутри кадра. */ +export function edgeAlpha(w: Window2, margin: number, x: number, z: number): number { + const d = Math.min(x - w.minX, w.maxX - x, z - w.minZ, w.maxZ - z); + return Math.max(0, Math.min(1, d / margin)); +} \ No newline at end of file diff --git a/v2/apps/game/src/waterShimmer.ts b/v2/apps/game/src/waterShimmer.ts index 68a5308..719b390 100644 --- a/v2/apps/game/src/waterShimmer.ts +++ b/v2/apps/game/src/waterShimmer.ts @@ -1,34 +1,34 @@ /** * Шиммер воды (полировка прудов): редкие светлые блики на поверхности. - * Отдельное voxel-облако НАД водой (не world.set — без ремешинга мира): - * блики вспыхивают и гаснут фазами от времени цикла, детерминированно - * (гейты кадр-равенств выключают слой вместе с пеплом мостом setAsh). + * Отдельный слой частиц движка — плоские квадраты НА воде (не воксели, + * не world.set — без ремешинга мира): блики вспыхивают и гаснут фазами + * от времени цикла, детерминированно (гейты кадр-равенств выключают слой + * вместе с пеплом мостом setAsh). */ -import type { VoxelCloud, VoxelRenderer } from '@rpg/engine'; +import type { ParticleLayer, VoxelRenderer } from '@rpg/engine'; -/** Воксель поверхности воды: центр куба слоя (блик выступает на 0.05). */ +/** Клетка поверхности воды: центр клетки (воксель + 0.5). */ export interface WaterCell { - x: number; // центр куба по x (воксель + 0.5) - z: number; // центр куба по z + x: number; // центр по x + z: number; // центр по z } -const STEP = 19; // каждый N-й воксель поверхности — потенциальный блик (редкие) +const STEP = 128; // каждый N-й воксель поверхности — потенциальный блик (редкие) const GLOW = 0.84; // порог фазы: выше — блик виден (вспышка короткая) -/** Сдвиг блика из плоскости куба воды: куб блика иначе объёмно перекрывается - * с кубом воды (копланарные вертикальные грани) — на кромках пруда z-fighting, - * порядок отрисовки облаков меняется от пересоздания → мерцающие пиксели. */ -const EPS = 0.03; +/** Высота поверхности воды (вода — слой y=2, верхняя грань y=3) + сдвиг + * против z-fighting с кубом воды. */ +const Y = 3.03; /** Блик: приглушённая рябь и яркая вспышка (поверх W2 #568590). */ const COLORS = ['#8fb8bf', '#cfe4e6'] as const; +const GLOW_ALPHA = 0.9; export class WaterShimmer { - private readonly cloud: VoxelCloud; + private readonly layer: ParticleLayer; /** Снапшот для моста: бликов в последней позе. */ private lastCount = 0; constructor(renderer: VoxelRenderer, private readonly cells: readonly WaterCell[]) { - this.cloud = renderer.addVoxelCloud([]); - this.cloud.mesh.castShadow = false; // блик — свет, не объект: без тени + this.layer = renderer.addFlatParticles(); } /** Вокселей воды в области и бликов сейчас (мост shimmerInfo). */ @@ -38,27 +38,27 @@ /** Поза бликов: время цикла (детерминизм гейтов). */ update(t: number): void { - const voxels: { pos: [number, number, number]; color: string }[] = []; + const points: { pos: [number, number, number]; color: string; alpha: number }[] = []; for (let i = 0; i < this.cells.length; i += STEP) { const c = this.cells[i]!; const phase = Math.sin(t * 0.9 + i * 2.399); // золотое сечение фаз if (phase <= GLOW) continue; - voxels.push({ - pos: [c.x + EPS, 2.55, c.z + EPS], - color: phase > 0.93 ? COLORS[1] : COLORS[0], // яркая вспышка редка + points.push({ + pos: [c.x, Y, c.z], + color: phase > 0.93 ? COLORS[1]! : COLORS[0]!, // яркая вспышка редка + alpha: GLOW_ALPHA, }); } - this.lastCount = voxels.length; - this.cloud.update(voxels); - this.cloud.mesh.castShadow = false; // пересоздание облака возвращает тень + this.lastCount = points.length; + this.layer.update(points); } /** Скрыть блики (не водная область / мост setAsh). */ hide(): void { - this.cloud.update([]); + this.layer.update([]); } dispose(): void { - this.cloud.dispose(); + this.layer.dispose(); } } \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index fcc17fb..a9351d8 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -23,6 +23,8 @@ 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 type { ParticlePoint, ParticleLayer } from './render/particles'; export type { CloudTiles, WorldTileAtlas } from './render/voxelRenderer'; export { ModelViewer, modelStats, orbitPosition } from './render/modelViewer'; export type { ModelStats, ModelViewerOptions } from './render/modelViewer'; diff --git a/v2/packages/engine/src/render/__tests__/particles.test.ts b/v2/packages/engine/src/render/__tests__/particles.test.ts new file mode 100644 index 0000000..e04a947 --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/particles.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { particleWindow, polyAABB } from '../polyWindow'; + +/** Базис изо-камеры демо (yaw 45°, возвышение как в voxelRenderer) — + * числа совпадают с численной проверкой three.js. */ +const ISO = (() => { + const d: [number, number, number] = [Math.cos(Math.PI / 4) * 0.5, 1, Math.sin(Math.PI / 4) * 0.5]; + const n = Math.hypot(...d); + const dir = d.map((v) => v / n) as [number, number, number]; // взгляд → камера + const f = [-dir[0]!, -dir[1]!, -dir[2]!] as const; // взгляд камеры + const rx = dir[2]!, rz = -dir[0]!; // r = norm(f × (0,1,0)) + const rl = Math.hypot(rx, rz); + const r = [rx / rl, 0, rz / rl] as const; + const u = [ + r[1]! * f[2]! - r[2]! * f[1]!, r[2]! * f[0]! - r[0]! * f[2]!, r[0]! * f[1]! - r[1]! * f[0]!, + ] as const; // u = r × f + return { r, u, f }; +})(); + +/** Параметры окна как в игре: viewSize 80, кадр 16:9, камера в 4·viewSize. */ +const PARAMS = { + r: ISO.r, u: ISO.u, f: ISO.f, + a: 80 * (16 / 9), h: 80, + k0: 0.1 - 320, k1: 800 - 320, + dy0: 0, dy1: 0, +}; + +/** Точка δ видна ⇔ |r·δ|≤a, |u·δ|≤h, k0 ≤ f·δ ≤ k1 (слою в тесте равна). */ +const onScreen = (d: readonly number[]): boolean => + Math.abs(d[0]! * ISO.r[0]! + d[1]! * ISO.r[1]! + d[2]! * ISO.r[2]!) <= PARAMS.a && + Math.abs(d[0]! * ISO.u[0]! + d[1]! * ISO.u[1]! + d[2]! * ISO.u[2]!) <= PARAMS.h && + d[0]! * ISO.f[0]! + d[1]! * ISO.f[1]! + d[2]! * ISO.f[2]! >= PARAMS.k0 && + d[0]! * ISO.f[0]! + d[1]! * ISO.f[1]! + d[2]! * ISO.f[2]! <= PARAMS.k1; + +describe('polyAABB — окно видимости частиц', () => { + it('наземный слой: след кадра симметричен и совпадает с геометрией', () => { + const box = particleWindow(PARAMS); + // след кадра на земле: a·|r.x| + h·|u.x| + вклад глубины вдоль взгляда + const half = PARAMS.a * Math.abs(ISO.r[0]!) + PARAMS.h * Math.abs(ISO.u[0]!) + 40 * Math.abs(ISO.f[0]!); + expect(box.maxX).toBeCloseTo(half, 1); + expect(box.minX).toBeCloseTo(-half, 1); + expect(box.maxZ).toBeCloseTo(half, 1); + expect(box.minZ).toBeCloseTo(-half, 1); + }); + + it('всё, что видно на экране в слое, лежит в окне (свойство по выборке)', () => { + for (const dy1 of [0, 10, 42]) { + const box = particleWindow({ ...PARAMS, dy0: -2, dy1 }); + // псевдослучайная выборка вокруг кадра (детерминированная) + let s = 12345; + const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648; + for (let i = 0; i < 4000; i++) { + const d = [(rnd() - 0.5) * 700, dy1 * rnd() - 2, (rnd() - 0.5) * 700] as const; + if (d[1]! > dy1) continue; + if (!onScreen(d)) continue; + expect(d[0]!).toBeGreaterThanOrEqual(box.minX - 1e-6); + expect(d[0]!).toBeLessThanOrEqual(box.maxX + 1e-6); + expect(d[2]!).toBeGreaterThanOrEqual(box.minZ - 1e-6); + expect(d[2]!).toBeLessThanOrEqual(box.maxZ + 1e-6); + } + } + }); + + it('высокий слой сдвигает и расширяет окно: верх экрана видит дальше', () => { + const low = particleWindow(PARAMS); + const high = particleWindow({ ...PARAMS, dy0: -2, dy1: 42 }); + expect(high.maxX).toBeGreaterThan(low.maxX); + expect(high.maxZ).toBeGreaterThan(low.maxZ); + // сдвиг от высоты: Δx = |f.x|·(dy1/|f.y|)·... верхняя точка слоя глубже + expect(high.maxX - low.maxX).toBeCloseTo(42 * (ISO.f[0]! / -ISO.f[1]!) * -1, 1); + }); + + it('полиэдр: окно пустого набора ограничений вырождается в точку', () => { + const box = polyAABB([[[1, 0, 0], 2], [[1, 0, 0], 2], [[0, 1, 0], 3], [[0, 1, 0], 3], [[0, 0, 1], 4], [[0, 0, 1], 4]]); + expect(box.minX).toBeCloseTo(2); + expect(box.maxX).toBeCloseTo(2); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/render/particles.ts b/v2/packages/engine/src/render/particles.ts new file mode 100644 index 0000000..40f935f --- /dev/null +++ b/v2/packages/engine/src/render/particles.ts @@ -0,0 +1,148 @@ +/** + * Слои частиц рендера: лёгкий 2D-рендер поверх воксельного мира — спрайты- + * билборды (THREE.Points, один draw call) и плоские квадраты, лежащие на + * земле/воде (InstancedBufferGeometry). Не воксели: без освещения, теней и + * AO — цвет и прозрачность на частицу, пересборка поз каждый кадр дёшева. + * Цвет пишется в буфер как есть (hex-компоненты без colorspace-конверсий) — + * совпадает с палитрой. + */ +import * as THREE from 'three'; + +/** Частица: позиция, цвет, прозрачность (по умолчанию 1), размер в юнитах (1). */ +export interface ParticlePoint { + pos: [number, number, number]; + color: string; + alpha?: number; + size?: number; +} + +/** Ручка слоя частиц: поза слоя каждый кадр (до ёмкости; рост пересоздаёт). */ +export interface ParticleLayer { + update(points: readonly ParticlePoint[]): void; + dispose(): void; +} + +/** 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]; +} + +const POINT_VERT = ` +attribute float aAlpha; +attribute float aSize; +varying vec3 vColor; +varying float vAlpha; +uniform float uPxScale; +void main() { + vColor = color; + vAlpha = aAlpha; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + gl_PointSize = aSize * uPxScale; +}`; +const POINT_FRAG = ` +varying vec3 vColor; +varying float vAlpha; +void main() { gl_FragColor = vec4(vColor, vAlpha); }`; + +const QUAD_VERT = ` +attribute vec3 iPos; +attribute vec3 iColor; +attribute float iAlpha; +attribute float iSize; +varying vec3 vColor; +varying float vAlpha; +void main() { + vColor = iColor; + vAlpha = iAlpha; + 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 { + 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); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', pos); + geo.setAttribute('color', col); + geo.setAttribute('aAlpha', alp); + geo.setAttribute('aSize', siz); + const mat = new THREE.ShaderMaterial({ + vertexShader: POINT_VERT, fragmentShader: POINT_FRAG, + uniforms: { uPxScale: { value: pxScale } }, vertexColors: true, ...FLAGS, + }); + const mesh = new THREE.Points(geo, mat); + mesh.frustumCulled = false; // bbox по инстансам не считается + scene.add(mesh); + const layer: ParticleLayer = { + update: (points) => { + if (points.length > capacity) { + scene.remove(mesh); geo.dispose(); mat.dispose(); + const fresh = createSpriteLayer(scene, points.length, pxScale); + layer.update = fresh.update; + layer.dispose = fresh.dispose; + fresh.update(points); // грабля: редирект без позы терял первый кадр + return; + } + points.forEach((p, i) => { + const [r, g, b] = rgb(p.color); + pos.setXYZ(i, p.pos[0], p.pos[1], p.pos[2]); + col.setXYZ(i, r, g, b); + alp.setX(i, p.alpha ?? 1); + siz.setX(i, p.size ?? 1); + }); + geo.setDrawRange(0, points.length); + pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true; + }, + dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); }, + }; + return layer; +} + +/** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики). */ +function createQuadLayer(scene: THREE.Scene, capacity: number): ParticleLayer { + const base = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2); // юнит-квад в XZ + const geo = new THREE.InstancedBufferGeometry(); + geo.index = base.index; + geo.setAttribute('position', base.getAttribute('position')); + const mkI = (itemSize: number): THREE.InstancedBufferAttribute => + new THREE.InstancedBufferAttribute(new Float32Array(capacity * itemSize), itemSize).setUsage(THREE.DynamicDrawUsage); + const pos = mkI(3), col = mkI(3), alp = mkI(1), siz = mkI(1); + geo.setAttribute('iPos', pos); + geo.setAttribute('iColor', col); + geo.setAttribute('iAlpha', alp); + geo.setAttribute('iSize', siz); + const mat = new THREE.ShaderMaterial({ vertexShader: QUAD_VERT, fragmentShader: POINT_FRAG, ...FLAGS }); + const mesh = new THREE.Mesh(geo, mat); + mesh.frustumCulled = false; + scene.add(mesh); + const layer: ParticleLayer = { + update: (points) => { + if (points.length > capacity) { + scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); + const fresh = createQuadLayer(scene, points.length); + layer.update = fresh.update; + layer.dispose = fresh.dispose; + fresh.update(points); // грабля: редирект без позы терял первый кадр + return; + } + points.forEach((p, i) => { + const [r, g, b] = rgb(p.color); + pos.setXYZ(i, p.pos[0], p.pos[1], p.pos[2]); + col.setXYZ(i, r, g, b); + alp.setX(i, p.alpha ?? 1); + siz.setX(i, p.size ?? 1); + }); + geo.instanceCount = points.length; + pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true; + }, + dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); }, + }; + return layer; +} + +/** Публичные фабрики: стартовая ёмкость и px-масштаб спрайтов задаёт рендер. */ +export { createSpriteLayer, createQuadLayer }; \ No newline at end of file diff --git a/v2/packages/engine/src/render/polyWindow.ts b/v2/packages/engine/src/render/polyWindow.ts new file mode 100644 index 0000000..28dff9c --- /dev/null +++ b/v2/packages/engine/src/render/polyWindow.ts @@ -0,0 +1,83 @@ +/** + * Окно видимости частиц: AABB полиэдра из полуплоскостей (сляб орто-камеры, + * пересечённый с горизонтальным слоем частиц). Точный перебор вершин — + * пересечений троек плоскостей: ограничений 8, комбинаций 56, дёшево. + */ + +type Plane = readonly [readonly [number, number, number], number]; + +/** AABB допустимой области {δ: n·δ ≤ b для всех плоскостей} по осям x, z. */ +export function polyAABB(planes: readonly Plane[]): { + minX: number; maxX: number; minZ: number; maxZ: number; +} { + let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; + const n = planes.length; + for (let i = 0; i < n; i++) + for (let j = i + 1; j < n; j++) + for (let k = j + 1; k < n; k++) { + const p = solve3(planes[i]![0], planes[i]![1], planes[j]![0], planes[j]![1], planes[k]![0], planes[k]![1]); + if (!p) continue; // вырожденный набор нормалей + let ok = true; + for (const [nv, b] of planes) + if (nv[0]! * p[0]! + nv[1]! * p[1]! + nv[2]! * p[2]! > b + 1e-6) { ok = false; break; } + if (!ok) continue; + minX = Math.min(minX, p[0]!); maxX = Math.max(maxX, p[0]!); + minZ = Math.min(minZ, p[2]!); maxZ = Math.max(maxZ, p[2]!); + } + return { minX, maxX, minZ, maxZ }; +} + +/** Пересечение трёх плоскостей n_i·δ = b_i (Крамер); null — если нормали + * зависимы (определитель ~0). */ +function solve3( + n1: readonly [number, number, number], b1: number, + n2: readonly [number, number, number], b2: number, + n3: readonly [number, number, number], b3: number, +): [number, number, number] | null { + const det = + n1[0]! * (n2[1]! * n3[2]! - n2[2]! * n3[1]!) - + n1[1]! * (n2[0]! * n3[2]! - n2[2]! * n3[0]!) + + n1[2]! * (n2[0]! * n3[1]! - n2[1]! * n3[0]!); + if (Math.abs(det) < 1e-9) return null; + const dx = + b1 * (n2[1]! * n3[2]! - n2[2]! * n3[1]!) - + n1[1]! * (b2 * n3[2]! - n2[2]! * b3) + + n1[2]! * (b2 * n3[1]! - n2[1]! * b3); + const dy = + n1[0]! * (b2 * n3[2]! - n2[2]! * b3) - + b1 * (n2[0]! * n3[2]! - n2[2]! * n3[0]!) + + n1[2]! * (n2[0]! * b3 - b2 * n3[0]!); + const dz = + n1[0]! * (n2[1]! * b3 - b2 * n3[1]!) - + n1[1]! * (n2[0]! * b3 - b2 * n3[0]!) + + b1 * (n2[0]! * n3[1]! - n2[1]! * n3[0]!); + return [dx / det, dy / det, dz / det]; +} + +/** Полуразмеры и слой — параметры окна частиц. */ +export interface WindowParams { + /** Базис камеры: r — экранная горизонталь, u — вертикаль, f — взгляд. */ + r: V3; u: V3; f: V3; + /** Полуразмеры фрустума: половина ширины и высоты. */ + a: number; h: number; + /** Глубины ближней/дальней плоскостей относительно цели камеры. */ + k0: number; k1: number; + /** Горизонтальный слой частиц относительно цели камеры. */ + dy0: number; dy1: number; +} +type V3 = readonly [number, number, number]; +export interface WindowBox { minX: number; maxX: number; minZ: number; maxZ: number } + +/** Окно видимости частиц: AABB сляба ортокамеры на слое частиц. Смещение δ + * от цели камеры видно на экране ⇔ |r·δ|≤a, |u·δ|≤h, k0 ≤ f·δ ≤ k1; слой + * добавляет δ.y ∈ [dy0,dy1]. Окно шире наземного следа кадра: высокую + * частицу видно дальше от цели, сляб уходит в глубину до дальней плоскости. + * Все ограничения — в форме n·δ ≤ b (нижние границы — с переворотом нормали). */ +export function particleWindow(p: WindowParams): WindowBox { + return polyAABB([ + [[-p.r[0]!, -p.r[1]!, -p.r[2]!], p.a], [p.r, p.a], + [[-p.u[0]!, -p.u[1]!, -p.u[2]!], p.h], [p.u, p.h], + [[-p.f[0]!, -p.f[1]!, -p.f[2]!], -p.k0], [p.f, p.k1], + [[0, -1, 0], -p.dy0], [[0, 1, 0], p.dy1], + ]); +} diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index c253644..d3dab93 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -12,6 +12,9 @@ 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 { particleWindow } from './polyWindow'; /** Облако деформированных вокселей (позы рига — не выровнены по сетке). */ export interface VoxelCloud { @@ -192,6 +195,56 @@ } /** + * Слой частиц-спрайтов (билборды, лицом к камере): пепел, мотыльки. + * Размер частицы — в мировых юнитах, переводится в пиксели буфера один + * раз (канвас рендера не меняется после создания). + */ + addParticles(): ParticleLayer { + const px = this.renderer.domElement.height / (2 * this.viewSize); + return createSpriteLayer(this.scene, 64, px); + } + + /** Слой плоских квадов, лежащих на земле/воде: дымка прудов, блики. */ + addFlatParticles(): ParticleLayer { + return createQuadLayer(this.scene, 64); + } + + /** + * Окно видимости частиц: AABB орто-фрустума, пересечённого с горизон- + * тальным слоем [y0,y1], вокруг цели камеры. Точка вне окна заведомо не + * проецируется на экран — там частицы заворачивают/респавнятся без + * «появления из воздуха». Окно заметно шире наземного следа кадра: + * высокую частицу видно дальше от цели, а сляб уходит в глубину до + * дальней плоскости (точно — для частиц много ниже камеры, как все + * игровые слои). + */ + visibleWindow(y0: number, y1: number): { minX: number; maxX: number; minZ: number; maxZ: number } { + // БЕЗ кэша: окно обязано ехать за камерой (грабля — кэш от первого + // запроса замирал на окне вокруг origin, частицы получали alpha 0) + this.camera.updateMatrixWorld(); + const e = this.camera.matrixWorld.elements; + // базис камеры: r — экранная горизонталь, u — вертикаль, f — взгляд + const r = [e[0]!, e[1]!, e[2]!] as const; + const u = [e[4]!, e[5]!, e[6]!] as const; + const f = [-e[8]!, -e[9]!, -e[10]!] as const; + const c = this.camera.position; + const t = this.target; + // глубина цели вдоль взгляда: ближняя/дальняя плоскости относительно неё + const D = f[0] * (t.x - c.x) + f[1] * (t.y - c.y) + f[2] * (t.z - c.z); + const box = particleWindow({ + r, u, f, + a: this.camera.right, h: this.camera.top, + k0: this.camera.near - D, k1: this.camera.far - D, + dy0: y0 - t.y, dy1: y1 - t.y, + }); + // AABB посчитан в дельтах от цели камеры — переводим в мировые координаты + return { + minX: box.minX + t.x, maxX: box.maxX + t.x, + minZ: box.minZ + t.z, maxZ: box.maxZ + t.z, + }; + } + + /** * Солнце дня: k — 0 рассвет → 0.5 полдень → 1 закат. Азимут едет по * мировой горизонтали (восток→запад), высота — по синусу дня; цвет теплеет * у горизонта. Тени на земле ползут от k — это ловит скриншот-гейт.