diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 091072f..b0a6e34 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1666,9 +1666,28 @@ дёргающие setTarget каждый тик, не ломаются. Конвенция yaw — угол «target → камера» в XZ (как screenMoveToWorld); pitch > 0 — камера смотрит вниз. Дефолты орбиты: yaw = ISO_YAW (непрерывный переход из изо), - pitch 0.5, dist = viewSize. ВРЕМЕННО в перспективных режимах окно - частиц и юниформы тумана считаются по изо-камере (guard до треков - окон/тумана) — NaN не бывает. + pitch 0.5, dist = viewSize. Перспективные подсистемы (все с изо-замком + «бит-в-бит прежнее поведение»): + - **Окно частиц**: `particleWindowPersp` (polyWindow.ts) — пирамида + вместо сляба, far cap `D·PERSP_WINDOW_FAR_K=3`; пустой фрустум (взгляд + в небо) → конечный вырожденный бокс в цели (иначе wrapV осадков + делит на размер окна и размножает NaN). `visibleWindow` ветвится по + режиму, сигнатура прежняя. + - **Туман**: `fogBandLayout` — изо даёт легаси viewSize·константы ТОЧНО, + перспектива — след земли фрустума [nearG, farG] с капом 6·viewSize + (`PERSP_FOG_FAR_K`); FOG_FRAG `#ifdef PERSP` — along из нелинейной + глубины, изо-ветка дословно; дефайн PERSP флипается в render(). + - **Спрайты**: общий блок `makeParticlePerspUniforms()` (uPxScale/uPersp/ + uRefDist, все слои шарят IUniform'ы — рост ёмкости не отрывается); + `createSpriteLayer(scene, cap, perspU, lights, lit)` — uPxScale теперь + в блоке (пер-слойный параметр убран). POINT_VERT: множитель + `mix(1, uRefDist/w, uPersp)`, калибровка `uPxScale·uRefDist = + (H/2)/tan(fov/2)` — точка на дистанции viewSize рисуется в изо-масштабе. + QUAD_VERT не тронут (квады на земле). + - **Теневые фрустумы**: `perspVisibleRadius(D, tanHalf, aspect, viewSize)` + — сечение пирамиды на глубине видимой земли (farG fogBandLayout, кламп + снизу ¼ изо-полуширины); известный предел — резкая линия включения + теней у горизонта в перспективе (каскады вне скоупа, docs/plan.md). - **VoxelRenderer.dispose()** (ревью): полное освобождение GPU-ресурсов — пост-проход, туман, свет с shadow map, контекст GL. Облако и меши — у владельцев. Обязателен для многоразовых экземпляров (ModelViewer diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 4735337..2518ca5 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -23,7 +23,7 @@ export { meshChunk, chunkBounds } from './world/worldMesher'; export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk'; export { VoxelRenderer, worldTileAtlas } from './render/voxelRenderer'; -export { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, particleEnv, litVisibility, PARTICLE_LIGHT_MAX, PARTICLE_ENV_SUN, PARTICLE_LIT_VIS, PARTICLE_LIT_GAMMA } from './render/particles'; +export { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, makeParticlePerspUniforms, packParticleLights, particleEnv, litVisibility, PARTICLE_LIGHT_MAX, PARTICLE_ENV_SUN, PARTICLE_LIT_VIS, PARTICLE_LIT_GAMMA } from './render/particles'; export { makeFogUniforms, fogColor, fogBandStart, FOG_ENV_SUN, FOG_BRIGHT, FOG_BANDS, FOG_BAND_START, FOG_BAND_STEP, FOG_BAND_SOFT, FOG_BAND_ALPHA } from './render/fog'; export { postPass } from './render/postShader'; export type { PostOptions, PostPassSpec } from './render/postShader'; diff --git a/v2/packages/engine/src/render/__tests__/cameraMath.test.ts b/v2/packages/engine/src/render/__tests__/cameraMath.test.ts index 6997e3e..fa35b9e 100644 --- a/v2/packages/engine/src/render/__tests__/cameraMath.test.ts +++ b/v2/packages/engine/src/render/__tests__/cameraMath.test.ts @@ -1,7 +1,7 @@ /** Тесты чистой математики камеры: направление взгляда и орбита — без WebGL. */ import { describe, expect, it } from 'vitest'; import { ISO_YAW } from '../voxelRenderer'; -import { camForward, orbitPosition } from '../cameraMath'; +import { camForward, orbitPosition, perspVisibleRadius, PERSP_FOV } from '../cameraMath'; describe('camForward (направление взгляда от yaw/pitch)', () => { it('pitch 0 — горизонтальный взгляд против орбит-направления', () => { @@ -32,4 +32,18 @@ const above = orbitPosition(Math.PI / 4, 0.5, 10, [0, 0, 0]); expect(above[1]).toBeCloseTo(10 * Math.sin(0.5)); }); +}); + +describe('perspVisibleRadius (полуширина для теневых фрустумов)', () => { + it('сечение пирамиды на глубине D растёт с D', () => { + const tanHalf = Math.tan(PERSP_FOV / 2); + expect(perspVisibleRadius(240, tanHalf, 16 / 9, 80)) + .toBeCloseTo(240 * tanHalf * (16 / 9)); + expect(perspVisibleRadius(480, tanHalf, 16 / 9, 80)) + .toBeGreaterThan(perspVisibleRadius(240, tanHalf, 16 / 9, 80)); + }); + it('кламп снизу четвертью изо-полуширины: вырожденный D не роняет фрустум', () => { + expect(perspVisibleRadius(0, Math.tan(PERSP_FOV / 2), 16 / 9, 80)) + .toBeCloseTo(80 * (16 / 9) * 0.25); + }); }); \ No newline at end of file diff --git a/v2/packages/engine/src/render/__tests__/particleLayers.test.ts b/v2/packages/engine/src/render/__tests__/particleLayers.test.ts index 7a4c707..b76562f 100644 --- a/v2/packages/engine/src/render/__tests__/particleLayers.test.ts +++ b/v2/packages/engine/src/render/__tests__/particleLayers.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from 'vitest'; import * as THREE from 'three'; import { - createQuadLayer, createSpriteLayer, makeParticleLightUniforms, + createQuadLayer, createSpriteLayer, makeParticleLightUniforms, makeParticlePerspUniforms, type ParticleLayer, type ParticlePoint, } from '../particles'; const pt = (i: number): ParticlePoint => ({ pos: [i, 1, 2], color: '#a8bacb' }); const lights = makeParticleLightUniforms(); +const perspU = makeParticlePerspUniforms(); +perspU.uPxScale!.value = 3; // прежний per-layer масштаб — теперь в общем блоке /** Регрессия #218: держатель слоя (WeatherSystem) держит методы, снятые со * слоя в момент создания (и вызывает их каждый тик). Раньше рост ёмкости @@ -17,7 +19,7 @@ * стабильн: сколько бы ни было ростов, в сцене ровно один меш. */ describe('слои частиц: рост ёмкости не плодит меши (#218)', () => { const factories: [string, (scene: THREE.Scene, cap: number) => ParticleLayer][] = [ - ['спрайты', (s, cap) => createSpriteLayer(s, cap, 3, lights, false)], + ['спрайты', (s, cap) => createSpriteLayer(s, cap, perspU, lights, false)], ['квады', (s, cap) => createQuadLayer(s, cap, lights, false)], ]; for (const [name, make] of factories) { @@ -44,11 +46,27 @@ } }); +/** Перспективное затухание спрайтов: общий блок юниформ шарится IUniform'ами — + * рост ёмкости (новый impl/меш) не отрывается от блока рендера. */ +describe('слои частиц: общий persp-блок переживает рост ёмкости', () => { + it('новый меш ссылается на те же IUniform', () => { + const scene = new THREE.Scene(); + const layer = createSpriteLayer(scene, 2, perspU, lights, false); + layer.update([pt(0), pt(1), pt(2), pt(3)]); // рост 2 → 4 + const mesh = scene.children[0] as THREE.Points; + const u = (mesh.material as THREE.ShaderMaterial).uniforms; + expect(u.uPxScale).toBe(perspU.uPxScale); + expect(u.uPersp).toBe(perspU.uPersp); + expect(u.uRefDist).toBe(perspU.uRefDist); + layer.dispose(); + }); +}); + /** Регрессия ревью: рост ёмкости пересоздаёт impl с дефолтным белым uTint — * слой должен помнить свой tint и переапплаить его в свежий меш. */ describe('слои частиц: tint переживает рост ёмкости', () => { const factories: [string, (scene: THREE.Scene, cap: number) => ParticleLayer][] = [ - ['спрайты', (s, cap) => createSpriteLayer(s, cap, 3, lights, false)], + ['спрайты', (s, cap) => createSpriteLayer(s, cap, perspU, lights, false)], ['квады', (s, cap) => createQuadLayer(s, cap, lights, false)], ]; for (const [name, make] of factories) { diff --git a/v2/packages/engine/src/render/cameraMath.ts b/v2/packages/engine/src/render/cameraMath.ts index 2a596f9..315a170 100644 --- a/v2/packages/engine/src/render/cameraMath.ts +++ b/v2/packages/engine/src/render/cameraMath.ts @@ -46,4 +46,20 @@ target[1] + dist * Math.sin(pitch), target[2] + dist * cp * Math.sin(yaw), ]; +} + +/** Полуширина видимой области перспективы на глубине D от камеры — для + * теневых фрустумов (setSun/setLightning): пирамида расширяется с глубиной, + * сечение на D = D·tan(fov/2)·max(aspect,1) (max — как в изо: фрустум квадратный, + * ориентируется на широкую сторону кадра). Кламп снизу четвертью изо-полуширины: + * близкая орбита/вырожденный D не роняют фрустум под кадр. Резкая линия + * включения теней у горизонта в перспективе — известный предел (каскады вне + * скоупа, docs/plan.md). Чистая функция. */ +export function perspVisibleRadius( + D: number, tanHalf: number, aspect: number, viewSize: number, +): number { + return Math.max( + tanHalf * Math.max(aspect, 1) * D, + viewSize * Math.max(aspect, 1) * 0.25, + ); } \ 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 67e6917..4f84c0b 100644 --- a/v2/packages/engine/src/render/particles.ts +++ b/v2/packages/engine/src/render/particles.ts @@ -65,6 +65,18 @@ /** Тип общего блока юниформ света (один экземпляр на все слои рендера). */ export type ParticleLightUniforms = Record; +/** Общий блок юниформ перспективного затухания спрайтов (один на рендер, + * все слои ссылаются на ТЕ ЖЕ IUniform — рост ёмкости режим не теряет). + * uPxScale флипает рендер при смене режима камеры, uPersp — микс 0 (изо) + * / 1 (перспектива), uRefDist — калибровочная дистанция (viewSize): + * uPxScale·uRefDist = (H/2)/tan(fov/2) — точка на дистанции viewSize + * рисуется в изо-масштабе пикселей, дальше сжимается по 1/w. */ +export type ParticlePerspUniforms = Record; + +export function makeParticlePerspUniforms(): ParticlePerspUniforms { + return { uPxScale: { value: 1 }, uPersp: { value: 0 }, uRefDist: { value: 1 } }; +} + /** Блок юниформ света частиц: живёт на рендере, все слои (и пересозданные * при росте ёмкости) ссылаются на ТЕ ЖЕ IUniform — свет не теряется. */ export function makeParticleLightUniforms(): ParticleLightUniforms { @@ -225,12 +237,16 @@ varying float vAlpha; varying vec3 vWorld; uniform float uPxScale; +uniform float uPersp; +uniform float uRefDist; void main() { vColor = color; vAlpha = aAlpha; vWorld = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - gl_PointSize = aSize * uPxScale; + // перспектива: спрайт сжимается с глубиной (uRefDist/w); в изо uPersp = 0 + // и w ортокамеры = 1 — множитель ровно 1, прежнее поведение бит-в-бит + gl_PointSize = aSize * uPxScale * mix(1.0, uRefDist / max(gl_Position.w, 1e-4), uPersp); }`; const POINT_FRAG = ` varying vec3 vColor; @@ -273,11 +289,12 @@ const FLAGS = { transparent: true, depthWrite: false, depthTest: true }; /** Внутренняя реализация спрайтового слоя БЕЗ роста ёмкости (рост — у фасада). - * lights — общий блок юниформ света рендера (ссылки на IUniform шарятся). - * lit — освещаемый режим: видимость по полученному свету (см. шапку). */ + * lights — общий блок юниформ света рендера, perspU — общий блок перспективного + * затухания (ссылки на IUniform шарятся). lit — освещаемый режим: видимость + * по полученному свету (см. шапку). */ function createSpriteImpl( - scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms, - lit: boolean, + scene: THREE.Scene, capacity: number, perspU: ParticlePerspUniforms, + lights: ParticleLightUniforms, lit: boolean, ) { const mk = (itemSize: number): THREE.BufferAttribute => new THREE.BufferAttribute(new Float32Array(capacity * itemSize), itemSize).setUsage(THREE.DynamicDrawUsage); @@ -289,7 +306,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) }, ...lights }, + uniforms: { ...perspU, uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights }, defines: { LIT: lit ? 1 : 0 }, vertexColors: true, ...FLAGS, }); @@ -327,18 +344,18 @@ * при росте подменяется только `impl` — держатели всегда в актуальном * меше, а первый кадр после роста рисуется сразу (impl.update(points)). */ function createSpriteLayer( - scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms, - lit: boolean, + scene: THREE.Scene, capacity: number, perspU: ParticlePerspUniforms, + lights: ParticleLightUniforms, lit: boolean, ): ParticleLayer { let cap = capacity; let tint = 0xffffff; // помним tint слоя: при росте ёмкости свежий impl - let impl = createSpriteImpl(scene, cap, pxScale, lights, lit); // получит дефолт + let impl = createSpriteImpl(scene, cap, perspU, lights, lit); // получит дефолт const layer: ParticleLayer = { update: (points) => { if (points.length > cap) { impl.dispose(); cap = points.length; - impl = createSpriteImpl(scene, cap, pxScale, lights, lit); + impl = createSpriteImpl(scene, cap, perspU, lights, lit); impl.setTint(tint); // иначе новый impl теряет tint слоя } impl.update(points); diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index a545a23..1103a2f 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -12,14 +12,14 @@ import type { PosedVoxel } from '../animation/rig'; import type { ResolvedTile } from '../models/format'; import { cloudDim, lerpColor } from './daynight'; -import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, particleEnv } from './particles'; +import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, makeParticlePerspUniforms, packParticleLights, particleEnv } from './particles'; import type { ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './particles'; import { createFogComposite, makeFogUniforms, fogColor, fogBandLayout } from './fog'; import { LIGHTNING_COLOR, LIGHTNING_FLASH, LIGHTNING_TOP, lightningEnvLight } from './lightning'; import { particleWindow, particleWindowPersp } from './polyWindow'; import { PERSP_DIST_K, PERSP_FOV, PERSP_PITCH_FIRST, PERSP_PITCH_THIRD, - PERSP_WINDOW_FAR_K, camForward, orbitPosition, + PERSP_WINDOW_FAR_K, camForward, orbitPosition, perspVisibleRadius, } from './cameraMath'; import type { CameraMode } from './cameraMath'; @@ -192,6 +192,8 @@ /** Общий блок юниформ света частиц: все слои ссылаются на те же IUniform — * рост ёмкости слоя и пересборка области свет не теряют. */ private readonly particleLightsU: ParticleLightUniforms = makeParticleLightUniforms(); + /** Общий блок юниформ перспективного затухания спрайтов (см. applySpriteUniforms). */ + private readonly particlePerspU = makeParticlePerspUniforms(); /** Цвет тумана (общий блок, шарится с композитным квадом тумана). */ private readonly fogU: Record = makeFogUniforms(); /** Туман: сила/время/ветер (setFog) и лениво собранный композитный проход. */ @@ -278,6 +280,7 @@ this.scene.add(this.flash); this.scene.add(this.flash.target); this.setSun(0.5); + this.applySpriteUniforms(); this.repositionCamera(); } @@ -444,12 +447,25 @@ /** * Слой частиц-спрайтов (билборды, лицом к камере): пепел, мотыльки. - * Размер частицы — в мировых юнитах, переводится в пиксели буфера один - * раз (канвас рендера не меняется после создания). + * Размер частицы — в мировых юнитах; перевод в пиксели буфера живёт + * в общем блоке particlePerspU (канвас рендера не меняется после + * создания, режим камеры флипает его в applySpriteUniforms). */ addParticles(opts: { lit?: boolean } = {}): ParticleLayer { + return createSpriteLayer(this.scene, 64, this.particlePerspU, this.particleLightsU, opts.lit ?? false); + } + + /** Юниформы спрайтов под режим камеры: изо — uPxScale = пиксели на + * мировую единицу ортокамеры, uPersp = 0 (множитель ровно 1); перспектива — + * uPxScale делится на tan(fov/2) и множитель uRefDist/w сжимает спрайт + * с глубиной: точка на дистанции viewSize рисуется в изо-масштабе + * (калибровка uPxScale·uRefDist = (H/2)/tan(fov/2)) — переход бесшовный. */ + private applySpriteUniforms(): void { const px = this.renderer.domElement.height / (2 * this.viewSize); - return createSpriteLayer(this.scene, 64, px, this.particleLightsU, opts.lit ?? false); + const persp = this.mode !== 'iso'; + this.particlePerspU.uPxScale!.value = persp ? px / Math.tan(PERSP_FOV / 2) : px; + this.particlePerspU.uPersp!.value = persp ? 1 : 0; + this.particlePerspU.uRefDist!.value = this.viewSize; } /** Слой плоских квадов, лежащих на земле/воде: дымка прудов, блики. */ @@ -559,6 +575,29 @@ * мировой горизонтали (восток→запад), высота — по синусу дня; цвет теплеет * у горизонта. Тени на земле ползут от k — это ловит скриншот-гейт. */ + /** + * Полуширина видимой области для теневых фрустумов. Изо — легаси + * viewSize·max(aspect,1); перспектива — сечение пирамиды на глубине + * видимой земли (perspVisibleRadius): глубина берётся как дальний край + * следа земли фрустума (farG из fogBandLayout, уже с капом 6·viewSize) + * под капом дальностей камеры. Резкая линия включения теней у горизонта + * в перспективе — известный предел (каскады вне скоупа, docs/plan.md). + */ + private visibleRadius(): number { + if (this.mode === 'iso') return this.viewSize * Math.max(this.viewAspect, 1); + const layout = fogBandLayout({ + mode: this.mode, + viewSize: this.viewSize, + pitch: this.orbit.pitch, + eyeH: Math.max(this.perspCamera.position.y - this.target.y, 0.5), + fov: PERSP_FOV, + }); + return perspVisibleRadius( + Math.min(layout.farG, this.perspCamera.far), + Math.tan(PERSP_FOV / 2), this.viewAspect, this.viewSize, + ); + } + setSun(k: number): void { this.sunK = k; const kk = Math.min(1, Math.max(0, k)); @@ -576,7 +615,7 @@ // резкая линия на экране) плюс длину тени самого высокого кастера // (чанк 64) при текущей высоте солнца: тень уходит от кастера на // h/tan(elev). far — от позиции солнца (dist) до дальнего края. - const visible = this.viewSize * Math.max(this.viewAspect, 1); + const visible = this.visibleRadius(); const r = Math.ceil(visible + SHADOW_CASTER_H / Math.tan(elev)); const cam = this.sun.shadow.camera; if (cam.right !== r) { @@ -667,7 +706,7 @@ ); this.flash.target.position.set(at[0], at[1], at[2]); this.flash.intensity = k * LIGHTNING_FLASH * (1 + this.nightK); - const visible = this.viewSize * Math.max(this.viewAspect, 1); + const visible = this.visibleRadius(); const r = Math.ceil(visible) + 2; const c = this.flash.shadow.camera; if (c.right !== r) { @@ -908,6 +947,7 @@ if (opts.dist !== undefined) this.orbit.dist = opts.dist; if (opts.eyeHeight !== undefined) this.eyeHeight = Math.max(0.5, opts.eyeHeight); this.clampOrbit(); + this.applySpriteUniforms(); this.repositionCamera(); }