File size: 37,300 Bytes
a2b07e0 47d9c12 a2b07e0 47d9c12 a2b07e0 5042fc2 a2b07e0 5042fc2 a2b07e0 585bfe0 a2b07e0 5042fc2 a2b07e0 47d9c12 a2b07e0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 | // The VOID car (shape inspired by the McLaren 765LT), modelled in code: the body and canopy are lofted from side-profile curves
// (a rounded superellipse cross-section at every station), the wheel wells are carved out of
// the sides, and a blueprint of grid lines can be swept away by a clipping plane to "build" it.
import * as THREE from "three";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
const X0 = -2.3, LEN = 4.6; // rear tip to nose, along +x
const WHEELS = [1.42, -1.38]; // front and rear axle x (a 2.8 m wheelbase)
const WR = 0.36, ARCH = 0.44, WELL = 0.62; // wheel radius, arch radius, how far in the wells are carved
// cosine interpolation through [u, value] keys (u from 0 at the rear to 1 at the nose)
function curve(keys, u) {
if (u <= keys[0][0]) return keys[0][1];
for (let i = 1; i < keys.length; i++) {
const [u1, v1] = keys[i], [u0, v0] = keys[i - 1];
if (u <= u1) { const s = (1 - Math.cos(Math.PI * (u - u0) / (u1 - u0))) / 2; return v0 + (v1 - v0) * s; }
}
return keys[keys.length - 1][1];
}
const spow = (v, p) => Math.sign(v) * Math.pow(Math.abs(v), p);
// long-tail proportions: a tall, blunt tail, wide rear haunches, a pinched waist where the door
// scoop is, wide front fenders and a low, narrow nose
const BODY = {
x0: X0, len: LEN, n: 6, tumble: .3,
top: [[0, .84], [.03, .93], [.12, .96], [.25, .96], [.4, .9], [.55, .84], [.66, .8], [.78, .68], [.9, .54], [.97, .44], [1, .36]],
bot: [[0, .3], [.04, .2], [.12, .16], [.88, .16], [.96, .2], [1, .28]],
half: [[0, .86], [.05, .95], [.18, 1.0], [.35, .96], [.5, .91], [.62, .93], [.8, .99], [.93, .93], [1, .62]],
ends: [.012, .06], fenders: true, scoop: true,
};
// a cab-forward teardrop: the screen starts far forward, the roof peaks over the doors and the
// glass runs back in a long taper over the engine
const CANOPY = {
x0: -1.35, len: 2.35, n: 4, tumble: .42,
top: [[0, .92], [.12, 1.0], [.3, 1.12], [.5, 1.19], [.65, 1.17], [.8, 1.06], [.92, .92], [1, .8]],
bot: [[0, .72], [1, .72]],
half: [[0, .42], [.2, .6], [.5, .72], [.75, .72], [.92, .64], [1, .5]],
ends: [.15, .06], fenders: false,
};
// the other cars in the garage, lofted the same way
export const MODELS = {
hydra: { name: "Hydra LTE242", body: BODY, canopy: CANOPY },
vandal: { name: "Vandal GT", note: "Muscle fastback", body: { x0: X0, len: LEN, n: 8, tumble: .2, fenders: true, scoop: false, ends: [.02, .03],
top: [[0, .92], [.06, .98], [.2, 1.0], [.35, .98], [.5, .95], [.62, .93], [.75, .9], [.9, .84], [.98, .74], [1, .66]], bot: [[0, .32], [.05, .22], [.12, .18], [.88, .18], [.96, .22], [1, .3]],
half: [[0, .86], [.06, .93], [.2, .96], [.5, .92], [.8, .94], [.95, .9], [1, .72]] },
canopy: { x0: -1.25, len: 2.0, n: 4, tumble: .35, ends: [.2, .08], fenders: false, top: [[0, .98], [.15, 1.05], [.35, 1.25], [.6, 1.28], [.8, 1.2], [.95, 1.0], [1, .95]], bot: [[0, .88], [1, .88]], half: [[0, .6], [.3, .72], [.7, .72], [1, .6]] } },
wraith: { name: "Wraith X", note: "Wedge hypercar", body: { x0: X0, len: LEN, n: 5, tumble: .32, fenders: true, scoop: true, ends: [.01, .04],
top: [[0, .8], [.05, .86], [.2, .88], [.4, .84], [.6, .74], [.8, .6], [.95, .45], [1, .34]], bot: [[0, .26], [.04, .16], [.12, .13], [.88, .13], [.96, .16], [1, .22]],
half: [[0, .9], [.05, 1.0], [.2, 1.02], [.4, .96], [.6, .95], [.82, .98], [.95, .9], [1, .55]] },
canopy: { x0: -.9, len: 2.3, n: 4, tumble: .45, ends: [.2, .05], fenders: false, top: [[0, .82], [.2, 1.0], [.5, 1.07], [.75, 1.0], [.95, .8], [1, .7]], bot: [[0, .68], [1, .62]], half: [[0, .38], [.3, .62], [.6, .66], [.9, .55], [1, .45]] } },
titan: { name: "Titan RS", note: "Super SUV", body: { x0: X0, len: LEN, n: 10, tumble: .12, fenders: true, scoop: false, ends: [.02, .03],
top: [[0, 1.15], [.05, 1.2], [.6, 1.2], [.75, 1.12], [.9, 1.0], [1, .9]], bot: [[0, .38], [.05, .28], [.12, .26], [.88, .26], [.95, .28], [1, .36]], half: [[0, .92], [.05, .98], [.5, 1.0], [.95, .98], [1, .85]] },
canopy: { x0: -2.15, len: 3.3, n: 10, tumble: .15, ends: [.02, .12], fenders: false, top: [[0, 1.15], [.05, 1.62], [.7, 1.66], [.85, 1.5], [1, 1.2]], bot: [[0, 1.1], [1, 1.1]], half: [[0, .86], [.5, .9], [1, .86]] } },
cab: { name: "Big Apple Cab", note: "A yellow cab with a jet kit", body: { x0: X0, len: LEN, n: 9, tumble: .15, fenders: true, scoop: false, ends: [.02, .03],
top: [[0, .9], [.05, .95], [.25, .96], [.35, .95], [.65, .93], [.8, .9], [.95, .84], [1, .76]], bot: [[0, .32], [.05, .22], [.12, .18], [.88, .18], [.96, .22], [1, .3]], half: [[0, .86], [.05, .92], [.5, .93], [.95, .9], [1, .75]] },
canopy: { x0: -1.35, len: 2.5, n: 8, tumble: .2, ends: [.1, .1], fenders: false, top: [[0, .95], [.15, 1.35], [.75, 1.38], [.9, 1.2], [1, .95]], bot: [[0, .9], [1, .9]], half: [[0, .75], [.5, .82], [1, .75]] } },
};
// a (nu+1) x nv grid of points on the lofted surface
function loft(spec, nu, nv) {
const P = new Float32Array((nu + 1) * nv * 3);
for (let i = 0; i <= nu; i++) {
const u = i / nu, x = spec.x0 + spec.len * u;
const t = curve(spec.top, u), b = curve(spec.bot, u), w = curve(spec.half, u);
const c = (t + b) / 2, h = (t - b) / 2;
const e = Math.pow(Math.max(0, Math.min(1, u / spec.ends[0], (1 - u) / spec.ends[1])), .35);
for (let j = 0; j < nv; j++) {
const th = (j / nv) * Math.PI * 2, cz = Math.cos(th), sy = Math.sin(th);
let z = w * spow(cz, 2 / spec.n), y = h * spow(sy, 2 / spec.n);
if (sy > 0) z *= 1 - spec.tumble * sy * sy;
if (spec.fenders && sy > 0) for (const xw of WHEELS) y += (xw > 0 ? .2 : .14) * Math.exp(-(((x - xw) / .55) ** 2)) * Math.abs(cz) ** 3;
z *= e; y = c + y * e;
if (spec.scoop && Math.abs(cz) > .3) { // the big intake carved into each door, deepest at its front
const xr = (x + .35) / .62, yr = (y - .6) / .17;
if (Math.abs(xr) < 1 && Math.abs(yr) < 1) z *= 1 - .17 * (1 - xr * xr) * Math.sqrt(1 - yr * yr) * (1.15 - .3 * xr);
}
if (spec.fenders) for (const xw of WHEELS) {
if ((x - xw) ** 2 + (y - WR) ** 2 < ARCH * ARCH && Math.abs(z) > WELL) z = Math.sign(z) * WELL;
}
const k = (i * nv + j) * 3; P[k] = x; P[k + 1] = y; P[k + 2] = z;
}
}
return { P, nu, nv };
}
function surface({ P, nu, nv }) {
const idx = [];
for (let i = 0; i < nu; i++) for (let j = 0; j < nv; j++) {
const a = i * nv + j, b = i * nv + (j + 1) % nv, c = (i + 1) * nv + j, d = (i + 1) * nv + (j + 1) % nv;
idx.push(a, c, b, b, c, d);
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(P, 3));
g.setIndex(idx); g.computeVertexNormals();
return g;
}
// blueprint: every few rings across the car, and every few lines along it
function gridLines({ P, nu, nv }, ringStep, lineStep) {
const pts = [], at = (i, j) => { const k = (i * nv + j) * 3; return [P[k], P[k + 1], P[k + 2]]; };
for (let i = 0; i <= nu; i += ringStep) for (let j = 0; j < nv; j++) pts.push(...at(i, j), ...at(i, (j + 1) % nv));
for (let j = 0; j < nv; j += lineStep) for (let i = 0; i < nu; i++) pts.push(...at(i, j), ...at(i + 1, j));
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
return g;
}
export function buildCar(model = "hydra") {
const M = MODELS[model] || MODELS.hydra, BS = M.body, CS = M.canopy;
const car = new THREE.Group();
const solid = new THREE.Group(), wire = new THREE.Group();
car.add(solid, wire);
// the reveal: solid shows behind the scan plane, blueprint in front of it
const solidClip = new THREE.Plane(new THREE.Vector3(-1, 0, 0), -3);
const wireClip = new THREE.Plane(new THREE.Vector3(1, 0, 0), 3);
const clip = [solidClip];
const flakes = (() => { // a fine noise normal map: the metallic flake that sparkles under the clear coat
const c = document.createElement("canvas"); c.width = c.height = 256; const g = c.getContext("2d"), img = g.createImageData(256, 256);
for (let i = 0; i < 256 * 256; i++) { const a = Math.random() * 6.28, r = Math.random() * .55; img.data[i * 4] = 128 + Math.cos(a) * r * 127; img.data[i * 4 + 1] = 128 + Math.sin(a) * r * 127; img.data[i * 4 + 2] = 255; img.data[i * 4 + 3] = 255; }
g.putImageData(img, 0, 0); const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(30, 30); return t;
})();
const paint = new THREE.MeshPhysicalMaterial({ color: 0x0d0d10, metalness: .6, roughness: .32, normalMap: flakes, normalScale: new THREE.Vector2(.12, .12), clearcoat: 1, clearcoatRoughness: .03, envMapIntensity: 1.5, clippingPlanes: clip });
const glass = new THREE.MeshPhysicalMaterial({ color: 0x0a0d12, metalness: .2, roughness: .02, clearcoat: 1, envMapIntensity: 1.8, transparent: true, opacity: .6, depthWrite: false, clippingPlanes: clip }); // tinted, so you can see the cabin
const carbon = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, metalness: .4, roughness: .55, clippingPlanes: clip });
const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: .9, clippingPlanes: clip });
const rim = new THREE.MeshStandardMaterial({ color: 0x121215, metalness: 1, roughness: .16, envMapIntensity: 1.6, clippingPlanes: clip });
const caliper = new THREE.MeshStandardMaterial({ color: 0xc1121f, metalness: .3, roughness: .4, clippingPlanes: clip });
const front = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xe8fbff).multiplyScalar(3), toneMapped: false, clippingPlanes: clip });
const rear = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2a3a).multiplyScalar(2.6), toneMapped: false, clippingPlanes: clip });
const trim = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .4, roughness: .3, clearcoat: 1, clippingPlanes: clip }); // the red accents
const ti2 = new THREE.MeshStandardMaterial({ color: 0x8d9099, metalness: 1, roughness: .25, side: THREE.DoubleSide, clippingPlanes: clip });
const pipeOf = () => new THREE.CylinderGeometry(.06, .06, .2, 20, 1, true).rotateZ(Math.PI / 2);
const lineMat = new THREE.LineBasicMaterial({ color: 0xeef0f5, transparent: true, opacity: .75, clippingPlanes: [wireClip] });
const bodyHi = loft(BS, 220, 96), canHi = loft(CS, 90, 64);
const bodyMesh = new THREE.Mesh(surface(bodyHi), paint), canMesh = new THREE.Mesh(surface(canHi), glass);
solid.add(bodyMesh, canMesh);
wire.add(new THREE.LineSegments(gridLines(loft(BS, 110, 48), 5, 4), lineMat), new THREE.LineSegments(gridLines(loft(CS, 40, 32), 5, 4), lineMat));
const topAt = u => curve(BS.top, u), halfAt = u => curve(BS.half, u);
const add = (geo, mat, x, y, z, parent = solid) => { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); parent.add(m); return m; };
// details: eye-socket headlights, hood vents, splitter, side blades and sills, flying
// buttresses, engine louvers, a full-width wing, a mesh tail with four high exhausts, a diffuser
const beam = (a, b, w, h, mat) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, a.distanceTo(b), h), mat); m.position.copy(a).add(b).multiplyScalar(.5); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize()); solid.add(m); return m; };
const exhausts = [];
if (model === "hydra") {
for (const s of [-1, 1]) {
const sock = add(new THREE.SphereGeometry(.2, 28, 14), carbon, 1.97, .52, s * .62);
sock.scale.set(.7, .42, 1.15); sock.rotation.y = s * .55; sock.rotation.x = s * .25;
const led = add(new THREE.BoxGeometry(.03, .022, .3), front, 2.04, .56, s * .64); led.rotation.y = s * .6; led.rotation.x = s * -.35;
add(new THREE.BoxGeometry(.03, .02, .12), front, 2.07, .49, s * .5).rotation.y = s * .25;
const vent = add(new THREE.BoxGeometry(.36, .012, .24), carbon, 1.5, .64, s * .3); vent.rotation.z = -.43;
const blade = add(new THREE.BoxGeometry(.62, .07, .02), carbon, .88, .4, s * .975); blade.rotation.z = .22;
add(new THREE.BoxGeometry(2.05, .07, .03), carbon, -.05, .21, s * .955);
add(new THREE.BoxGeometry(2.05, .012, .034), trim, -.05, .248, s * .956); // red pinstripe on the sill
beam(new THREE.Vector3(-.45, 1.1, s * .6), new THREE.Vector3(-2.05, .95, s * .86), .07, .11, paint);
const tl = add(new THREE.BoxGeometry(.03, .03, .56), rear, -2.29, .82, s * .52); tl.rotation.x = s * .05;
add(new THREE.BoxGeometry(.03, .2, .03), rear, -2.28, .72, s * .8);
}
for (let k = 0; k < 6; k++) add(new THREE.BoxGeometry(.06, .015, .92), carbon, -1.42 - k * .11, .975, 0);
add(new THREE.BoxGeometry(.3, .03, 1.62), carbon, 2.06, .175, 0);
add(new THREE.BoxGeometry(.02, .032, 1.62), trim, 2.2, .175, 0); // red splitter lip
const wing = add(new THREE.BoxGeometry(.38, .03, 1.94), carbon, -2.12, 1.02, 0); wing.rotation.z = .12;
for (const z of [-.97, .97]) add(new THREE.BoxGeometry(.42, .16, .02), trim, -2.1, .98, z);
add(new THREE.BoxGeometry(.04, .32, 1.5), carbon, -2.3, .58, 0);
for (let k = -3; k <= 3; k++) add(new THREE.BoxGeometry(.6, .2, .025), carbon, -2.06, .25, k * .2).rotation.z = -.42;
const ti = new THREE.MeshStandardMaterial({ color: 0x6d7079, metalness: 1, roughness: .3, side: THREE.DoubleSide, clippingPlanes: clip });
const pipe = new THREE.CylinderGeometry(.05, .05, .22, 24, 1, true).rotateZ(Math.PI / 2), soot = new THREE.CircleGeometry(.045, 20).rotateY(-Math.PI / 2);
for (const y of [.6, .72]) for (const z of [-.065, .065]) { add(pipe, ti, -2.28, y, z); add(soot, rubber, -2.37, y, z); }
for (const y of [.6, .72]) for (const z of [-.065, .065]) exhausts.push([-2.38, y, z]);
} else {
// shared details: headlights, tail lights, a splitter, sills, mirrors, twin exhausts
const nose = X0 + LEN, ny = topAt(.96) - .1, rearY = topAt(.03) - .12;
for (const s of [-1, 1]) {
add(new THREE.BoxGeometry(.05, .07, .36), front, nose - .1, ny, s * halfAt(.94) * .62).rotation.y = s * .35;
add(new THREE.BoxGeometry(.05, .08, .5), rear, X0 + .03, rearY, s * halfAt(.04) * .62);
add(new THREE.BoxGeometry(2.1, .07, .03), carbon, 0, curve(BS.bot, .5) + .05, s * (halfAt(.5) - .02));
const mir = add(new THREE.BoxGeometry(.12, .08, .16), paint, CS.x0 + CS.len * .82, curve(CS.bot, .8) + .06, s * (curve(CS.half, .8) + .1)); mir.rotation.y = s * .2;
add(pipeOf(), ti2, X0 + .02, curve(BS.bot, .05) + .1, s * .35); exhausts.push([X0 - .08, curve(BS.bot, .05) + .1, s * .35]);
}
add(new THREE.BoxGeometry(.3, .03, halfAt(.97) * 1.6), carbon, nose - .2, curve(BS.bot, .97) + .02, 0);
add(new THREE.BoxGeometry(.02, .032, halfAt(.97) * 1.6), trim, nose - .06, curve(BS.bot, .97) + .02, 0);
add(new THREE.BoxGeometry(.02, .05, halfAt(.02) * 1.4), rear, X0 + .01, rearY + .07, 0);
if (model === "cab") { // the roof sign and the checker stripe
const signT = (() => { const c = document.createElement("canvas"); c.width = 256; c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#fff6d0"; g.fillRect(0, 0, 256, 64); g.fillStyle = "#111"; g.font = "900 40px sans-serif"; g.textAlign = "center"; g.fillText("TAXI", 128, 48); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
const sign = add(new THREE.BoxGeometry(.55, .22, .9), new THREE.MeshBasicMaterial({ map: signT, toneMapped: false, color: new THREE.Color(1.6, 1.6, 1.6), clippingPlanes: clip }), CS.x0 + CS.len * .45, curve(CS.top, .45) + .1, 0);
sign.rotation.y = Math.PI / 2;
}
}
add(new THREE.BoxGeometry(3.6, .03, 1.7), carbon, -.1, .16, 0); // flat floor
// wheels: a rounded tyre, a deep-dish rim with twin spokes and a centre lock, a brake disc
// (all spinning) and a fixed caliper
const wheels = [];
const V2 = (x, y) => new THREE.Vector2(x, y);
const tyreG = new THREE.LatheGeometry([V2(.265, -.15), V2(.32, -.156), V2(.35, -.145), V2(.362, -.115), V2(WR + .004, 0), V2(.362, .115), V2(.35, .145), V2(.32, .156), V2(.265, .15)], 72).rotateX(Math.PI / 2);
const barrelG = new THREE.LatheGeometry([V2(.262, .15), V2(.27, .13), V2(.268, -.13), V2(.24, -.14)], 64).rotateX(Math.PI / 2);
const lipG = new THREE.TorusGeometry(.266, .009, 8, 64);
const discG = new THREE.CylinderGeometry(.2, .2, .03, 48).rotateX(Math.PI / 2), hatG = new THREE.CylinderGeometry(.09, .09, .06, 32).rotateX(Math.PI / 2);
const nutG = new THREE.CylinderGeometry(.045, .045, .05, 6).rotateX(Math.PI / 2);
const spokeG = new THREE.BoxGeometry(.026, .2, .028).translate(0, .155, 0);
const disc = new THREE.MeshStandardMaterial({ color: 0x5a5c62, metalness: .8, roughness: .45, clippingPlanes: clip, emissive: 0xff4a10, emissiveIntensity: 0 }); // glows when the brakes are hammered
const wireWheel = new THREE.EdgesGeometry(new THREE.CylinderGeometry(WR, WR, .3, 28).rotateX(Math.PI / 2), 20);
for (const xw of WHEELS) for (const s of [-1, 1]) {
const spin = new THREE.Group(); spin.position.set(xw, WR, s * .83); solid.add(spin);
spin.add(new THREE.Mesh(tyreG, rubber), new THREE.Mesh(barrelG, carbon));
const lip = new THREE.Mesh(lipG, rim); lip.position.z = s * .14; spin.add(lip);
const d = new THREE.Mesh(discG, disc); d.position.z = -s * .04; spin.add(d);
const hat = new THREE.Mesh(hatG, rim); hat.position.z = s * .04; spin.add(hat);
const nut = new THREE.Mesh(nutG, caliper); nut.position.z = s * .12; spin.add(nut);
for (let k = 0; k < 5; k++) for (const off of [-.11, .11]) { // five twin spokes, dished in toward the hub
const sp = new THREE.Mesh(spokeG, rim); sp.position.z = s * .1;
sp.rotation.z = k / 5 * Math.PI * 2 + off; sp.rotation.x = s * .12; spin.add(sp);
}
const cal = add(new THREE.BoxGeometry(.24, .13, .08), caliper, xw - .16, WR + .13, s * .7); cal.rotation.z = .65;
const wl = new THREE.LineSegments(wireWheel, lineMat); wl.position.set(xw, WR, s * .83); wire.add(wl);
wheels.push(spin);
}
// the glowing scan plane that sweeps along the car during the build
const scanTex = (() => {
const c = document.createElement("canvas"); c.width = 4; c.height = 128;
const g = c.getContext("2d"), gr = g.createLinearGradient(0, 0, 0, 128);
gr.addColorStop(0, "rgba(255,255,255,0)"); gr.addColorStop(.5, "rgba(255,255,255,1)"); gr.addColorStop(1, "rgba(255,255,255,0)");
g.fillStyle = gr; g.fillRect(0, 0, 4, 128); return new THREE.CanvasTexture(c);
})();
const scan = new THREE.Mesh(new THREE.PlaneGeometry(2.6, 1.6), new THREE.MeshBasicMaterial({ map: scanTex, transparent: true, opacity: .0, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
scan.rotation.y = Math.PI / 2; scan.rotation.z = Math.PI / 2; scan.position.y = .7; car.add(scan);
// flight kit: four thrusters that swing from straight down (lift-off) to straight back (cruise),
// and two swept wings that slide out of the sides, with nav lights on the tips
const housing = new THREE.MeshStandardMaterial({ color: 0x18181c, metalness: .85, roughness: .3 });
const core = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xdff6ff).multiplyScalar(4), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
const outer = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4f8dff).multiplyScalar(2.2), transparent: true, opacity: .55, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false, side: THREE.DoubleSide });
const nozG = new THREE.CylinderGeometry(.12, .16, .26, 24, 1, true), ringG = new THREE.TorusGeometry(.15, .025, 8, 28).rotateX(Math.PI / 2);
const flameG = new THREE.ConeGeometry(.14, 1, 20, 1, true).rotateX(Math.PI).translate(0, -.5, 0);
const thrusters = [];
for (const [x, y] of [[.9, .3], [-.95, .38]]) for (const s of [-1, 1]) {
const pivot = new THREE.Group(); pivot.position.set(x, y, s * 1.0); car.add(pivot);
const noz = new THREE.Mesh(nozG, housing); noz.position.y = -.05;
const ring = new THREE.Mesh(ringG, front); ring.position.y = -.18;
const fo = new THREE.Mesh(flameG, outer), fc = new THREE.Mesh(flameG, core);
fo.position.y = fc.position.y = -.18; fc.scale.set(.45, 1, .45);
pivot.add(noz, ring, fo, fc); pivot.scale.setScalar(0);
thrusters.push({ pivot, fo, fc, seed: Math.random() * 10 });
}
const glowLight = new THREE.PointLight(0x6aa8ff, 0, 9, 2); glowLight.position.set(0, -.4, 0); car.add(glowLight);
const wingShape = new THREE.Shape([[.7, 0], [-1.05, 0], [-1.35, 2.25], [-.75, 2.25]].map(([a, b]) => new THREE.Vector2(a, b)));
const wingG = new THREE.ExtrudeGeometry(wingShape, { depth: .05, bevelEnabled: true, bevelThickness: .015, bevelSize: .02, bevelSegments: 2 }).rotateX(-Math.PI / 2);
const wings = [];
for (const s of [-1, 1]) {
const w = new THREE.Mesh(wingG, paint); w.position.set(-.15, .6, s * .85); w.scale.set(1, 1, 0); car.add(w);
const nav = new THREE.Mesh(new THREE.SphereGeometry(.05, 12, 8), new THREE.MeshBasicMaterial({ color: new THREE.Color(s > 0 ? 0x22ff66 : 0xff2233).multiplyScalar(3), toneMapped: false }));
nav.position.set(-1.1, .04, -2.25); w.add(nav);
const edge = new THREE.Mesh(new THREE.BoxGeometry(1.8, .02, .02), front); edge.position.set(-.18, .06, -.02); w.add(edge);
wings.push({ w, s, nav });
}
// fewer draw calls: the fixed detail parts (lights, vents, splitter, sills...) and each wheel's parts are merged per material
const mergeStatic = (grp, keep) => {
const by = new Map();
for (const o of [...grp.children]) {
if (!o.isMesh || keep.has(o) || o.children.length || o.material.map) continue;
o.updateMatrix(); const g = o.geometry.index ? o.geometry.toNonIndexed() : o.geometry.clone(); g.applyMatrix4(o.matrix);
for (const k of Object.keys(g.attributes)) if (k !== "position" && k !== "normal") g.deleteAttribute(k);
if (!g.attributes.normal) g.computeVertexNormals();
if (!by.has(o.material)) by.set(o.material, []); by.get(o.material).push(g); grp.remove(o);
}
for (const [m, list] of by) { const mesh = new THREE.Mesh(mergeGeometries(list), m); mesh.castShadow = true; grp.add(mesh); }
};
mergeStatic(solid, new Set([bodyMesh, canMesh]));
for (const w of wheels) mergeStatic(w, new Set());
// ---- garage parts: spoilers, liveries, exhaust flames ----
const spoilers = new THREE.Group(); solid.add(spoilers);
// active aero: a carbon wing stowed in the tail that rises at speed and flips up as an air brake under hard braking
const aero = new THREE.Group(), aeroWing = new THREE.Group(); solid.add(aero); aero.add(aeroWing);
{ const y0 = topAt(.05), w = Math.max(1.4, halfAt(.05) * 1.9), x = X0 + .3;
aero.position.set(x, y0 - .02, 0);
const blade = new THREE.Mesh(new THREE.BoxGeometry(.42, .03, w), carbon); blade.position.x = -.06; aeroWing.add(blade);
for (const z of [-1, 1]) { const ep = new THREE.Mesh(new THREE.BoxGeometry(.46, .14, .02), carbon); ep.position.set(-.06, .03, z * w / 2); aeroWing.add(ep); }
const ledG = new THREE.Mesh(new THREE.BoxGeometry(.02, .012, w * .9), rear); ledG.position.set(-.27, 0, 0); aeroWing.add(ledG);
for (const z of [-.3, .3]) { const st = new THREE.Mesh(new THREE.BoxGeometry(.05, .4, .04).translate(0, -.2, 0), carbon); st.position.set(0, 0, z * w); aeroWing.add(st); }
aero.userData = { lift: 0, tilt: 0 }; }
function setSpoiler(k) {
spoilers.clear();
const y0 = topAt(.04), w = Math.max(1.5, halfAt(.05) * 2), x = X0 + .18;
const part = (geo, m, px, py, pz, rz = 0) => { const o = new THREE.Mesh(geo, m); o.position.set(px, py, pz); o.rotation.z = rz; spoilers.add(o); o.castShadow = true; return o; };
if (k === "ducktail") part(new THREE.BoxGeometry(.32, .05, w * .9), paint, x + .05, y0 + .04, 0, .28);
if (k === "gt") { part(new THREE.BoxGeometry(.45, .035, w + .25), carbon, x - .05, y0 + .42, 0, .1); for (const z of [-1, 1]) { part(new THREE.BoxGeometry(.08, .42, .03), carbon, x, y0 + .21, z * w * .3); part(new THREE.BoxGeometry(.5, .2, .02), trim, x - .05, y0 + .42, z * (w + .25) / 2); } }
if (k === "swan") { part(new THREE.BoxGeometry(.5, .04, w + .3), carbon, x - .1, y0 + .55, 0, .14); for (const z of [-1, 1]) { part(new THREE.BoxGeometry(.06, .6, .03), carbon, x + .05, y0 + .3, z * w * .28, -.35); part(new THREE.BoxGeometry(.55, .24, .02), trim, x - .1, y0 + .55, z * (w + .3) / 2); } }
if (k === "rally") { part(new THREE.BoxGeometry(.4, .04, w * .95), paint, x + .25, topAt(.12) + .12, 0, .05); for (const z of [-1, 1]) part(new THREE.BoxGeometry(.32, .14, .02), paint, x + .25, topAt(.12) + .06, z * w * .46); }
mergeStatic(spoilers, new Set());
}
const liveryMat = new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: .3, clearcoat: 1, side: THREE.DoubleSide, clippingPlanes: clip, polygonOffset: true, polygonOffsetFactor: -2 });
const livery = new THREE.Group(); solid.add(livery);
const LP = bodyHi.P, LNV = bodyHi.nv, LNU = bodyHi.nu, pt = (i, j) => { const k = (i * LNV + ((j % LNV) + LNV) % LNV) * 3; return [LP[k], LP[k + 1], LP[k + 2]]; };
const patch = (i0, i1, js, push) => { // a strip of the body surface, lifted a hair off the paint
const pos = [];
for (let i = i0; i < i1; i++) for (let a = 0; a < js.length - 1; a++) {
const q = [pt(i, js[a]), pt(i + 1, js[a]), pt(i + 1, js[a + 1]), pt(i, js[a + 1])].map(([x, y, z]) => [x, y + push[1], z + Math.sign(z) * push[2]]);
pos.push(...q[0], ...q[1], ...q[2], ...q[0], ...q[2], ...q[3]);
}
const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); g.computeVertexNormals();
livery.add(new THREE.Mesh(g, liveryMat));
};
const T4 = LNV / 4;
function setLivery(k, hex) {
livery.clear(); liveryMat.color.set(hex || "#ffffff");
if (k === "stripes") { patch(4, LNU - 2, [T4 - 5, T4 - 4, T4 - 3], [0, .006, 0]); patch(4, LNU - 2, [T4 + 3, T4 + 4, T4 + 5], [0, .006, 0]); }
if (k === "center") patch(4, LNU - 2, [T4 - 2, T4 - 1, T4, T4 + 1, T4 + 2], [0, .006, 0]);
if (k === "side") { patch(20, LNU - 30, [-1, 0, 1], [0, 0, .006]); patch(20, LNU - 30, [LNV / 2 - 1, LNV / 2, LNV / 2 + 1], [0, 0, .006]); }
if (k === "race") {
patch(4, LNU - 2, [T4 - 5, T4 - 4, T4 - 3], [0, .006, 0]); patch(4, LNU - 2, [T4 + 3, T4 + 4, T4 + 5], [0, .006, 0]);
const c = document.createElement("canvas"); c.width = c.height = 128; const g = c.getContext("2d"); g.fillStyle = "#fff"; g.beginPath(); g.arc(64, 64, 60, 0, 7); g.fill(); g.fillStyle = "#111"; g.font = "900 70px sans-serif"; g.textAlign = "center"; g.fillText("42", 64, 89);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
for (const side of [0, LNV / 2]) { const [x, y, z] = pt(Math.round(LNU * .5), side); const d = new THREE.Mesh(new THREE.CircleGeometry(.2, 24), new THREE.MeshBasicMaterial({ map: t, transparent: true, clippingPlanes: clip })); d.position.set(x, y + .02, z + Math.sign(z) * .012); d.rotation.y = z > 0 ? 0 : Math.PI; livery.add(d); }
}
mergeStatic(livery, new Set());
}
const flameMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4fa8ff).multiplyScalar(3), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
const flames = exhausts.map(([x, y, z]) => { const f = new THREE.Mesh(new THREE.ConeGeometry(.07, 1, 12, 1, true).rotateZ(Math.PI / 2).translate(-.5, 0, 0), flameMat); f.position.set(x, y, z); f.visible = false; solid.add(f); return f; });
const bodyBase = bodyMesh.geometry.attributes.position.array.slice(), canBase = canMesh.geometry.attributes.position.array.slice();
const wear = { total: 0, burn: 0, cracked: false }; let paintHex = "#0d0d10";
const crackTex = () => { // a starburst of cracks on the screen
const c = document.createElement("canvas"); c.width = c.height = 256; const g = c.getContext("2d"); g.fillStyle = "#9aa4ae"; g.fillRect(0, 0, 256, 256);
g.strokeStyle = "rgba(255,255,255,.95)"; g.lineWidth = 1.2;
for (let n = 0; n < 3; n++) { const ox = 60 + Math.random() * 140, oy = 60 + Math.random() * 140; for (let k = 0; k < 14; k++) { let x = ox, y = oy, a = Math.random() * 6.28; g.beginPath(); g.moveTo(x, y); for (let j = 0; j < 7; j++) { a += (Math.random() - .5) * .7; x += Math.cos(a) * 14; y += Math.sin(a) * 14; g.lineTo(x, y); } g.stroke(); } for (let r = 8; r < 50; r += 12) { g.beginPath(); g.arc(ox, oy, r, 0, 6.3); g.stroke(); } }
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(1.5, 1.5); return t;
};
// ---- finishing details: panel seams (doors, hood), LED light strips, number plates ----
{
const seamMat = new THREE.MeshBasicMaterial({ color: 0x050505, side: THREE.DoubleSide, clippingPlanes: clip, polygonOffset: true, polygonOffsetFactor: -2, transparent: true, opacity: .85 });
const seamPatch = (i0, js, push) => {
const pos = [];
for (let a = 0; a < js.length - 1; a++) { const q = [pt(i0, js[a]), pt(i0 + 1, js[a]), pt(i0 + 1, js[a + 1]), pt(i0, js[a + 1])].map(([x, y, z]) => [x, y + push[1], z + Math.sign(z) * push[2]]); pos.push(...q[0], ...q[1], ...q[2], ...q[0], ...q[2], ...q[3]); }
const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); solid.add(new THREE.Mesh(g, seamMat));
};
const sideJ = (c) => Array.from({ length: 11 }, (_, k) => c - 5 + k); // the side of the car from the sill up to the waist
for (const u of [.36, .55]) { const i = Math.round(LNU * u); seamPatch(i, sideJ(0), [0, 0, .004]); seamPatch(i, sideJ(LNV / 2), [0, 0, .004]); }
const hoodI = Math.round(LNU * .8); seamPatch(hoodI, Array.from({ length: 13 }, (_, k) => T4 - 6 + k), [0, .004, 0]);
// LED strips: a light bar across the nose and a full-width tail bar
const nose = X0 + LEN;
const drl = new THREE.Mesh(new THREE.BoxGeometry(.03, .025, halfAt(.93) * 1.5), front); drl.position.set(nose - .14, topAt(.93) - .02, 0); solid.add(drl);
const tail = new THREE.Mesh(new THREE.BoxGeometry(.03, .04, halfAt(.03) * 1.7), rear); tail.position.set(X0 + .02, topAt(.03) - .07, 0); solid.add(tail);
// number plates
const plateT = (() => { const c = document.createElement("canvas"); c.width = 256; c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#f4f4f0"; g.fillRect(0, 0, 256, 64); g.strokeStyle = "#111"; g.lineWidth = 4; g.strokeRect(3, 3, 250, 58); g.fillStyle = "#111"; g.font = "900 40px Inter, sans-serif"; g.textAlign = "center"; g.fillText("VOID·242", 128, 47); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
const plateM = new THREE.MeshStandardMaterial({ map: plateT, roughness: .4, metalness: .2, clippingPlanes: clip });
const pf = new THREE.Mesh(new THREE.PlaneGeometry(.52, .13), plateM); pf.position.set(nose + .005, curve(BS.bot, .98) + .14, 0); pf.rotation.y = Math.PI / 2; solid.add(pf);
const pr = new THREE.Mesh(new THREE.PlaneGeometry(.52, .13), plateM); pr.position.set(X0 - .005, curve(BS.bot, .03) + .22, 0); pr.rotation.y = -Math.PI / 2; solid.add(pr);
}
return {
group: car, wheels, clip, glowLight, materials: { carbon, front }, model, name: M.name,
setSpoiler, setLivery,
setTint(k) { const T = { light: [0x1a2a3a, .32, .1], dark: [0x0a0d12, .6, .1], limo: [0x030405, .9, .1], mirror: [0x9aa4b0, .88, .95] }[k] || [0x0a0d12, .6, .1]; glass.color.setHex(T[0]); glass.opacity = T[1]; glass.metalness = T[2]; },
setFlameColor(hex) { flameMat.color.set(hex).multiplyScalar(3); },
setFlame(power, time) { for (const f of flames) { f.visible = power > .02; f.scale.set(.4 + power * (1.1 + .3 * Math.sin(time * 60 + f.position.z * 9)), 1 + power * .6, 1 + power * .6); } },
// power 0..1 is flame size, angle 0 = pointing down, 1 = pointing back, deploy 0..1 brings them out
setThrust(power, angle, deploy, time) {
for (const t of thrusters) {
t.pivot.scale.setScalar(Math.max(.0001, deploy)); t.pivot.visible = deploy > .005;
t.pivot.rotation.z = -angle * Math.PI / 2;
const f = power * (.82 + .18 * Math.sin(time * 41 + t.seed) * Math.sin(time * 23 + t.seed * 3));
t.fo.scale.set(1 + power * .3, Math.max(.001, f * 2.6), 1 + power * .3);
t.fc.scale.set(.45, Math.max(.001, f * 1.5), .45);
t.fo.visible = t.fc.visible = power > .01;
}
glowLight.intensity = power * 30;
},
setWings(t, time) {
for (const { w, s, nav } of wings) {
w.scale.z = Math.max(.0001, t) * -s; w.rotation.x = s * (1 - t) * .4; w.visible = t > .01;
nav.visible = t > .6 && (Math.sin(time * 6 + s) > -.2);
}
},
setReveal(t) {
const x = t >= .999 ? 1e5 : -2.7 + 5.4 * t; // once built, never clip (the car drives far from the origin)
solidClip.constant = x; wireClip.constant = -x;
scan.position.x = x; scan.material.opacity = t > .01 && t < .99 ? .55 : 0;
wire.visible = t < .999;
},
setPaint(hex) { paint.color.set(hex); paintHex = hex; if (wear.burn) paint.color.lerp(new THREE.Color(0x141210), wear.burn); },
// damage you can see: dents pushed into the bodywork where the hit was, a cracking windscreen, scorched paint
damage(local, power) {
wear.total += power;
for (const [mesh, kk] of [[bodyMesh, 1], [canMesh, .6]]) {
const pos = mesh.geometry.attributes.position, r = .55 + Math.min(1.1, power * .025), depth = Math.min(.32, power * .006) * kk;
let hit = false;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i), d = Math.hypot(x - local.x, (y - local.y) * 1.4, z - local.z);
if (d > r) continue;
// pushed in toward the middle of the car (and back from the nose or tail on a head-on hit)
const f = (1 - d / r) ** 2 * depth, dy = .6 - y, dz = -z, l = Math.hypot(dy, dz) || 1, ax = Math.abs(local.x) > 1.5 ? -Math.sign(local.x) * 1.4 : 0;
pos.setXYZ(i, x + ax * f, y + dy / l * f * .7, z + dz / l * f * 1.3); hit = true;
}
if (hit) { pos.needsUpdate = true; mesh.geometry.computeVertexNormals(); }
}
if (wear.total > 25 && !wear.cracked) { wear.cracked = true; glass.map = crackTex(); glass.needsUpdate = true; }
wear.burn = Math.min(.85, Math.max(wear.burn, (wear.total - 60) / 160)); if (wear.burn > 0) { paint.color.set(paintHex).lerp(new THREE.Color(0x141210), wear.burn); paint.roughness = Math.max(paint.roughness, .3 + wear.burn * .6); }
},
wreck() { wear.burn = .9; paint.color.set(0x161412); paint.roughness = .95; paint.clearcoat = 0; glass.opacity = .25; if (!wear.cracked) { wear.cracked = true; glass.map = crackTex(); glass.needsUpdate = true; } spoilers.visible = false; },
repair() {
bodyMesh.geometry.attributes.position.array.set(bodyBase); bodyMesh.geometry.attributes.position.needsUpdate = true; bodyMesh.geometry.computeVertexNormals();
canMesh.geometry.attributes.position.array.set(canBase); canMesh.geometry.attributes.position.needsUpdate = true; canMesh.geometry.computeVertexNormals();
wear.total = 0; wear.burn = 0; wear.cracked = false; glass.map = null; glass.needsUpdate = true; paint.color.set(paintHex); spoilers.visible = true;
},
get wear() { return wear; },
setFinish(f) {
const F = { gloss: [.6, .32, 1, .12], satin: [.4, .5, .35, .06], matte: [.15, .8, 0, 0] }[f] || [.6, .32, 1, .12];
paint.metalness = F[0]; paint.roughness = F[1]; paint.clearcoat = F[2]; paint.normalScale.setScalar(F[3]);
},
setAccent(hex) { caliper.color.set(hex); },
// brakes: the tail lights flare and the discs glow when you brake hard at speed
setBrakes(k, heat) { rear.color.setRGB(2.6 * (1 + k * 1.2), .42 * (1 + k * 1.2), .58 * (1 + k)); disc.emissiveIntensity += ((heat || 0) * 2.2 - disc.emissiveIntensity) * .08; },
// the active wing: up above 110 km/h, air-brake tilt when braking hard; it stays stowed if a fixed spoiler is fitted
setAero(kmh, braking, dt) { const A = aero.userData, on = !spoilers.children.length; aero.visible = on; if (!on) return;
A.lift += ((kmh > 110 || braking > .5 && kmh > 60 ? 1 : 0) - A.lift) * Math.min(1, dt * 3); A.tilt += ((braking > .5 && kmh > 60 ? 1 : 0) - A.tilt) * Math.min(1, dt * 5);
aeroWing.position.y = .03 + A.lift * .38; aeroWing.rotation.z = .08 + A.tilt * .95; aeroWing.visible = A.lift > .02; },
setTrim(hex) { trim.color.set(hex); },
setRim(hex) { rim.color.set(hex); },
setLight(hex) { front.color.set(hex).multiplyScalar(3); },
};
}
|