tower / files / src / views / pipe-hull.ts
pipe-hull.ts
6368edcf 2026-09-28 Pavel Safronov | 1 | /** |
| 2 | * The piping skin: an exterior experiment, Tower as industrial plant (D-077). Not canon. | |
| 3 | * | |
| 4 | * The author asked for "something industrial like the Alien movies, with panels and pipes | |
| 5 | * and exhaust vents, only on a huge scale". So the bare skin becomes plating, and on it: | |
| 6 | * | |
| 7 | * - **Bands**: collars of structure round the whole hull every ~36 km, each flanked by a | |
| 8 | * pair of hoop pipes. They break the 540 km into stations, as a ship's frames do. | |
| 9 | * - **Pipe runs**: bundles of two to five pipes along the axis between bands, a few hundred | |
| 10 | * metres to a kilometre and a half across, held off the plating on brackets. | |
| 11 | * - **Greebles**: blocks, clustered like plant rooms, now and then a tower. | |
| 12 | * - **Vents**: housings with a grille that glows hot, and a plume of exhaust above — each | |
| 13 | * switching on and off at random, the grille and the plume independently. | |
| 14 | * - **Thrusters**: pods of four bells near each end, pointing out, spinward, antispinward | |
| 15 | * and along the axis — what holds Tower's attitude (§1.6). They fire rarely, one bell of a | |
| 16 | * pod at a time, with a long flame and real light on the plating round them. | |
| 17 | * | |
| 18 | * All of it wears a layer of dust, thickest on what faces outward (`dusty`). | |
| 19 | * | |
| 20 | * Everything sits outside the skin, R + HULL_D, and nothing is drawn inside it, so the | |
| 21 | * windows still look into Tower and not into scaffolding. It is all instanced, so the few | |
| 22 | * thousand parts cost a handful of draw calls. Seeded: the same plant on every load. | |
| 23 | */ | |
| 24 | import { | |
| 25 | AdditiveBlending, BoxGeometry, type BufferGeometry, Color, ConeGeometry, CylinderGeometry, | |
| 26 | DoubleSide, Group, InstancedBufferAttribute, InstancedMesh, Matrix4, Mesh, MeshStandardMaterial, | |
| 27 | PlaneGeometry, PointLight, RingGeometry, ShaderMaterial, TorusGeometry, Vector3, | |
| 28 | } from 'three' | |
| 29 | import { HULL_DEPTH_M, LENGTH_M, RADIUS_M } from '../canon/constants.js' | |
| 30 | import { mulberry32 } from '../terrain/noise.js' | |
| 31 | import { BUMP_GLSL } from './rock-hull.js' | |
| 32 | ||
| 33 | const KM = 1000 | |
| 34 | const R = RADIUS_M / KM | |
| 35 | const L = LENGTH_M / KM | |
| 36 | const HULL_D = HULL_DEPTH_M / KM | |
| 37 | /** The skin everything is bolted to. */ | |
| 38 | const SKIN = R + HULL_D | |
| 39 | const SEED = 0x9177 | |
| 40 | ||
| 41 | /** Panels round the circumference: a whole number, so the plating closes on itself. */ | |
| 42 | const PANELS_AROUND = 40 | |
| 43 | /** Panel length along the axis, km. */ | |
| 44 | const PANEL_ALONG = 8 | |
| 45 | ||
| 46 | const ZAXIS = new Vector3(0, 0, 1) | |
| 47 | /** Kilometres at each end kept for the thrusters: no bands, plant or vents. */ | |
| 48 | const THRUSTER_ZONE = 12 | |
| 49 | ||
| 50 | export interface PipeHull { | |
| 51 | /** The parts on the skin; the plating itself is `plating`, a material for the hull. */ | |
| 52 | group: Group | |
| 53 | plating: MeshStandardMaterial | |
| 54 | /** Real seconds: steps the vents, the grilles and the thrusters. */ | |
| 55 | update(t: number): void | |
ab6ae94f 2026-09-28 Pavel Safronov | 56 | /** Where the thruster pods stand, so rock laid over the plant can be dug clear of them. */ |
| 57 | pods: { theta: number; z: number }[] | |
6368edcf 2026-09-28 Pavel Safronov | 58 | } |
| 59 | ||
| 60 | /** How a set of emitters switches: on for a while, off for a while, both at random. */ | |
| 61 | export interface BurstTiming { | |
| 62 | /** Seconds on, min and max. */ | |
| 63 | on: [number, number] | |
| 64 | /** Seconds off, min and max. */ | |
| 65 | off: [number, number] | |
| 66 | /** Seconds to come up to full, and to die away after. */ | |
| 67 | attack: number | |
| 68 | release: number | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Emitters firing at random, each on its own clock. `level[i]` is 0..1: rising over | |
| 73 | * `attack` when it lights, falling over `release` when it goes out, so nothing snaps. The | |
| 74 | * first gap is drawn from the whole off range, so they do not all start in step. | |
| 75 | */ | |
| 76 | export class Bursts { | |
| 77 | readonly level: Float32Array | |
| 78 | private lit: Uint8Array | |
| 79 | private changedAt: Float64Array | |
| 80 | private next: Float64Array | |
| 81 | private from: Float32Array | |
| 82 | ||
| 83 | constructor(readonly count: number, private timing: BurstTiming, private rand: () => number) { | |
| 84 | this.level = new Float32Array(count) | |
| 85 | this.lit = new Uint8Array(count) | |
| 86 | this.changedAt = new Float64Array(count) | |
| 87 | this.next = new Float64Array(count).fill(NaN) | |
| 88 | this.from = new Float32Array(count) | |
| 89 | } | |
| 90 | ||
| 91 | step(t: number): void { | |
| 92 | const { on, off, attack, release } = this.timing | |
| 93 | const pick = ([a, b]: [number, number]) => a + (b - a) * this.rand() | |
| 94 | for (let i = 0; i < this.count; i++) { | |
| 95 | if (Number.isNaN(this.next[i]!)) this.next[i] = t + off[1] * this.rand() | |
| 96 | // A long pause (a hidden tab) can pass several changes; take them in turn. | |
| 97 | while (t >= this.next[i]!) { | |
| 98 | this.from[i] = this.level[i]! | |
| 99 | this.changedAt[i] = this.next[i]! | |
| 100 | this.lit[i] = this.lit[i] ? 0 : 1 | |
| 101 | this.next[i] = this.next[i]! + pick(this.lit[i] ? on : off) | |
| 102 | } | |
| 103 | const since = t - this.changedAt[i]! | |
| 104 | this.level[i] = this.lit[i] | |
| 105 | ? Math.min(1, this.from[i]! + since / attack) | |
| 106 | : Math.max(0, this.from[i]! - since / release) | |
| 107 | } | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | /** A per-instance level the emitter shaders read, updated from a `Bursts` every frame. */ | |
| 112 | function levels(geometry: BufferGeometry, count: number): InstancedBufferAttribute { | |
| 113 | const a = new InstancedBufferAttribute(new Float32Array(count), 1) | |
| 114 | geometry.setAttribute('aLevel', a) | |
| 115 | return a | |
| 116 | } | |
| 117 | ||
| 118 | /** Outward normal and spinward tangent at angle theta. */ | |
| 119 | const frame = (theta: number) => ({ | |
| 120 | n: new Vector3(Math.cos(theta), Math.sin(theta), 0), | |
| 121 | t: new Vector3(-Math.sin(theta), Math.cos(theta), 0), | |
| 122 | }) | |
| 123 | ||
ab6ae94f 2026-09-28 Pavel Safronov | 124 | /** |
| 125 | * `bare` says where the plating is not under rock (D-081). The plant rooms and vents go | |
| 126 | * only there; the bands and pipe runs are laid regardless and run under the rock. Without | |
| 127 | * it, everywhere is bare — and the draws from the seed are the same, so it is the same | |
| 128 | * plant as ever. | |
| 129 | */ | |
| 130 | export function buildPipeHull(bare: (theta: number, z: number) => boolean = () => true): PipeHull { | |
6368edcf 2026-09-28 Pavel Safronov | 131 | const rand = mulberry32(SEED) |
| 132 | const range = (a: number, b: number) => a + (b - a) * rand() | |
| 133 | const group = new Group() | |
| 134 | const m = new Matrix4() | |
| 135 | const s = new Vector3() | |
| 136 | ||
| 137 | // ------------------------------------------------------------------ bands | |
| 138 | const stations: number[] = [] | |
| 139 | // Clear of the last 16 km at each end, which is the thrusters'. | |
| 140 | for (let z = 14; z < L - 16; z += range(30, 42)) stations.push(z) | |
| 141 | const bandMat = metal(0x5d605c, 0.55, 0.5) | |
| 142 | const hoopMat = metal(0x4c4f4d, 0.4, 0.65) | |
| 143 | const BAND_W = 2.6 | |
| 144 | const BAND_H = 1.1 | |
| 145 | for (const z of stations) { | |
| 146 | // An open drum and its two end faces: never a closed cylinder, whose discs would | |
| 147 | // stand inside Tower and be seen through the windows. | |
| 148 | const drum = new CylinderGeometry(SKIN + BAND_H, SKIN + BAND_H, BAND_W, 256, 1, true) | |
| 149 | drum.rotateX(Math.PI / 2) | |
| 150 | drum.translate(0, 0, z) | |
| 151 | group.add(new Mesh(drum, bandMat)) | |
| 152 | for (const side of [-1, 1]) { | |
| 153 | const face = new RingGeometry(SKIN, SKIN + BAND_H, 256) | |
| 154 | face.translate(0, 0, z + (side * BAND_W) / 2) | |
| 155 | group.add(new Mesh(face, bandMat)) | |
| 156 | const tube = 0.42 | |
| 157 | const hoop = new TorusGeometry(SKIN + tube + 0.05, tube, 10, 320) | |
| 158 | hoop.translate(0, 0, z + side * (BAND_W / 2 + 1.4)) | |
| 159 | group.add(new Mesh(hoop, hoopMat)) | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | // ------------------------------------------------------------- pipe runs | |
| 164 | interface Lane { theta: number; halfWidth: number } | |
| 165 | const lanes: Lane[] = [] | |
| 166 | const pipes: { theta: number; z0: number; z1: number; r: number; lift: number }[] = [] | |
| 167 | for (let i = 0; i < 14; i++) { | |
| 168 | const theta = range(0, 2 * Math.PI) | |
| 169 | const count = 2 + Math.floor(rand() * 4) | |
| 170 | const radii = Array.from({ length: count }, () => range(0.3, 1.1)) | |
| 171 | const width = radii.reduce((a, r) => a + 2 * r + 0.25, 0) | |
| 172 | lanes.push({ theta, halfWidth: width / 2 + 0.6 }) | |
| 173 | // Each lane runs through some stations and not others, so the runs start and stop. | |
| 174 | for (let k = 0; k + 1 < stations.length; k++) { | |
| 175 | if (rand() < 0.3) continue | |
| 176 | const z0 = stations[k]! + BAND_W / 2 + 0.3 | |
| 177 | const z1 = stations[k + 1]! - BAND_W / 2 - 0.3 | |
| 178 | let across = -width / 2 | |
| 179 | for (const r of radii) { | |
| 180 | across += r + 0.125 | |
| 181 | pipes.push({ theta: theta + across / SKIN, z0, z1, r, lift: 0.35 }) | |
| 182 | across += r + 0.125 | |
| 183 | } | |
| 184 | } | |
| 185 | } | |
| 186 | const pipeMesh = new InstancedMesh(new CylinderGeometry(1, 1, 1, 14, 1), metal(0xffffff, 0.5, 0.6), pipes.length) | |
| 187 | const tint = new Color() | |
| 188 | pipes.forEach((p, i) => { | |
| 189 | const { n, t } = frame(p.theta) | |
| 190 | // Cylinder y along the axis; (n, z, −t) keeps the basis right-handed. | |
| 191 | m.makeBasis(n, ZAXIS, t.clone().negate()) | |
| 192 | m.scale(s.set(p.r, p.z1 - p.z0, p.r)) | |
| 193 | m.setPosition(n.multiplyScalar(SKIN + p.lift + p.r).addScaledVector(ZAXIS, (p.z0 + p.z1) / 2)) | |
| 194 | pipeMesh.setMatrixAt(i, m) | |
| 195 | pipeMesh.setColorAt(i, tint.setHSL(range(0.08, 0.14), range(0.02, 0.12), range(0.3, 0.45))) | |
| 196 | }) | |
| 197 | group.add(pipeMesh) | |
| 198 | ||
| 199 | // ------------------------------------------------------ brackets & greebles | |
| 200 | // Boxes: x radial (height), y spinward (width), z along the axis (length). | |
| 201 | const boxes: { theta: number; z: number; h: number; w: number; l: number; shade: number }[] = [] | |
| 202 | for (const p of pipes) { | |
| 203 | for (let z = p.z0 + 2; z < p.z1 - 1; z += range(4, 7)) { | |
| 204 | boxes.push({ theta: p.theta, z, h: p.lift + p.r, w: 2.6 * p.r + 0.3, l: 0.35, shade: 0.28 }) | |
| 205 | } | |
| 206 | } | |
| 207 | const inLane = (theta: number, halfWidthKm: number) => lanes.some((ln) => { | |
| 208 | let d = Math.abs(theta - ln.theta) % (2 * Math.PI) | |
| 209 | if (d > Math.PI) d = 2 * Math.PI - d | |
| 210 | return d * SKIN < ln.halfWidth + halfWidthKm | |
| 211 | }) | |
| 212 | const inBand = (z: number, halfLen: number) => | |
| 213 | stations.some((st) => Math.abs(z - st) < BAND_W / 2 + 2.3 + halfLen) | |
| 214 | const place = (theta: number, z: number, w: number, l: number) => | |
| 215 | z - l / 2 > THRUSTER_ZONE && z + l / 2 < L - THRUSTER_ZONE | |
ab6ae94f 2026-09-28 Pavel Safronov | 216 | && !inLane(theta, w / 2) && !inBand(z, l / 2) && bare(theta, z) |
| 217 | // Plant rooms: clusters of blocks, big ones among small ones, and the odd tower. Each | |
| 218 | // is centred on bare ground, so with rock over half the hull they crowd into the rest. | |
6368edcf 2026-09-28 Pavel Safronov | 219 | for (let c = 0; c < 170; c++) { |
ab6ae94f 2026-09-28 Pavel Safronov | 220 | let theta0: number |
| 221 | let z0: number | |
| 222 | do { | |
| 223 | theta0 = range(0, 2 * Math.PI) | |
| 224 | z0 = range(2, L - 2) | |
| 225 | } while (!bare(theta0, z0)) | |
6368edcf 2026-09-28 Pavel Safronov | 226 | const n = 6 + Math.floor(rand() * 16) |
| 227 | for (let j = 0; j < n; j++) { | |
| 228 | const w = range(0.6, 4.5) | |
| 229 | const l = range(0.8, 7) | |
| 230 | const tall = rand() < 0.06 | |
| 231 | const h = tall ? range(3, 6) : range(0.3, 1.8) | |
| 232 | const theta = theta0 + range(-6, 6) / SKIN | |
| 233 | const z = z0 + range(-7, 7) | |
| 234 | if (place(theta, z, w, l)) boxes.push({ theta, z, h, w, l, shade: range(0.25, 0.5) }) | |
| 235 | } | |
| 236 | } | |
| 237 | const boxMesh = new InstancedMesh(new BoxGeometry(1, 1, 1), metal(0xffffff, 0.35, 0.7), boxes.length) | |
| 238 | boxes.forEach((b, i) => { | |
| 239 | const { n, t } = frame(b.theta) | |
| 240 | m.makeBasis(n, t, ZAXIS) | |
| 241 | m.scale(s.set(b.h, b.w, b.l)) | |
| 242 | m.setPosition(n.multiplyScalar(SKIN + b.h / 2).addScaledVector(ZAXIS, b.z)) | |
| 243 | boxMesh.setMatrixAt(i, m) | |
| 244 | boxMesh.setColorAt(i, tint.setHSL(range(0.07, 0.13), range(0.03, 0.1), b.shade)) | |
| 245 | }) | |
| 246 | group.add(boxMesh) | |
| 247 | ||
| 248 | // ------------------------------------------------------------------ vents | |
| 249 | const vents: { theta: number; z: number; size: number }[] = [] | |
| 250 | while (vents.length < 64) { | |
| 251 | const theta = range(0, 2 * Math.PI) | |
| 252 | const z = range(6, L - 6) | |
| 253 | const size = range(1.8, 3.6) | |
| 254 | if (place(theta, z, size + 1, size + 1)) vents.push({ theta, z, size }) | |
| 255 | } | |
| 256 | const VENT_H = 0.9 | |
| 257 | const housing = new InstancedMesh(new BoxGeometry(1, 1, 1), metal(0x3a3c3b, 0.3, 0.75), vents.length) | |
| 258 | const grilleGeometry = new PlaneGeometry(1, 1) | |
| 259 | const grilleLevel = levels(grilleGeometry, vents.length) | |
| 260 | const grille = new InstancedMesh(grilleGeometry, grilleMaterial(), vents.length) | |
| 261 | const plumeGeometry = new ConeGeometry(1, 1, 20, 1, true) | |
| 262 | const plumeLevel = levels(plumeGeometry, vents.length) | |
| 263 | const plume = new InstancedMesh(plumeGeometry, plumeMaterial(), vents.length) | |
| 264 | plume.renderOrder = 5 | |
| 265 | vents.forEach((v, i) => { | |
| 266 | const { n, t } = frame(v.theta) | |
| 267 | m.makeBasis(n, t, ZAXIS).scale(s.set(VENT_H, v.size + 0.8, v.size + 0.8)) | |
| 268 | m.setPosition(n.clone().multiplyScalar(SKIN + VENT_H / 2).addScaledVector(ZAXIS, v.z)) | |
| 269 | housing.setMatrixAt(i, m) | |
| 270 | // The grille: a plane facing out, just proud of the housing's top. (t, z, n) is right-handed. | |
| 271 | m.makeBasis(t, ZAXIS, n).scale(s.set(v.size, v.size, 1)) | |
| 272 | m.setPosition(n.clone().multiplyScalar(SKIN + VENT_H + 0.02).addScaledVector(ZAXIS, v.z)) | |
| 273 | grille.setMatrixAt(i, m) | |
| 274 | // The plume: a cone with its point at the grille, widening outward. (t, −n, z). | |
| 275 | const len = v.size * range(3, 5) | |
| 276 | m.makeBasis(t, n.clone().negate(), ZAXIS).scale(s.set(v.size * 1.6, len, v.size * 1.6)) | |
| 277 | m.setPosition(n.clone().multiplyScalar(SKIN + VENT_H + len / 2).addScaledVector(ZAXIS, v.z)) | |
| 278 | plume.setMatrixAt(i, m) | |
| 279 | }) | |
| 280 | group.add(housing, grille, plume) | |
| 281 | ||
| 282 | // -------------------------------------------------------------- thrusters | |
| 283 | // Pods round each end, in the zone kept clear for them. Each has four bells: out, | |
| 284 | // spinward, antispinward, and along the axis away from the world. | |
| 285 | const PODS_PER_END = 6 | |
| 286 | const POD = { h: 3.2, w: 6, l: 7 } | |
| 287 | const BELL = { len: 2.6, throat: 0.5, exit: 1.4 } | |
| 288 | const bells: { at: Vector3; dir: Vector3 }[] = [] | |
| 289 | const pods: { theta: number; z: number }[] = [] | |
| 290 | for (const [zPod, axial] of [[THRUSTER_ZONE / 2, -1], [L - THRUSTER_ZONE / 2, 1]] as const) { | |
| 291 | const offset = range(0, 2 * Math.PI) | |
| 292 | for (let k = 0; k < PODS_PER_END; k++) { | |
| 293 | const theta = offset + (k / PODS_PER_END) * 2 * Math.PI | |
| 294 | pods.push({ theta, z: zPod }) | |
| 295 | const { n, t } = frame(theta) | |
| 296 | const centre = n.clone().multiplyScalar(SKIN + POD.h / 2).addScaledVector(ZAXIS, zPod) | |
| 297 | for (const [dir, half] of [ | |
| 298 | [n, POD.h / 2], [t, POD.w / 2], [t.clone().negate(), POD.w / 2], | |
| 299 | [ZAXIS.clone().multiplyScalar(axial), POD.l / 2], | |
| 300 | ] as const) { | |
| 301 | bells.push({ at: centre.clone().addScaledVector(dir, half), dir: dir.clone() }) | |
| 302 | } | |
| 303 | } | |
| 304 | } | |
| 305 | const podMesh = new InstancedMesh(new BoxGeometry(1, 1, 1), metal(0x4a4c4a, 0.5, 0.55), pods.length) | |
| 306 | pods.forEach((p, i) => { | |
| 307 | const { n, t } = frame(p.theta) | |
| 308 | m.makeBasis(n, t, ZAXIS).scale(s.set(POD.h, POD.w, POD.l)) | |
| 309 | m.setPosition(n.multiplyScalar(SKIN + POD.h / 2).addScaledVector(ZAXIS, p.z)) | |
| 310 | podMesh.setMatrixAt(i, m) | |
| 311 | }) | |
| 312 | // A bell: open, narrow at the pod and wide at its mouth (Three's cylinder is wide at +y). | |
| 313 | const bellMesh = new InstancedMesh( | |
| 314 | new CylinderGeometry(BELL.exit, BELL.throat, BELL.len, 20, 1, true), | |
| 315 | dusty(new MeshStandardMaterial({ color: 0x6b645c, metalness: 0.7, roughness: 0.4, side: DoubleSide })), | |
| 316 | bells.length) | |
| 317 | const flameGeometry = flare(3.2, 24) | |
| 318 | const coreGeometry = flare(1.2, 16) | |
| 319 | const flameLevel = levels(flameGeometry, bells.length) | |
| 320 | const coreLevel = levels(coreGeometry, bells.length) | |
| 321 | const flame = new InstancedMesh(flameGeometry, flameMaterial(new Color(1.0, 0.55, 0.2), 0.55), bells.length) | |
| 322 | const core = new InstancedMesh(coreGeometry, flameMaterial(new Color(0.75, 0.85, 1.0), 1.1), bells.length) | |
| 323 | flame.renderOrder = core.renderOrder = 6 | |
| 324 | const basis = (dir: Vector3) => { | |
| 325 | // Any unit a square to dir, then b = a × dir: (a, dir, b) is right-handed, y along dir. | |
| 326 | const a = new Vector3().crossVectors(dir, Math.abs(dir.z) < 0.9 ? ZAXIS : new Vector3(1, 0, 0)).normalize() | |
| 327 | return m.makeBasis(a, dir, new Vector3().crossVectors(a, dir)) | |
| 328 | } | |
| 329 | bells.forEach((b, i) => { | |
| 330 | basis(b.dir).setPosition(b.at.clone().addScaledVector(b.dir, BELL.len / 2)) | |
| 331 | bellMesh.setMatrixAt(i, m) | |
| 332 | // Flames: the bell's width at its mouth, fanning out as they go. The outer one long, | |
| 333 | // the core a third as long and hardly spreading. | |
| 334 | const mouth = b.at.clone().addScaledVector(b.dir, BELL.len) | |
| 335 | const len = range(28, 42) | |
| 336 | basis(b.dir).scale(s.set(BELL.exit, len, BELL.exit)) | |
| 337 | flame.setMatrixAt(i, m.setPosition(mouth.clone().addScaledVector(b.dir, len / 2))) | |
| 338 | basis(b.dir).scale(s.set(BELL.exit * 0.8, len * 0.35, BELL.exit * 0.8)) | |
| 339 | core.setMatrixAt(i, m.setPosition(mouth.clone().addScaledVector(b.dir, len * 0.175))) | |
| 340 | }) | |
| 341 | group.add(podMesh, bellMesh, flame, core) | |
| 342 | ||
| 343 | /** | |
| 344 | * Real light from the burns, on the plating and the pods round them. Two lights, handed | |
| 345 | * to whichever bells are burning brightest: a light per bell would recompile every | |
| 346 | * standard material in the scene for 48 lights, and more than two at once is rare. | |
| 347 | */ | |
| 348 | const burnLights = [0, 1].map(() => { | |
| 349 | const light = new PointLight(0xffa860, 0, 80, 1) | |
| 350 | group.add(light) | |
| 351 | return light | |
| 352 | }) | |
| 353 | ||
| 354 | for (const mesh of [pipeMesh, boxMesh, housing, grille, plume, podMesh, bellMesh, flame, core]) { | |
| 355 | mesh.computeBoundingSphere() | |
| 356 | } | |
| 357 | ||
| 358 | // The timings, the heart of the change: vents often, grilles on their own clock, and | |
| 359 | // thrusters rarely — one bell of a pod at a time. | |
| 360 | const live = mulberry32(SEED + 7) | |
| 361 | const plumes = new Bursts(vents.length, { on: [1.5, 4], off: [2, 12], attack: 0.35, release: 1.2 }, live) | |
| 362 | const grilles = new Bursts(vents.length, { on: [1, 7], off: [0.5, 5], attack: 0.15, release: 0.6 }, live) | |
| 363 | const burns = new Bursts(pods.length, { on: [2, 5], off: [25, 80], attack: 0.12, release: 0.5 }, live) | |
| 364 | const burnBell = new Int8Array(pods.length) | |
| 365 | const wasBurning = new Uint8Array(pods.length) | |
| 366 | const timed = [grille.material, plume.material, flame.material, core.material] as ShaderMaterial[] | |
| 367 | return { | |
| 368 | group, | |
ab6ae94f 2026-09-28 Pavel Safronov | 369 | pods, |
6368edcf 2026-09-28 Pavel Safronov | 370 | plating: platingMaterial(), |
| 371 | update(t) { | |
| 372 | for (const mat of timed) mat.uniforms['uTime']!.value = t | |
| 373 | plumes.step(t) | |
| 374 | grilles.step(t) | |
| 375 | burns.step(t) | |
| 376 | plumeLevel.set(plumes.level) | |
| 377 | grilleLevel.set(grilles.level) | |
| 378 | // A pod fires one bell per burn, chosen when it lights. | |
| 379 | flameLevel.array.fill(0) | |
| 380 | for (let p = 0; p < pods.length; p++) { | |
| 381 | const burning = burns.level[p]! > 0 | |
| 382 | if (burning && !wasBurning[p]) burnBell[p] = Math.floor(live() * 4) | |
| 383 | wasBurning[p] = burning ? 1 : 0 | |
| 384 | if (burning) flameLevel.array[p * 4 + burnBell[p]!] = burns.level[p]! | |
| 385 | } | |
| 386 | coreLevel.set(flameLevel.array) | |
| 387 | for (const a of [plumeLevel, grilleLevel, flameLevel, coreLevel]) a.needsUpdate = true | |
| 388 | // The two brightest burns get the lights, at the bell's mouth. | |
| 389 | const order = [...flameLevel.array.keys()].sort((a, b) => flameLevel.array[b]! - flameLevel.array[a]!) | |
| 390 | burnLights.forEach((light, k) => { | |
| 391 | const i = order[k]! | |
| 392 | const level = flameLevel.array[i]! | |
| 393 | light.intensity = level * 60 | |
| 394 | if (level > 0) light.position.copy(bells[i]!.at).addScaledVector(bells[i]!.dir, BELL.len + 2) | |
| 395 | }) | |
| 396 | }, | |
| 397 | } | |
| 398 | } | |
| 399 | ||
| 400 | /** | |
| 401 | * A flame's shape: an open tube of radius 1 at its base (y = −0.5, the bell's mouth) that | |
| 402 | * flares to `spread` at its far end — exhaust in vacuum has nothing to hold it together. | |
| 403 | * Growing as the square of the distance keeps it slim near the hull, where a sideways | |
| 404 | * flame runs closest to the plating, and lets it bloom once it is clear. | |
| 405 | */ | |
| 406 | function flare(spread: number, around: number): BufferGeometry { | |
| 407 | const g = new CylinderGeometry(1, 1, 1, around, 16, true) | |
| 408 | const pos = g.attributes['position']! | |
| 409 | for (let i = 0; i < pos.count; i++) { | |
| 410 | const along = pos.getY(i) + 0.5 | |
| 411 | const k = 1 + (spread - 1) * along * along | |
| 412 | pos.setXYZ(i, pos.getX(i) * k, pos.getY(i), pos.getZ(i) * k) | |
| 413 | } | |
| 414 | g.computeVertexNormals() | |
| 415 | return g | |
| 416 | } | |
| 417 | ||
| 418 | function metal(color: number, metalness: number, roughness: number): MeshStandardMaterial { | |
| 419 | return dusty(new MeshStandardMaterial({ color, metalness, roughness })) | |
| 420 | } | |
| 421 | ||
| 422 | /** | |
| 423 | * A layer of dust over a material (author: "too shiny, let's add a nice layer of dust"). | |
| 424 | * | |
| 425 | * Where it lies, the surface takes the dust's colour and goes fully matte and non-metallic, | |
| 426 | * which is what takes the shine off: bare metal under the fill lights reads as chrome. It | |
| 427 | * lies thickest on faces turned outward — the tops of pipes, blocks and plating — thinner on | |
| 428 | * the sides, and hardly at all underneath, broken into drifts by noise so it is not a | |
| 429 | * uniform wash. Out here "up" is outward: it is the way everything on the hull is thrown. | |
| 430 | * | |
| 431 | * Worked out in the hull's own frame, from the vertex, so it follows instanced parts too, | |
| 432 | * and chained after any shader change the material already has (the plating's panels). | |
| 433 | */ | |
| 434 | export function dusty<M extends MeshStandardMaterial>(mat: M): M { | |
| 435 | const before = mat.onBeforeCompile.bind(mat) | |
| 436 | const key = mat.customProgramCacheKey.bind(mat) | |
| 437 | mat.onBeforeCompile = (shader, renderer) => { | |
| 438 | before(shader, renderer) | |
| 439 | shader.vertexShader = shader.vertexShader | |
| 440 | .replace('#include <common>', '#include <common>\nvarying vec3 vDustPos;\nvarying vec3 vDustN;') | |
| 441 | .replace('#include <project_vertex>', `#include <project_vertex> | |
| 442 | vec4 dustPos = vec4(transformed, 1.0); | |
| 443 | vec3 dustN = objectNormal; | |
| 444 | #ifdef USE_INSTANCING | |
| 445 | dustPos = instanceMatrix * dustPos; | |
| 446 | dustN = mat3(instanceMatrix) * dustN; | |
| 447 | #endif | |
| 448 | vDustPos = dustPos.xyz; | |
| 449 | vDustN = dustN;`) | |
| 450 | shader.fragmentShader = shader.fragmentShader | |
| 451 | .replace('#include <common>', `#include <common>\n${DUST_GLSL}`) | |
| 452 | .replace('#include <metalnessmap_fragment>', `#include <metalnessmap_fragment> | |
| 453 | float dust = dustCover(vDustPos, normalize(vDustN) * (gl_FrontFacing ? 1.0 : -1.0)); | |
| 454 | diffuseColor.rgb = mix(diffuseColor.rgb, DUST_COLOUR * (0.85 + 0.3 * dustGrain(vDustPos)), dust); | |
| 455 | roughnessFactor = mix(roughnessFactor, 1.0, dust); | |
| 456 | metalnessFactor = mix(metalnessFactor, 0.0, dust);`) | |
| 457 | } | |
| 458 | // Three caches programs by onBeforeCompile's source, and every dusty material shares | |
| 459 | // this wrapper's; the plating's panels must not be mistaken for a plain one. | |
| 460 | mat.customProgramCacheKey = () => `dust|${key()}|${before.toString()}` | |
| 461 | return mat | |
| 462 | } | |
| 463 | ||
| 464 | const DUST_GLSL = /* glsl */` | |
| 465 | varying vec3 vDustPos; | |
| 466 | varying vec3 vDustN; | |
| 467 | const vec3 DUST_COLOUR = vec3(0.56, 0.52, 0.46); | |
| 468 | ||
| 469 | float dustHash(vec3 p) { | |
| 470 | p = fract(p * 0.1031); | |
| 471 | p += dot(p, p.zyx + 31.32); | |
| 472 | return fract((p.x + p.y) * p.z); | |
| 473 | } | |
| 474 | float dustNoise(vec3 p) { | |
| 475 | vec3 i = floor(p); | |
| 476 | vec3 f = fract(p); | |
| 477 | f = f * f * (3.0 - 2.0 * f); | |
| 478 | float a = mix(mix(dustHash(i), dustHash(i + vec3(1, 0, 0)), f.x), | |
| 479 | mix(dustHash(i + vec3(0, 1, 0)), dustHash(i + vec3(1, 1, 0)), f.x), f.y); | |
| 480 | float b = mix(mix(dustHash(i + vec3(0, 0, 1)), dustHash(i + vec3(1, 0, 1)), f.x), | |
| 481 | mix(dustHash(i + vec3(0, 1, 1)), dustHash(i + vec3(1, 1, 1)), f.x), f.y); | |
| 482 | return mix(a, b, f.z); | |
| 483 | } | |
| 484 | // Fine variation within the dust, so a dusted face is not a flat colour. | |
| 485 | float dustGrain(vec3 p) { | |
| 486 | return dustNoise(p * 3.0) * 0.6 + dustNoise(p * 11.0) * 0.4 - 0.5; | |
| 487 | } | |
| 488 | /** How much dust lies here, 0..1: by how far the face turns outward, broken into drifts. */ | |
| 489 | float dustCover(vec3 p, vec3 n) { | |
| 490 | float out_ = dot(n, normalize(vec3(p.xy, 0.0))); | |
| 491 | float drift = dustNoise(p / 6.0) * 0.65 + dustNoise(p / 1.7) * 0.35; | |
| 492 | float lie = 0.3 + 0.55 * smoothstep(-0.2, 0.9, out_); | |
| 493 | return clamp(smoothstep(0.25, 0.75, drift * 0.55 + lie * 0.75) * lie * 1.25, 0.0, 0.92); | |
| 494 | } | |
| 495 | ` | |
| 496 | ||
| 497 | // ------------------------------------------------------------------ shaders | |
| 498 | ||
| 499 | const INSTANCED_VERT = /* glsl */` | |
| 500 | attribute float aLevel; | |
| 501 | varying vec2 vUv; | |
| 502 | varying float vId; | |
| 503 | varying float vLevel; | |
| 504 | void main() { | |
| 505 | vUv = uv; | |
| 506 | vId = float(gl_InstanceID); | |
| 507 | vLevel = aLevel; | |
| 508 | gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0); | |
| 509 | } | |
| 510 | ` | |
| 511 | ||
| 512 | /** | |
| 513 | * Slats glowing orange-hot between dark bars when the vent's grille is lit, and a dull | |
| 514 | * ember between times, so an unlit vent still reads as one. | |
| 515 | */ | |
| 516 | function grilleMaterial(): ShaderMaterial { | |
| 517 | return new ShaderMaterial({ | |
| 518 | uniforms: { uTime: { value: 0 } }, | |
| 519 | vertexShader: INSTANCED_VERT, | |
| 520 | fragmentShader: /* glsl */` | |
| 521 | uniform float uTime; | |
| 522 | varying vec2 vUv; | |
| 523 | varying float vId; | |
| 524 | varying float vLevel; | |
| 525 | void main() { | |
| 526 | vec2 p = abs(vUv - 0.5); | |
| 527 | float rim = step(0.42, max(p.x, p.y)); | |
| 528 | float slat = smoothstep(0.25, 0.45, abs(fract(vUv.y * 9.0) - 0.5)); | |
| 529 | float flicker = 0.9 + 0.1 * sin(uTime * 9.0 + vId * 2.1); | |
| 530 | float heat = 0.06 + 1.6 * vLevel * flicker; | |
| 531 | vec3 hot = vec3(1.0, 0.45, 0.12) * (1.0 - slat) * heat; | |
| 532 | gl_FragColor = vec4(mix(hot, vec3(0.05), rim), 1.0); | |
| 533 | #include <colorspace_fragment> | |
| 534 | } | |
| 535 | `, | |
| 536 | }) | |
| 537 | } | |
| 538 | ||
| 539 | /** | |
| 540 | * Exhaust: a pale cone, brightest at the grille and gone by its mouth, streaked and | |
| 541 | * drifting. It shoots out as the vent opens — only as much of the cone as the level has | |
| 542 | * reached is drawn — and fades when it shuts. There is no air out here to hold a plume; it | |
| 543 | * is the look that was asked for, under the same licence as the beam's rays. | |
| 544 | */ | |
| 545 | function plumeMaterial(): ShaderMaterial { | |
| 546 | return new ShaderMaterial({ | |
| 547 | uniforms: { uTime: { value: 0 } }, | |
| 548 | vertexShader: INSTANCED_VERT, | |
| 549 | fragmentShader: /* glsl */` | |
| 550 | uniform float uTime; | |
| 551 | varying vec2 vUv; | |
| 552 | varying float vId; | |
| 553 | varying float vLevel; | |
| 554 | void main() { | |
| 555 | // uv.y is 1 at the cone's point, which sits on the grille. | |
| 556 | float along = vUv.y; | |
| 557 | float reach = smoothstep(1.0 - vLevel * 1.1, 1.05 - vLevel * 1.1, along); | |
| 558 | float streak = 0.6 + 0.4 * sin(vUv.x * 40.0 + vId * 3.0 + uTime * 0.7) | |
| 559 | * sin(along * 12.0 - uTime * (1.5 + fract(vId * 0.61))); | |
| 560 | float a = along * along * streak * 0.2 * vLevel * reach; | |
| 561 | gl_FragColor = vec4(vec3(0.85, 0.84, 0.8) * a, 1.0); | |
| 562 | } | |
| 563 | `, | |
| 564 | transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide, | |
| 565 | }) | |
| 566 | } | |
| 567 | ||
| 568 | /** | |
| 569 | * A thruster's flame: brightest at the bell's mouth and gone by its far end, flickering | |
| 570 | * fast, and reaching out as it lights. uv.y runs 0 at the mouth to 1 at the far end. | |
| 571 | */ | |
| 572 | function flameMaterial(color: Color, gain: number): ShaderMaterial { | |
| 573 | return new ShaderMaterial({ | |
| 574 | uniforms: { uTime: { value: 0 }, uColor: { value: color }, uGain: { value: gain } }, | |
| 575 | vertexShader: INSTANCED_VERT, | |
| 576 | fragmentShader: /* glsl */` | |
| 577 | uniform float uTime; | |
| 578 | uniform vec3 uColor; | |
| 579 | uniform float uGain; | |
| 580 | varying vec2 vUv; | |
| 581 | varying float vId; | |
| 582 | varying float vLevel; | |
| 583 | void main() { | |
| 584 | if (vLevel <= 0.0) discard; | |
| 585 | float along = vUv.y; | |
| 586 | float reach = 1.0 - smoothstep(vLevel - 0.1, vLevel, along); | |
| 587 | float flicker = 0.8 + 0.2 * sin(uTime * 37.0 + vId * 5.0) * sin(uTime * 23.0 + along * 20.0); | |
| 588 | float a = pow(1.0 - along, 1.6) * reach * flicker * vLevel * uGain; | |
| 589 | gl_FragColor = vec4(uColor * a, 1.0); | |
| 590 | } | |
| 591 | `, | |
| 592 | transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide, | |
| 593 | }) | |
| 594 | } | |
| 595 | ||
| 596 | /** | |
| 597 | * The plating, for the hull cylinder itself. Panels on a grid of PANELS_AROUND by | |
| 598 | * PANEL_ALONG km, some split in two or four; each panel its own shade, some ribbed, and | |
| 599 | * grime streaked along the axis. Seams are grooves, bump-mapped, drawn a fixed fraction | |
| 600 | * of a pixel wider as the camera backs off, so they fade rather than alias. | |
| 601 | */ | |
| 602 | function platingMaterial(): MeshStandardMaterial { | |
| 603 | /** | |
| 604 | * Two-sided, unlike the bare skin: the parts bolted on are solid from every side, and | |
| 605 | * from inside the tube — in through a window — the plating's inner face is what keeps | |
| 606 | * them hidden wherever the ground has not been drawn yet. The ground is nearer, so once | |
| 607 | * it is there it covers this. | |
| 608 | */ | |
| 609 | const mat = new MeshStandardMaterial({ | |
| 610 | color: 0x7a7c77, metalness: 0.4, roughness: 0.65, side: DoubleSide, | |
| 611 | }) | |
| 612 | mat.onBeforeCompile = (shader) => { | |
| 613 | shader.vertexShader = shader.vertexShader | |
| 614 | .replace('#include <common>', '#include <common>\nvarying vec3 vPlate;') | |
| 615 | .replace('#include <begin_vertex>', '#include <begin_vertex>\nvPlate = position;') | |
| 616 | shader.fragmentShader = shader.fragmentShader | |
| 617 | .replace('#include <common>', `#include <common>\n${PLATE_GLSL}\n${BUMP_GLSL}`) | |
| 618 | .replace('#include <map_fragment>', `#include <map_fragment> | |
| 619 | vec3 plateTint; | |
| 620 | float plateH = plating(vPlate, plateTint); | |
| 621 | diffuseColor.rgb *= plateTint;`) | |
| 622 | .replace('#include <normal_fragment_maps>', `#include <normal_fragment_maps> | |
| 623 | normal = bumpNormal(-vViewPosition, normal, vec2(dFdx(plateH), dFdy(plateH)), faceDirection);`) | |
| 624 | } | |
| 625 | return dusty(mat) | |
| 626 | } | |
| 627 | ||
| 628 | const PLATE_GLSL = /* glsl */` | |
| 629 | varying vec3 vPlate; | |
| 630 | float plateHash(vec2 p) { | |
| 631 | vec3 q = fract(vec3(p.xyx) * 0.1031); | |
| 632 | q += dot(q, q.yzx + 33.33); | |
| 633 | return fract((q.x + q.y) * q.z); | |
| 634 | } | |
| 635 | ||
| 636 | /** | |
| 637 | * Relief in km (grooves negative) and a colour multiplier, at a point on the hull. | |
| 638 | * u runs round in panel units and closes exactly on itself; the pixel's footprint is | |
| 639 | * taken from the position, which has no seam, rather than from u, which jumps at ±π. | |
| 640 | */ | |
| 641 | float plating(vec3 p, out vec3 tint) { | |
| 642 | float px = length(fwidth(p)); | |
| 643 | float around = (atan(p.y, p.x) / 6.2831853 + 0.5) * ${PANELS_AROUND.toFixed(1)}; | |
| 644 | float cellKm = ${(2 * Math.PI * SKIN / PANELS_AROUND).toFixed(4)}; | |
| 645 | vec2 g = vec2(around, p.z / ${PANEL_ALONG.toFixed(1)}); | |
| 646 | vec2 cell = floor(g); | |
| 647 | vec2 f = fract(g); | |
| 648 | float h = plateHash(cell); | |
| 649 | // Split some panels: in two along the axis, in two round, or in four. | |
| 650 | vec2 split = vec2(h < 0.3 || (h > 0.55 && h < 0.75) ? 2.0 : 1.0, h > 0.3 && h < 0.75 ? 2.0 : 1.0); | |
| 651 | vec2 sub = floor(f * split); | |
| 652 | vec2 sf = fract(f * split); | |
| 653 | float id = plateHash(cell * 7.3 + sub + 1.7); | |
| 654 | // Distance to the panel's edge, km, and the groove it makes. | |
| 655 | vec2 edgeKm = min(sf, 1.0 - sf) * vec2(cellKm, ${PANEL_ALONG.toFixed(1)}) / split; | |
| 656 | float e = min(edgeKm.x, edgeKm.y); | |
ab6ae94f 2026-09-28 Pavel Safronov | 657 | // Seams fade out once a sub-panel is only a few pixels across: a grid of lines each a |
| 658 | // pixel wide, a few pixels apart, beats against the pixel grid (moiré). | |
| 659 | float seamFade = 1.0 - smoothstep(0.08, 0.2, px / min(cellKm / split.x, ${PANEL_ALONG.toFixed(1)} / split.y)); | |
| 660 | float groove = (1.0 - smoothstep(0.05, 0.05 + max(0.06, px * 1.2), e)) * seamFade; | |
6368edcf 2026-09-28 Pavel Safronov | 661 | float height = -0.08 * groove; |
ab6ae94f 2026-09-28 Pavel Safronov | 662 | // Ribbed panels: raised ribs across the axis. They are rings round the hull, so what |
| 663 | // matters is how fast z runs across a pixel, which at a glancing angle is far more than | |
| 664 | // the pixel's overall size suggests. Gone before the ribs are three pixels apart: any | |
| 665 | // closer and they beat against the pixel grid in rings of moiré (author, 2026-09-28). | |
| 666 | float zPx = fwidth(p.z); | |
6368edcf 2026-09-28 Pavel Safronov | 667 | float ribbed = step(0.82, id); |
| 668 | float rib = ribbed * smoothstep(0.3, 0.5, abs(fract(p.z / 0.9) - 0.5)) * 0.05 | |
ab6ae94f 2026-09-28 Pavel Safronov | 669 | * (1.0 - smoothstep(0.12, 0.3, max(px, zPx) / 0.9)); |
6368edcf 2026-09-28 Pavel Safronov | 670 | height += rib; |
| 671 | // Grime: long streaks along the axis, as if something had run down the hull in spin. | |
ab6ae94f 2026-09-28 Pavel Safronov | 672 | // Hard-edged stripes ~1.3 km wide; averaged away as they near a pixel, for the same reason. |
| 673 | float grime = mix(0.5, plateHash(vec2(floor(around * 6.0), floor(p.z / 23.0))), | |
| 674 | 1.0 - smoothstep(0.15, 0.4, px / 1.3)); | |
6368edcf 2026-09-28 Pavel Safronov | 675 | float shade = mix(0.72, 1.12, id) * mix(0.8, 1.0, grime) * (1.0 - 0.55 * groove); |
| 676 | tint = vec3(shade) * mix(vec3(1.0), vec3(1.05, 1.0, 0.9), step(0.93, id)); | |
| 677 | return height; | |
| 678 | } | |
| 679 | ` |