void / static /world.js
Death242's picture
Static site
0e305b7 verified
Raw History Blame Contribute Delete
24.8 kB
// The shared world for Watch and Play: renderer, lights, the night city, the track (straight,
// a 9 m 360 loop shifted sideways so it can exit, a run-up, a 33° ramp), the flight path after
// the ramp, the landing zones with beacons, and the effects (shadow, underglow, speed lines, exhaust).
import * as THREE from "three";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { Sky } from "three/addons/objects/Sky.js";
import { buildCity } from "./city.js";
export const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)), lerp = (a, b, t) => a + (b - a) * t;
export const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a)); return t * t * (3 - 2 * t); };
export const keyed = (K, p) => {
if (p <= K[0][0]) return K[0][1];
for (let i = 1; i < K.length; i++) if (p <= K[i][0]) return lerp(K[i - 1][1], K[i][1], (p - K[i - 1][0]) / (K[i][0] - K[i - 1][0]));
return K[K.length - 1][1];
};
export const G = 9.81, R = 9, LZ = 4.2, HW = 2.3, RAMP_X = 120;
// landing zones, metres in world space (x east along the boulevard from the start line, z to the right);
// filled in once the city is built: the Central Park lawn, rooftop helipads, and a couple you should not land on
export const ZONES = [];
export const PAD_R = 28;
export async function createWorld(canvas, COARSE) {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: "high-performance", stencil: false });
renderer.shadowMap.enabled = !COARSE; renderer.shadowMap.type = THREE.PCFShadowMap; renderer.shadowMap.autoUpdate = false; // redrawn every few frames (main.js): it was half of every frame
renderer.localClippingEnabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2;
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, COARSE ? 1.5 : 1.75));
const scene = new THREE.Scene();
// the sky: night (blue hour), sunset and day, each a gradient with a glow where the sun or the city is
const SKIES = {
night: { stops: [[0, "#060b18"], [.4, "#13203d"], [.55, "#2c3a63"], [.63, "#6a5a7a"], [.7, "#2a2d3c"], [1, "#1a1c24"]], fog: 0x2c3550, hemi: [0x9fb0d8, 0x22242c, 1.0], sun: [0x9fb4ff, .55], exp: 1.15, glow: 1, stars: true, sunPos: [-300, 420, -500] },
sunset: { stops: [[0, "#1d2a55"], [.35, "#4a4a8a"], [.5, "#c46a6a"], [.58, "#ff9a4a"], [.64, "#ffd27a"], [.7, "#6a5048"], [1, "#2a2420"]], fog: 0x8a6a6a, hemi: [0xffc8a8, 0x3a3030, 1.2], sun: [0xffa860, 2.4], exp: 1.1, glow: .55, stars: false, sunPos: [-800, 160, -300] },
day: { stops: [[0, "#2a6ad0"], [.4, "#5a9ae8"], [.58, "#a8d0f4"], [.66, "#e4eef6"], [.7, "#9aa4ac"], [1, "#6a7078"]], fog: 0xb8cce0, hemi: [0xdfeeff, 0x6a7a5a, 1.5], sun: [0xfff4e0, 3.2], exp: 1.0, glow: .12, stars: false, sunPos: [-400, 700, -350] },
};
// (a new texture each time: three caches the cube it makes from an equirect background)
let skyTex = null;
const paintSky = k => {
const c = document.createElement("canvas"); c.width = 4; c.height = 512;
const g = c.getContext("2d"), gr = g.createLinearGradient(0, 0, 0, 512); for (const [o, col] of SKIES[k].stops) gr.addColorStop(o, col); g.fillStyle = gr; g.fillRect(0, 0, 4, 512);
if (skyTex) skyTex.dispose();
skyTex = new THREE.CanvasTexture(c); skyTex.colorSpace = THREE.SRGBColorSpace; skyTex.mapping = THREE.EquirectangularReflectionMapping; scene.background = skyTex;
};
paintSky("night");
scene.fog = new THREE.Fog(0x2c3550, 90, 900);
const studio = new THREE.Scene();
studio.add(new THREE.Mesh(new THREE.SphereGeometry(50, 32, 16), new THREE.MeshBasicMaterial({ color: 0x232838, side: THREE.BackSide })));
const strip = (w, h, x, y, z, ry, rx, k) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffffff).multiplyScalar(k), side: THREE.DoubleSide })); m.position.set(x, y, z); m.rotation.set(rx, ry, 0); studio.add(m); };
strip(20, 8, 0, 12, 0, 0, Math.PI / 2, 1.1); // one big soft panel overhead
for (const s of [-1, 1]) strip(16, 4, 0, 4, s * 14, 0, 0, .7); // a soft panel each side
studio.add(new THREE.Mesh(new THREE.PlaneGeometry(100, 100).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x3a4050 })));
const pmrem = new THREE.PMREMGenerator(renderer);
const studioEnv = pmrem.fromScene(studio, .02).texture;
scene.environment = studioEnv;
const camera = new THREE.PerspectiveCamera(40, 1, .03, 9000);
const hemi = new THREE.HemisphereLight(0xb8c2de, 0x2a2a32, 1.3); scene.add(hemi);
const key = new THREE.DirectionalLight(0xffffff, 1.1); key.position.set(-6, 10, 8); scene.add(key);
// the sun (or the moon): casts real shadows in a box that follows the car
const sun = new THREE.DirectionalLight(0x9fb4ff, .55); sun.castShadow = !COARSE; scene.add(sun, sun.target);
sun.shadow.mapSize.set(2048, 2048); Object.assign(sun.shadow.camera, { left: -70, right: 70, top: 70, bottom: -70, near: 10, far: 1400 }); sun.shadow.bias = -.0004; sun.shadow.normalBias = .04;
const rimL = new THREE.DirectionalLight(0xbfd4ff, .6); rimL.position.set(8, 4, -10); scene.add(rimL);
const camLight = new THREE.DirectionalLight(0xffffff, .35); scene.add(camLight, camLight.target); // follows the camera so black paint always catches light
// post: multisampled HDR render (smooth edges), bloom, tone mapping, then a cinematic grade (vignette, grain, contrast)
const rt = new THREE.WebGLRenderTarget(2, 2, { type: THREE.HalfFloatType, samples: COARSE ? 0 : 4 });
const composer = new EffectComposer(renderer, rt);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(256, 256), .42, .35, .9);
composer.addPass(bloom); composer.addPass(new OutputPass());
const grade = new ShaderPass({
uniforms: { tDiffuse: { value: null }, time: { value: 0 }, blur: { value: 0 }, vig: { value: .32 }, grain: { value: .012 }, sat: { value: 1.08 }, contrast: { value: 1.06 }, sharp: { value: .25 } },
vertexShader: "varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }",
fragmentShader: `uniform sampler2D tDiffuse; uniform float time, vig, grain, sat, contrast, blur, sharp; varying vec2 vUv;
float h(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main(){ vec4 c = texture2D(tDiffuse, vUv);
if (blur > .001) { vec2 d = vUv - .5; vec4 acc = c; for (int i = 1; i < 7; i++) acc += texture2D(tDiffuse, vUv - d * blur * float(i) * .011); c = acc / 7.; } // speed blur toward the edges
vec3 col = c.rgb;
if (sharp > .001 && blur < .001) { vec2 px = 1. / vec2(textureSize(tDiffuse, 0)); vec3 n = texture2D(tDiffuse, vUv + vec2(px.x, 0.)).rgb + texture2D(tDiffuse, vUv - vec2(px.x, 0.)).rgb + texture2D(tDiffuse, vUv + vec2(0., px.y)).rgb + texture2D(tDiffuse, vUv - vec2(0., px.y)).rgb; col = max(col + (col - n * .25) * sharp, 0.); } // unsharp mask: crisper edges and road texture
float l = dot(col, vec3(.2126, .7152, .0722)); col = mix(vec3(l), col, sat); col = (col - .5) * contrast + .5;
vec2 d = vUv - .5; col *= 1. - vig * smoothstep(.25, .85, dot(d, d) * 2.2);
col += (h(vUv * 1000. + time) - .5) * grain;
gl_FragColor = vec4(clamp(col, 0., 1.), c.a); }`,
});
composer.addPass(grade);
// the sky for day and sunset: Rayleigh/Mie scattering with a real sun disc (night keeps the painted gradient and stars)
const sky = new Sky(); sky.scale.setScalar(900); sky.visible = false; sky.frustumCulled = false; scene.add(sky);
const skyU = sky.material.uniforms;
const canvasTex = (w, h, draw, repeat) => {
const c = document.createElement("canvas"); c.width = w; c.height = h; draw(c.getContext("2d"), w, h);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = renderer.capabilities.getMaxAnisotropy();
if (repeat) { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(...repeat); }
return t;
};
const radial = (inner, outer) => canvasTex(128, 128, (g, w, h) => { const r = g.createRadialGradient(64, 64, 0, 64, 64, 64); r.addColorStop(0, inner); r.addColorStop(1, outer); g.fillStyle = r; g.fillRect(0, 0, w, h); });
// stars over the Gulf, then the city itself (city.js)
{
const n = 1400, pos = new Float32Array(n * 3);
for (let i = 0; i < n; i++) { const th = Math.random() * Math.PI * 2, ph = Math.acos(Math.random() * .9), r = 6000; pos.set([Math.cos(th) * Math.sin(ph) * r, Math.cos(ph) * r * .8 + 80, Math.sin(th) * Math.sin(ph) * r], i * 3); }
const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.BufferAttribute(pos, 3));
var stars = new THREE.Points(g, new THREE.PointsMaterial({ color: 0xffffff, size: 2.2, sizeAttenuation: false, fog: false, transparent: true, opacity: .7 })); scene.add(stars);
}
const city = await buildCity(scene, canvasTex, COARSE), buildings = city.buildings;
ZONES.length = 0;
ZONES.push({ id: "PARK", x: city.lawn.x, z: city.lawn.z, y: 0, safe: true, note: "Central Park, the big lawn" });
city.helipads.slice().sort((a, b) => b.y - a.y).slice(0, 6).forEach((h, k) => ZONES.push({ id: ["SUMMIT", "APEX", "SPIRE", "CROWN", "ZENITH", "ORBIT"][k], x: h.x, z: h.z, y: h.y, safe: true, note: `Rooftop helipad, ${Math.round(h.y)} m up`, r: h.r }));
ZONES.push({ id: "HUDSON", x: -1180, z: -200, y: 0, safe: false, note: "The Hudson River: water" });
ZONES.push({ id: "TIMES", x: 160, z: -280, y: 0, safe: false, note: "Times Square: billboards and crowds" });
// shadows: everything solid casts and catches them
// (only buildings and the big solid pieces cast; the thousands of small props only catch shadows)
scene.traverse(o => { if (o.isMesh && !o.material.transparent && !o.material.isMeshBasicMaterial) { o.receiveShadow = true; o.castShadow = !!o.userData.solid; } });
// the city reflected in its own glass: one cube photo from the middle of midtown, filtered for rough and smooth surfaces
const cubeRT = new THREE.WebGLCubeRenderTarget(COARSE ? 128 : 256, { type: THREE.HalfFloatType });
const cubeCam = new THREE.CubeCamera(1, 3000, cubeRT);
let cityEnv = null;
function captureCity() {
cubeCam.position.set(160, 60, -300); sky.position.copy(cubeCam.position); cubeCam.update(renderer, scene);
if (cityEnv) cityEnv.dispose(); cityEnv = pmrem.fromCubemap(cubeRT.texture).texture;
for (const m of city.glassMats) { m.envMap = cityEnv; m.needsUpdate = true; }
scene.environment = cityEnv;
}
let timeOfDay = "night", wxDim = 1, wxOver = false, hemiBase = 1;
const sunDir = new THREE.Vector3(-300, 420, -500).normalize();
function setTime(k) {
const S = SKIES[k]; timeOfDay = k; paintSky(k);
sky.visible = k !== "night" && !wxOver;
if (wxOver) { // overcast: a flat grey sky (darker at night)
const c = document.createElement("canvas"); c.width = 4; c.height = 512; const g2 = c.getContext("2d"), gr = g2.createLinearGradient(0, 0, 0, 512);
const top = k === "night" ? "#0d1016" : k === "sunset" ? "#4a4650" : "#7c838e", mid = k === "night" ? "#1d222b" : k === "sunset" ? "#7a6a6a" : "#a9b0b8";
gr.addColorStop(0, top); gr.addColorStop(.6, mid); gr.addColorStop(1, top); g2.fillStyle = gr; g2.fillRect(0, 0, 4, 512);
if (skyTex) skyTex.dispose(); skyTex = new THREE.CanvasTexture(c); skyTex.colorSpace = THREE.SRGBColorSpace; skyTex.mapping = THREE.EquirectangularReflectionMapping; scene.background = skyTex;
}
if (sky.visible) { skyU.turbidity.value = k === "day" ? 3 : 8; skyU.rayleigh.value = k === "day" ? 1.2 : 2.8; skyU.mieCoefficient.value = k === "day" ? .004 : .008; skyU.mieDirectionalG.value = .85; skyU.sunPosition.value.set(...S.sunPos).normalize(); }
scene.fog.color.setHex(S.fog); hemi.color.setHex(S.hemi[0]); hemi.groundColor.setHex(S.hemi[1]); hemi.intensity = S.hemi[2];
sun.color.setHex(S.sun[0]); sun.intensity = S.sun[1]; sunDir.set(...S.sunPos).normalize();
hemi.intensity *= wxDim; sun.intensity *= wxDim * wxDim; hemiBase = hemi.intensity;
if (wxOver) scene.fog.color.lerp(new THREE.Color(k === "night" ? 0x151820 : 0x8a919a), .7);
renderer.toneMappingExposure = S.exp; stars.visible = S.stars && !wxOver; key.intensity = k === "day" ? .4 : 1.1;
for (const m of city.emissive) {
if (m.isMeshStandardMaterial) { if (m.userData.base0 == null) m.userData.base0 = m.emissiveIntensity; m.emissiveIntensity = m.userData.base0 * S.glow; }
else { if (!m.userData.base0) m.userData.base0 = m.color.clone(); m.color.copy(m.userData.base0).multiplyScalar(Math.max(.55, S.glow)); }
}
bloom.strength = k === "day" ? .18 : k === "sunset" ? .32 : .42;
captureCity();
}
// quality: Low / Medium / High / Ultra (the adaptive system steps it down by itself when frames run slow)
const QUALITY = { lite: { pr: .7, shadow: 0, bloom: false, grade: false, msaa: 0, bres: .25, shEvery: 9, flat: true }, low: { pr: .75, shadow: 0, bloom: false, grade: false, msaa: 0, bres: .25 }, medium: { pr: 1, shadow: 1024, bloom: true, grade: true, msaa: 0, bres: .3, shEvery: 4 }, high: { pr: 1.25, shadow: 2048, bloom: true, grade: true, msaa: 4, bres: .5, shEvery: 3 }, ultra: { pr: 2, shadow: 4096, bloom: true, grade: true, msaa: 4, bres: .5, shEvery: 2 } };
let quality = "high"; const W0 = { bres: .5, flat: false };
function setQuality(q) {
const Q = QUALITY[q] || QUALITY.high; quality = q;
const on = Q.shadow > 0;
if (renderer.shadowMap.enabled !== on) { renderer.shadowMap.enabled = on; scene.traverse(o => { if (o.material) (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => m.needsUpdate = true); }); }
sun.castShadow = on;
if (on && sun.shadow.mapSize.x !== Q.shadow) { sun.shadow.mapSize.set(Q.shadow, Q.shadow); if (sun.shadow.map) { sun.shadow.map.dispose(); sun.shadow.map = null; } }
bloom.enabled = Q.bloom; grade.enabled = Q.grade; W0.bres = Q.bres;
// Lite also drops the surface relief maps (each costs a texture read per pixel, which built-in graphics feel)
if (!!Q.flat !== W0.flat) { W0.flat = !!Q.flat; scene.traverse(o => { const m = o.material; if (!m || Array.isArray(m) || !(m.normalMap || m.userData.nm)) return; if (W0.flat) { m.userData.nm = m.normalMap; m.normalMap = null; } else if (m.userData.nm) { m.normalMap = m.userData.nm; } m.needsUpdate = true; }); }
// multisampling only where the card can afford it (it is the biggest cost on laptop graphics)
for (const t of [composer.renderTarget1, composer.renderTarget2]) if (t.samples !== Q.msaa) { t.samples = Q.msaa; t.dispose(); }
return Math.min(devicePixelRatio || 1, Q.pr);
}
function setWeatherLook(dim, over) { wxDim = dim; wxOver = over; setTime(timeOfDay); }
function setFlash(f) { hemi.intensity = hemiBase + f * 5; }
function follow(p) { sun.position.copy(p).addScaledVector(sunDir, 600); sun.target.position.copy(p); sun.target.updateMatrixWorld(); }
// landing zones: a glowing ring, an H, and a tall beacon you can see from far away
for (const z of ZONES) {
const col = z.safe ? 0x3dff8a : 0xff4455, c3 = new THREE.Color(col).multiplyScalar(2), R0 = z.r || PAD_R, y = (z.y || 0) + .12;
const ring = new THREE.Mesh(new THREE.RingGeometry(R0 - 1, R0, 64).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: c3, toneMapped: false }));
ring.position.set(z.x, y, z.z); scene.add(ring);
if (!z.y) { const h = new THREE.Mesh(new THREE.PlaneGeometry(R0, R0).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({
map: canvasTex(128, 128, (g) => { g.fillStyle = "#fff"; g.font = "900 96px sans-serif"; g.textAlign = "center"; g.fillText("H", 64, 100); }), transparent: true, opacity: .55, color: col,
})); h.position.set(z.x, y + .01, z.z); scene.add(h); }
const beacon = new THREE.Mesh(new THREE.CylinderGeometry(.8, .8, 260, 8, 1, true).translate(0, 130, 0), new THREE.MeshBasicMaterial({ color: c3, transparent: true, opacity: .18, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, toneMapped: false }));
beacon.position.set(z.x, y, z.z); scene.add(beacon);
}
// the track points, with a moving frame (forward, side, up) at every one
const P = [];
for (let x = -60; x < 16; x += .25) P.push(new THREE.Vector3(x, 0, 0));
const loopIn = P.length, LOOPN = 420;
for (let i = 0; i <= LOOPN; i++) { const t = i / LOOPN, th = -Math.PI / 2 + t * Math.PI * 2; P.push(new THREE.Vector3(16 + R * Math.cos(th), R + R * Math.sin(th), LZ * smooth(0, 1, t))); }
const loopOut = P.length - 1;
for (let x = 16.25; x <= RAMP_X; x += .25) P.push(new THREE.Vector3(x, 0, LZ));
const rampIn = P.length - 1;
for (let x = RAMP_X + .25; x <= RAMP_X + 14.001; x += .25) { const u = (x - RAMP_X) / 14; P.push(new THREE.Vector3(x, 4.5 * u * u, LZ)); }
const roadEnd = P.length - 1;
const X1 = RAMP_X + 14; // the ramp lip; then up on the thrusters and out over the city, the corniche and downtown
const air = new THREE.CatmullRomCurve3([[X1, 4.5, LZ], [X1 + 5, 8.2, LZ], [X1 + 10, 13, LZ + .1], [X1 + 15, 20, LZ + .4], [X1 + 21, 30, LZ + 1], [X1 + 29, 43, LZ + 2], [X1 + 42, 62, 8], [X1 + 66, 90, 14],
[X1 + 110, 125, 26], [X1 + 180, 160, 48], [X1 + 300, 190, 90], [X1 + 480, 205, 150], [X1 + 720, 210, 210], [X1 + 1000, 208, 250], [X1 + 1300, 205, 255], [X1 + 1650, 200, 230], [X1 + 2050, 196, 190], [X1 + 2500, 192, 150]].map(a => new THREE.Vector3(...a)), false, "centripetal").getSpacedPoints(3200);
for (let i = 1; i < air.length; i++) P.push(air[i]);
const N = P.length, D = new Float32Array(N), F = [], S = [], U = [], Z = new THREE.Vector3(0, 0, 1);
for (let i = 1; i < N; i++) D[i] = D[i - 1] + P[i].distanceTo(P[i - 1]);
for (let i = 0; i < N; i++) {
const f = P[Math.min(N - 1, i + 1)].clone().sub(P[Math.max(0, i - 1)]).normalize();
const s = Z.clone().addScaledVector(f, -f.dot(Z)).normalize();
F.push(f); S.push(s); U.push(new THREE.Vector3().crossVectors(s, f));
}
const idxX = (x, from = 0) => { for (let i = from; i < N; i++) if (P[i].x >= x) return i; return N - 1; };
const sample = (d, out) => {
let lo = 0, hi = N - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (D[m] <= d) lo = m; else hi = m; }
const t = clamp((d - D[lo]) / Math.max(1e-6, D[hi] - D[lo]));
out.p.lerpVectors(P[lo], P[hi], t); out.f.lerpVectors(F[lo], F[hi], t).normalize();
out.s.lerpVectors(S[lo], S[hi], t).normalize(); out.u.lerpVectors(U[lo], U[hi], t).normalize();
out.i = lo; return out;
};
function ribbon(i0, i1, a, b, mat) {
const pos = [], uv = [], idx = [];
for (let i = i0; i <= i1; i++) {
for (const [sOff, uOff, v] of [[a[0], a[1], 0], [b[0], b[1], 1]]) { const q = P[i].clone().addScaledVector(S[i], sOff).addScaledVector(U[i], uOff); pos.push(q.x, q.y, q.z); uv.push(D[i] / 8, v); }
if (i < i1) { const k = (i - i0) * 2; idx.push(k, k + 1, k + 2, k + 1, k + 3, k + 2); }
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2));
g.setIndex(idx); g.computeVertexNormals();
const m = new THREE.Mesh(g, mat); scene.add(m); return m;
}
const roadTex = canvasTex(256, 64, (g, w, h) => {
g.fillStyle = "#0e0e12"; g.fillRect(0, 0, w, h);
for (let i = 0; i < 900; i++) { g.fillStyle = `rgba(255,255,255,${Math.random() * .05})`; g.fillRect(Math.random() * w, Math.random() * h, 1, 1); }
g.fillStyle = "rgba(255,255,255,.3)"; g.fillRect(0, 3, w, 2); g.fillRect(0, h - 5, w, 2); g.fillRect(0, h / 2 - 1, w / 2, 2);
});
roadTex.wrapS = THREE.RepeatWrapping;
const roadMat = new THREE.MeshStandardMaterial({ map: roadTex, color: 0xb8bcc6, roughness: .85, metalness: 0, envMapIntensity: .25, side: THREE.DoubleSide });
const sideMat = new THREE.MeshStandardMaterial({ color: 0x111115, roughness: .5, metalness: .6, side: THREE.DoubleSide });
const edgeMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffffff).multiplyScalar(1.05), toneMapped: false, side: THREE.DoubleSide });
ribbon(0, roadEnd, [-HW, 0], [HW, 0], roadMat);
ribbon(loopIn - 4, roadEnd, [-HW, -.4], [HW, -.4], sideMat);
for (const s of [-1, 1]) {
ribbon(loopIn - 4, roadEnd, [s * HW, 0], [s * HW, -.4], sideMat);
ribbon(0, roadEnd, [s * HW, .06], [s * HW, .02], edgeMat);
ribbon(loopIn, loopOut, [s * HW, .1], [s * HW, .75], new THREE.MeshPhysicalMaterial({ color: 0x9fb0c8, transparent: true, opacity: .14, roughness: .05, metalness: .2, side: THREE.DoubleSide, depthWrite: false }));
}
{
const pm = new THREE.MeshStandardMaterial({ color: 0x141418, metalness: .7, roughness: .4 });
for (let x = RAMP_X + 2; x <= RAMP_X + 13; x += 2.75) { const y = 4.5 * ((x - RAMP_X) / 14) ** 2 - .4; for (const s of [-1, 1]) { const c = new THREE.Mesh(new THREE.CylinderGeometry(.14, .18, y, 12).translate(0, y / 2, 0), pm); c.position.set(x, 0, LZ + s * 1.8); scene.add(c); } }
for (const dx of [-4.5, 4.5]) { const foot = new THREE.Mesh(new THREE.BoxGeometry(1.2, 1.6, LZ + HW * 2 + .6).translate(0, .8, 0), pm); foot.position.set(16 + dx, -.4, LZ / 2); scene.add(foot); }
}
// effects
const shadow = new THREE.Mesh(new THREE.PlaneGeometry(6.4, 3.4), new THREE.MeshBasicMaterial({ map: radial("rgba(0,0,0,.92)", "rgba(0,0,0,0)"), transparent: true, depthWrite: false }));
shadow.rotation.x = -Math.PI / 2; shadow.position.y = .01; scene.add(shadow);
const glow = new THREE.Mesh(new THREE.PlaneGeometry(6.2, 3.4), new THREE.MeshBasicMaterial({ map: radial("rgba(255,255,255,1)", "rgba(255,255,255,0)"), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0, toneMapped: false }));
glow.rotation.x = -Math.PI / 2; glow.position.y = .02; scene.add(glow);
const LINES = 110, lines = new THREE.InstancedMesh(new THREE.BoxGeometry(1, .012, .012), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false }), LINES);
const lineSeed = Array.from({ length: LINES }, () => [Math.random() * 80 - 40, Math.random() * 7 - 1.5, (Math.random() < .5 ? -1 : 1) * (2.2 + Math.random() * 9)]);
lines.frustumCulled = false; scene.add(lines);
const smokeTex = radial("rgba(200,210,225,.55)", "rgba(200,210,225,0)"), puffs = [];
for (let i = 0; i < 90; i++) { const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: smokeTex, transparent: true, depthWrite: false, opacity: 0 })); s.visible = false; scene.add(s); puffs.push({ s, life: 0, v: new THREE.Vector3() }); }
return {
renderer, scene, camera, composer, bloom, grade, sky, sunDir, setWeatherLook, setFlash, weatherFog: 1, setQuality, get quality() { return quality; }, QUALITY, get bloomRes() { return W0.bres; }, camLight, canvasTex, radial, buildings, city, setTime, follow, captureCity, get timeOfDay() { return timeOfDay; }, studioEnv,
roads: city.roads, footprints: city.footprints, parks: city.parks, water: city.water, beaches: city.beaches, walkways: city.walkways, bounds: city.bounds, credit: city.credit,
track: { P, F, S, U, D, N, loopIn, loopOut, rampIn, roadEnd, idxX, sample },
fx: { shadow, glow, lines, LINES, lineSeed, puffs, puffI: 0 },
};
}
// a reusable moving frame, and "car-local to world" along it
export const frame = () => ({ p: new THREE.Vector3(), f: new THREE.Vector3(), s: new THREE.Vector3(), u: new THREE.Vector3(), i: 0 });
export const loc = (fr, x, y, z) => fr.p.clone().addScaledVector(fr.f, x).addScaledVector(fr.u, y).addScaledVector(fr.s, z);
export const local = (car, v) => v.clone().applyMatrix4(car.group.matrixWorld);
// where each zone is relative to a position and heading (km, forward/side), nearest first
export function zonesFrom(pos, fwd) {
const fh = new THREE.Vector2(fwd.x, fwd.z); if (fh.lengthSq() < 1e-6) fh.set(1, 0); fh.normalize();
const sh = new THREE.Vector2(-fh.y, fh.x);
return ZONES.map(z => { const rel = new THREE.Vector2(z.x - pos.x, z.z - pos.z); return { ...z, dist: rel.length() / 1000, fwd: rel.dot(fh) / 1000, side: rel.dot(sh) / 1000 }; }).sort((a, b) => a.dist - b.dist);
}