307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import * as THREE from 'three';
|
|
import type { SceneResourceBag } from '@/scene/resources';
|
|
import type { MutableChapterPalette } from '@/scene/world/palette';
|
|
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
|
import { SUN_DISTANCE_Z } from '@/scene/world/metrics';
|
|
import { createArrivalMeteors } from './arrival-meteors';
|
|
|
|
export interface SunWorld {
|
|
group: WorldGroup;
|
|
update(
|
|
palette: Readonly<MutableChapterPalette>,
|
|
routeProgress: number,
|
|
elapsedSeconds: number,
|
|
driveEnergy?: number,
|
|
arrived?: boolean,
|
|
): void;
|
|
}
|
|
|
|
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 arrivalColor = new THREE.Color(0xffead0);
|
|
let wasArrived = false;
|
|
let arrivalStartedAt = 0;
|
|
const uniforms = {
|
|
uColor: { value: sunColor },
|
|
uHazeColor: { value: new THREE.Color(night.haze) },
|
|
uScanBands: { value: 11 },
|
|
uWarmth: { value: night.warmth },
|
|
uRouteProgress: { value: 0 },
|
|
uTime: { value: 0 },
|
|
uDriveEnergy: { value: 0 },
|
|
uArrival: { value: 0 },
|
|
};
|
|
const geometry = resources.own(
|
|
'geometry',
|
|
new THREE.CircleGeometry(126, 160),
|
|
);
|
|
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 vec3 uHazeColor;
|
|
uniform float uScanBands;
|
|
uniform float uWarmth;
|
|
uniform float uRouteProgress;
|
|
uniform float uTime;
|
|
uniform float uDriveEnergy;
|
|
uniform float uArrival;
|
|
varying vec2 vSunUv;
|
|
|
|
void main() {
|
|
vec2 solar = vSunUv;
|
|
float low = 1.0 - solar.y;
|
|
solar.x += sin(solar.y * 132.0 + uTime * 0.42) * 0.0015 * low * low;
|
|
float radius = length(solar - 0.5);
|
|
float softEdge = 1.0 - smoothstep(0.496, 0.5, radius);
|
|
float stripes = (0.65 - solar.y) * 17.0;
|
|
float phase = fract(stripes);
|
|
float gap = mix(0.12, 0.61, clamp(stripes / 11.0, 0.0, 1.0));
|
|
float separated = smoothstep(gap, gap + 0.024, phase);
|
|
float bandMask = solar.y > 0.65 ? 1.0 : separated;
|
|
float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65);
|
|
float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
|
|
* sin(solar.y * 31.0 - uTime * 0.032);
|
|
vec3 vermilion = mix(vec3(1.0, 0.03, 0.4), vec3(1.0, 0.24, 0.12), uArrival);
|
|
vec3 coral = mix(uColor, vec3(1.0, 0.3, 0.06), 0.6);
|
|
vec3 crown = mix(vec3(1.0, 0.8, 0.12), vec3(1.0, 0.9, 0.6), uArrival);
|
|
vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y));
|
|
core = mix(core, crown, smoothstep(0.47, 1.0, solar.y));
|
|
float filament = pow(abs(sin(solar.x * 39.0 + solar.y * 25.0 + convection * 2.4)), 16.0);
|
|
core *= 0.87 + convection * 0.055 + filament * 0.07;
|
|
float limb = pow(smoothstep(0.38, 0.498, radius), 4.0);
|
|
core += vec3(1.0, 0.24, 0.095) * (limb * 0.22 + lip * 0.20);
|
|
float extinction = smoothstep(0.025, 0.19, solar.y);
|
|
if(softEdge * bandMask * extinction < 0.02) discard;
|
|
// Kept below ACES's shoulder so the disc stays saturated instead of cream.
|
|
gl_FragColor = vec4(core * (0.74 + uWarmth * 0.08), softEdge * bandMask * extinction);
|
|
#include <tonemapping_fragment>
|
|
#include <colorspace_fragment>
|
|
}
|
|
`,
|
|
transparent: true,
|
|
depthWrite: true,
|
|
depthTest: true,
|
|
blending: THREE.NormalBlending,
|
|
toneMapped: true,
|
|
}),
|
|
);
|
|
const disc = new THREE.Mesh(geometry, material);
|
|
disc.name = 'horizon-sun';
|
|
disc.position.set(0, 79, SUN_DISTANCE_Z);
|
|
group.add(disc);
|
|
|
|
const limbMaterial = resources.own(
|
|
'material',
|
|
new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
uColor: { value: sunColor.clone() },
|
|
uEnergy: { value: 0 },
|
|
uScale: { value: 1 },
|
|
uRingRadius: { value: 0.49 },
|
|
uRingOpacity: { value: 0 },
|
|
uArrival: { value: 0 },
|
|
},
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform vec3 uColor;
|
|
uniform float uEnergy;
|
|
uniform float uScale;
|
|
uniform float uRingRadius;
|
|
uniform float uRingOpacity;
|
|
uniform float uArrival;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
float radius = distance(vUv, vec2(0.5)) * uScale;
|
|
float ring = smoothstep(0.477, 0.488, radius)
|
|
* (1.0 - smoothstep(0.491, 0.499, radius));
|
|
float echo = (1.0 - smoothstep(0.003, 0.009, abs(radius - uRingRadius))) * uRingOpacity;
|
|
gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07 + uArrival * 0.55),
|
|
ring * (0.22 + uEnergy * 0.025 + uArrival * 0.2) + echo * 0.9);
|
|
#include <tonemapping_fragment>
|
|
#include <colorspace_fragment>
|
|
}
|
|
`,
|
|
transparent: true,
|
|
depthWrite: false,
|
|
depthTest: true,
|
|
blending: THREE.AdditiveBlending,
|
|
toneMapped: true,
|
|
}),
|
|
);
|
|
const limb = new THREE.Mesh(geometry, limbMaterial);
|
|
limb.name = 'sun-limb-glow';
|
|
limb.position.copy(disc.position);
|
|
limb.position.z -= 0.3;
|
|
limb.scale.setScalar(1.034);
|
|
group.add(limb);
|
|
|
|
const haloTexture = resources.own('texture', createHaloTexture());
|
|
const haloMaterial = resources.own(
|
|
'material',
|
|
new THREE.SpriteMaterial({
|
|
map: haloTexture,
|
|
color: sunColor.clone(),
|
|
transparent: true,
|
|
opacity: 0.13,
|
|
depthWrite: false,
|
|
depthTest: true,
|
|
blending: THREE.AdditiveBlending,
|
|
toneMapped: true,
|
|
}),
|
|
);
|
|
const halo = new THREE.Sprite(haloMaterial);
|
|
halo.name = 'horizon-halo';
|
|
halo.position.copy(disc.position);
|
|
halo.position.z -= 1.2;
|
|
halo.scale.set(330, 232, 1);
|
|
group.add(halo);
|
|
|
|
const bleedMaterial = resources.own(
|
|
'material',
|
|
new THREE.SpriteMaterial({
|
|
map: haloTexture,
|
|
color: sunColor.clone(),
|
|
transparent: true,
|
|
opacity: 0.075,
|
|
depthWrite: false,
|
|
depthTest: true,
|
|
blending: THREE.AdditiveBlending,
|
|
toneMapped: true,
|
|
}),
|
|
);
|
|
const bleed = new THREE.Sprite(bleedMaterial);
|
|
bleed.name = 'horizon-light-bleed';
|
|
bleed.position.set(0, 7, SUN_DISTANCE_Z - 2.2);
|
|
bleed.scale.set(560, 118, 1);
|
|
group.add(bleed);
|
|
const meteors = createArrivalMeteors(resources);
|
|
group.add(meteors.mesh);
|
|
|
|
return {
|
|
group,
|
|
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0, arrived = false) {
|
|
const progress = clamp01(routeProgress);
|
|
const energy = clamp01(driveEnergy);
|
|
const time = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
|
|
if (arrived && !wasArrived) arrivalStartedAt = time;
|
|
wasArrived = arrived;
|
|
const arrivalAge = time - arrivalStartedAt;
|
|
const arrivalGlow = arrived ? clamp01(arrivalAge / 1.2) : 0;
|
|
const arrivalPhase = clamp01((arrivalAge - 0.5) / 3.3);
|
|
// Meteors and the broad halo swell remain a one-shot arrival finale.
|
|
const arrivalPulse = arrived && arrivalPhase > 0 && arrivalPhase < 1
|
|
? Math.sin(Math.PI * arrivalPhase) ** 2
|
|
: 0;
|
|
// Only the rim repeats, using the existing visibility-aware scene clock.
|
|
const ringPhase = (Math.max(0, arrivalAge - 0.5) % 3) / 3;
|
|
const ringPulse = arrived ? Math.sin(Math.PI * ringPhase) ** 2 : 0;
|
|
meteors.update(arrivalAge, arrived);
|
|
sunColor.copy(palette.sun).lerp(arrivalColor, arrivalGlow * 0.55);
|
|
uniforms.uHazeColor.value.copy(palette.haze);
|
|
haloMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22)
|
|
.lerp(arrivalColor, arrivalGlow * 0.7);
|
|
bleedMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22);
|
|
limbMaterial.uniforms.uColor.value.copy(sunColor);
|
|
uniforms.uWarmth.value = clamp01(palette.warmth);
|
|
uniforms.uRouteProgress.value = progress;
|
|
uniforms.uTime.value = time;
|
|
uniforms.uDriveEnergy.value = energy;
|
|
uniforms.uArrival.value = arrivalGlow;
|
|
limbMaterial.uniforms.uEnergy.value = energy;
|
|
const ringScale = ringPulse > 0 ? 1.9 : 1;
|
|
limb.scale.setScalar(1.034 * ringScale);
|
|
limbMaterial.uniforms.uScale.value = ringScale;
|
|
limbMaterial.uniforms.uRingRadius.value = 0.49 + ringPhase * 0.36;
|
|
limbMaterial.uniforms.uRingOpacity.value = ringPulse;
|
|
limbMaterial.uniforms.uArrival.value = arrivalGlow;
|
|
haloMaterial.opacity = 0.095 + palette.warmth * 0.03 + energy * 0.015 + arrivalGlow * 0.08 + arrivalPulse * 0.1;
|
|
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
|
|
halo.scale.set(480 + energy * 18 + arrivalGlow * 40 + arrivalPulse * 76, 360 + energy * 10 + arrivalGlow * 30 + arrivalPulse * 56, 1);
|
|
disc.position.y = 79 + palette.warmth * 14;
|
|
limb.position.y = disc.position.y;
|
|
halo.position.y = disc.position.y;
|
|
bleed.position.y = 7 + palette.warmth * 3;
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|