diff --git a/v2/packages/engine/src/render/__tests__/fog.test.ts b/v2/packages/engine/src/render/__tests__/fog.test.ts index 784efed..07097de 100644 --- a/v2/packages/engine/src/render/__tests__/fog.test.ts +++ b/v2/packages/engine/src/render/__tests__/fog.test.ts @@ -4,7 +4,8 @@ * и шейдер тестируются пробами с WebGL — здесь только математика. */ import { describe, expect, it } from 'vitest'; -import { fogColor, fogBandStart, FOG_ENV_SUN, FOG_BRIGHT, FOG_BAND_START, FOG_BAND_STEP } from '../fog'; +import { fogColor, fogBandStart, fogBandLayout, FOG_ENV_SUN, FOG_BRIGHT, FOG_BAND_START, FOG_BAND_STEP, PERSP_FOG_FAR_K, FOG_BANDS } from '../fog'; +import { PERSP_FOV } from '../cameraMath'; import { clampWeather } from '../weather'; describe('fogColor (цвет тумана из env-света)', () => { @@ -57,6 +58,31 @@ }); }); +describe('fogBandLayout (разметка слоёв под камеру)', () => { + it('изо — легаси-масштаб viewSize·констант ТОЧНО (регрессионный замок)', () => { + const l = fogBandLayout({ mode: 'iso', viewSize: 80, pitch: 0.5, eyeH: 30, fov: PERSP_FOV }); + expect(l.start).toBe(80 * FOG_BAND_START); + expect(l.step).toBe(80 * FOG_BAND_STEP); + }); + + it('перспектива: стопка на следе земли фрустума [nearG, farG]', () => { + const l = fogBandLayout({ mode: 'third', viewSize: 80, pitch: 0.5, eyeH: 30, fov: PERSP_FOV }); + expect(l.nearG).toBeCloseTo(30 / Math.tan(0.5 + PERSP_FOV / 2)); + const farUncapped = 30 / Math.tan(Math.max(0.5 - PERSP_FOV / 2, 0.05)); + expect(l.farG).toBeCloseTo(Math.min(farUncapped, 80 * PERSP_FOG_FAR_K)); + expect(l.start).toBeCloseTo(l.nearG * 0.9); + expect(l.start + l.step * (FOG_BANDS - 1)).toBeCloseTo(l.farG); + expect(l.step).toBeGreaterThan(0); + }); + + it('взгляд в небо (1st person): кап farG работает, разметка конечна', () => { + const l = fogBandLayout({ mode: 'first', viewSize: 80, pitch: -1.2, eyeH: 14, fov: PERSP_FOV }); + expect(Number.isFinite(l.start + l.step)).toBe(true); + expect(l.farG).toBeLessThanOrEqual(80 * PERSP_FOG_FAR_K); + expect(l.step).toBeGreaterThan(0); + }); +}); + describe('clampWeather: fog клампится как остальные поля', () => { it('недостающее поле — дефолт 0, лишние значения — кламп', () => { expect(clampWeather({}).fog).toBe(0); diff --git a/v2/packages/engine/src/render/fog.ts b/v2/packages/engine/src/render/fog.ts index cc0aa28..5f32892 100644 --- a/v2/packages/engine/src/render/fog.ts +++ b/v2/packages/engine/src/render/fog.ts @@ -21,6 +21,7 @@ */ import * as THREE from 'three'; import type { RgbIntensity } from './particles'; +import type { CameraMode } from './cameraMath'; /** Блок юниформ цвета тумана: один экземпляр на рендер, композит ссылается * на ТЕ ЖЕ IUniform (как свет частиц) — рендер обновляет в конце setSun. */ @@ -76,6 +77,50 @@ return viewSize * (FOG_BAND_START + k * FOG_BAND_STEP); } +// ---- Разметка слоёв в перспективных режимах ---- +// Пирамида не даёт фиксированной полосы глубины: след кадра по земле зависит +// от pitch и высоты камеры. Слои размечаем по наземному следу фрустума: +// нижняя грань кадра (pitch+fov/2 ниже горизонта) бьёт землю на nearG, +// верхняя (pitch−fov/2, кламп 0.05 — взгляд выше горизонта не бьёт землю +// вблизи) — на farG = eyeH/tan(...); стопка растягивается на этот след. + +/** Cap дальнего края стопки (в долях viewSize): в 1st person у горизонта + * farG ушёл бы в бесконечность — стопка размазывается в телеграфный столб. */ +export const PERSP_FOG_FAR_K = 6; + +/** Разметка слоёв тумана под камеру. Чистая функция: iso — легаси-масштаб + * viewSize·констант (регрессионный замок изо-кадра), persp — след земли + * фрустума: start = nearG·0.9 (задел на подъём), шаг до farG. Вырожденные + * случаи (взгляд в небо: обе грани выше горизонта) дают конечную стопку — + * start/step клампятся, NaN невозможен. */ +export function fogBandLayout(cam: { + mode: CameraMode; + /** Размер кадра (полувысота орто / калибровка капа перспективы). */ + viewSize: number; + /** Перспектива: pitch орбиты (рад, >0 — камера смотрит вниз) и высота + * камеры над плоскостью цели. */ + pitch: number; + eyeH: number; + /** Вертикальный fov (рад). */ + fov: number; +}): { start: number; step: number; nearG: number; farG: number } { + if (cam.mode === 'iso') { + return { + start: cam.viewSize * FOG_BAND_START, + step: cam.viewSize * FOG_BAND_STEP, + nearG: cam.viewSize * FOG_BAND_START, + farG: cam.viewSize * (FOG_BAND_START + (FOG_BANDS - 1) * FOG_BAND_STEP), + }; + } + const bottom = Math.min(Math.max(cam.pitch + cam.fov / 2, 0.05), 1.5); + const nearG = cam.eyeH / Math.tan(bottom); + const top = Math.max(cam.pitch - cam.fov / 2, 0.05); + const farG = Math.min(cam.eyeH / Math.tan(top), cam.viewSize * PERSP_FOG_FAR_K); + const start = Math.min(nearG, farG) * 0.9; + const step = Math.max((farG - start) / (FOG_BANDS - 1), cam.viewSize * 0.001); + return { start, step, nearG, farG }; +} + /** Композитный шейдер тумана: сэмпл сцены + глубина → мировая точка → * стопка слоёв по дистанции от камеры. SRGB_OUT 1 — квад рисуется прямо * на канвас (нет пост-прохода игры): нужна конверсия линейного RT. */ @@ -91,6 +136,10 @@ uniform float uFar; uniform float uHalfW; uniform float uHalfH; +uniform float uBandStart; +uniform float uBandStep; +uniform float uTanHalf; +uniform float uAspect; uniform vec3 uFogColor; uniform float uStrength; uniform float uTime; @@ -109,23 +158,41 @@ } void main() { vec3 scene = texture2D(tDiffuse, vUv).rgb; - // ортокамера: глубина буфера линейна — дистанция вдоль взгляда без деления float depth = texture2D(tDepth, vUv).x; +#ifdef PERSP + // перспектива: глубина буфера нелинейна — восстанавливаем дистанцию + // вдоль взгляда (along = near·far/(far − (far−near)·depth)), полуразмеры + // кадра растут с along по тангенсу fov + float along = (uNear * uFar) / (uFar - (uFar - uNear) * depth); + float halfH = uTanHalf * along; + float halfW = halfH * uAspect; +#else + // ортокамера: глубина буфера линейна — дистанция вдоль взгляда без деления float along = uNear + depth * (uFar - uNear); + float halfH = uHalfH; + float halfW = uHalfW; +#endif vec2 ndc = vUv * 2.0 - 1.0; - vec3 wp = uCamPos + uCamF * along + uCamR * (ndc.x * uHalfW) + uCamU * (ndc.y * uHalfH); + vec3 wp = uCamPos + uCamF * along + uCamR * (ndc.x * halfW) + uCamU * (ndc.y * halfH); // грабля: глубину пикселя меряем ОТ КАМЕРЫ, не от цели — слои размечены - // в долях viewSize вдоль взгляда от камеры (ближний край кадра ~2.9·V, - // дальний ~5.1·V; от цели тот же кадр — это −1.12·V..+1.12·V, и слои на - // 2.5..5·V от цели дали бы band = 0 всюду — туман невидим) + // вдоль взгляда от камеры (см. fogBandLayout: изо — доли viewSize, + // перспектива — след земли фрустума) float d = dot(wp - uCamPos, uCamF); float fog = 0.0; for (int k = 0; k < ${FOG_BANDS}; k++) { float fk = float(k); +#ifdef PERSP + // k-й слой копится с его глубины: разметка юниформами (fogBandLayout), + // мягкость фронта = полшага (та же пропорция FOG_BAND_SOFT/STEP) + float band = smoothstep( + uBandStart - uBandStep * 0.5 + fk * uBandStep, + uBandStart + uBandStep * 0.5 + fk * uBandStep, d); +#else // k-й слой копится с его глубины: дальние пиксели под большей стопкой float band = smoothstep( uHalfH * ${(FOG_BAND_START - FOG_BAND_SOFT).toFixed(2)} + fk * uHalfH * ${FOG_BAND_STEP.toFixed(2)}, uHalfH * ${(FOG_BAND_START + FOG_BAND_SOFT).toFixed(2)} + fk * uHalfH * ${FOG_BAND_STEP.toFixed(2)}, d); +#endif // клубы слоя: fbm по мировым XZ, свой дрейф; глубокие слои медленнее // (параллакс объёма) и с собственным сдвигом узора vec2 p = wp.xz * 0.09; @@ -166,6 +233,8 @@ uCamF: { value: new THREE.Vector3() }, uNear: { value: 0.1 }, uFar: { value: 1 }, uHalfW: { value: 1 }, uHalfH: { value: 1 }, + uBandStart: { value: 1 }, uBandStep: { value: 1 }, + uTanHalf: { value: 0.5 }, uAspect: { value: 1 }, uStrength: { value: 0 }, uTime: { value: 0 }, uWind: { value: new THREE.Vector2() }, }, diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index 810301b..a545a23 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -14,7 +14,7 @@ import { cloudDim, lerpColor } from './daynight'; import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, particleEnv } from './particles'; import type { ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './particles'; -import { createFogComposite, makeFogUniforms, fogColor } from './fog'; +import { createFogComposite, makeFogUniforms, fogColor, fogBandLayout } from './fog'; import { LIGHTNING_COLOR, LIGHTNING_FLASH, LIGHTNING_TOP, lightningEnvLight } from './lightning'; import { particleWindow, particleWindowPersp } from './polyWindow'; import { @@ -784,12 +784,19 @@ this.renderer.setRenderTarget(this.fogPass.rt); this.renderer.render(this.scene, this.camera); this.updateFogUniforms(); - // квад на канвасе обязан конверсию sRGB делать сам (RT линейный) + // квад на канвасе обязан конверсию sRGB делать сам (RT линейный); + // PERSP переключает реконструкцию глубины (нелинейная в перспективе) const srgb = this.post ? 0 : 1; if ((this.fogPass.mat.defines!.SRGB_OUT as number) !== srgb) { this.fogPass.mat.defines!.SRGB_OUT = srgb; this.fogPass.mat.needsUpdate = true; } + const perspDefine = this.mode !== 'iso' ? 1 : 0; + if ((this.fogPass.mat.defines!.PERSP ?? 0) !== perspDefine) { + if (perspDefine) this.fogPass.mat.defines!.PERSP = 1; + else delete this.fogPass.mat.defines!.PERSP; + this.fogPass.mat.needsUpdate = true; + } this.renderer.setRenderTarget(this.post ? this.post.rt : null); this.renderer.render(this.fogPass.scene, this.fogPass.camera); if (this.post) { @@ -808,21 +815,36 @@ } } - /** Юниформы композита тумана: базис камеры (позиция/взгляд/оси кадра), - * линейный диапазон глубины и текущие сила/время/ветер. */ + /** Юниформы композита тумана: базис АКТИВНОЙ камеры (позиция/взгляд/оси + * кадра), диапазон глубины, разметка слоёв (fogBandLayout: изо — легаси + * в долях viewSize, перспектива — след земли фрустума) и сила/время/ветер. */ private updateFogUniforms(): void { const u = this.fogPass!.mat.uniforms; - // ВРЕМЕННО (до трека тумана перспективы): орто-реконструкция по - // изо-камере даже в перспективных режимах - const e = this.isoCamera.matrixWorld.elements; - (u.uCamPos!.value as THREE.Vector3).copy(this.isoCamera.position); + const persp = this.mode !== 'iso'; + const cam = persp ? this.perspCamera : this.isoCamera; + const e = cam.matrixWorld.elements; + (u.uCamPos!.value as THREE.Vector3).copy(cam.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.isoCamera.near; - u.uFar!.value = this.isoCamera.far; - u.uHalfW!.value = this.isoCamera.right; - u.uHalfH!.value = this.isoCamera.top; + u.uNear!.value = cam.near; + u.uFar!.value = cam.far; + if (persp) { + u.uTanHalf!.value = Math.tan(PERSP_FOV / 2); + u.uAspect!.value = this.viewAspect; + } else { + u.uHalfW!.value = this.isoCamera.right; + u.uHalfH!.value = this.isoCamera.top; + } + const layout = fogBandLayout({ + mode: this.mode, + viewSize: this.viewSize, + pitch: this.orbit.pitch, + eyeH: Math.max(cam.position.y - this.target.y, 0.5), + fov: PERSP_FOV, + }); + u.uBandStart!.value = layout.start; + u.uBandStep!.value = layout.step; u.uStrength!.value = this.fogStrength; u.uTime!.value = this.fogTime; (u.uWind!.value as THREE.Vector2).set(this.fogWind[0], this.fogWind[1]);