diff --git a/v2/packages/engine/src/render/__tests__/sky.test.ts b/v2/packages/engine/src/render/__tests__/sky.test.ts new file mode 100644 index 0000000..cb42c81 --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/sky.test.ts @@ -0,0 +1,156 @@ +/** + * Тесты чистой математики неба: дуги светил (совпадение с прежней формулой + * setSun — регрессионный замок рефакторинга), луна/фазы, палитра неба, + * яркость звёзд, упаковка тел. Шейдер и композит тестируются пробами + * с WebGL — здесь только математика. + */ +import { describe, expect, it } from 'vitest'; +import { + celestialDir, linearRgb, moonK, moonPhase, packSkyBodies, skyColors, + starBrightness, sunDirection, sunElevation, sunTint, SKY_BODY_MAX, +} from '../sky'; + +describe('дуга светила', () => { + // Замок рефакторинга setSun: та же формула посчитанными числами + it('k=0.5 (полдень) совпадает с прежней формулой setSun', () => { + const az = Math.PI * 0.5; + const elev = Math.sin(0.5 * Math.PI) * (Math.PI / 3) + 0.12; + const d = sunDirection(0.5); + expect(d[0]).toBeCloseTo(Math.cos(az) * Math.cos(elev), 15); + expect(d[1]).toBeCloseTo(Math.sin(elev), 15); + expect(d[2]).toBeCloseTo(Math.sin(az) * Math.cos(elev), 15); + }); + + it('восход на востоке, закат на западе (симметрия по x/z)', () => { + const rise = sunDirection(0); + const set = sunDirection(1); + // k=0 → az=π (x<0), k=1 → az=0 (x>0); elev=0.12 — солнце не на горизонте + expect(rise[0]).toBeCloseTo(-Math.cos(0.12), 12); + expect(set[0]).toBeCloseTo(Math.cos(0.12), 12); + expect(rise[2]).toBeCloseTo(0, 9); + expect(set[2]).toBeCloseTo(0, 9); + expect(rise[1]).toBeCloseTo(set[1], 15); // одна высота + }); + + it('высота монотонна на [0, 0.5] и симметрична', () => { + let prev = -1; + for (let i = 0; i <= 10; i++) { + const e = sunElevation(i / 20); + expect(e).toBeGreaterThan(prev); + prev = e; + } + expect(sunElevation(0)).toBeCloseTo(sunElevation(1), 15); + }); + + it('unit-вектор на всей дуге', () => { + for (let i = 0; i <= 20; i++) { + const [x, y, z] = sunDirection(i / 20); + expect(Math.hypot(x, y, z)).toBeCloseTo(1, 12); + } + }); + + it('celestialDir без клампа: k=1.5 — светило под горизонтом', () => { + expect(celestialDir(1.5)[1]).toBeLessThan(0); + expect(celestialDir(-0.25)[1]).toBeLessThan(0); + }); +}); + +describe('луна', () => { + it('moonK: зенит в 0:00, встаёт в 18:00, садится в 6:00', () => { + expect(moonK(0)).toBeCloseTo(0.5, 12); + expect(moonK(18)).toBeCloseTo(0, 12); + expect(moonK(6)).toBeCloseTo(0, 12); // mod: садится = снова 0 + expect(moonK(22)).toBeCloseTo(((22 - 18) / 12) % 1, 12); + }); + + it('moonPhase: цикл 8 суток — 0 → 1 → 0', () => { + expect(moonPhase(0)).toBeCloseTo(0, 12); // новолуние + expect(moonPhase(24 * 4)).toBeCloseTo(1, 12); // полнолуние в середине цикла + expect(moonPhase(24 * 8)).toBeCloseTo(0, 12); // снова новолуние + const q = moonPhase(24 * 2); // четверть + expect(q).toBeGreaterThan(0.3); + expect(q).toBeLessThan(0.7); + }); +}); + +describe('sunTint', () => { + it('полдень нейтральный, у горизонта тёплый', () => { + expect(sunTint(0.5)).toEqual([1, 1, 1]); + const [r, g, b] = sunTint(0); // восход + expect(r).toBe(1); + expect(g).toBeLessThan(1); + expect(b).toBeLessThan(g); + }); +}); + +describe('skyColors', () => { + const luma = (c: number): number => + (((c >> 16) & 0xff) * 0.3 + ((c >> 8) & 0xff) * 0.6 + (c & 0xff) * 0.1) / 255; + + it('день светлее ночи (оба цвета)', () => { + const day = skyColors(0, 0); + const night = skyColors(1, 0); + expect(luma(day.zenith)).toBeGreaterThan(luma(night.zenith) * 2); + expect(luma(day.horizon)).toBeGreaterThan(luma(night.horizon) * 2); + }); + + it('золотой час тёплый у горизонта (r > b)', () => { + const dusk = skyColors(0.45, 0); // DUSK_PEAK + const r = (dusk.horizon >> 16) & 0xff; + const b = dusk.horizon & 0xff; + expect(r).toBeGreaterThan(b); + }); + + it('ночная палитра синее-тёмная, не чёрная и не серая', () => { + const night = skyColors(1, 0); + expect(night.zenith).not.toBe(0); + expect(luma(night.zenith)).toBeLessThan(0.1); + }); + + it('облачность обесцвечивает и темнеет (каналы сходятся)', () => { + const clear = skyColors(0, 0); + const cloudy = skyColors(0, 1); + const spread = (c: number): number => + ((c >> 16) & 0xff) - (c & 0xff); + expect(Math.abs(spread(cloudy.zenith))).toBeLessThan(Math.abs(spread(clear.zenith))); + expect(luma(cloudy.zenith)).toBeLessThan(luma(clear.zenith)); + }); +}); + +describe('starBrightness', () => { + it('0 днём, 1 в ясную ночь, облачность гасит', () => { + expect(starBrightness(0, 0)).toBe(0); + expect(starBrightness(1, 0)).toBeCloseTo(1, 12); + expect(starBrightness(1, 1)).toBeCloseTo(0.15, 12); + expect(starBrightness(0.5, 0.5)).toBeGreaterThan(0); + expect(starBrightness(0.5, 0.5)).toBeLessThan(1); + }); +}); + +describe('packSkyBodies', () => { + it('нормализует направление и кладёт дефолты (phase 1, intensity 1)', () => { + const p = packSkyBodies([ + { dir: [2, 0, 0], angRadius: 0.05, color: 0xffffff }, + ]); + expect(p.count).toBe(1); + expect(p.dirs[0][0]).toBe(1); + expect(p.params[0][2]).toBe(1); // intensity + expect(p.params[0][3]).toBe(1); // phase — полный диск + }); + + it('клампит отрицательную интенсивность и обрезает по SKY_BODY_MAX', () => { + const bodies = Array.from({ length: SKY_BODY_MAX + 2 }, () => ({ + dir: [0, 1, 0] as const, angRadius: 0.05, color: 0xffffff, + })); + const p = packSkyBodies(bodies); + expect(p.count).toBe(SKY_BODY_MAX); + const neg = packSkyBodies([{ dir: [0, 1, 0], angRadius: 0.05, color: 0xffffff, intensity: -3 }]); + expect(neg.params[0][2]).toBe(0); + }); + + it('цвет — линейный RGB (белый остаётся 1, средний серый темнеет)', () => { + expect(linearRgb(0xffffff)).toEqual([1, 1, 1]); + const [r] = linearRgb(0x808080); + expect(r).toBeLessThan(0.5); // sRGB 0.5 → линейный ~0.216 + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/render/sky.ts b/v2/packages/engine/src/render/sky.ts new file mode 100644 index 0000000..9765fff --- /dev/null +++ b/v2/packages/engine/src/render/sky.ts @@ -0,0 +1,159 @@ +/** + * Чистая математика неба (подсистема небесных светил): дуги светил, фазы + * луны, палитра неба по суткам/облачности, яркость звёзд и упаковка тел + * в юниформы. Без three и рендера — тестируется в Vitest. + * + * Конвенция дуги: k — 0 восход → 0.5 зенит → 1 закат; та же формула, что + * у DirectionalLight солнца в VoxelRenderer.setSun (диск обязан совпадать + * со светом — обе стороны зовут одну функцию). + */ +import { cloudDim, DUSK_PEAK, lerpColor } from './daynight'; + +/** Максимум небесных тел в кадре (юниформ-массивы шейдера). */ +export const SKY_BODY_MAX = 4; + +/** + * Высота светила на дуге k (рад): не ниже ~7° в дневной дуге. Отдельная + * функция — фрустуму теней нужен сам угол, а не вектор. + */ +export function sunElevation(k: number): number { + return Math.sin(k * Math.PI) * (Math.PI / 3) + 0.12; +} + +/** + * Направление на светило (unit, мировые оси) на дуге k: 0 восход → 0.5 + * зенит → 1 закат. БЕЗ клампа: k вне [0,1] уводит светило под горизонт + * (луна ночью ходит по своей дуге через mod). + */ +export function celestialDir(k: number): readonly [number, number, number] { + const az = Math.PI * (1 - k); // 180°→0°: восток → юг → запад + const elev = sunElevation(k); + return [Math.cos(az) * Math.cos(elev), Math.sin(elev), Math.sin(az) * Math.cos(elev)]; +} + +/** Направление солнца для СВЕТА: кламп k в [0,1] — бит-в-бит прежний setSun. */ +export function sunDirection(k: number): readonly [number, number, number] { + return celestialDir(Math.min(1, Math.max(0, k))); +} + +/** k луны по часам: встаёт в 18:00, зенит в 0:00, садится в 6:00. */ +export function moonK(hours: number): number { + return (((hours - 18) / 12) % 1 + 1) % 1; +} + +/** + * Фаза луны 0..1 (0 новолуние → 0.5 четверть → 1 полнолуние): косинусный + * цикл по суткам игры — светлая доля растёт и убывает плавно, без + * «перещёлкивания» полная→тёмная. Дефолтный цикл — 8 суток. + */ +export function moonPhase(hours: number, cycleDays = 8): number { + if (cycleDays <= 0) return 1; + const days = hours / 24; + return (1 - Math.cos(2 * Math.PI * (days % cycleDays) / cycleDays)) / 2; +} + +/** Тёплый тон света у горизонта (warm = 1 − sin(kπ)): [1, 1−0.25w, 1−0.45w]. */ +export function sunTint(k: number): readonly [number, number, number] { + const warm = 1 - Math.sin(Math.min(1, Math.max(0, k)) * Math.PI); + return [1, 1 - warm * 0.25, 1 - warm * 0.45]; +} + +/** Пара цветов неба: зенит + горизонт. */ +export interface SkyPalette { + zenith: number; + horizon: number; +} + +const SKY_DAY: SkyPalette = { zenith: 0x4a7ec2, horizon: 0xa8c4de }; +const SKY_DUSK: SkyPalette = { zenith: 0x35507a, horizon: 0xe8a060 }; +const SKY_NIGHT: SkyPalette = { zenith: 0x060a18, horizon: 0x101828 }; + +/** + * Палитра неба: трёхопорный микс по фактору ночи (день → золотой час → + * ночь, как dayNightAmbient), затем облачность — обесцвечивание к серому + * и затемнение (та же арифметика cloudDim, что у фона сцены). + */ +export function skyColors(nightK: number, cloudK: number): SkyPalette { + const k = Math.min(1, Math.max(0, nightK)); + const mixPair = (a: SkyPalette, b: SkyPalette, t: number): SkyPalette => ({ + zenith: lerpColor(a.zenith, b.zenith, t), + horizon: lerpColor(a.horizon, b.horizon, t), + }); + const pair = k <= 0 ? SKY_DAY : k >= 1 ? SKY_NIGHT + : k < DUSK_PEAK ? mixPair(SKY_DAY, SKY_DUSK, k / DUSK_PEAK) + : mixPair(SKY_DUSK, SKY_NIGHT, (k - DUSK_PEAK) / (1 - DUSK_PEAK)); + return { + zenith: cloudDim(pair.zenith, cloudK, 0.7, 0.3), + horizon: cloudDim(pair.horizon, cloudK, 0.7, 0.3), + }; +} + +/** Яркость звёзд 0..1: ночь включает, облачность гасит (засветка). */ +export function starBrightness(nightK: number, cloudK: number): number { + const n = Math.min(1, Math.max(0, nightK)); + const c = Math.min(1, Math.max(0, cloudK)); + return n * (1 - 0.85 * c); +} + +/** Небесное тело: диск+ореол на сфере (движок ставит dir, игра — всё). */ +export interface SkyBody { + /** Unit-направление на тело в мировых осях (диск совпадает со светом; + * ненормализованное нормализуется в packSkyBodies). */ + dir: readonly [number, number, number]; + /** Угловой радиус диска (рад), ~0.045. */ + angRadius: number; + /** Цвет диска 0xRRGGBB (sRGB; в юниформы — линейный RGB). */ + color: number; + /** Масштаб ореола (рад), 0 — нет. */ + glow?: number; + /** Яркость 0..1+, дефолт 1 (игра гасит ночью). */ + intensity?: number; + /** Терминатор 0..1: 0 новолуние, 1 полный диск (солнце — дефолт 1). */ + phase?: number; +} + +/** Упакованные тела — плоские массивы для юниформ шейдера. */ +export interface PackedSkyBodies { + dirs: number[][]; + colors: number[][]; + params: number[][]; + count: number; +} + +/** sRGB-канал 0..1 → линейный (ColorManagement three — та же формула). */ +export function srgbToLinear(c: number): number { + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +/** Цвет 0xRRGGBB → линейный RGB-тройка. */ +export function linearRgb(color: number): [number, number, number] { + return [ + srgbToLinear(((color >> 16) & 0xff) / 255), + srgbToLinear(((color >> 8) & 0xff) / 255), + srgbToLinear((color & 0xff) / 255), + ]; +} + +/** + * Упаковка тел в юниформ-блоки: клампы (angRadius/glow > 0, интенсивность + * ≥ 0, фаза 0..1), направление — как дали (шейдер ожидает unit; ненормали- + * зованное клампится через acos — потому нормализуем здесь), лишние тела + * обрезаются до SKY_BODY_MAX. Чистая — тестируется без WebGL. + */ +export function packSkyBodies(bodies: readonly SkyBody[]): PackedSkyBodies { + const dirs: number[][] = []; + const colors: number[][] = []; + const params: number[][] = []; + for (const b of bodies.slice(0, SKY_BODY_MAX)) { + const len = Math.hypot(b.dir[0], b.dir[1], b.dir[2]) || 1; + dirs.push([b.dir[0] / len, b.dir[1] / len, b.dir[2] / len]); + colors.push(linearRgb(b.color)); + params.push([ + Math.max(1e-4, b.angRadius), + Math.max(0, b.glow ?? 0), + Math.max(0, b.intensity ?? 1), + Math.min(1, Math.max(0, b.phase ?? 1)), + ]); + } + return { dirs, colors, params, count: dirs.length }; +} \ No newline at end of file diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index 1103a2f..c091c63 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -22,6 +22,7 @@ PERSP_WINDOW_FAR_K, camForward, orbitPosition, perspVisibleRadius, } from './cameraMath'; import type { CameraMode } from './cameraMath'; +import { sunDirection, sunElevation, sunTint } from './sky'; /** Облако деформированных вокселей (позы рига — не выровнены по сетке). */ export interface VoxelCloud { @@ -601,13 +602,15 @@ setSun(k: number): void { this.sunK = k; const kk = Math.min(1, Math.max(0, k)); - const az = Math.PI * (1 - kk); // 180°→0°: восток → юг → запад - const elev = Math.sin(kk * Math.PI) * (Math.PI / 3) + 0.12; // не ниже ~7° + // направление/высота — чистые функции неба (диск неба ставится на + // тот же луч; elev нужен и фрустуму теней) + const dir = sunDirection(kk); + const elev = sunElevation(kk); const dist = this.viewSize * 3; this.sun.position.set( - this.target.x + dist * Math.cos(az) * Math.cos(elev), - this.target.y + dist * Math.sin(elev), - this.target.z + dist * Math.sin(az) * Math.cos(elev), + this.target.x + dist * dir[0], + this.target.y + dist * dir[1], + this.target.z + dist * dir[2], ); this.sun.target.position.copy(this.target); // Теневой фрустум накрывает всю видимую область (полуширина кадра @@ -638,9 +641,9 @@ // интерьер — и днём, облачность — в пасмурный остаток this.sun.intensity = (0.4 + 1.1 * Math.sin(kk * Math.PI)) * (1 - 0.92 * this.nightK) * this.daySunScale * (1 - 0.6 * this.cloudK); - // тёплый свет у горизонта (F1), нейтральный в полдень - const warm = 1 - Math.sin(kk * Math.PI); - this.sun.color.setRGB(1, 1 - warm * 0.25, 1 - warm * 0.45); + // тёплый свет у горизонта (F1), нейтральный в полдень — чистая функция неба + const [tr, tg, tb] = sunTint(kk); + this.sun.color.setRGB(tr, tg, tb); // «тенька сверху» пасмурности: зенитный свет с тенью — контактная // тень прямо под объектом (косые солнечные лучи туда не достают). // Мягкость общая с солнцем: рассеяный свет и здесь размывает край.