/**
* Небо (подсистема небесных светил): чистая математика дуг/фаз/палитры
* (тестируется в Vitest) и SkyLayer — шейдерная сфера BackSide вокруг
* активной камеры: градиент зенит→горизонт, звёзды по хэшу cube-face
* ячеек, диски+ореолы небесных тел (до SKY_BODY_MAX, у луны — фаза).
*
* Конвенция дуги: k — 0 восход → 0.5 зенит → 1 закат; та же формула, что
* у DirectionalLight солнца в VoxelRenderer.setSun (диск обязан совпадать
* со светом — обе стороны зовут одну функцию).
*/
import * as THREE from 'three';
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;
}
/** Опции неба (VoxelRenderer.setSky): null — прежний фон бит-в-бит. */
export interface SkyOptions {
/** Небесные тела (до SKY_BODY_MAX): солнце, луна, вторая луна… */
bodies?: readonly SkyBody[];
/** Часы игры 0..24 — снапшот skyInfo/фаз (диски задаёт игра телами). */
hours?: number;
/** Звёзды (дефолт true; яркость сама гаснет днём/в облачность). */
stars?: boolean;
}
/** 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 };
}
/** Радиус небесной сферы в долях viewSize: край 9·viewSize < far 10·viewSize. */
export const SKY_RADIUS_K = 9;
const SKY_VERT = /* glsl */ `
varying vec3 vDir;
void main() {
// направление в ЛОКАЛЬНЫХ осях (сфера центрируется на камере — локальное
// направление = направление взгляда). Нормализуем здесь, на highp: fp16
// varying больших vWorld (~R=720) даёт ступени по меридианам на крутом
// smoothstep у горизонта
vDir = normalize(position);
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}`;
const SKY_FRAG = /* glsl */ `
uniform vec3 uZenith; // линейный RGB (юниформы — линейные, конверсия в конце)
uniform vec3 uHorizon;
uniform float uStars; // 0..1 — starBrightness (ночь × облачность)
uniform float uTime;
uniform vec3 uBodyDir[${SKY_BODY_MAX}];
uniform vec3 uBodyColor[${SKY_BODY_MAX}];
uniform vec4 uBodyParams[${SKY_BODY_MAX}]; // angRadius, glow, intensity, phase
uniform int uBodyCount;
varying vec3 vDir;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
/** Дизер градиента: хэш экранных координат. Вход сперва сжимается в [0,1)
* (точный fract — целые до 2^24 не теряют биты), иначе hash21 на больших
* целых даёт вертикальные полосы на крутых градиентах. */
float dither(vec2 fc) {
vec3 p3 = fract(vec3(fc.x, fc.y, fc.x * 0.618 + fc.y) / 1024.0);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
/** Звёзды: хэш по cube-face ячейкам направления (без атласа) — работает
* для любого v, знак v.y не важен (в изо небо под горизонтом). */
float starField(vec3 v, float t) {
vec3 a = abs(v);
vec2 uv; float face;
if (a.x >= a.y && a.x >= a.z) { uv = v.zy / a.x; face = v.x > 0.0 ? 0.0 : 1.0; }
else if (a.y >= a.z) { uv = v.xz / a.y; face = v.y > 0.0 ? 2.0 : 3.0; }
else { uv = v.xy / a.z; face = v.z > 0.0 ? 4.0 : 5.0; }
vec2 g = (uv * 0.5 + 0.5) * 96.0; // ~96 ячеек на грань
vec2 cell = floor(g);
float h = hash21(cell + face * 371.0);
if (h > 0.1) return 0.0; // звезда ~в каждой 10-й ячейке
vec2 sp = vec2(hash21(cell + 17.0), hash21(cell + 91.0)) * 0.6 + 0.2;
float d = length(fract(g) - sp);
float tw = 0.75 + 0.25 * sin(t * (1.5 + h * 20.0) + h * 40.0); // мерцание
return smoothstep(0.12, 0.0, d) * tw;
}
void main() {
// направление взгляда: сфера центрирована на камере, локальная позиция
// и есть направление (для орто и перспективы одинаково)
vec3 v = normalize(vDir);
// градиент зенит → горизонт; под горизонтом — дымка чуть темнее
vec3 c = mix(uHorizon, uZenith, smoothstep(0.0, 0.55, v.y));
c = mix(uHorizon * 0.82, c, smoothstep(-0.25, 0.0, v.y));
// звёзды поверх градиента (гасятся uStars днём и в облачность)
c += vec3(1.0) * starField(v, uTime) * uStars;
// небесные тела: диск (мягкий край) + фаза (терминатор) + ореол
for (int i = 0; i < ${SKY_BODY_MAX}; i++) {
if (i >= uBodyCount) break;
vec3 bd = uBodyDir[i];
float ang = acos(clamp(dot(v, bd), -1.0, 1.0));
vec4 prm = uBodyParams[i];
// локальный базис диска для фазы: e1 — горизонталь тела, e2 = bd×e1
vec3 e1 = normalize(abs(bd.y) < 0.9
? cross(bd, vec3(0.0, 1.0, 0.0)) : cross(bd, vec3(1.0, 0.0, 0.0)));
vec3 e2 = cross(bd, e1);
vec2 p = vec2(dot(v - bd, e1), dot(v - bd, e2)) / prm.x;
float r2 = dot(p, p);
// терминатор: нормаль псевдо-сферы против фазовой плоскости;
// тёмная часть — пепельный свет ×0.08
vec3 n = vec3(p, sqrt(max(0.0, 1.0 - r2)));
float th = 3.14159265 * prm.w;
float lit = mix(0.08, 1.0,
smoothstep(-0.08, 0.15, dot(n, vec3(sin(th), 0.0, cos(th)))));
float disk = (1.0 - smoothstep(prm.x * 0.85, prm.x, ang)) * lit * prm.z;
vec3 col = uBodyColor[i] * (disk + exp(-ang / max(prm.y, 1e-4)) * 0.55 * prm.z * step(1e-4, prm.y));
c += col;
}
// статичный дизер против бандинга градиента (детерминизм кадра)
c += (dither(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(c, 1.0);
// конверсия следует за текущим RT (fog-RT линейно, прямой рендер sRGB)
#include <colorspace_fragment>
}`;
/** Ручка слоя неба: меш в сцене + юниформы; движок владеет позицией (кадр),
* игра — телами/часами, цвета синхронизирует рендер из skyColors. */
export interface SkyLayer {
readonly mesh: THREE.Mesh;
readonly uniforms: Record<string, THREE.IUniform>;
/** Палитра (sRGB 0xRRGGBB) → линейные юниформы. */
setColors(zenith: number, horizon: number): void;
/** Тела (упаковка packSkyBodies: клампы, линейный RGB, обрезка до 4). */
setBodies(bodies: readonly SkyBody[]): void;
dispose(): void;
}
/** Небесная сфера BackSide с шейдером: depthWrite off (мир поверх по
* depth), renderOrder −1 (рисуется первой), frustumCulled off (центр
* прыгает за камерой). Позицию меша ставит рендер каждый кадр. */
export function createSkyLayer(scene: THREE.Scene, radius: number): SkyLayer {
const uniforms: Record<string, THREE.IUniform> = {
uZenith: { value: new THREE.Vector3() },
uHorizon: { value: new THREE.Vector3() },
uStars: { value: 0 },
uTime: { value: 0 },
uBodyDir: { value: Array.from({ length: SKY_BODY_MAX }, () => new THREE.Vector3(0, 1, 0)) },
uBodyColor: { value: Array.from({ length: SKY_BODY_MAX }, () => new THREE.Vector3()) },
uBodyParams: { value: Array.from({ length: SKY_BODY_MAX }, () => new THREE.Vector4()) },
uBodyCount: { value: 0 },
};
const mat = new THREE.ShaderMaterial({
vertexShader: SKY_VERT,
fragmentShader: SKY_FRAG,
uniforms,
side: THREE.BackSide,
depthWrite: false,
});
const geo = new THREE.SphereGeometry(radius, 32, 16);
const mesh = new THREE.Mesh(geo, mat);
mesh.renderOrder = -1;
mesh.frustumCulled = false;
mesh.castShadow = false;
scene.add(mesh);
return {
mesh,
uniforms,
setColors(zenith: number, horizon: number): void {
const [zr, zg, zb] = linearRgb(zenith);
(uniforms.uZenith.value as THREE.Vector3).set(zr, zg, zb);
const [hr, hg, hb] = linearRgb(horizon);
(uniforms.uHorizon.value as THREE.Vector3).set(hr, hg, hb);
},
setBodies(bodies: readonly SkyBody[]): void {
const packed = packSkyBodies(bodies);
for (let i = 0; i < SKY_BODY_MAX; i++) {
if (i >= packed.count) continue;
const d = packed.dirs[i]!;
const c = packed.colors[i]!;
const p = packed.params[i]!;
(uniforms.uBodyDir.value as THREE.Vector3[])[i].set(d[0]!, d[1]!, d[2]!);
(uniforms.uBodyColor.value as THREE.Vector3[])[i].set(c[0]!, c[1]!, c[2]!);
(uniforms.uBodyParams.value as THREE.Vector4[])[i].set(p[0]!, p[1]!, p[2]!, p[3]!);
}
uniforms.uBodyCount.value = packed.count;
},
dispose(): void {
scene.remove(mesh);
geo.dispose();
mat.dispose();
},
};
}