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, 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 #include } `, 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 #include } `, 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; }