diff --git a/v2/packages/engine/src/render/__tests__/polyWindowPersp.test.ts b/v2/packages/engine/src/render/__tests__/polyWindowPersp.test.ts new file mode 100644 index 0000000..11164d8 --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/polyWindowPersp.test.ts @@ -0,0 +1,50 @@ +/** Тесты окна частиц перспективной камеры (пирамиды) — без WebGL. */ +import { describe, expect, it } from 'vitest'; +import { particleWindowPersp } from '../polyWindow'; + +/** Пирамида «прототипа»: камера в (0, 20, 30), смотрит в target (0, 0, 0) + * сверху-сзади, вертикальный fov 60° (tanHalf 0.577), аспект 16/9. */ +function proto(dy0 = -2, dy1 = 40, far = 200) { + const target: [number, number, number] = [0, 0, 0]; + const camPos: [number, number, number] = [0, 20, 30]; + const f = [0, -0.555, -0.832] as const; // ≈ взгляд в target (нормирован) + const r = [1, 0, 0] as const; // экранная горизонталь + const u = [0, 0.832, -0.555] as const; // экранная вертикаль + return { r, u, f, camPos, target, tanHalf: 0.577, aspect: 16 / 9, near: 0.5, far, dy0, dy1 }; +} + +const inside = (b: { minX: number; maxX: number; minZ: number; maxZ: number }, x: number, z: number) => + x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ; + +describe('particleWindowPersp (окно пирамиды)', () => { + it('точка земли перед камерой внутри окна, за камерой — снаружи', () => { + const b = particleWindowPersp(proto()); + expect(inside(b, 0, -10)).toBe(true); // перед камерой, на земле + expect(inside(b, 0, 60)).toBe(false); // позади камеры + expect(inside(b, 0, 35)).toBe(false); // под самой камерой — ниже нижней грани кадра + }); + it('окно расширяется от камеры: дальняя земля шире ближней', () => { + const b = particleWindowPersp(proto()); + // точка дальше по взгляду на том же луче — всё ещё внутри + expect(inside(b, 30, -40)).toBe(true); + expect(b.maxX).toBeGreaterThan(10); // полуширина растёт с глубиной + }); + it('вырожденные углы не дают NaN (окно конечное или пустое)', () => { + const p = proto(-2, 40, 200); + const b = particleWindowPersp({ ...p, tanHalf: 1e-6, far: 1e-6 }); + expect(Number.isFinite(b.minX + b.maxX + b.minZ + b.maxZ)).toBe(true); + }); + it('фрустум без пересечения со слоем (взгляд в небо) — конечное вырожденное окно', () => { + // far позади near (вызов с cap'ом D=0 при взгляде вверх): полиэдр пуст, + // но контракт — конечный бокс, а не ±Infinity (wrapV делит на размер) + const b = particleWindowPersp(proto(-2, 40, 0.1)); + expect(Number.isFinite(b.minX + b.maxX + b.minZ + b.maxZ)).toBe(true); + expect(b.maxX - b.minX).toBeLessThan(1); // вырожденный бокс в точке цели + }); + it('far cap режет окно: меньший cap — уже окно', () => { + const wide = particleWindowPersp(proto(-2, 40, 200)); + const narrow = particleWindowPersp(proto(-2, 40, 40)); + expect(narrow.maxX - narrow.minX).toBeLessThan(wide.maxX - wide.minX); + expect(narrow.maxZ - narrow.minZ).toBeLessThan(wide.maxZ - wide.minZ); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/render/cameraMath.ts b/v2/packages/engine/src/render/cameraMath.ts index 867d407..2a596f9 100644 --- a/v2/packages/engine/src/render/cameraMath.ts +++ b/v2/packages/engine/src/render/cameraMath.ts @@ -22,6 +22,11 @@ * дальше — теряем кадр. */ export const PERSP_DIST_K: readonly [number, number] = [0.25, 3]; +/** Cap дальней границы окна частиц в долях глубины цели: без него окно + * первого лица при взгляде вдоль горизонта уходит в бесконечность — + * плотность размазывается, wrap гоняет частицы по километрам. */ +export const PERSP_WINDOW_FAR_K = 3; + /** Направление взгляда (unit): горизонталь противоположна орбит-направлению * «target → камера». Чистая функция. */ export function camForward(yaw: number, pitch: number): [number, number, number] { diff --git a/v2/packages/engine/src/render/polyWindow.ts b/v2/packages/engine/src/render/polyWindow.ts index 28dff9c..dac39a2 100644 --- a/v2/packages/engine/src/render/polyWindow.ts +++ b/v2/packages/engine/src/render/polyWindow.ts @@ -81,3 +81,53 @@ [[0, -1, 0], -p.dy0], [[0, 1, 0], p.dy1], ]); } + +/** Параметры окна ПЕРСПЕКТИВНОЙ камеры (пирамида вместо сляба). */ +export interface PerspWindowParams { + /** Базис камеры: r — экранная горизонталь, u — вертикаль, f — взгляд. */ + r: V3; u: V3; f: V3; + /** Позиция камеры и цель (система отсчёта δ = точка − target). */ + camPos: V3; target: V3; + /** tan(fov/2) по вертикали и аспект кадра (полуширина = tanHalf·aspect). */ + tanHalf: number; aspect: number; + /** Ближняя/дальняя плоскости (far — уже с cap'ом вызывающего). */ + near: number; far: number; + /** Горизонтальный слой частиц относительно target.y. */ + dy0: number; dy1: number; +} + +/** Окно частиц для перспективной камеры: та же схема «плоскости → polyAABB», + * но боковые грани проходят через камеру (n·(x−cam) ≤ K·f·(x−cam) при + * n = ±r/±u, K = tanHalf·[aspect]) — в δ = x − target это n·δ ≤ n·d, + * d = camPos − target. Потребители (wrap/плотность осадков) остаются + * прежними — окно просто становится перспективно-корректным AABB. + * Контракт: окно ВСЕГДА конечное — если фрустум не пересекает слой частиц + * (1st person смотрит в небо, far cap'ом срезан в ноль), полиэдр пуст и + * polyAABB вернул бы ±Infinity, а wrapV у потребителей делит на размер + * окна. В этом случае возвращаем вырожденный бокс в точке цели: после + * маржи weatherWindow он превращается в крошечное окно вокруг цели — + * частицы заворачиваются туда и гасятся краем кадра, NaN невозможен. + * Чистая функция (тесты без WebGL). */ +export function particleWindowPersp(p: PerspWindowParams): WindowBox { + const K = p.tanHalf * p.aspect; // полуширина пирамиды на единицу глубины + const T = p.tanHalf; + const d: V3 = [p.camPos[0] - p.target[0], p.camPos[1] - p.target[1], p.camPos[2] - p.target[2]]; + // плоскость n·δ ≤ n·d: точка δ внутри боковой грани ⇔ её отклонение от + // оси взгляда растёт не быстрее радиуса пирамиды + const side = (n: [number, number, number]): Plane => { + const b = n[0]! * d[0]! + n[1]! * d[1]! + n[2]! * d[2]!; + return [n, b]; + }; + const fd = p.f[0]! * d[0]! + p.f[1]! * d[1]! + p.f[2]! * d[2]!; + const box = polyAABB([ + side([p.r[0]! - K * p.f[0]!, p.r[1]! - K * p.f[1]!, p.r[2]! - K * p.f[2]!]), + side([-p.r[0]! - K * p.f[0]!, -p.r[1]! - K * p.f[1]!, -p.r[2]! - K * p.f[2]!]), + side([p.u[0]! - T * p.f[0]!, p.u[1]! - T * p.f[1]!, p.u[2]! - T * p.f[2]!]), + side([-p.u[0]! - T * p.f[0]!, -p.u[1]! - T * p.f[1]!, -p.u[2]! - T * p.f[2]!]), + [[-p.f[0]!, -p.f[1]!, -p.f[2]!], -fd - p.near], // near: f·δ ≥ near + [[p.f[0]!, p.f[1]!, p.f[2]!], fd + p.far], // far: f·δ ≤ far + [[0, -1, 0], -p.dy0], [[0, 1, 0], p.dy1], + ]); + const finite = [box.minX, box.maxX, box.minZ, box.maxZ].every(Number.isFinite); + return finite ? box : { minX: 0, maxX: 0, minZ: 0, maxZ: 0 }; // пустой фрустум → точка цели +} diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index 3dd6a2b..810301b 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -16,10 +16,10 @@ import type { ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './particles'; import { createFogComposite, makeFogUniforms, fogColor } from './fog'; import { LIGHTNING_COLOR, LIGHTNING_FLASH, LIGHTNING_TOP, lightningEnvLight } from './lightning'; -import { particleWindow } from './polyWindow'; +import { particleWindow, particleWindowPersp } from './polyWindow'; import { PERSP_DIST_K, PERSP_FOV, PERSP_PITCH_FIRST, PERSP_PITCH_THIRD, - camForward, orbitPosition, + PERSP_WINDOW_FAR_K, camForward, orbitPosition, } from './cameraMath'; import type { CameraMode } from './cameraMath'; @@ -512,24 +512,41 @@ * игровые слои). */ visibleWindow(y0: number, y1: number): { minX: number; maxX: number; minZ: number; maxZ: number } { - // ВРЕМЕННО (до трека окон пирамиды): орто-математика по изо-камере - // даже в перспективных режимах — right/top есть только у ортокамеры. - this.isoCamera.updateMatrixWorld(); - const e = this.isoCamera.matrixWorld.elements; + // БЕЗ кэша: окно обязано ехать за камерой (грабля — кэш от первого + // запроса замирал на окне вокруг origin, частицы получали alpha 0) + const cam = this.camera; + cam.updateMatrixWorld(); + const e = cam.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.isoCamera.position; + const c = cam.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.isoCamera.right, h: this.isoCamera.top, - k0: this.isoCamera.near - D, k1: this.isoCamera.far - D, - dy0: y0 - t.y, dy1: y1 - t.y, - }); + let box: { minX: number; maxX: number; minZ: number; maxZ: number }; + if (this.mode === 'iso') { + // глубина цели вдоль взгляда: ближняя/дальняя плоскости относительно неё + const D = f[0] * (t.x - c.x) + f[1] * (t.y - c.y) + f[2] * (t.z - c.z); + box = particleWindow({ + r, u, f, + a: this.isoCamera.right, h: this.isoCamera.top, + k0: this.isoCamera.near - D, k1: this.isoCamera.far - D, + dy0: y0 - t.y, dy1: y1 - t.y, + }); + } else { + // пирамида перспективы; far с cap'ом от цели — иначе при взгляде + // вдоль горизонта (1st person) окно уходит в бесконечность: + // плотность размазывается, wrap гоняет частицы по километрам + const D = Math.max(0, f[0] * (t.x - c.x) + f[1] * (t.y - c.y) + f[2] * (t.z - c.z)); + box = particleWindowPersp({ + r, u, f, + camPos: [c.x, c.y, c.z], target: [t.x, t.y, t.z], + tanHalf: Math.tan(PERSP_FOV / 2), aspect: this.viewAspect, + near: this.perspCamera.near, + far: Math.min(this.perspCamera.far, D * PERSP_WINDOW_FAR_K), + dy0: y0 - t.y, dy1: y1 - t.y, + }); + } // AABB посчитан в дельтах от цели камеры — переводим в мировые координаты return { minX: box.minX + t.x, maxX: box.maxX + t.x,