diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 2518ca5..5e851dc 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -23,6 +23,12 @@ export { meshChunk, chunkBounds } from './world/worldMesher'; export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk'; export { VoxelRenderer, worldTileAtlas } from './render/voxelRenderer'; +export { + celestialDir, createSkyLayer, linearRgb, moonK, moonPhase, packSkyBodies, + SKY_BODY_MAX, SKY_RADIUS_K, skyColors, srgbToLinear, starBrightness, + sunDirection, sunElevation, sunTint, +} from './render/sky'; +export type { SkyBody, SkyLayer, SkyOptions, SkyPalette, PackedSkyBodies } from './render/sky'; export { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, makeParticlePerspUniforms, packParticleLights, particleEnv, litVisibility, PARTICLE_LIGHT_MAX, PARTICLE_ENV_SUN, PARTICLE_LIT_VIS, PARTICLE_LIT_GAMMA } from './render/particles'; export { makeFogUniforms, fogColor, fogBandStart, FOG_ENV_SUN, FOG_BRIGHT, FOG_BANDS, FOG_BAND_START, FOG_BAND_STEP, FOG_BAND_SOFT, FOG_BAND_ALPHA } from './render/fog'; export { postPass } from './render/postShader'; diff --git a/v2/packages/engine/src/render/__tests__/sky.test.ts b/v2/packages/engine/src/render/__tests__/sky.test.ts index cb42c81..acbbcf6 100644 --- a/v2/packages/engine/src/render/__tests__/sky.test.ts +++ b/v2/packages/engine/src/render/__tests__/sky.test.ts @@ -1,13 +1,14 @@ /** - * Тесты чистой математики неба: дуги светил (совпадение с прежней формулой - * setSun — регрессионный замок рефакторинга), луна/фазы, палитра неба, - * яркость звёзд, упаковка тел. Шейдер и композит тестируются пробами - * с WebGL — здесь только математика. + * Тесты неба: чистая математика (дуги светил — замок рефакторинга setSun, + * луна/фазы, палитра, звёзды, упаковка тел) и фабрика SkyLayer без WebGL + * (сцена/геометрия — чистые JS-объекты; шейдер — пробами с WebGL). */ import { describe, expect, it } from 'vitest'; +import * as THREE from 'three'; import { - celestialDir, linearRgb, moonK, moonPhase, packSkyBodies, skyColors, - starBrightness, sunDirection, sunElevation, sunTint, SKY_BODY_MAX, + celestialDir, createSkyLayer, linearRgb, moonK, moonPhase, packSkyBodies, + skyColors, starBrightness, sunDirection, sunElevation, sunTint, + SKY_BODY_MAX, SKY_RADIUS_K, } from '../sky'; describe('дуга светила', () => { @@ -153,4 +154,52 @@ const [r] = linearRgb(0x808080); expect(r).toBeLessThan(0.5); // sRGB 0.5 → линейный ~0.216 }); -}); \ No newline at end of file +}); +describe('createSkyLayer (headless: сцена без WebGL)', () => { + it('меш BackSide в сцене, renderOrder −1, без frustum-куллинга', () => { + const scene = new THREE.Scene(); + const sky = createSkyLayer(scene, 720); + const mesh = scene.children.find((o) => o === sky.mesh); + expect(mesh).toBeDefined(); + expect((sky.mesh.material as THREE.ShaderMaterial).side).toBe(THREE.BackSide); + expect(sky.mesh.renderOrder).toBe(-1); + expect(sky.mesh.frustumCulled).toBe(false); + sky.dispose(); + }); + + it('dispose снимает меш со сцены', () => { + const scene = new THREE.Scene(); + const sky = createSkyLayer(scene, 720); + expect(scene.children.includes(sky.mesh)).toBe(true); + sky.dispose(); + expect(scene.children.includes(sky.mesh)).toBe(false); + }); + + it('setColors: sRGB → линейные юниформы (белый 1, серый темнеет)', () => { + const scene = new THREE.Scene(); + const sky = createSkyLayer(scene, 720); + sky.setColors(0xffffff, 0x808080); + const z = sky.uniforms.uZenith.value as THREE.Vector3; + expect(z.x).toBeCloseTo(1); + const h = sky.uniforms.uHorizon.value as THREE.Vector3; + expect(h.x).toBeLessThan(0.5); + sky.dispose(); + }); + + it('setBodies: счётчик тел и фаза попадают в юниформы', () => { + const scene = new THREE.Scene(); + const sky = createSkyLayer(scene, 720); + sky.setBodies([{ dir: [0, 1, 0], angRadius: 0.05, color: 0xffffff, phase: 0.4 }]); + expect(sky.uniforms.uBodyCount.value).toBe(1); + const prm = (sky.uniforms.uBodyParams.value as THREE.Vector4[])[0]!; + expect(prm.w).toBeCloseTo(0.4); + sky.dispose(); + }); +}); + +describe('SKY_RADIUS_K', () => { + // край сферы обязан быть внутри far (=10·viewSize) обеих камер + it('9·viewSize < far 10·viewSize', () => { + expect(SKY_RADIUS_K).toBeLessThan(10); + }); +}); diff --git a/v2/packages/engine/src/render/sky.ts b/v2/packages/engine/src/render/sky.ts index 9765fff..12642a6 100644 --- a/v2/packages/engine/src/render/sky.ts +++ b/v2/packages/engine/src/render/sky.ts @@ -1,12 +1,14 @@ /** - * Чистая математика неба (подсистема небесных светил): дуги светил, фазы - * луны, палитра неба по суткам/облачности, яркость звёзд и упаковка тел - * в юниформы. Без three и рендера — тестируется в Vitest. + * Небо (подсистема небесных светил): чистая математика дуг/фаз/палитры + * (тестируется в 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'; /** Максимум небесных тел в кадре (юниформ-массивы шейдера). */ @@ -120,6 +122,16 @@ 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); @@ -156,4 +168,169 @@ ]); } 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 +}`; + +/** Ручка слоя неба: меш в сцене + юниформы; движок владеет позицией (кадр), + * игра — телами/часами, цвета синхронизирует рендер из skyColors. */ +export interface SkyLayer { + readonly mesh: THREE.Mesh; + readonly uniforms: Record; + /** Палитра (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 = { + 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(); + }, + }; } \ 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 c091c63..ade1502 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -22,7 +22,11 @@ PERSP_WINDOW_FAR_K, camForward, orbitPosition, perspVisibleRadius, } from './cameraMath'; import type { CameraMode } from './cameraMath'; -import { sunDirection, sunElevation, sunTint } from './sky'; +import { + createSkyLayer, SKY_RADIUS_K, skyColors, starBrightness, sunDirection, + sunElevation, sunTint, +} from './sky'; +import type { SkyLayer, SkyOptions } from './sky'; /** Облако деформированных вокселей (позы рига — не выровнены по сетке). */ export interface VoxelCloud { @@ -202,6 +206,9 @@ private fogTime = 0; private fogWind: readonly [number, number] = [0, 0]; private fogPass: ReturnType | null = null; + /** Слой неба (шейдерная сфера): null — фон сцены, бит-в-бит прежний. */ + private sky: SkyLayer | null = null; + private skyOpts: SkyOptions | null = null; private sunK = 0.5; private nightK = 0; /** Облачность 0..1: гасит солнце и ambient, фон — к пасмурному серому. @@ -365,6 +372,58 @@ this.ambient.color.set(cloudDim(base, this.cloudK, 0.7, 0.25)); if (!this.scene.background) this.scene.background = new THREE.Color(); (this.scene.background as THREE.Color).set(cloudDim(this.lastNightTint, this.cloudK, 0.7, 0.3)); + this.skySyncColors(); // небо живёт той же палитрой, что и фон + } + + /** Небо: сфера-градиент вокруг активной камеры + небесные тела + звёзды. + * null — выключить и удалить слой (фон сцены остаётся как был). Тела + * задаёт игра каждый тик (диски совпадают со светом — одни функции). + * Цвета неба/звёзды рендер ведёт сам от nightK/cloudK (skySyncColors). */ + setSky(opts: SkyOptions | null): void { + this.skyOpts = opts; + if (!opts) { + if (this.sky) { + this.sky.dispose(); + this.sky = null; + } + return; + } + if (!this.sky) { + this.sky = createSkyLayer(this.scene, this.viewSize * SKY_RADIUS_K); + } + this.sky.setBodies(opts.bodies ?? []); + this.skySyncColors(); + } + + /** Снапшот неба для моста/гейтов: null — небо выключено. */ + skyInfo(): { + enabled: boolean; + stars: boolean; + hours: number | null; + bodyCount: number; + bodies: Array<{ phase: number; intensity: number; glow: number }>; + } | null { + if (!this.skyOpts) return null; + return { + enabled: this.sky !== null, + stars: this.skyOpts.stars !== false, + hours: this.skyOpts.hours ?? null, + bodyCount: this.skyOpts.bodies?.length ?? 0, + bodies: (this.skyOpts.bodies ?? []).map((b) => ({ + phase: b.phase ?? 1, + intensity: b.intensity ?? 1, + glow: b.glow ?? 0, + })), + }; + } + + /** Палитра неба и звёзды — из тех же nightK/cloudK, что фон сцены. */ + private skySyncColors(): void { + if (!this.sky) return; + const pal = skyColors(this.nightK, this.cloudK); + this.sky.setColors(pal.zenith, pal.horizon); + const on = this.skyOpts?.stars !== false; + this.sky.uniforms.uStars.value = on ? starBrightness(this.nightK, this.cloudK) : 0; } /** Точечный источник (не бросает тень): мерцание — через setIntensity. @@ -800,6 +859,11 @@ * экземпляров (вьювер модели создаёт рендерер на каждое открытие). */ dispose(): void { this.clearPostPass(); + if (this.sky) { + this.sky.dispose(); + this.sky = null; + this.skyOpts = null; + } if (this.fogPass) { this.fogPass.rt.dispose(); this.fogPass.mat.dispose(); @@ -817,6 +881,9 @@ * в post.rt игры (пост-проход поверх тумана) или прямо на канвас. */ render(): void { this.camera.updateMatrixWorld(); + // небо центрируем на активной камере каждый кадр (на target нельзя: + // орто-луч до сферы ~в 4 раза дальше — далеко за far) + if (this.sky) this.sky.mesh.position.copy(this.camera.position); if (this.fogStrength > 0.001) { if (!this.fogPass) { const size = new THREE.Vector2();