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