From 5c52de9134f6dd52c8df972bb2af0aaa3fee29e7 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 14:52:03 +0300 Subject: [PATCH] feat: layer the nightdrive sky and atmosphere --- src/journey/scroll-drive-controller.ts | 6 +- src/scene/post-processing.ts | 92 +++++++ src/scene/render-quality.ts | 15 +- src/scene/three.d.ts | 3 + src/scene/world/atmosphere.ts | 273 +++++++++++++++++++++ src/scene/world/environment.ts | 117 +++++---- src/scene/world/palette.ts | 2 + src/scene/world/particles.ts | 40 --- src/scene/world/sun.ts | 164 +++++++++++-- src/scene/world/terrain.ts | 18 +- src/styles/world.css | 88 +++++-- tests/browser/journey-integration.spec.ts | 29 ++- tests/unit/scene-resources.test.ts | 9 +- tests/unit/scroll-drive-controller.test.ts | 15 +- tests/unit/world-models.test.ts | 98 +++++++- 15 files changed, 801 insertions(+), 168 deletions(-) create mode 100644 src/scene/post-processing.ts create mode 100644 src/scene/world/atmosphere.ts delete mode 100644 src/scene/world/particles.ts diff --git a/src/journey/scroll-drive-controller.ts b/src/journey/scroll-drive-controller.ts index 29b767a..ed4ed42 100644 --- a/src/journey/scroll-drive-controller.ts +++ b/src/journey/scroll-drive-controller.ts @@ -74,13 +74,14 @@ export function createScrollDriveController(): ScrollDriveController { return cruiseSnapshot(input); } + const deltaY = input.scrollY - previousSample.scrollY; + const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0; if (elapsedMs > BACKGROUND_GAP_MS) { previousSample = { ...input }; smoothedAcceleration = 0; - return cruiseSnapshot(input); + return { ...cruiseSnapshot(input), direction }; } - const deltaY = input.scrollY - previousSample.scrollY; const rawSpeed = Math.abs(deltaY) / elapsedMs; const impulse = clamp(rawSpeed / MAX_RAW_SPEED, 0, 1); const timeConstantMs = impulse > smoothedAcceleration @@ -93,7 +94,6 @@ export function createScrollDriveController(): ScrollDriveController { } previousSample = { ...input }; - const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0; return { scrollY: input.scrollY, routeProgress: routeProgress(input), diff --git a/src/scene/post-processing.ts b/src/scene/post-processing.ts new file mode 100644 index 0000000..3c8ebda --- /dev/null +++ b/src/scene/post-processing.ts @@ -0,0 +1,92 @@ +import * as THREE from 'three'; +import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; +import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; +import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; +import type { SceneResourceBag } from '@/scene/resources'; + +export interface PostProcessor { + resize(width: number, height: number, pixelRatio: number): void; + render(scene: unknown, camera: unknown): void; + dispose(): void; +} + +interface ComposerLike { + addPass(pass: unknown): void; + setPixelRatio(pixelRatio: number): void; + setSize(width: number, height: number): void; + render(): void; + dispose(): void; +} + +interface RenderPassLike { + scene: unknown; + camera: unknown; + dispose(): void; +} + +interface BloomPassLike { + dispose(): void; +} + +export interface PostProcessingFactories { + createComposer(renderer: unknown): ComposerLike; + createRenderPass(scene: unknown, camera: unknown): RenderPassLike; + createBloomPass(): BloomPassLike; +} + +const productionFactories: PostProcessingFactories = { + createComposer: (renderer) => new EffectComposer(renderer), + createRenderPass: (scene, camera) => new RenderPass(scene, camera), + createBloomPass: () => new UnrealBloomPass( + new THREE.Vector2(1, 1), + 0.46, + 0.32, + 0.84, + ), +}; + +export function createPostProcessor( + resources: SceneResourceBag, + renderer: unknown, + factories: PostProcessingFactories = productionFactories, +): PostProcessor { + const composer = factories.createComposer(renderer); + const renderPass = factories.createRenderPass(new THREE.Scene(), new THREE.Camera()); + const bloomPass = factories.createBloomPass(); + composer.addPass(renderPass); + composer.addPass(bloomPass); + let disposed = false; + + const processor: PostProcessor = { + resize(width, height, pixelRatio) { + if (disposed) return; + composer.setPixelRatio(clamp(pixelRatio, 0.5, 2)); + composer.setSize( + Math.max(1, Math.floor(finiteOr(width, 1))), + Math.max(1, Math.floor(finiteOr(height, 1))), + ); + }, + render(scene, camera) { + if (disposed) return; + renderPass.scene = scene; + renderPass.camera = camera; + composer.render(); + }, + dispose() { + if (disposed) return; + disposed = true; + renderPass.dispose(); + bloomPass.dispose(); + composer.dispose(); + }, + }; + return resources.own('pass', processor); +} + +function finiteOr(value: number, fallback: number): number { + return Number.isFinite(value) ? value : fallback; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return THREE.MathUtils.clamp(finiteOr(value, minimum), minimum, maximum); +} diff --git a/src/scene/render-quality.ts b/src/scene/render-quality.ts index bc0202e..863e370 100644 --- a/src/scene/render-quality.ts +++ b/src/scene/render-quality.ts @@ -5,8 +5,9 @@ export interface RenderQualitySettings { roadSegments: number; terrainSegments: number; terrainOctaves: 1 | 2 | 3; - particleCount: number; + starCount: number; streakCount: number; + postScale: number; postEffects: boolean; } @@ -16,8 +17,9 @@ const settings: 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 }, + uColor: { value: new THREE.Color(0xb9ecf2) }, + }, + vertexShader: ` + attribute float starSize; + attribute float brightness; + uniform float uDistance; + 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 = starSize * (250.0 / max(1.0, -viewPosition.z)); + 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); +} diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 382e031..fa3a286 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import type { EnvironmentChapter, JourneySnapshot } from '@/journey/types'; +import type { JourneySnapshot } from '@/journey/types'; import type { SceneResourceBag } from '@/scene/resources'; import type { SceneBuildContext, SceneGeneration } from '@/scene/types'; import { renderQualityFor } from '@/scene/render-quality'; @@ -8,21 +8,18 @@ import { createTerrain } from '@/scene/world/terrain'; import { createSun } from '@/scene/world/sun'; import { createCar } from '@/scene/world/car'; import { createSign } from '@/scene/world/sign'; -import { createParticles } from '@/scene/world/particles'; +import { createAtmosphere } from '@/scene/world/atmosphere'; +import { createPostProcessor } from '@/scene/post-processing'; +import { + createMutableChapterPalette, + writeChapterPalette, + type MutableChapterPalette, +} from '@/scene/world/palette'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; declare const __PORTFOLIO_TEST_BUILD__: boolean; const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; -const skyColors: Readonly> = { - 'deep-night': 0x040713, - systems: 0x060918, - infrastructure: 0x080b1d, - builds: 0x0d1025, - 'pre-dawn': 0x18152c, - destination: 0x2b2035, -}; - export function createChaseCamera(aspect = 1) { const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 320); camera.position.set(0, 4.8, 12.5); @@ -62,9 +59,11 @@ export async function createProceduralSceneGeneration( renderer.toneMappingExposure = 1.05; const scene = new THREE.Scene(); - const background = new THREE.Color(skyColors['deep-night']); + const currentPalette = createMutableChapterPalette('deep-night'); + const targetPalette = createMutableChapterPalette('deep-night'); + const background = currentPalette.sky.clone(); scene.background = background; - scene.fog = new THREE.FogExp2(0x080c18, 0.012); + scene.fog = new THREE.FogExp2(currentPalette.fog.getHex(), 0.012); const camera = createChaseCamera(); const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25); @@ -78,43 +77,19 @@ export async function createProceduralSceneGeneration( const sun = createSun(context.resources); const car = createCar(context.resources); const sign = createSign(context.resources); - const particles = createParticles(context.resources, quality.particleCount); - scene.add(road.group, terrain.group, sun.mesh, car.group, sign.group, particles.points); + const atmosphere = createAtmosphere(context.resources, quality); + scene.add( + atmosphere.group, + road.group, + terrain.group, + sun.group, + car.group, + sign.group, + ); - const postTarget = quality.postEffects - ? context.resources.own('target', new THREE.WebGLRenderTarget(1, 1, { - depthBuffer: true, - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - })) + const postProcessor = quality.postEffects + ? createPostProcessor(context.resources, renderer) : null; - const postScene = postTarget ? new THREE.Scene() : null; - const postCamera = postTarget ? new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) : null; - if (postScene && postTarget) { - const postGeometry = context.resources.own('geometry', new THREE.PlaneGeometry(2, 2)); - const postMaterial = context.resources.own('material', new THREE.ShaderMaterial({ - uniforms: { source: { value: postTarget.texture } }, - vertexShader: ` - varying vec2 vUv; - void main() { - vUv = uv; - gl_Position = vec4(position.xy, 0.0, 1.0); - } - `, - fragmentShader: ` - uniform sampler2D source; - varying vec2 vUv; - void main() { - vec4 color = texture2D(source, vUv); - float vignette = smoothstep(0.92, 0.24, distance(vUv, vec2(0.5))); - gl_FragColor = vec4(color.rgb * mix(0.82, 1.0, vignette), color.a); - } - `, - depthTest: false, - depthWrite: false, - })); - postScene.add(new THREE.Mesh(postGeometry, postMaterial)); - } let width = 0; let height = 0; let driveDistance = 0; @@ -132,10 +107,7 @@ export async function createProceduralSceneGeneration( renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); - postTarget?.setSize( - Math.max(1, Math.floor(width * quality.pixelRatio)), - Math.max(1, Math.floor(height * quality.pixelRatio)), - ); + postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale); if (TEST_BUILD && context.quality !== 'medium') { window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({ tier: context.quality, @@ -176,6 +148,8 @@ export async function createProceduralSceneGeneration( resize(); const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000)); lastNow = now; + writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0); + dampPalette(currentPalette, targetPalette, deltaSeconds); driveDistance += snapshot.driveSpeed * deltaSeconds * 18; road.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration); terrain.update( @@ -183,22 +157,27 @@ export async function createProceduralSceneGeneration( snapshot.driveSpeed, snapshot.chapter, snapshot.routeProgress, + currentPalette, ); - sun.update(snapshot.chapter, snapshot.routeProgress); + sun.update(currentPalette, snapshot.routeProgress); car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000); sign.update(snapshot.sign); - particles.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration); - background.setHex(skyColors[snapshot.chapter]); + atmosphere.update( + driveDistance, + snapshot.driveSpeed, + snapshot.acceleration, + currentPalette, + ); + background.copy(currentPalette.sky); + scene.fog.color.copy(currentPalette.fog); + scene.fog.density = 0.0105 + (1 - currentPalette.warmth) * 0.0025; const fov = 52 + Math.min(7, snapshot.acceleration * 4.5); if (camera.fov !== fov) { camera.fov = fov; camera.updateProjectionMatrix(); } - if (postTarget) { - renderer.setRenderTarget(postTarget); - renderer.render(scene, camera); - renderer.setRenderTarget(null); - renderer.render(postScene, postCamera); + if (postProcessor) { + postProcessor.render(scene, camera); } else { renderer.render(scene, camera); } @@ -211,3 +190,21 @@ export async function createProceduralSceneGeneration( }, }; } + +function dampPalette( + current: MutableChapterPalette, + target: Readonly, + deltaSeconds: number, +): void { + const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) * 2.8); + current.sky.lerp(target.sky, mix); + current.fog.lerp(target.fog, mix); + current.terrainLow.lerp(target.terrainLow, mix); + current.terrainHigh.lerp(target.terrainHigh, mix); + current.sun.lerp(target.sun, mix); + current.haze.lerp(target.haze, mix); + current.warmth = THREE.MathUtils.lerp(current.warmth, target.warmth, mix); + current.terrainEnergy = THREE.MathUtils.lerp(current.terrainEnergy, target.terrainEnergy, mix); + current.starVisibility = THREE.MathUtils.lerp(current.starVisibility, target.starVisibility, mix); + current.streakEnergy = THREE.MathUtils.lerp(current.streakEnergy, target.streakEnergy, mix); +} diff --git a/src/scene/world/palette.ts b/src/scene/world/palette.ts index a3c6520..9bf7f98 100644 --- a/src/scene/world/palette.ts +++ b/src/scene/world/palette.ts @@ -29,7 +29,9 @@ export interface MutableChapterPalette { export interface MutableColor { clone(): MutableColor; + copy(source: MutableColor): MutableColor; getHex(): number; + lerp(target: MutableColor, progress: number): MutableColor; lerpColors(from: MutableColor, to: MutableColor, progress: number): MutableColor; setHex(value: number): MutableColor; } diff --git a/src/scene/world/particles.ts b/src/scene/world/particles.ts deleted file mode 100644 index 574c943..0000000 --- a/src/scene/world/particles.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as THREE from 'three'; -import type { SceneResourceBag } from '@/scene/resources'; - -export function createParticles(resources: SceneResourceBag, count: number) { - const positions = new Float32Array(count * 3); - for (let index = 0; index < count; index += 1) { - const offset = index * 3; - positions[offset] = seeded(index * 3) * 30 - 15; - positions[offset + 1] = seeded(index * 3 + 1) * 7 + 0.2; - positions[offset + 2] = -(seeded(index * 3 + 2) * 150); - } - const geometry = resources.own('geometry', new THREE.BufferGeometry()); - geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - const material = resources.own('material', new THREE.PointsMaterial({ - color: 0xa8f5ff, - size: 0.07, - transparent: true, - opacity: 0.42, - depthWrite: false, - })); - const points = new THREE.Points(geometry, material); - - return { - points, - update(distance: number, speed: number, acceleration: number) { - points.position.z = modulo(distance * (1 + speed * 2), 150); - material.opacity = Math.min(0.78, 0.25 + speed * 0.28 + acceleration * 0.18); - material.size = 0.055 + Math.min(0.1, speed * 0.04); - }, - }; -} - -function seeded(value: number): number { - const result = Math.sin(value * 91.713 + 17.17) * 43758.5453; - return result - Math.floor(result); -} - -function modulo(value: number, divisor: number): number { - return ((value % divisor) + divisor) % divisor; -} diff --git a/src/scene/world/sun.ts b/src/scene/world/sun.ts index fc8c5a7..bab8c58 100644 --- a/src/scene/world/sun.ts +++ b/src/scene/world/sun.ts @@ -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> = { - '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, 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; +} diff --git a/src/scene/world/terrain.ts b/src/scene/world/terrain.ts index 918316a..e55a57b 100644 --- a/src/scene/world/terrain.ts +++ b/src/scene/world/terrain.ts @@ -2,7 +2,7 @@ import * as THREE from 'three'; import type { EnvironmentChapter } from '@/journey/types'; import type { RenderQualitySettings } from '@/scene/render-quality'; import type { SceneResourceBag } from '@/scene/resources'; -import { CHAPTER_PALETTES } from '@/scene/world/palette'; +import { CHAPTER_PALETTES, type MutableChapterPalette } from '@/scene/world/palette'; export interface TerrainWorld { group: WorldGroup; @@ -11,6 +11,7 @@ export interface TerrainWorld { speed: number, chapter: EnvironmentChapter, routeProgress: number, + palette?: Readonly, ): void; } @@ -131,14 +132,19 @@ export function createTerrain( return { group, - update(distance, speed, chapter, routeProgress) { - const palette = CHAPTER_PALETTES[chapter]; + update(distance, speed, chapter, routeProgress, palette) { + const chapterPalette = CHAPTER_PALETTES[chapter]; uniforms.uDistance.value = finiteOrZero(distance); uniforms.uSpeed.value = clamp01(speed); uniforms.uRouteProgress.value = clamp01(routeProgress); - uniforms.uTerrainEnergy.value = palette.terrainEnergy; - lowColor.setHex(palette.terrainLow); - highColor.setHex(palette.terrainHigh); + uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy; + if (palette) { + lowColor.copy(palette.terrainLow); + highColor.copy(palette.terrainHigh); + } else { + lowColor.setHex(chapterPalette.terrainLow); + highColor.setHex(chapterPalette.terrainHigh); + } }, }; } diff --git a/src/styles/world.css b/src/styles/world.css index 0bb259f..ef848eb 100644 --- a/src/styles/world.css +++ b/src/styles/world.css @@ -29,23 +29,83 @@ object-fit: cover; background: radial-gradient( - circle at 50% 53%, - rgb(247 105 115 / 82%) 0 7%, - transparent 7.3% + ellipse at 42% 89%, + rgb(255 45 85 / 72%) 0 0.65%, + rgb(255 45 85 / 20%) 0.9% 2.6%, + transparent 4.8% + ), + radial-gradient( + ellipse at 58% 89%, + rgb(255 45 85 / 72%) 0 0.65%, + rgb(255 45 85 / 20%) 0.9% 2.6%, + transparent 4.8% + ), + linear-gradient( + 166deg, + transparent 0 67%, + rgb(12 21 31 / 98%) 67.2% 76%, + transparent 76.2% + ), + linear-gradient( + 194deg, + transparent 0 67%, + rgb(12 21 31 / 98%) 67.2% 76%, + transparent 76.2% + ), + radial-gradient( + ellipse at 50% 91%, + rgb(5 9 15 / 100%) 0 13%, + transparent 13.4% + ), + radial-gradient( + ellipse at 50% 55.8%, + transparent 0 4.8%, + rgb(4 8 20 / 88%) 5% 5.35%, + transparent 5.55% + ), + radial-gradient( + ellipse at 50% 54.6%, + rgb(244 173 103 / 92%) 0 6.7%, + rgb(199 75 145 / 65%) 7.1% 9.8%, + transparent 14% + ), + repeating-linear-gradient( + 0deg, + transparent 0 5.2%, + rgb(4 8 20 / 88%) 5.3% 5.65%, + transparent 5.75% 7.2% ), linear-gradient( 165deg, - transparent 0 60%, - rgb(73 198 215 / 34%) 60.2% 60.5%, - transparent 60.7% + transparent 0 55%, + rgb(120 216 229 / 54%) 55.12% 55.32%, + transparent 55.5% ), linear-gradient( 195deg, - transparent 0 60%, - rgb(73 198 215 / 34%) 60.2% 60.5%, - transparent 60.7% + transparent 0 55%, + rgb(120 216 229 / 54%) 55.12% 55.32%, + transparent 55.5% ), - linear-gradient(180deg, #050817 0 48%, #24142c 67%, #080c17 100%); + conic-gradient( + from 167deg at 50% 55%, + rgb(5 9 17 / 96%) 0 12deg, + transparent 12.4deg 335.6deg, + rgb(5 9 17 / 96%) 336deg 360deg + ), + repeating-linear-gradient( + 0deg, + transparent 0 6.5%, + rgb(73 198 215 / 14%) 6.65% 6.8%, + transparent 6.95% 9.5% + ), + radial-gradient( + ellipse at 50% 62%, + rgb(131 41 109 / 42%) 0, + transparent 48% + ), + linear-gradient(180deg, #030713 0 38%, #0c1025 54%, #26162e 69%, #050a14 100%); + background-size: cover; } .journey-canvas { @@ -276,13 +336,7 @@ @media (prefers-reduced-motion: reduce) { .journey-poster { - background: - radial-gradient( - circle at 50% 53%, - rgb(247 105 115 / 48%) 0 7%, - transparent 7.3% - ), - linear-gradient(180deg, #050817 0 48%, #24142c 67%, #080c17 100%); + filter: saturate(0.86) brightness(0.92); } } diff --git a/tests/browser/journey-integration.spec.ts b/tests/browser/journey-integration.spec.ts index 4a8f037..3a912f1 100644 --- a/tests/browser/journey-integration.spec.ts +++ b/tests/browser/journey-integration.spec.ts @@ -22,6 +22,13 @@ test('keeps navigation, HUD, progress, chapter, and arrival on the same next-fra page, distUrl, }) => { + const graphicsErrors: string[] = []; + page.on('console', (message) => { + if ( + message.type() === 'error' + && /THREE\.WebGLProgram|Shader Error|VALIDATE_STATUS/iu.test(message.text()) + ) graphicsErrors.push(message.text()); + }); await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', /running|failed|static/u); const chapterColors: string[] = []; @@ -60,16 +67,13 @@ test('keeps navigation, HUD, progress, chapter, and arrival on the same next-fra const instrumentation = await readSceneInstrumentation(page); expect(instrumentation.maximumCanvasCount).toBeLessThanOrEqual(1); - const expectedChapterColors = (page.viewportSize()?.width ?? 0) < 768 - ? [ - '#040713', '#040713', '#060918', '#080b1d', - '#0d1025', '#0d1025', '#18152c', '#2b2035', - ] - : [ - '#000102', '#000102', '#000102', '#010103', - '#010105', '#010105', '#020206', '#060409', - ]; - expect(chapterColors).toEqual(expectedChapterColors); + const chapterEnergy = chapterColors.map(colorEnergy); + for (let index = 1; index < chapterEnergy.length; index += 1) { + expect(chapterEnergy[index]).toBeGreaterThanOrEqual(chapterEnergy[index - 1] ?? 0); + } + expect(chapterEnergy.at(-1)).toBeGreaterThan(chapterEnergy[0] ?? 0); + expect(new Set(chapterColors).size).toBeGreaterThanOrEqual(4); + expect(graphicsErrors).toEqual([]); }); test('draws only the independently eligible next roadside sign during passive forward travel', async ({ @@ -99,3 +103,8 @@ test('draws only the independently eligible next roadside sign during passive fo const labels = (await readSceneInstrumentation(page)).routeLabelsDrawn; expect([...new Set(labels)]).toEqual([oracle.expectedLabel]); }); + +function colorEnergy(color: string): number { + const value = Number.parseInt(color.slice(1), 16); + return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff); +} diff --git a/tests/unit/scene-resources.test.ts b/tests/unit/scene-resources.test.ts index cbad943..81c1da8 100644 --- a/tests/unit/scene-resources.test.ts +++ b/tests/unit/scene-resources.test.ts @@ -160,8 +160,9 @@ describe('scene render quality', () => { roadSegments: 192, terrainSegments: 96, terrainOctaves: 3, - particleCount: 384, + starCount: 384, streakCount: 48, + postScale: 1, postEffects: true, }); expect(medium).toEqual({ @@ -169,8 +170,9 @@ describe('scene render quality', () => { roadSegments: 144, terrainSegments: 64, terrainOctaves: 2, - particleCount: 192, + starCount: 192, streakCount: 28, + postScale: 0.7, postEffects: true, }); expect(low).toEqual({ @@ -178,8 +180,9 @@ describe('scene render quality', () => { roadSegments: 96, terrainSegments: 32, terrainOctaves: 1, - particleCount: 64, + starCount: 64, streakCount: 12, + postScale: 0, postEffects: false, }); }); diff --git a/tests/unit/scroll-drive-controller.test.ts b/tests/unit/scroll-drive-controller.test.ts index 2d72ddb..59324b7 100644 --- a/tests/unit/scroll-drive-controller.test.ts +++ b/tests/unit/scroll-drive-controller.test.ts @@ -100,7 +100,7 @@ describe('scroll drive controller', () => { expect(snapshot.driveSpeed).toBeCloseTo(0.9490152503018645, 14); }); - it('resets transient velocity and establishes a baseline after a gap over 250 ms', () => { + it('resets transient velocity and establishes a baseline after a stationary gap over 250 ms', () => { const controller = createScrollDriveController(); controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 }); controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 }); @@ -115,6 +115,19 @@ describe('scroll drive controller', () => { expect(controller.sample({ scrollY: 600, now: 451, maxScrollY: 1000 }).direction).toBe(1); }); + it('preserves signed movement while dropping acceleration on the first delayed sample', () => { + const controller = createScrollDriveController(); + controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 }); + + expect(controller.sample({ scrollY: 500, now: 351, maxScrollY: 1000 })).toEqual({ + scrollY: 500, + routeProgress: 0.5, + direction: 1, + acceleration: 0, + driveSpeed: 0.18, + }); + }); + it.each([ ['downward', 0, 1000, 1], ['upward', 1000, 0, -1], diff --git a/tests/unit/world-models.test.ts b/tests/unit/world-models.test.ts index 58e8009..d9f48d9 100644 --- a/tests/unit/world-models.test.ts +++ b/tests/unit/world-models.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import * as THREE from 'three'; import type { EnvironmentChapter } from '@/journey/types'; import { renderQualityFor } from '@/scene/render-quality'; @@ -10,6 +10,9 @@ import { } from '@/scene/world/palette'; import { createRoad } from '@/scene/world/road'; import { createTerrain } from '@/scene/world/terrain'; +import { createSun } from '@/scene/world/sun'; +import { createAtmosphere } from '@/scene/world/atmosphere'; +import { createPostProcessor } from '@/scene/post-processing'; describe('procedural world models', () => { it('scales geometry and atmosphere work monotonically by live quality tier', () => { @@ -132,6 +135,99 @@ describe('procedural world models', () => { expect(target.warmth).toBeGreaterThan(CHAPTER_PALETTES['deep-night'].warmth); expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.warmth); }); + + it('renders the sun as a scan-banded disc with a separately owned halo', () => { + const resources = new SceneResourceBag(); + const sun = createSun(resources); + const disc = sun.group.getObjectByName('sun-disc') as ShaderRenderable; + const halo = sun.group.getObjectByName('sun-halo'); + + expect(disc).toBeInstanceOf(THREE.Mesh); + expect(halo).toBeInstanceOf(THREE.Sprite); + expect(disc.material.uniforms).toMatchObject({ + uScanBands: { value: 14 }, + uWarmth: { value: CHAPTER_PALETTES['deep-night'].warmth }, + }); + expect(disc.material.fragmentShader).toContain('scanBand'); + expect(resources.snapshot().owned.texture).toBe(1); + + const palette = createMutableChapterPalette('destination'); + sun.update(palette, 1); + expect(disc.material.uniforms.uWarmth?.value).toBe(1); + expect(disc.material.uniforms.uRouteProgress?.value).toBe(1); + resources.dispose(); + }); + + it('builds deterministic stars, haze layers, and quality-capped speed streaks', () => { + const highResources = new SceneResourceBag(); + const lowResources = new SceneResourceBag(); + const highQuality = renderQualityFor('high', 1, false); + const lowQuality = renderQualityFor('low', 1, true); + const high = createAtmosphere(highResources, highQuality); + const low = createAtmosphere(lowResources, lowQuality); + const highStars = high.group.getObjectByName('atmosphere-stars') as ShaderRenderable; + const lowStars = low.group.getObjectByName('atmosphere-stars') as ShaderRenderable; + const highStreaks = high.group.getObjectByName('atmosphere-streaks') as ShaderRenderable; + const lowStreaks = low.group.getObjectByName('atmosphere-streaks') as ShaderRenderable; + + expect(highStars.geometry.getAttribute('position').count).toBe(highQuality.starCount); + expect(lowStars.geometry.getAttribute('position').count).toBe(lowQuality.starCount); + expect(highStreaks.geometry.getAttribute('position').count).toBe(highQuality.streakCount * 2); + expect(lowStreaks.geometry.getAttribute('position').count).toBe(lowQuality.streakCount * 2); + expect(high.group.getObjectByName('horizon-haze-near')).toBeInstanceOf(THREE.Mesh); + expect(high.group.getObjectByName('horizon-haze-far')).toBeInstanceOf(THREE.Mesh); + + const highStarPositions = Array.from(highStars.geometry.getAttribute('position').array); + const secondResources = new SceneResourceBag(); + const secondHigh = createAtmosphere(secondResources, highQuality); + const secondStars = secondHigh.group.getObjectByName('atmosphere-stars') as ShaderRenderable; + expect(Array.from(secondStars.geometry.getAttribute('position').array)).toEqual(highStarPositions); + + const palette = createMutableChapterPalette('builds'); + const before = highResources.snapshot(); + for (let index = 0; index < 300; index += 1) { + high.update(index, 0.7, 0.8, palette); + } + expect(highResources.snapshot()).toEqual(before); + expect(highStreaks.material.uniforms.uAcceleration?.value).toBe(0.8); + expect(highStreaks.material.uniforms.uVisibility?.value).toBeGreaterThan(0); + + highResources.dispose(); + lowResources.dispose(); + secondResources.dispose(); + }); + + it('disposes a composer and its passes exactly once', () => { + const resources = new SceneResourceBag(); + const composer = { + addPass: vi.fn(), + setPixelRatio: vi.fn(), + setSize: vi.fn(), + render: vi.fn(), + dispose: vi.fn(), + }; + const renderPass = { scene: null, camera: null, setSize: vi.fn(), dispose: vi.fn() }; + const bloomPass = { setSize: vi.fn(), dispose: vi.fn() }; + const processor = createPostProcessor(resources, {}, { + createComposer: () => composer, + createRenderPass: () => renderPass, + createBloomPass: () => bloomPass, + }); + + processor.resize(1_200, 800, 1.25); + processor.render({}, {}); + processor.dispose(); + processor.dispose(); + resources.dispose(); + + expect(composer.addPass).toHaveBeenCalledTimes(2); + expect(composer.setPixelRatio).toHaveBeenCalledWith(1.25); + expect(composer.setSize).toHaveBeenCalledWith(1_200, 800); + expect(composer.render).toHaveBeenCalledOnce(); + expect(renderPass.dispose).toHaveBeenCalledOnce(); + expect(bloomPass.dispose).toHaveBeenCalledOnce(); + expect(composer.dispose).toHaveBeenCalledOnce(); + }); }); function paletteSnapshot(palette: ReturnType) {