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.
213 lines
6.6 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|