fuzzyworld

tower / files / src / views / pipe-hull.ts

pipe-hull.ts

Blame: the commit that last changed each line · file · history

6368edcf 2026-09-28 Pavel Safronov1/**
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 */
24import {
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'
29import { HULL_DEPTH_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
30import { mulberry32 } from '../terrain/noise.js'
31import { BUMP_GLSL } from './rock-hull.js'
32
33const KM = 1000
34const R = RADIUS_M / KM
35const L = LENGTH_M / KM
36const HULL_D = HULL_DEPTH_M / KM
37/** The skin everything is bolted to. */
38const SKIN = R + HULL_D
39const SEED = 0x9177
40
41/** Panels round the circumference: a whole number, so the plating closes on itself. */
42const PANELS_AROUND = 40
43/** Panel length along the axis, km. */
44const PANEL_ALONG = 8
45
46const ZAXIS = new Vector3(0, 0, 1)
47/** Kilometres at each end kept for the thrusters: no bands, plant or vents. */
48const THRUSTER_ZONE = 12
49
50export 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 Safronov56 /** 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 Safronov58}
59
60/** How a set of emitters switches: on for a while, off for a while, both at random. */
61export 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 */
76export 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. */
112function 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. */
119const 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 Safronov124/**
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 */
130export function buildPipeHull(bare: (theta: number, z: number) => boolean = () => true): PipeHull {
6368edcf 2026-09-28 Pavel Safronov131 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 Safronov216 && !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 Safronov219 for (let c = 0; c < 170; c++) {
ab6ae94f 2026-09-28 Pavel Safronov220 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 Safronov226 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 Safronov369 pods,
6368edcf 2026-09-28 Pavel Safronov370 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 */
406function 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
418function 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 */
434export 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
464const DUST_GLSL = /* glsl */`
465varying vec3 vDustPos;
466varying vec3 vDustN;
467const vec3 DUST_COLOUR = vec3(0.56, 0.52, 0.46);
468
469float 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}
474float 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.
485float 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. */
489float 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
499const INSTANCED_VERT = /* glsl */`
500attribute float aLevel;
501varying vec2 vUv;
502varying float vId;
503varying float vLevel;
504void 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 */
516function 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 */
545function 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 */
572function 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 */
602function 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
628const PLATE_GLSL = /* glsl */`
629varying vec3 vPlate;
630float 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 */
641float 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 Safronov657 // 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 Safronov661 float height = -0.08 * groove;
ab6ae94f 2026-09-28 Pavel Safronov662 // 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 Safronov667 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 Safronov669 * (1.0 - smoothstep(0.12, 0.3, max(px, zPx) / 0.9));
6368edcf 2026-09-28 Pavel Safronov670 height += rib;
671 // Grime: long streaks along the axis, as if something had run down the hull in spin.
ab6ae94f 2026-09-28 Pavel Safronov672 // 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 Safronov675 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`