Files
Portfolio/src/scene/world/arrival-meteors.ts
LucaP f46131723b feat: finish portfolio title, navigation, and destination effects
Add the approved name animation, rounded docking navigation, localized keyboard focus cue, unified builds card, and destination copy hierarchy.

Introduce lightweight one-shot meteors and a sun ring that repeats every three seconds at Contact. Open the email action in a separate browsing context. Preserve reduced-motion behavior and keep private handoff and visual-check artifacts out of version control.

Validation: 649 tests passed; all 20 failures in world-models and SceneCanvas reproduce on baseline 103427f. Typecheck, artifact verification, and transfer budgets passed. Client/SSR preview build passed before this commit. Existing baseline test failures remain unchanged.
2026-09-05 22:16:42 +03:00

92 lines
3.7 KiB
TypeScript

import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import { SUN_DISTANCE_Z } from './metrics';
/** Three authored streaks, batched into six triangles. No simulation or textures. */
export function createArrivalMeteors(resources: SceneResourceBag) {
const flights = [
{ x: 160, y: 335, dx: -250, dy: -100, delay: 0.1, duration: 1.8, length: 116 },
{ x: 280, y: 300, dx: -240, dy: -90, delay: 0.9, duration: 1.8, length: 95 },
{ x: 100, y: 345, dx: -205, dy: -115, delay: 1.7, duration: 1.9, length: 130 },
];
const positions: number[] = [];
const uvs: number[] = [];
const timing: number[] = [];
const travel: number[] = [];
const corners = [[0, 0], [1, 0], [0, 1], [1, 0], [1, 1], [0, 1]] as const;
for (const flight of flights) {
const distance = Math.hypot(flight.dx, flight.dy);
const dx = flight.dx / distance;
const dy = flight.dy / distance;
for (const [u, v] of corners) {
positions.push(
flight.x + (u - 1) * flight.length * dx - (v - 0.5) * 7 * dy,
flight.y + (u - 1) * flight.length * dy + (v - 0.5) * 7 * dx,
0,
);
uvs.push(u, v);
timing.push(flight.delay, flight.duration);
travel.push(flight.dx, flight.dy);
}
}
const geometry = resources.own('geometry', new THREE.BufferGeometry());
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.setAttribute('aFlight', new THREE.Float32BufferAttribute(timing, 2));
geometry.setAttribute('aTravel', new THREE.Float32BufferAttribute(travel, 2));
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uTime: { value: -1 } },
vertexShader: `
uniform float uTime;
attribute vec2 aFlight;
attribute vec2 aTravel;
varying vec2 vUv;
varying float vLife;
void main() {
float phase = (uTime - aFlight.x) / aFlight.y;
vLife = smoothstep(0.0, 0.18, phase) * (1.0 - smoothstep(0.72, 1.0, phase));
vUv = uv;
vec3 point = position + vec3(aTravel * clamp(phase, 0.0, 1.0), 0.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(point, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
varying float vLife;
void main() {
float across = abs(vUv.y * 2.0 - 1.0);
float taper = pow(vUv.x, 1.4);
float core = (1.0 - smoothstep(0.06, 0.28, across)) * taper;
float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper;
float head = (1.0 - smoothstep(0.0, 1.0,
length(vec2((vUv.x - 0.965) * 30.0, across))));
float alpha = vLife * max(glow * 0.8, head);
if (alpha < 0.005) discard;
vec3 light = mix(vec3(0.22, 0.62, 1.0), vec3(1.0, 0.94, 0.8), max(core, head));
gl_FragColor = vec4(light * 1.8, alpha);
#include <colorspace_fragment>
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: true,
toneMapped: false,
}));
const mesh = new THREE.Mesh(geometry, material);
mesh.name = 'arrival-meteors';
mesh.position.z = SUN_DISTANCE_Z + 8;
// Flight displacement lives in the shader; visibility is explicitly bounded.
mesh.frustumCulled = false;
mesh.visible = false;
let warmed = false;
return { mesh, update(age: number, active: boolean) {
// One invisible startup draw warms the exact live pipeline (including
// its post-processing color space), so arrival never compiles a variant.
const playing = active && age >= 0 && age < 4;
mesh.visible = playing || !warmed;
if (mesh.visible) material.uniforms.uTime.value = playing ? age : -1;
warmed = true;
} };
}