diff --git a/src/audio/drive-texture.ts b/src/audio/drive-texture.ts index d6b030b..0fb02d1 100644 --- a/src/audio/drive-texture.ts +++ b/src/audio/drive-texture.ts @@ -13,7 +13,7 @@ const PASS_BY_SECONDS = 1.6; export interface DriveTexture { setEnergy(sample: DriveEnergySample): void; - /** The exit gates going by: a filtered air sweep across the stereo field. */ + /** Passing under the gantry: a filtered air sweep across the stereo field. */ passBy?(): void; dispose(): void; } diff --git a/src/audio/soundtrack-engine.ts b/src/audio/soundtrack-engine.ts index 468cd84..ef03558 100644 --- a/src/audio/soundtrack-engine.ts +++ b/src/audio/soundtrack-engine.ts @@ -554,8 +554,8 @@ export function createSoundtrackEngine( }, setChapterIntent(intent) { if (closed) return; - // The scene runs its exit gates on every chapter change; the drive - // texture answers with the air of the gates going by. + // Every chapter change passes under the overhead gantry; the drive + // texture answers with the air of the structure going by. if (intent.sectionId !== latestChapter.sectionId) driveTexture?.passBy?.(); latestChapter = intent; mixer.setChapterIntent(intent); diff --git a/src/scene/cinematic-grade.ts b/src/scene/cinematic-grade.ts index b3564ed..dee2870 100644 --- a/src/scene/cinematic-grade.ts +++ b/src/scene/cinematic-grade.ts @@ -48,8 +48,6 @@ export interface LensState { time: number; /** Letterbox bar height as a fraction of the frame, top and bottom. */ letterbox: number; - /** Additive exposure flash, 0 at rest. */ - flash: number; /** Drive energy: widens aberration and streaks a little. */ energy: number; } @@ -70,7 +68,6 @@ export class LensFinishPass extends Pass { uAberration: { value: 0.0035 }, uGrain: { value: 0.03 }, uVignette: { value: 0.42 }, - uFlash: { value: 0 }, toneMappingExposure: { value: 1 }, }; @@ -108,7 +105,6 @@ export class LensFinishPass extends Pass { uniform float uAberration; uniform float uGrain; uniform float uVignette; - uniform float uFlash; varying vec2 vUv; #include #include @@ -139,7 +135,6 @@ export class LensFinishPass extends Pass { float streakLevel = max( dot( streak, vec3( 0.3, 0.45, 0.25 ) ) - 0.08, 0.0 ); color += vec3( 0.32, 0.62, 1.0 ) * streakLevel * uStreak; } - color *= 1.0 + uFlash; color = CustomToneMapping( color ); color = sRGBTransferOETF( vec4( color, 1.0 ) ).rgb; float vignette = smoothstep( 0.18, 1.05, length( centered * vec2( 1.0, 0.86 ) ) * 1.42 ); @@ -165,7 +160,6 @@ export class LensFinishPass extends Pass { const energy = THREE.MathUtils.clamp(state.energy, 0, 1); this.uniforms.uTime.value = state.time; this.uniforms.uLetterbox.value = THREE.MathUtils.clamp(state.letterbox, 0, 0.2); - this.uniforms.uFlash.value = Math.max(0, state.flash); this.uniforms.uAberration.value = 0.0035 + energy * 0.006; if (this.uniforms.tStreak.value) { this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6); diff --git a/src/scene/world/districts.ts b/src/scene/world/districts.ts index 1ed07c8..0b6bd51 100644 --- a/src/scene/world/districts.ts +++ b/src/scene/world/districts.ts @@ -224,11 +224,11 @@ export function createDistricts( varying float vRise; void main() { ${FADES} - // Neon tubes catch as the district finishes rising: a brief - // stutter, then steady (no per-instance CPU work). + // Neon tubes warm up as the district finishes rising. A smooth + // ignite, never a stutter: nothing in the scene flashes faster + // than 3 Hz (photosensitivity, round 5). float on = smoothstep(0.8, 1.0, vRise); - float stutter = step(0.35, fract(sin(floor(uTime * 22.0) + vWorld.z * 0.07) * 43758.5)); - on *= mix(stutter, 1.0, step(0.985, vRise)); + on *= on; vec3 tone = mix(vec3(0.55, 0.95, 1.0), uAccent, ${accentMix.toFixed(2)}); // Close passes dim, so overhead neon never floods the bloom pass. float near = mix(0.3, 1.0, smoothstep(5.0, 32.0, vDepth)); diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 078aeab..6100357 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -20,7 +20,6 @@ import { createPostProcessor } from '@/scene/post-processing'; import { installCinematicGrade } from '@/scene/cinematic-grade'; import { createSkyline } from '@/scene/world/skyline'; import { createTraffic } from '@/scene/world/traffic'; -import { createExitGates } from '@/scene/world/exit-gates'; import { createDriveDynamics } from '@/scene/drive-dynamics'; import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics'; import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition'; @@ -129,7 +128,6 @@ export async function createProceduralSceneGeneration( const horizonAir = createHorizonAir(context.resources); const skyline = createSkyline(context.resources); const traffic = createTraffic(context.resources, quality); - const exitGates = createExitGates(context.resources); const authoredCar = await loadGrandTourer(context.signal); const car = createCar(context.resources, authoredCar); car.setDetail(mobileOrTablet ? 'reduced' : 'full'); @@ -149,7 +147,6 @@ export async function createProceduralSceneGeneration( horizonAir.group, skyline.group, traffic.group, - exitGates.group, car.group, speedWake.group, sign.group, @@ -350,7 +347,6 @@ export async function createProceduralSceneGeneration( 18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48, elapsedSeconds, ); - const cut = exitGates.update(snapshot.activeSectionId, deltaSeconds); sun.update( currentPalette, snapshot.routeProgress, @@ -589,12 +585,11 @@ export async function createProceduralSceneGeneration( } } if (postProcessor) { - postProcessor.setDynamics(Math.max(response.bloomEnergy, cut * 0.45)); + postProcessor.setDynamics(response.bloomEnergy); postProcessor.setLens({ time: elapsedSeconds, letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)), - flash: cut * 0.08, - energy: Math.max(overdrive, cut), + energy: overdrive, }); postProcessor.render(scene, camera); } else { diff --git a/src/scene/world/exit-gates.ts b/src/scene/world/exit-gates.ts deleted file mode 100644 index 1df5bc4..0000000 --- a/src/scene/world/exit-gates.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as THREE from 'three'; -import type { SceneResourceBag } from '@/scene/resources'; -import type { SectionId } from '@/content/types'; - -/** - * The cut between chapters: on every exit change the car runs a short gallery - * of light gates in the next chapter's accent. Nine portal frames rush past the - * camera in about two seconds while the lens flashes and the field of view - * opens. At rest the gates are hidden and cost no draw call. - */ -const GATES = 9; -const SPACING = 17; -const START_Z = -150; -const TRAVEL = GATES * SPACING + 205; -const DURATION = 2.1; -const HALF_SPAN = 11.2; -const HEIGHT = 10.5; - -export const SECTION_ACCENTS: Readonly> = { - start: 0xff3f9e, - profile: 0x3fe6f2, - experience: 0xffb15e, - infrastructure: 0x41e9d6, - 'selected-builds': 0xff5b8a, - 'additional-work': 0xb57bff, - toolkit: 0x47d2ff, - contact: 0xffc078, -}; - -export function createExitGates(resources: SceneResourceBag) { - const group = new THREE.Group(); - group.name = 'exit-gates'; - group.visible = false; - const uniforms = { - uColor: { value: new THREE.Color(SECTION_ACCENTS.profile) }, - uIntensity: { value: 0 }, - }; - const material = resources.own( - 'material', - new THREE.ShaderMaterial({ - uniforms, - vertexShader: /* glsl */ ` - varying float vDepth; - varying vec3 vLocal; - void main() { - vLocal = position; - vec4 view = modelViewMatrix * instanceMatrix * vec4(position, 1.0); - vDepth = -view.z; - gl_Position = projectionMatrix * view; - }`, - fragmentShader: /* glsl */ ` - uniform vec3 uColor; - uniform float uIntensity; - varying float vDepth; - varying vec3 vLocal; - void main() { - float near = smoothstep(5.0, 26.0, vDepth); - float far = 1.0 - smoothstep(140.0, 230.0, vDepth); - vec3 hot = mix(uColor, vec3(1.0), 0.12); - gl_FragColor = vec4(hot * 1.35 * uIntensity * near * far, 1.0); - #include - #include - }`, - transparent: true, - depthWrite: false, - blending: THREE.AdditiveBlending, - }), - ); - const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1)); - const bars = new THREE.InstancedMesh(box, material, GATES * 4); - bars.name = 'exit-gate-frames'; - bars.frustumCulled = false; - bars.instanceMatrix.setUsage(THREE.DynamicDrawUsage); - group.add(bars); - const dummy = new THREE.Object3D(); - - let progress = 1; - let previous: SectionId | null = null; - let warmFrames = 2; - - const place = (index: number, x: number, y: number, z: number, sx: number, sy: number) => { - dummy.position.set(x, y, z); - dummy.scale.set(sx, sy, 0.14); - dummy.updateMatrix(); - bars.setMatrixAt(index, dummy.matrix); - }; - - return { - group, - /** Returns the cut's energy, 0 at rest, for the lens and the camera. */ - update(section: SectionId, deltaSeconds: number): number { - if (previous !== null && section !== previous) { - uniforms.uColor.value.setHex(SECTION_ACCENTS[section]); - // A cut already under way keeps its momentum instead of restarting. - if (progress >= 0.7) progress = 0; - } - previous = section; - if (warmFrames > 0) { - // Draw the gates unlit for the first frames so the shader program - // compiles at the on-ramp, not as a stall on the first exit. - warmFrames -= 1; - uniforms.uIntensity.value = 0; - group.visible = true; - return 0; - } - if (progress >= 1) { - group.visible = false; - return 0; - } - progress = Math.min(1, progress + Math.max(0, deltaSeconds) / DURATION); - const eased = 1 - Math.pow(1 - progress, 1.6); - const travel = eased * TRAVEL; - const envelope = THREE.MathUtils.smoothstep(progress, 0, 0.14) - * (1 - THREE.MathUtils.smoothstep(progress, 0.78, 1)); - uniforms.uIntensity.value = envelope; - for (let i = 0; i < GATES; i++) { - const z = START_Z - i * SPACING + travel; - const base = i * 4; - place(base, -HALF_SPAN, HEIGHT / 2, z, 0.1, HEIGHT); - place(base + 1, HALF_SPAN, HEIGHT / 2, z, 0.1, HEIGHT); - place(base + 2, 0, HEIGHT, z, HALF_SPAN * 2 + 0.1, 0.1); - // A floor seam makes each gate cross the asphalt as a line of light. - place(base + 3, 0, 0.03, z, HALF_SPAN * 2, 0.05); - } - bars.instanceMatrix.needsUpdate = true; - group.visible = true; - return envelope; - }, - }; -} diff --git a/src/styles/wayfinding.css b/src/styles/wayfinding.css index 16f20b7..9d9076a 100644 --- a/src/styles/wayfinding.css +++ b/src/styles/wayfinding.css @@ -179,7 +179,7 @@ body::after { } @media (prefers-reduced-motion: no-preference) { .portfolio-section[data-current]::before { - animation: exit-strike 1.1s steps(1, end) both; + animation: exit-strike 1.1s ease-out both; } .portfolio-section[data-current] { animation: exit-panel-light 1.6s cubic-bezier(0.2, 0.75, 0.2, 1) both; @@ -192,14 +192,13 @@ body::after { } } } -/* A neon tube catching: two short flickers, then steady. */ +/* A neon tube warming up: one soft swell, then steady. Below 3 Hz by design + (photosensitivity): a single rise over the whole duration. */ @keyframes exit-strike { 0% { opacity: 0.25; } - 12% { opacity: 1; } - 18% { opacity: 0.35; } - 30% { opacity: 1; } - 36% { opacity: 0.55; } - 48%, 100% { opacity: 1; } + 45% { opacity: 0.85; } + 60% { opacity: 0.7; } + 100% { opacity: 1; } } @keyframes exit-panel-light { 0% { border-top-color: color-mix(in srgb, var(--chapter-accent, var(--accent)) 95%, transparent); }