feat: layer the nightdrive sky and atmosphere

This commit is contained in:
2026-09-02 14:52:03 +03:00
parent 3c932cb840
commit 5c52de9134
15 changed files with 801 additions and 168 deletions

View File

@@ -1,33 +1,153 @@
import * as THREE from 'three';
import type { EnvironmentChapter } from '@/journey/types';
import type { SceneResourceBag } from '@/scene/resources';
import type { MutableChapterPalette } from '@/scene/world/palette';
import { CHAPTER_PALETTES } from '@/scene/world/palette';
const chapterWarmth: Readonly<Record<EnvironmentChapter, number>> = {
'deep-night': 0,
systems: 0.12,
infrastructure: 0.24,
builds: 0.42,
'pre-dawn': 0.68,
destination: 1,
};
export interface SunWorld {
group: WorldGroup;
update(palette: Readonly<MutableChapterPalette>, routeProgress: number): void;
}
export function createSun(resources: SceneResourceBag) {
const geometry = resources.own('geometry', new THREE.CircleGeometry(18, 64));
const material = resources.own('material', new THREE.MeshBasicMaterial({
color: 0xff6f83,
interface WorldGroup {
add(...objects: unknown[]): void;
getObjectByName(name: string): unknown;
}
export function createSun(resources: SceneResourceBag): SunWorld {
const group = new THREE.Group();
group.name = 'sun-world';
const night = CHAPTER_PALETTES['deep-night'];
const sunColor = new THREE.Color(night.sun);
const uniforms = {
uColor: { value: sunColor },
uScanBands: { value: 14 },
uWarmth: { value: night.warmth },
uRouteProgress: { value: 0 },
};
const geometry = resources.own('geometry', new THREE.CircleGeometry(20, 96));
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms,
vertexShader: `
varying vec2 vSunUv;
void main() {
vSunUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uScanBands;
uniform float uWarmth;
uniform float uRouteProgress;
varying vec2 vSunUv;
void main() {
float radius = distance(vSunUv, vec2(0.5));
float softEdge = 1.0 - smoothstep(0.435, 0.5, radius);
float lowerDisc = 1.0 - smoothstep(0.42, 0.76, vSunUv.y);
float scanBand = smoothstep(0.16, 0.28, fract(vSunUv.y * uScanBands));
float bandCut = mix(1.0, 0.32 + scanBand * 0.68, lowerDisc * 0.82);
float horizonFade = smoothstep(0.02, 0.14, vSunUv.y);
float luminance = 0.72 + uWarmth * 0.18 + uRouteProgress * 0.06;
gl_FragColor = vec4(uColor * luminance, softEdge * bandCut * horizonFade * 0.9);
}
`,
transparent: true,
opacity: 0.82,
fog: false,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
toneMapped: true,
}));
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(0, 12, -128);
const disc = new THREE.Mesh(geometry, material);
disc.name = 'sun-disc';
disc.position.set(0, 11.5, -142);
group.add(disc);
const haloTexture = resources.own('texture', createHaloTexture());
const haloMaterial = resources.own('material', new THREE.SpriteMaterial({
map: haloTexture,
color: sunColor.clone(),
transparent: true,
opacity: 0.24,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
toneMapped: true,
}));
const halo = new THREE.Sprite(haloMaterial);
halo.name = 'sun-halo';
halo.position.copy(disc.position);
halo.position.z -= 0.5;
halo.scale.set(57, 43, 1);
group.add(halo);
return {
mesh,
update(chapter: EnvironmentChapter, routeProgress: number) {
const warmth = Math.max(chapterWarmth[chapter], routeProgress);
material.color.setRGB(1, 0.34 + warmth * 0.24, 0.42 + warmth * 0.22);
mesh.position.y = 12 + warmth * 5;
group,
update(palette, routeProgress) {
const progress = clamp01(routeProgress);
sunColor.copy(palette.sun);
haloMaterial.color.copy(palette.sun);
uniforms.uWarmth.value = clamp01(palette.warmth);
uniforms.uRouteProgress.value = progress;
haloMaterial.opacity = 0.18 + palette.warmth * 0.12;
disc.position.y = 11.5 + palette.warmth * 4.8;
halo.position.y = disc.position.y;
},
};
}
function createHaloTexture() {
const size = 64;
if (typeof document !== 'undefined') {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
let context: CanvasRenderingContext2D | null = null;
try {
context = canvas.getContext('2d');
} catch {
context = null;
}
if (context) {
const gradient = context.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2,
);
gradient.addColorStop(0, 'rgb(255 255 255 / 90%)');
gradient.addColorStop(0.26, 'rgb(255 255 255 / 32%)');
gradient.addColorStop(1, 'rgb(255 255 255 / 0%)');
context.fillStyle = gradient;
context.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
return texture;
}
}
const pixels = new Uint8Array(size * size * 4);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const distance = Math.hypot(x - size / 2, y - size / 2) / (size / 2);
const alpha = Math.round(255 * Math.max(0, 1 - distance) ** 2.4);
const offset = (y * size + x) * 4;
pixels[offset] = 255;
pixels[offset + 1] = 255;
pixels[offset + 2] = 255;
pixels[offset + 3] = alpha;
}
}
const texture = new THREE.DataTexture(pixels, size, size, THREE.RGBAFormat);
texture.needsUpdate = true;
texture.generateMipmaps = false;
return texture;
}
function clamp01(value: number): number {
return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0;
}