diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index eafef8d..901dfbd 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1174,8 +1174,16 @@ квад на y=2.55 ниже поверхности и отсекается depth-тестом (воксель- кубы старого шиммера торчали верхней гранью — потому работали). Блики/дымка — на y=3.03/3.05. - - **рост ёмкости слоя**: после редиректа на свежий слой обязателен - `fresh.update(points)` — иначе первый кадр после роста пуст. + - **рост ёмкости слоя** (#218): редирект `layer.update = fresh.update` + ломается, когда держатель слоя снял методы заранее — ссылка навсегда + указывает в замыкание старой реализации, каждый тик сверх ёмкости + создаёт новый слой с однократной записью, замороженные снимки дождя + копятся в сцене (пелена, ~46 слоёв на скрине пользователя). Теперь + фабрики (`createSpriteLayer`/`createQuadLayer`) держат стабильный + фасад: публичный объект один на всю жизнь слоя, при росте подменяется + только внутренняя реализация; первый кадр после роста рисуется сразу + (`impl.update(points)`). Регрессия — `particleLayers.test.ts` + (метод снят заранее, росты и повторные превышения — меш в сцене один). - **скорость дрейфа — по v1**: v1 задаёт px/s при 32 px/юнит; пепел v1 2..7 px/s ≈ 1.2 юнита/с v2. Медленный дрейф за 40 тиков гейта — субпиксельный, гейт «пепел дрейфует» не видит движения. diff --git a/v2/packages/engine/src/render/__tests__/particleLayers.test.ts b/v2/packages/engine/src/render/__tests__/particleLayers.test.ts new file mode 100644 index 0000000..04b1e6f --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/particleLayers.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import * as THREE from 'three'; +import { + createQuadLayer, createSpriteLayer, makeParticleLightUniforms, + type ParticleLayer, type ParticlePoint, +} from '../particles'; + +const pt = (i: number): ParticlePoint => ({ pos: [i, 1, 2], color: '#a8bacb' }); +const lights = makeParticleLightUniforms(); + +/** Регрессия #218: держатель слоя (WeatherSystem) держит методы, снятые со + * слоя в момент создания (и вызывает их каждый тик). Раньше рост ёмкости + * перезаписывал методы слоя методами свежей реализации — но снятая ранее + * ссылка продолжала указывать в мёртвое замыкание: каждый тик сверх + * ёмкости создавал новый слой с однократной записью, и замороженные + * снимки дождя копились в сцене (пелена на экране). Теперь фасад + * стабильн: сколько бы ни было ростов, в сцене ровно один меш. */ +describe('слои частиц: рост ёмкости не плодит меши (#218)', () => { + const factories: [string, (scene: THREE.Scene, cap: number) => ParticleLayer][] = [ + ['спрайты', (s, cap) => createSpriteLayer(s, cap, 3, lights)], + ['квады', (s, cap) => createQuadLayer(s, cap, lights)], + ]; + for (const [name, make] of factories) { + it(`${name}: превышение ёмкости подменяет меш, а не плодит слои`, () => { + const scene = new THREE.Scene(); + const layer = make(scene, 2); + const update = layer.update; // как WeatherSystem: метод снят заранее + const meshes = () => scene.children.length; + expect(meshes()).toBe(1); + update([pt(0), pt(1)]); + expect(meshes()).toBe(1); + update([pt(0), pt(1), pt(2), pt(3)]); // рост 2 → 4 + expect(meshes()).toBe(1); + update([pt(0), pt(1), pt(2)]); // выше старой ёмкости, ниже новой + expect(meshes()).toBe(1); + update([pt(0), pt(1), pt(2), pt(3)]); // повторное превышение + expect(meshes()).toBe(1); + layer.setTint(0x40465c); // тинт после роста идёт в живой меш + layer.dispose(); + expect(meshes()).toBe(0); + layer.dispose(); // повторный dispose безопасен + expect(meshes()).toBe(0); + }); + } +}); \ No newline at end of file diff --git a/v2/packages/engine/src/render/particles.ts b/v2/packages/engine/src/render/particles.ts index 0d749cc..0387313 100644 --- a/v2/packages/engine/src/render/particles.ts +++ b/v2/packages/engine/src/render/particles.ts @@ -200,11 +200,11 @@ const FLAGS = { transparent: true, depthWrite: false, depthTest: true }; -/** Фабрика слоя спрайтов (отдельная функция — рекурсия при росте ёмкости). +/** Внутренняя реализация спрайтового слоя БЕЗ роста ёмкости (рост — у фасада). * lights — общий блок юниформ света рендера (ссылки на IUniform шарятся). */ -function createSpriteLayer( +function createSpriteImpl( 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); @@ -221,17 +221,8 @@ 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, lights); - layer.update = fresh.update; - layer.setTint = fresh.setTint; - layer.dispose = fresh.dispose; - fresh.update(points); // грабля: редирект без позы терял первый кадр - return; - } + return { + update: (points: readonly ParticlePoint[]) => { points.forEach((p, i) => { const [r, g, b] = rgb(p.color); pos.setXYZ(i, p.pos[0], p.pos[1], p.pos[2]); @@ -242,17 +233,46 @@ geo.setDrawRange(0, points.length); pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true; }, - setTint: (color) => { + setTint: (color: number) => { const v = mat.uniforms.uTint.value as THREE.Vector3; v.set(((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255); }, dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); }, }; +} + +/** Фабрика слоя спрайтов: стабильный фасад над подменяемой реализацией. + * Грабля «сломанная цепочка редиректа»: раньше при росте ёмкости слой + * перезаписывал СВОИ методы методами свежего слоя (`layer.update = + * fresh.update`) — держатель старого слоя (WeatherSystem) продолжал + * звать update старого объекта, тот уходил в мёртвое замыкание + * предыдущей реализации и при каждом превышении ёмкости создавал + * НОВЫЙ слой с однократной записью: замороженные снимки дождя копились + * в сцене (пелена на скрине #218). Теперь фасад один на всю жизнь слоя, + * при росте подменяется только `impl` — держатели всегда в актуальном + * меше, а первый кадр после роста рисуется сразу (impl.update(points)). */ +function createSpriteLayer( + scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms, +): ParticleLayer { + let cap = capacity; + let impl = createSpriteImpl(scene, cap, pxScale, lights); + const layer: ParticleLayer = { + update: (points) => { + if (points.length > cap) { + impl.dispose(); + cap = points.length; + impl = createSpriteImpl(scene, cap, pxScale, lights); + } + impl.update(points); + }, + setTint: (color) => impl.setTint(color), + dispose: () => impl.dispose(), + }; return layer; } -/** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики). */ -function createQuadLayer(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms): ParticleLayer { +/** Внутренняя реализация слоя квадов без роста ёмкости (рост — у фасада). */ +function createQuadImpl(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms) { const base = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2); // юнит-квад в XZ const geo = new THREE.InstancedBufferGeometry(); geo.index = base.index; @@ -271,17 +291,8 @@ 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, lights); - layer.update = fresh.update; - layer.setTint = fresh.setTint; - layer.dispose = fresh.dispose; - fresh.update(points); // грабля: редирект без позы терял первый кадр - return; - } + return { + update: (points: readonly ParticlePoint[]) => { points.forEach((p, i) => { const [r, g, b] = rgb(p.color); pos.setXYZ(i, p.pos[0], p.pos[1], p.pos[2]); @@ -292,12 +303,31 @@ geo.instanceCount = points.length; pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true; }, - setTint: (color) => { + setTint: (color: number) => { const v = mat.uniforms.uTint.value as THREE.Vector3; v.set(((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255); }, dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); }, }; +} + +/** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики) — + * стабильный фасад, рост ёмкости подменяет impl (см. createSpriteLayer). */ +function createQuadLayer(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms): ParticleLayer { + let cap = capacity; + let impl = createQuadImpl(scene, cap, lights); + const layer: ParticleLayer = { + update: (points) => { + if (points.length > cap) { + impl.dispose(); + cap = points.length; + impl = createQuadImpl(scene, cap, lights); + } + impl.update(points); + }, + setTint: (color) => impl.setTint(color), + dispose: () => impl.dispose(), + }; return layer; }