fix(scene): remove exit-gate flashes and sub-3 Hz-unsafe flickers
This commit is contained in:
@@ -13,7 +13,7 @@ const PASS_BY_SECONDS = 1.6;
|
|||||||
|
|
||||||
export interface DriveTexture {
|
export interface DriveTexture {
|
||||||
setEnergy(sample: DriveEnergySample): void;
|
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;
|
passBy?(): void;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -554,8 +554,8 @@ export function createSoundtrackEngine(
|
|||||||
},
|
},
|
||||||
setChapterIntent(intent) {
|
setChapterIntent(intent) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
// The scene runs its exit gates on every chapter change; the drive
|
// Every chapter change passes under the overhead gantry; the drive
|
||||||
// texture answers with the air of the gates going by.
|
// texture answers with the air of the structure going by.
|
||||||
if (intent.sectionId !== latestChapter.sectionId) driveTexture?.passBy?.();
|
if (intent.sectionId !== latestChapter.sectionId) driveTexture?.passBy?.();
|
||||||
latestChapter = intent;
|
latestChapter = intent;
|
||||||
mixer.setChapterIntent(intent);
|
mixer.setChapterIntent(intent);
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ export interface LensState {
|
|||||||
time: number;
|
time: number;
|
||||||
/** Letterbox bar height as a fraction of the frame, top and bottom. */
|
/** Letterbox bar height as a fraction of the frame, top and bottom. */
|
||||||
letterbox: number;
|
letterbox: number;
|
||||||
/** Additive exposure flash, 0 at rest. */
|
|
||||||
flash: number;
|
|
||||||
/** Drive energy: widens aberration and streaks a little. */
|
/** Drive energy: widens aberration and streaks a little. */
|
||||||
energy: number;
|
energy: number;
|
||||||
}
|
}
|
||||||
@@ -70,7 +68,6 @@ export class LensFinishPass extends Pass {
|
|||||||
uAberration: { value: 0.0035 },
|
uAberration: { value: 0.0035 },
|
||||||
uGrain: { value: 0.03 },
|
uGrain: { value: 0.03 },
|
||||||
uVignette: { value: 0.42 },
|
uVignette: { value: 0.42 },
|
||||||
uFlash: { value: 0 },
|
|
||||||
toneMappingExposure: { value: 1 },
|
toneMappingExposure: { value: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,7 +105,6 @@ export class LensFinishPass extends Pass {
|
|||||||
uniform float uAberration;
|
uniform float uAberration;
|
||||||
uniform float uGrain;
|
uniform float uGrain;
|
||||||
uniform float uVignette;
|
uniform float uVignette;
|
||||||
uniform float uFlash;
|
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
#include <tonemapping_pars_fragment>
|
#include <tonemapping_pars_fragment>
|
||||||
#include <colorspace_pars_fragment>
|
#include <colorspace_pars_fragment>
|
||||||
@@ -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 );
|
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 += vec3( 0.32, 0.62, 1.0 ) * streakLevel * uStreak;
|
||||||
}
|
}
|
||||||
color *= 1.0 + uFlash;
|
|
||||||
color = CustomToneMapping( color );
|
color = CustomToneMapping( color );
|
||||||
color = sRGBTransferOETF( vec4( color, 1.0 ) ).rgb;
|
color = sRGBTransferOETF( vec4( color, 1.0 ) ).rgb;
|
||||||
float vignette = smoothstep( 0.18, 1.05, length( centered * vec2( 1.0, 0.86 ) ) * 1.42 );
|
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);
|
const energy = THREE.MathUtils.clamp(state.energy, 0, 1);
|
||||||
this.uniforms.uTime.value = state.time;
|
this.uniforms.uTime.value = state.time;
|
||||||
this.uniforms.uLetterbox.value = THREE.MathUtils.clamp(state.letterbox, 0, 0.2);
|
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;
|
this.uniforms.uAberration.value = 0.0035 + energy * 0.006;
|
||||||
if (this.uniforms.tStreak.value) {
|
if (this.uniforms.tStreak.value) {
|
||||||
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
|
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
|
||||||
|
|||||||
@@ -224,11 +224,11 @@ export function createDistricts(
|
|||||||
varying float vRise;
|
varying float vRise;
|
||||||
void main() {
|
void main() {
|
||||||
${FADES}
|
${FADES}
|
||||||
// Neon tubes catch as the district finishes rising: a brief
|
// Neon tubes warm up as the district finishes rising. A smooth
|
||||||
// stutter, then steady (no per-instance CPU work).
|
// 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 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 *= on;
|
||||||
on *= mix(stutter, 1.0, step(0.985, vRise));
|
|
||||||
vec3 tone = mix(vec3(0.55, 0.95, 1.0), uAccent, ${accentMix.toFixed(2)});
|
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.
|
// Close passes dim, so overhead neon never floods the bloom pass.
|
||||||
float near = mix(0.3, 1.0, smoothstep(5.0, 32.0, vDepth));
|
float near = mix(0.3, 1.0, smoothstep(5.0, 32.0, vDepth));
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { createPostProcessor } from '@/scene/post-processing';
|
|||||||
import { installCinematicGrade } from '@/scene/cinematic-grade';
|
import { installCinematicGrade } from '@/scene/cinematic-grade';
|
||||||
import { createSkyline } from '@/scene/world/skyline';
|
import { createSkyline } from '@/scene/world/skyline';
|
||||||
import { createTraffic } from '@/scene/world/traffic';
|
import { createTraffic } from '@/scene/world/traffic';
|
||||||
import { createExitGates } from '@/scene/world/exit-gates';
|
|
||||||
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
||||||
import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
|
import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
|
||||||
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
||||||
@@ -129,7 +128,6 @@ export async function createProceduralSceneGeneration(
|
|||||||
const horizonAir = createHorizonAir(context.resources);
|
const horizonAir = createHorizonAir(context.resources);
|
||||||
const skyline = createSkyline(context.resources);
|
const skyline = createSkyline(context.resources);
|
||||||
const traffic = createTraffic(context.resources, quality);
|
const traffic = createTraffic(context.resources, quality);
|
||||||
const exitGates = createExitGates(context.resources);
|
|
||||||
const authoredCar = await loadGrandTourer(context.signal);
|
const authoredCar = await loadGrandTourer(context.signal);
|
||||||
const car = createCar(context.resources, authoredCar);
|
const car = createCar(context.resources, authoredCar);
|
||||||
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
|
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
|
||||||
@@ -149,7 +147,6 @@ export async function createProceduralSceneGeneration(
|
|||||||
horizonAir.group,
|
horizonAir.group,
|
||||||
skyline.group,
|
skyline.group,
|
||||||
traffic.group,
|
traffic.group,
|
||||||
exitGates.group,
|
|
||||||
car.group,
|
car.group,
|
||||||
speedWake.group,
|
speedWake.group,
|
||||||
sign.group,
|
sign.group,
|
||||||
@@ -350,7 +347,6 @@ export async function createProceduralSceneGeneration(
|
|||||||
18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48,
|
18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48,
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
);
|
);
|
||||||
const cut = exitGates.update(snapshot.activeSectionId, deltaSeconds);
|
|
||||||
sun.update(
|
sun.update(
|
||||||
currentPalette,
|
currentPalette,
|
||||||
snapshot.routeProgress,
|
snapshot.routeProgress,
|
||||||
@@ -589,12 +585,11 @@ export async function createProceduralSceneGeneration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (postProcessor) {
|
if (postProcessor) {
|
||||||
postProcessor.setDynamics(Math.max(response.bloomEnergy, cut * 0.45));
|
postProcessor.setDynamics(response.bloomEnergy);
|
||||||
postProcessor.setLens({
|
postProcessor.setLens({
|
||||||
time: elapsedSeconds,
|
time: elapsedSeconds,
|
||||||
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
|
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
|
||||||
flash: cut * 0.08,
|
energy: overdrive,
|
||||||
energy: Math.max(overdrive, cut),
|
|
||||||
});
|
});
|
||||||
postProcessor.render(scene, camera);
|
postProcessor.render(scene, camera);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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<Record<SectionId, number>> = {
|
|
||||||
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 <tonemapping_fragment>
|
|
||||||
#include <colorspace_fragment>
|
|
||||||
}`,
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -179,7 +179,7 @@ body::after {
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.portfolio-section[data-current]::before {
|
.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] {
|
.portfolio-section[data-current] {
|
||||||
animation: exit-panel-light 1.6s cubic-bezier(0.2, 0.75, 0.2, 1) both;
|
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 {
|
@keyframes exit-strike {
|
||||||
0% { opacity: 0.25; }
|
0% { opacity: 0.25; }
|
||||||
12% { opacity: 1; }
|
45% { opacity: 0.85; }
|
||||||
18% { opacity: 0.35; }
|
60% { opacity: 0.7; }
|
||||||
30% { opacity: 1; }
|
100% { opacity: 1; }
|
||||||
36% { opacity: 0.55; }
|
|
||||||
48%, 100% { opacity: 1; }
|
|
||||||
}
|
}
|
||||||
@keyframes exit-panel-light {
|
@keyframes exit-panel-light {
|
||||||
0% { border-top-color: color-mix(in srgb, var(--chapter-accent, var(--accent)) 95%, transparent); }
|
0% { border-top-color: color-mix(in srgb, var(--chapter-accent, var(--accent)) 95%, transparent); }
|
||||||
|
|||||||
Reference in New Issue
Block a user