Newer
Older
rpg / v2 / packages / engine / src / render / sky.ts
/**
 * Чистая математика неба (подсистема небесных светил): дуги светил, фазы
 * луны, палитра неба по суткам/облачности, яркость звёзд и упаковка тел
 * в юниформы. Без 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 };
}