import * as THREE from 'three'; import type { RenderQualitySettings } from '@/scene/render-quality'; import type { SceneResourceBag } from '@/scene/resources'; import type { MutableChapterPalette } from '@/scene/world/palette'; import { CHAPTER_PALETTES } from '@/scene/world/palette'; export interface AtmosphereWorld { group: WorldGroup; update( distance: number, speed: number, acceleration: number, palette: Readonly, ): void; } interface WorldGroup { add(...objects: unknown[]): void; getObjectByName(name: string): unknown; } export function createAtmosphere( resources: SceneResourceBag, quality: RenderQualitySettings, ): AtmosphereWorld { const group = new THREE.Group(); group.name = 'atmosphere-world'; const night = CHAPTER_PALETTES['deep-night']; const skyGeometry = resources.own('geometry', new THREE.PlaneGeometry(520, 280)); const skyMaterial = resources.own('material', new THREE.ShaderMaterial({ uniforms: { uSkyColor: { value: new THREE.Color(night.sky) }, uHazeColor: { value: new THREE.Color(night.haze) }, uWarmth: { value: night.warmth }, }, vertexShader: ` varying vec2 vSkyUv; void main() { vSkyUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform vec3 uSkyColor; uniform vec3 uHazeColor; uniform float uWarmth; varying vec2 vSkyUv; void main() { float horizon = 1.0 - smoothstep(0.08, 0.72, vSkyUv.y); float upperNight = smoothstep(0.35, 1.0, vSkyUv.y); vec3 color = mix(uSkyColor, uHazeColor, horizon * (0.38 + uWarmth * 0.2)); color *= mix(1.04, 0.72, upperNight); gl_FragColor = vec4(color, 1.0); } `, depthWrite: false, depthTest: false, fog: false, toneMapped: true, })); const sky = new THREE.Mesh(skyGeometry, skyMaterial); sky.name = 'atmosphere-sky'; sky.position.set(0, 69, -300); sky.renderOrder = -100; group.add(sky); const starGeometry = resources.own('geometry', createStarGeometry(quality.starCount)); const starMaterial = resources.own('material', new THREE.ShaderMaterial({ uniforms: { uVisibility: { value: night.starVisibility }, uDistance: { value: 0 }, uPointScale: { value: 18 }, uMaxPointSize: { value: 4.5 }, uColor: { value: new THREE.Color(0xb9ecf2) }, }, vertexShader: ` attribute float starSize; attribute float brightness; uniform float uDistance; uniform float uPointScale; uniform float uMaxPointSize; varying float vBrightness; void main() { vec3 star = position; star.x += sin(position.z * 0.017 + uDistance * 0.004) * 0.08; vec4 viewPosition = modelViewMatrix * vec4(star, 1.0); vBrightness = brightness; gl_PointSize = clamp( starSize * (uPointScale / max(1.0, -viewPosition.z)), 0.75, uMaxPointSize ); gl_Position = projectionMatrix * viewPosition; } `, fragmentShader: ` uniform vec3 uColor; uniform float uVisibility; varying float vBrightness; void main() { float radius = distance(gl_PointCoord, vec2(0.5)); float point = 1.0 - smoothstep(0.2, 0.5, radius); gl_FragColor = vec4(uColor, point * vBrightness * uVisibility); } `, transparent: true, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, toneMapped: true, })); const stars = new THREE.Points(starGeometry, starMaterial); stars.name = 'atmosphere-stars'; stars.frustumCulled = false; group.add(stars); const hazeGeometry = resources.own('geometry', new THREE.PlaneGeometry(260, 58)); const nearHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.2)); const farHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.12)); const farHaze = new THREE.Mesh(hazeGeometry, farHazeMaterial); farHaze.name = 'horizon-haze-far'; farHaze.position.set(0, 10, -214); farHaze.scale.set(1.35, 1.1, 1); group.add(farHaze); const nearHaze = new THREE.Mesh(hazeGeometry, nearHazeMaterial); nearHaze.name = 'horizon-haze-near'; nearHaze.position.set(0, 8, -132); group.add(nearHaze); const streakGeometry = resources.own('geometry', createStreakGeometry(quality.streakCount)); const streakMaterial = resources.own('material', new THREE.ShaderMaterial({ uniforms: { uDistance: { value: 0 }, uAcceleration: { value: 0 }, uVisibility: { value: 0 }, uColor: { value: new THREE.Color(0x79dae5) }, }, vertexShader: ` attribute float endFactor; uniform float uDistance; uniform float uAcceleration; varying float vTail; void main() { vec3 streak = position; float depthCycle = mod(streak.z + uDistance * 1.4 + 280.0, 286.0) - 280.0; streak.z = depthCycle + endFactor * (1.2 + uAcceleration * 18.0); vTail = endFactor; gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0); } `, fragmentShader: ` uniform vec3 uColor; uniform float uVisibility; varying float vTail; void main() { gl_FragColor = vec4(uColor, uVisibility * mix(0.15, 0.72, vTail)); } `, transparent: true, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, toneMapped: true, })); const streaks = new THREE.LineSegments(streakGeometry, streakMaterial); streaks.name = 'atmosphere-streaks'; streaks.frustumCulled = false; group.add(streaks); return { group, update(distance, speed, acceleration, palette) { const safeAcceleration = clamp01(acceleration); const visibility = smoothstep(0.18, 0.82, safeAcceleration) * palette.streakEnergy; starMaterial.uniforms.uDistance.value = finiteOrZero(distance); starMaterial.uniforms.uVisibility.value = palette.starVisibility; streakMaterial.uniforms.uDistance.value = finiteOrZero(distance) * (0.72 + clamp01(speed) * 0.28); streakMaterial.uniforms.uAcceleration.value = safeAcceleration; streakMaterial.uniforms.uVisibility.value = visibility; skyMaterial.uniforms.uSkyColor.value.copy(palette.sky); skyMaterial.uniforms.uHazeColor.value.copy(palette.haze); skyMaterial.uniforms.uWarmth.value = palette.warmth; nearHazeMaterial.uniforms.uColor.value.copy(palette.haze); farHazeMaterial.uniforms.uColor.value.copy(palette.haze); nearHazeMaterial.uniforms.uEnergy.value = 0.16 + palette.warmth * 0.08; farHazeMaterial.uniforms.uEnergy.value = 0.09 + palette.warmth * 0.06; }, }; } function createStarGeometry(count: number) { const positions = new Float32Array(count * 3); const sizes = new Float32Array(count); const brightness = new Float32Array(count); for (let index = 0; index < count; index += 1) { const offset = index * 3; positions[offset] = seeded(index, 11) * 210 - 105; positions[offset + 1] = 8 + seeded(index, 23) * 72; positions[offset + 2] = -18 - seeded(index, 37) * 270; sizes[index] = 2.4 + seeded(index, 53) * 4.8; brightness[index] = 0.28 + seeded(index, 71) * 0.72; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('starSize', new THREE.BufferAttribute(sizes, 1)); geometry.setAttribute('brightness', new THREE.BufferAttribute(brightness, 1)); return geometry; } function createStreakGeometry(count: number) { const positions = new Float32Array(count * 2 * 3); const ends = new Float32Array(count * 2); for (let index = 0; index < count; index += 1) { const x = (seeded(index, 101) > 0.5 ? 1 : -1) * (12 + seeded(index, 103) * 52); const y = 0.8 + seeded(index, 107) * 12; const z = -8 - seeded(index, 109) * 272; const offset = index * 6; positions[offset] = x; positions[offset + 1] = y; positions[offset + 2] = z; positions[offset + 3] = x; positions[offset + 4] = y + seeded(index, 113) * 0.8; positions[offset + 5] = z; ends[index * 2] = 0; ends[index * 2 + 1] = 1; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('endFactor', new THREE.BufferAttribute(ends, 1)); return geometry; } function createHazeMaterial(color: number, energy: number) { return new THREE.ShaderMaterial({ uniforms: { uColor: { value: new THREE.Color(color) }, uEnergy: { value: energy }, }, vertexShader: ` varying vec2 vHazeUv; void main() { vHazeUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform vec3 uColor; uniform float uEnergy; varying vec2 vHazeUv; void main() { float vertical = 1.0 - smoothstep(0.04, 0.84, abs(vHazeUv.y - 0.42) * 1.9); float horizontal = 1.0 - smoothstep(0.26, 0.5, abs(vHazeUv.x - 0.5)); gl_FragColor = vec4(uColor, vertical * horizontal * uEnergy); } `, transparent: true, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, toneMapped: true, }); } function seeded(index: number, salt: number): number { const result = Math.sin((index + 1) * 91.713 + salt * 17.17) * 43_758.5453; return result - Math.floor(result); } function finiteOrZero(value: number): number { return Number.isFinite(value) ? value : 0; } function clamp01(value: number): number { return THREE.MathUtils.clamp(finiteOrZero(value), 0, 1); } function smoothstep(edge0: number, edge1: number, value: number): number { const progress = THREE.MathUtils.clamp((value - edge0) / (edge1 - edge0), 0, 1); return progress * progress * (3 - 2 * progress); }