Files
Portfolio/src/scene/world/expressway.ts
LucaP 5d5935a1f8 feat: immerse the portfolio in a chromatic synthwave night
Carry rose, violet, coral, electric blue and cyan through the interface, road, terrain, signs and atmosphere. Add a Blender-authored paint edition and matching runtime fallback captures. Interpolate atmospheric color on a small sky lattice and enrich existing speed effects without another rendering pass.

Preserve approved copy, project media, metadata, navigation contracts and adaptive audio. Verified with typecheck, client/SSR builds, validated prerendering, artifact and transfer checks, responsive Chrome interaction, fallback checks and Intel-backed performance sampling.
2026-09-05 13:17:53 +03:00

213 lines
6.6 KiB
TypeScript

import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SectionId } from '@/content/types';
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
/** A bounded kit of physical roadside objects. Instance origins wrap behind the
* camera; the entire object moves together, so long beams never tear at a seam. */
export function createExpressway(
resources: SceneResourceBag,
quality: RenderQualitySettings,
) {
const group = new THREE.Group();
group.name = 'expressway-architecture';
const uniforms = {
uDistance: { value: 0 },
uEnergy: { value: 0 },
uArchitecture: { value: 0 },
uWarmth: { value: 0 },
};
const vertexShader = `
uniform float uDistance;
uniform float uArchitecture;
varying vec3 vNormal;
varying vec3 vWorld;
varying float vDepth;
void main() {
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
vec4 p = instanceMatrix * vec4(position, 1.0);
p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z;
p.y *= 1.0 + uArchitecture * 0.22;
vNormal = normalize(mat3(instanceMatrix) * normal);
vWorld = p.xyz;
vec4 view = modelViewMatrix * p;
vDepth = -view.z;
gl_Position = projectionMatrix * view;
}
`;
function material(luminous: boolean, coral = false) {
return resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader,
fragmentShader: `
uniform float uEnergy;
uniform float uWarmth;
varying vec3 vNormal;
varying vec3 vWorld;
varying float vDepth;
void main() {
float farFade = 1.0 - smoothstep(380.0, 660.0, vDepth);
float nearFade = smoothstep(0.0, 9.0, vDepth);
vec3 cyan = mix(vec3(1.0, 0.025, 0.34), vec3(0.02, 0.78, 0.93), step(0.0, vWorld.x));
vec3 coral = vec3(1.0, 0.18, 0.095);
float facing = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.5, 0.9, 0.6))));
vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5)` : 'vec3(0.028, 0.012, 0.046) + vec3(0.09, 0.055, 0.14) * pow(facing, 2.0)'};
${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''}
if(farFade * nearFade < 0.02) discard;
gl_FragColor = vec4(color, farFade * nearFade);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: !luminous,
depthTest: true,
blending: luminous ? THREE.AdditiveBlending : THREE.NormalBlending,
}),
);
}
const graphite = material(false);
const cyan = material(true);
const coral = material(true, true);
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
const dummy = new THREE.Object3D();
type Part = {
x: number;
y: number;
z: number;
sx: number;
sy: number;
sz: number;
rz?: number;
};
function batch(
name: string,
parts: Part[],
surface: ReturnType<typeof material>,
) {
const mesh = resources.own(
'other',
new THREE.InstancedMesh(box, surface, parts.length),
);
mesh.name = name;
mesh.frustumCulled = false;
parts.forEach((part, i) => {
dummy.position.set(part.x, part.y, part.z);
dummy.scale.set(part.sx, part.sy, part.sz);
dummy.rotation.set(0, 0, part.rz ?? 0);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
});
mesh.instanceMatrix.needsUpdate = true;
group.add(mesh);
}
const posts: Part[] = [],
reflectors: Part[] = [],
rails: Part[] = [];
// Full density retains a recognisable rhythm even at the lowest quality.
const pairs = quality.postEffects ? 56 : 40;
for (let i = 0; i < pairs; i++)
for (const side of [-1, 1]) {
const z = 30 - (i * WORLD_DEPTH) / pairs;
posts.push({ x: side * 9.5, y: 0.63, z, sx: 0.22, sy: 1.26, sz: 0.32 });
reflectors.push({
x: side * 9.48,
y: 1.1,
z: z + 0.18,
sx: 0.14,
sy: 0.22,
sz: 0.03,
});
rails.push({
x: side * 9.5,
y: 0.56,
z,
sx: 0.16,
sy: 0.18,
sz: WORLD_DEPTH / pairs + 0.1,
});
}
batch('barrier-posts', posts, graphite);
batch('barrier-reflectors', reflectors, coral);
batch('continuous-low-rails', rails, graphite);
const fins: Part[] = [],
lights: Part[] = [],
feet: Part[] = [];
for (let i = 0; i < 8; i++)
for (const side of [-1, 1]) {
const z = -24 - (i * WORLD_DEPTH) / 8;
// The canted cap is the repeated silhouette: road furniture, not a tunnel.
fins.push({ x: side * 12.6, y: 5.0, z, sx: 0.38, sy: 10, sz: 0.64 });
fins.push({
x: side * 10.45,
y: 10.55,
z,
sx: 0.32,
sy: 4.6,
sz: 0.64,
rz: side * 1.31,
});
lights.push({
x: side * 12.36,
y: 5.9,
z: z + 0.34,
sx: 0.075,
sy: 7.5,
sz: 0.035,
});
lights.push({
x: side * 10.4,
y: 10.4,
z: z + 0.34,
sx: 0.07,
sy: 4.35,
sz: 0.04,
rz: side * 1.31,
});
feet.push({ x: side * 12.6, y: 0.24, z, sx: 1.1, sy: 0.48, sz: 1.5 });
}
batch('swept-signal-pylons', fins, graphite);
batch('recessed-pylon-lights', lights, cyan);
batch('pylon-foundations', feet, graphite);
// Far roadside monolith pairs give the waves an occasional architectural edge.
const landmarks: Part[] = [];
for (let i = 0; i < 6; i++)
for (const side of [-1, 1]) {
const z = -110 - (i * WORLD_DEPTH) / 6;
for (let j = 0; j < 3; j++) {
landmarks.push({
x: side * (34 + j * 4.5),
y: (15 + j * 6) / 2,
z: z - j * 3,
sx: 1.2,
sy: 15 + j * 6,
sz: 2.6,
rz: side * 0.1,
});
}
}
batch('ridge-observatories', landmarks, graphite);
let architecture = 0;
return {
group,
update(
distance: number,
energy: number,
warmth: number,
section: SectionId,
dt: number,
) {
const target =
section === 'infrastructure' ? 1 : section === 'toolkit' ? 0.65 : 0;
architecture += (target - architecture) * (1 - Math.exp(-dt * 0.7));
uniforms.uDistance.value = distance;
uniforms.uEnergy.value = energy;
uniforms.uArchitecture.value = architecture;
uniforms.uWarmth.value = warmth;
},
};
}