diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 33bcce1..091072f 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1655,6 +1655,20 @@ панели бьёт (счётчик +1). Грабля замера: нижняя полоса строк — 400..540 (высота кадра 540; выход за границы декодера даёт NaN без падения). +- **Режимы камеры** (трек в работе): `CameraMode = 'iso' | 'third' | 'first'` + (`render/cameraMath.ts` — чистая математика: `camForward(yaw, pitch)`, + `orbitPosition` (переехал из modelViewer с реэкспортом), `PERSP_FOV` 50°, + клампы pitch/dist). Рендер держит ОБЕ камеры (isoCamera + perspCamera), + публичное `camera` — getter активной (union `Orthographic | Perspective`). + `setCameraMode(mode, opts?)` / `setOrbit({yaw,pitch,dist})` / `orbitState()` + / `setEyeHeight(h)` / `cameraInfo()`. Семантика `setTarget` — «точка + интереса» без изменений: repositionCamera ветвится по режиму, игры, + дёргающие setTarget каждый тик, не ломаются. Конвенция yaw — угол + «target → камера» в XZ (как screenMoveToWorld); pitch > 0 — камера + смотрит вниз. Дефолты орбиты: yaw = ISO_YAW (непрерывный переход из изо), + pitch 0.5, dist = viewSize. ВРЕМЕННО в перспективных режимах окно + частиц и юниформы тумана считаются по изо-камере (guard до треков + окон/тумана) — NaN не бывает. - **VoxelRenderer.dispose()** (ревью): полное освобождение GPU-ресурсов — пост-проход, туман, свет с shadow map, контекст GL. Облако и меши — у владельцев. Обязателен для многоразовых экземпляров (ModelViewer diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 75dfc23..1894951 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -1397,9 +1397,10 @@ renderer.setLightningShadows(on); }, /** Окно видимости частиц (диагностика плотности осадков/пепла): AABB - * следа кадра для слоя [y0,y1] + параметры камеры. */ + * следа кадра для слоя [y0,y1] + параметры камеры. ВРЕМЕННО поля + * ортокамеры (до переписи на renderer.cameraInfo в треке камеры). */ windowInfo(y0 = 0, y1 = 40) { - const cam = renderer.camera; + const cam = renderer.camera as THREE.OrthographicCamera; return { ...renderer.visibleWindow(y0, y1), cam: { right: cam.right, top: cam.top, near: cam.near, far: cam.far }, diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index afa8f96..4735337 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -112,4 +112,6 @@ export { LightningSystem, lightningEnvelope, boltPoints, lightningEnvLight, LIGHTNING_TOP, LIGHTNING_DURATION, LIGHTNING_FLASH, LIGHTNING_ENV_SHARE, LIGHTNING_COLOR } from './render/lightning'; export type { LightningInfo, BoltPoint } from './render/lightning'; export type { PointLightOptions, PointLightHandle, SpotLightOptions, SpotLightHandle } from './render/voxelRenderer'; +export type { CameraMode } from './render/cameraMath'; +export { PERSP_FOV, camForward } from './render/cameraMath'; // setDayAmbient/setNight/addPointLight/addSpotLight — методы класса VoxelRenderer diff --git a/v2/packages/engine/src/render/__tests__/cameraMath.test.ts b/v2/packages/engine/src/render/__tests__/cameraMath.test.ts new file mode 100644 index 0000000..6997e3e --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/cameraMath.test.ts @@ -0,0 +1,35 @@ +/** Тесты чистой математики камеры: направление взгляда и орбита — без WebGL. */ +import { describe, expect, it } from 'vitest'; +import { ISO_YAW } from '../voxelRenderer'; +import { camForward, orbitPosition } from '../cameraMath'; + +describe('camForward (направление взгляда от yaw/pitch)', () => { + it('pitch 0 — горизонтальный взгляд против орбит-направления', () => { + const f = camForward(ISO_YAW, 0); + expect(f[1]).toBeCloseTo(0); // горизонталь (−0 тоже горизонталь) + // орбита «target → камера» при yaw π/4 — (+x,+z); взгляд — в минус + expect(f[0]).toBeCloseTo(-Math.cos(ISO_YAW)); + expect(f[2]).toBeCloseTo(-Math.sin(ISO_YAW)); + }); + it('pitch > 0 — камера смотрит вниз (Y отрицателен, грабля «из-под земли»)', () => { + const f = camForward(0.5, 0.5); + expect(f[1]).toBeLessThan(0); + }); + it('pitch < 0 — взгляд вверх; длина всегда 1', () => { + const up = camForward(1.0, -0.3); + expect(up[1]).toBeGreaterThan(0); + const len = Math.hypot(...camForward(2.7, -0.8)); + expect(len).toBeCloseTo(1); + }); +}); + +describe('orbitPosition (переезд из modelViewer: формула та же)', () => { + it('yaw π/4 — камера в (+x,+z) от цели, pitch поднимает по Y', () => { + const pos = orbitPosition(Math.PI / 4, 0, 10, [1, 2, 3]); + expect(pos[0]).toBeCloseTo(1 + 10 * Math.cos(Math.PI / 4)); + expect(pos[1]).toBeCloseTo(2); + expect(pos[2]).toBeCloseTo(3 + 10 * Math.sin(Math.PI / 4)); + const above = orbitPosition(Math.PI / 4, 0.5, 10, [0, 0, 0]); + expect(above[1]).toBeCloseTo(10 * Math.sin(0.5)); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/render/cameraMath.ts b/v2/packages/engine/src/render/cameraMath.ts new file mode 100644 index 0000000..867d407 --- /dev/null +++ b/v2/packages/engine/src/render/cameraMath.ts @@ -0,0 +1,44 @@ +/** + * Чистая математика перспективной камеры (тесты без WebGL). + * Конвенция yaw — угол «target → камера» в XZ (как у орбиты ModelViewer + * и screenMoveToWorld): камера стоит в yaw-направлении от цели и смотрит + * в противоположную горизонталь. pitch > 0 — камера смотрит вниз, + * pitch < 0 — вверх. Знак Y согласован с граблёй «камера НАД сценой»: + * отрицательный forward.y при pitch > 0 — взгляд к земле. + */ + +/** Вертикальный FOV перспективной камеры (рад, ~50°). */ +export const PERSP_FOV = 50 * (Math.PI / 180); + +/** Режим камеры: изометрия (ортокамера), третье и первое лицо (перспектива). */ +export type CameraMode = 'iso' | 'third' | 'first'; + +/** Клампы pitch орбиты (рад): third person — камера не ныряет под землю + * и не съезжает на горизонт; first person — свободнее, можно смотреть вверх. */ +export const PERSP_PITCH_THIRD: readonly [number, number] = [0.08, 1.35]; +export const PERSP_PITCH_FIRST: readonly [number, number] = [-1.2, 1.2]; + +/** Клампы дистанции орбиты в долях viewSize: ближе — упираемся в героя, + * дальше — теряем кадр. */ +export const PERSP_DIST_K: readonly [number, number] = [0.25, 3]; + +/** Направление взгляда (unit): горизонталь противоположна орбит-направлению + * «target → камера». Чистая функция. */ +export function camForward(yaw: number, pitch: number): [number, number, number] { + const cp = Math.cos(pitch); + return [-Math.cos(yaw) * cp, -Math.sin(pitch), -Math.sin(yaw) * cp]; +} + +/** Позиция камеры на орбите вокруг target: yaw — угол «target → камера» + * в XZ, pitch > 0 — камера выше цели. Чистая функция (вьювер модели + * переэкспортирует её — прежний дом). */ +export function orbitPosition( + yaw: number, pitch: number, dist: number, target: readonly [number, number, number], +): [number, number, number] { + const cp = Math.cos(pitch); + return [ + target[0] + dist * cp * Math.cos(yaw), + target[1] + dist * Math.sin(pitch), + target[2] + dist * cp * Math.sin(yaw), + ]; +} \ No newline at end of file diff --git a/v2/packages/engine/src/render/modelViewer.ts b/v2/packages/engine/src/render/modelViewer.ts index 4f3ed77..c48553d 100644 --- a/v2/packages/engine/src/render/modelViewer.ts +++ b/v2/packages/engine/src/render/modelViewer.ts @@ -11,9 +11,13 @@ import { poseVoxels } from '../animation/rig'; import type { AnimClip } from '../animation/clip'; import { samplePose } from '../animation/clip'; -import type { Vec3 } from '../animation/mat'; import { VoxelRenderer, type CloudTiles } from './voxelRenderer'; import type { VoxelCloud } from './voxelRenderer'; +import { orbitPosition } from './cameraMath'; + +// орбита — общая математика камеры (camMath): third person рендера живёт +// на той же формуле; реэкспорт держит прежний дом функции +export { orbitPosition }; /** Статы модели для панели (числовой снапшот агентом). */ export interface ModelStats { @@ -27,18 +31,6 @@ tiles: number; } -/** Сферическая орбита камеры: yaw/pitch вокруг target на дистанции. */ -export function orbitPosition( - yaw: number, pitch: number, dist: number, target: readonly [number, number, number], -): Vec3 { - const cp = Math.cos(pitch); - return [ - target[0] + dist * cp * Math.cos(yaw), - target[1] + dist * Math.sin(pitch), - target[2] + dist * cp * Math.sin(yaw), - ]; -} - /** Центр bbox занятых вокселей (ось вращения вьювера — модель крутится * вокруг своей середины, а не вокруг угла сетки). Пустая модель — центр * размера сетки. */ diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index 57af270..3dd6a2b 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -17,6 +17,11 @@ import { createFogComposite, makeFogUniforms, fogColor } from './fog'; import { LIGHTNING_COLOR, LIGHTNING_FLASH, LIGHTNING_TOP, lightningEnvLight } from './lightning'; import { particleWindow } from './polyWindow'; +import { + PERSP_DIST_K, PERSP_FOV, PERSP_PITCH_FIRST, PERSP_PITCH_THIRD, + camForward, orbitPosition, +} from './cameraMath'; +import type { CameraMode } from './cameraMath'; /** Облако деформированных вокселей (позы рига — не выровнены по сетке). */ export interface VoxelCloud { @@ -158,7 +163,19 @@ export class VoxelRenderer { readonly renderer: THREE.WebGLRenderer; readonly scene: THREE.Scene; - readonly camera: THREE.OrthographicCamera; + /** Активная камера по режиму (iso → ортокамера, third/first → перспектива). */ + get camera(): THREE.OrthographicCamera | THREE.PerspectiveCamera { + return this.mode === 'iso' ? this.isoCamera : this.perspCamera; + } + private readonly isoCamera: THREE.OrthographicCamera; + private readonly perspCamera: THREE.PerspectiveCamera; + /** Текущий режим камеры (см. setCameraMode). */ + private mode: CameraMode = 'iso'; + /** Состояние орбиты/взгляда перспективных режимов (yaw «target→камера», + * pitch: >0 — камера смотрит вниз, dist — для third person). */ + private orbit = { yaw: ISO_YAW, pitch: 0.5, dist: 0 }; + /** Высота глаз над target в первом лице (мировые юниты; задаёт игра). */ + private eyeHeight = 4; private readonly sun: THREE.DirectionalLight; /** Зенитный свет пасмурности (вниз, с тенью): минимальная контактная * тень прямо под объектами — рассеяный свет переотражается сверху. */ @@ -224,7 +241,12 @@ this.scene = new THREE.Scene(); - this.camera = this.makeCamera(width, height); + this.isoCamera = this.makeCamera(width, height); + // перспектива для third/first: far как у изо — консистентность глубины + this.perspCamera = new THREE.PerspectiveCamera( + (PERSP_FOV * 180) / Math.PI, this.viewAspect, 0.5, this.viewSize * 10, + ); + this.orbit.dist = this.viewSize; this.ambient = new THREE.HemisphereLight(0x94949e, 0x343b2c, 0.55); // P5 / G0 this.scene.add(this.ambient); @@ -490,22 +512,22 @@ * игровые слои). */ 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; + // ВРЕМЕННО (до трека окон пирамиды): орто-математика по изо-камере + // даже в перспективных режимах — right/top есть только у ортокамеры. + this.isoCamera.updateMatrixWorld(); + const e = this.isoCamera.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 c = this.isoCamera.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, + 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, }); // AABB посчитан в дельтах от цели камеры — переводим в мировые координаты @@ -773,15 +795,17 @@ * линейный диапазон глубины и текущие сила/время/ветер. */ private updateFogUniforms(): void { const u = this.fogPass!.mat.uniforms; - const e = this.camera.matrixWorld.elements; - (u.uCamPos!.value as THREE.Vector3).copy(this.camera.position); + // ВРЕМЕННО (до трека тумана перспективы): орто-реконструкция по + // изо-камере даже в перспективных режимах + const e = this.isoCamera.matrixWorld.elements; + (u.uCamPos!.value as THREE.Vector3).copy(this.isoCamera.position); (u.uCamF!.value as THREE.Vector3).set(-e[8]!, -e[9]!, -e[10]!); (u.uCamR!.value as THREE.Vector3).set(e[0]!, e[1]!, e[2]!); (u.uCamU!.value as THREE.Vector3).set(e[4]!, e[5]!, e[6]!); - u.uNear!.value = this.camera.near; - u.uFar!.value = this.camera.far; - u.uHalfW!.value = this.camera.right; - u.uHalfH!.value = this.camera.top; + u.uNear!.value = this.isoCamera.near; + u.uFar!.value = this.isoCamera.far; + u.uHalfW!.value = this.isoCamera.right; + u.uHalfH!.value = this.isoCamera.top; u.uStrength!.value = this.fogStrength; u.uTime!.value = this.fogTime; (u.uWind!.value as THREE.Vector2).set(this.fogWind[0], this.fogWind[1]); @@ -796,15 +820,104 @@ ); } - /** Поставить камеру в изо-направление от текущего target. */ + /** Поставить камеру по текущему режиму: изо — старое направление, + * third — орбита вокруг target, first — глаза над target. Зовётся + * setTarget'ом каждый тик слежения — точка интереса обновляется, + * ориентация живёт в орбит-состоянии. */ private repositionCamera(): void { + if (this.mode === 'iso') return this.repositionIso(); + const cam = this.perspCamera; + if (this.mode === 'third') { + const pos = orbitPosition(this.orbit.yaw, this.orbit.pitch, this.orbit.dist, + [this.target.x, this.target.y, this.target.z]); + cam.position.set(...pos); + cam.lookAt(this.target); + } else { + // first: глаза над target, взгляд по yaw/pitch (camForward) + cam.position.set(this.target.x, this.target.y + this.eyeHeight, this.target.z); + const f = camForward(this.orbit.yaw, this.orbit.pitch); + cam.lookAt(cam.position.x + f[0], cam.position.y + f[1], cam.position.z + f[2]); + } + cam.updateMatrixWorld(); + } + + /** Поставить камеру в изо-направление от текущего target. */ + private repositionIso(): void { const dir = new THREE.Vector3( Math.cos(ISO_YAW) * ISO_TAN, 1, // камера НАД сценой (минус давал вид из-под земли: верхние грани без света) Math.sin(ISO_YAW) * ISO_TAN, ).normalize(); - this.camera.position.copy(this.target).addScaledVector(dir, this.viewSize * 4); - this.camera.lookAt(this.target); + this.isoCamera.position.copy(this.target).addScaledVector(dir, this.viewSize * 4); + this.isoCamera.lookAt(this.target); + } + + /** Режим камеры (мост/панель/гейты). */ + cameraMode(): CameraMode { + return this.mode; + } + + /** Выбрать режим камеры: iso — ортокамера диметрии 2:1, third — орбита + * вокруг target, first — глаза над target. opts задают стартовое + * состояние орбиты/высоту глаз (после — setOrbit/setEyeHeight). */ + setCameraMode(mode: CameraMode, opts: { + yaw?: number; pitch?: number; dist?: number; eyeHeight?: number; + } = {}): void { + this.mode = mode; + if (opts.yaw !== undefined) this.orbit.yaw = opts.yaw; + if (opts.pitch !== undefined) this.orbit.pitch = opts.pitch; + if (opts.dist !== undefined) this.orbit.dist = opts.dist; + if (opts.eyeHeight !== undefined) this.eyeHeight = Math.max(0.5, opts.eyeHeight); + this.clampOrbit(); + this.repositionCamera(); + } + + /** Повернуть/отдалить перспективную камеру (мышь-ввод игры): yaw — угол + * «target→камера» в XZ, pitch > 0 — вниз, dist — только third person. */ + setOrbit(params: { yaw?: number; pitch?: number; dist?: number }): void { + if (params.yaw !== undefined) this.orbit.yaw = params.yaw; + if (params.pitch !== undefined) this.orbit.pitch = params.pitch; + if (params.dist !== undefined) this.orbit.dist = params.dist; + this.clampOrbit(); + this.repositionCamera(); + } + + /** Клампы орбиты по режиму: pitch — не нырять под землю (грабля знака Y), + * dist — в пределах PERSP_DIST_K·viewSize. */ + private clampOrbit(): void { + const [p0, p1] = this.mode === 'first' ? PERSP_PITCH_FIRST : PERSP_PITCH_THIRD; + this.orbit.pitch = Math.min(p1, Math.max(p0, this.orbit.pitch)); + this.orbit.dist = Math.min(this.viewSize * PERSP_DIST_K[1], + Math.max(this.viewSize * PERSP_DIST_K[0], this.orbit.dist)); + } + + /** Снапшот орбиты/взгляда (мост, ввод игры). */ + orbitState(): { yaw: number; pitch: number; dist: number } { + return { ...this.orbit }; + } + + /** Высота глаз в первом лице над target (мировые юниты). */ + setEyeHeight(h: number): void { + this.eyeHeight = Math.max(0.5, h); + if (this.mode === 'first') this.repositionCamera(); + } + + /** Снапшот камеры (мост/гейты): режим, геометрия активной камеры, орбита. */ + cameraInfo(): { + mode: CameraMode; fov: number; near: number; far: number; + pos: [number, number, number]; viewSize: number; aspect: number; + yaw: number; pitch: number; dist: number; eyeHeight: number; + } { + const cam = this.camera; + return { + mode: this.mode, + fov: this.mode === 'iso' ? 0 : (cam as THREE.PerspectiveCamera).fov, + near: cam.near, far: cam.far, + pos: [cam.position.x, cam.position.y, cam.position.z], + viewSize: this.viewSize, aspect: this.viewAspect, + yaw: this.orbit.yaw, pitch: this.orbit.pitch, dist: this.orbit.dist, + eyeHeight: this.eyeHeight, + }; } }