tower / files / src / views / rock-hull.ts
rock-hull.ts
908833aa 2026-09-28 Pavel Safronov | 1 | /** |
| 2 | * The rock skin: an exterior experiment, Tower dressed as an asteroid (D-076). Not canon. | |
| 3 | * | |
| 4 | * The author asked to try different outsides, starting with "a rocky surface, almost | |
| 5 | * like someone tried to make it look like an asteroid". So this is a shell laid over the | |
| 6 | * metal skin, never cut into it: every point is at least ROCK_MIN_KM out from the wall's | |
| 7 | * outer face (D-044), so nothing inside Tower — sea floors, tile skirts — can show | |
| 8 | * through, and the windows at the ends are left clear. | |
| 9 | * | |
| 10 | * The shape is in two scales, because the camera spans two: | |
| 11 | * - **Geometry** carries what reads from a thousand kilometres off: lumps a few | |
| 12 | * kilometres high, and craters up to thirty across with raised rims. | |
| 13 | * - **The shader** carries what reads from sixty: grit, small pits and colour mottling, | |
| 14 | * bump-mapped from noise in the hull's own frame, faded out as a pixel grows wider | |
| 15 | * than the detail so the far hull does not shimmer. | |
| 16 | * | |
| 17 | * Everything is seeded, so it is the same rock on every load. | |
| 18 | */ | |
| 19 | import { | |
| 20 | BufferAttribute, BufferGeometry, Color, Mesh, MeshStandardMaterial, | |
| 21 | } from 'three' | |
| 22 | import { HULL_DEPTH_M, LENGTH_M, RADIUS_M } from '../canon/constants.js' | |
| 23 | import { mulberry32, simplex } from '../terrain/noise.js' | |
| 24 | ||
| 25 | const KM = 1000 | |
| 26 | const R = RADIUS_M / KM | |
| 27 | const L = LENGTH_M / KM | |
| 28 | const HULL_D = HULL_DEPTH_M / KM | |
| 29 | /** The skin the rock sits on. */ | |
| 30 | const SKIN = R + HULL_D | |
| 31 | /** The thinnest the rock ever gets, km: always a little proud of the metal. */ | |
| 32 | export const ROCK_MIN_KM = 0.2 | |
| 33 | ||
| 34 | const SEED = 0x70c4 | |
| 35 | /** Vertices around, and rows along, the side. ~0.7 km apart around, ~0.8 km along. */ | |
| 36 | const AROUND = 480 | |
| 37 | const ALONG = 680 | |
| 38 | /** Rings across each end face, from the window's edge out to the side. */ | |
| 39 | const END_RINGS = 6 | |
| 40 | ||
| 41 | interface Crater { theta: number; z: number; r: number } | |
| 42 | ||
ab6ae94f 2026-09-28 Pavel Safronov | 43 | /** |
| 44 | * Where the rock lies, for the combined dress (D-081): 0..1 at a point on the hull, rock | |
| 45 | * wherever it is at least ROCK_COVER. The reading is that the rock was heaped on as | |
| 46 | * shielding, and the plant is bare where it was never covered or has been dug out. | |
| 47 | */ | |
| 48 | export type RockCover = (theta: number, z: number) => number | |
| 49 | export const ROCK_COVER = 0.5 | |
| 50 | /** | |
| 51 | * How much taller the rock is laid over the plant than on its own (author: "the rock feels | |
| 52 | * like it can be taller, at least another 50%"). Banks with bare plating beside them need | |
| 53 | * the height to read as heaps; the rock alone has no bare ground to be measured against. | |
| 54 | */ | |
| 55 | const COVER_LIFT = 1.6 | |
| 56 | ||
| 57 | /** A patch dug clear of rock: round a thruster pod, whose flames would otherwise hit it. */ | |
| 58 | export interface Clearing { theta: number; z: number; r: number } | |
| 59 | ||
| 60 | /** | |
| 61 | * The cover field: broad banks and drifts of rock, tens of kilometres across, with bare | |
| 62 | * ground between; about three fifths rock (author: "a slightly higher concentration"). `clearings` are dug out, with a sloped edge. | |
| 63 | */ | |
| 64 | export function rockCover(clearings: Clearing[] = [], seed = SEED + 3): RockCover { | |
| 65 | const broad = simplex(seed) | |
| 66 | const fine = simplex(seed + 1) | |
| 67 | return (theta, z) => { | |
| 68 | const x = Math.cos(theta) * SKIN | |
| 69 | const y = Math.sin(theta) * SKIN | |
| 70 | let c = 0.6 + 0.42 * broad(x / 80, y / 80, z / 80) + 0.18 * fine(x / 22, y / 22, z / 22) | |
| 71 | for (const k of clearings) { | |
| 72 | let dt = Math.abs(theta - k.theta) | |
| 73 | if (dt > Math.PI) dt = 2 * Math.PI - dt | |
| 74 | const d = Math.hypot(dt * SKIN, z - k.z) | |
| 75 | c *= smoothstep(k.r, k.r * 1.5, d) | |
| 76 | } | |
| 77 | return Math.max(0, Math.min(1, c)) | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | const smoothstep = (a: number, b: number, x: number) => { | |
| 82 | const t = Math.max(0, Math.min(1, (x - a) / (b - a))) | |
| 83 | return t * t * (3 - 2 * t) | |
| 84 | } | |
| 85 | ||
908833aa 2026-09-28 Pavel Safronov | 86 | /** |
| 87 | * Rock thickness at a point on the side, km above the skin. | |
| 88 | * | |
| 89 | * Lumps: two octaves of broad noise, so the outline against the stars is irregular rather | |
| 90 | * than a cylinder with texture. Craters: a power-law population — many small, few large, | |
| 91 | * as on any real airless body — each a bowl a fifth as deep as it is wide with a raised | |
| 92 | * rim that falls away outside it. | |
| 93 | */ | |
| 94 | export function rockShell(seed = SEED): (theta: number, z: number) => number { | |
| 95 | const lumps = simplex(seed) | |
| 96 | const detail = simplex(seed + 1) | |
| 97 | const rand = mulberry32(seed + 2) | |
| 98 | const craters: Crater[] = [] | |
| 99 | for (let i = 0; i < 1400; i++) { | |
| 100 | // r ∝ u^-1.4 between 0.8 and 16 km: a steep cumulative size distribution. | |
| 101 | const r = Math.min(16, 0.8 * rand() ** -0.7) | |
| 102 | craters.push({ theta: rand() * 2 * Math.PI, z: -HULL_D + rand() * (L + 2 * HULL_D), r }) | |
| 103 | } | |
| 104 | // Binned along z so a vertex only looks at craters that could reach it. | |
| 105 | const BIN = 20 | |
| 106 | const bins: Crater[][] = Array.from({ length: Math.ceil((L + 40) / BIN) + 1 }, () => []) | |
| 107 | const binOf = (z: number) => Math.max(0, Math.min(bins.length - 1, Math.floor((z + 20) / BIN))) | |
| 108 | for (const c of craters) { | |
| 109 | for (let b = binOf(c.z - 2 * c.r); b <= binOf(c.z + 2 * c.r); b++) bins[b]!.push(c) | |
| 110 | } | |
| 111 | ||
| 112 | return (theta, z) => { | |
| 113 | const x = Math.cos(theta) * SKIN | |
| 114 | const y = Math.sin(theta) * SKIN | |
| 115 | let h = 1.6 | |
| 116 | + 1.7 * lumps(x / 70, y / 70, z / 70) | |
| 117 | + 0.8 * lumps(x / 24 + 11, y / 24, z / 24) | |
| 118 | + 0.3 * detail(x / 7, y / 7, z / 7) | |
| 119 | for (const c of bins[binOf(z)]!) { | |
| 120 | let dt = Math.abs(theta - c.theta) | |
| 121 | if (dt > Math.PI) dt = 2 * Math.PI - dt | |
| 122 | const dx = dt * SKIN | |
| 123 | const dz = z - c.z | |
| 124 | const d2 = dx * dx + dz * dz | |
| 125 | if (d2 > 4 * c.r * c.r) continue | |
| 126 | const q = Math.sqrt(d2) / c.r | |
| 127 | const rim = 0.06 * c.r | |
| 128 | h += q < 1 ? 0.2 * c.r * (q * q - 1) + rim : rim * Math.exp(-((q - 1) * (q - 1)) / 0.12) | |
| 129 | } | |
| 130 | return Math.max(ROCK_MIN_KM, h) | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * The shell as one mesh: the sun end's face, the side, the far end's face, as rings of | |
| 136 | * AROUND vertices joined in order. Rings share their seam — index i wraps to 0 — so the | |
| 137 | * normals are smooth all the way round. | |
ab6ae94f 2026-09-28 Pavel Safronov | 138 | * |
| 139 | * Given a `cover` (D-081), the rock thins toward the edge of each bank and is cut away | |
| 140 | * where the cover falls below ROCK_COVER, so the plating shows through. The cut is made | |
| 141 | * per pixel, from the cover carried on each vertex, so its edge is not the mesh's grid. | |
908833aa 2026-09-28 Pavel Safronov | 142 | */ |
ab6ae94f 2026-09-28 Pavel Safronov | 143 | export function buildRockHull(cover?: RockCover): Mesh { |
| 144 | const shell = rockShell() | |
| 145 | const height = cover | |
| 146 | ? (theta: number, z: number) => | |
| 147 | Math.max(ROCK_MIN_KM, COVER_LIFT * shell(theta, z) * smoothstep(ROCK_COVER, ROCK_COVER + 0.05, cover(theta, z))) | |
| 148 | : shell | |
| 149 | const rings: { r: (i: number) => number; z: (i: number) => number; c: Float32Array }[] = [] | |
908833aa 2026-09-28 Pavel Safronov | 150 | const thetaOf = (i: number) => (i / AROUND) * 2 * Math.PI |
| 151 | const z0 = -HULL_D | |
| 152 | const z1 = L + HULL_D | |
| 153 | const sideAt = (z: number) => { | |
| 154 | const row = new Float32Array(AROUND) | |
| 155 | for (let i = 0; i < AROUND; i++) row[i] = SKIN + height(thetaOf(i), z) | |
| 156 | return row | |
| 157 | } | |
ab6ae94f 2026-09-28 Pavel Safronov | 158 | const coverAt = (z: number) => { |
| 159 | const row = new Float32Array(AROUND).fill(1) | |
| 160 | if (cover) for (let i = 0; i < AROUND; i++) row[i] = cover(thetaOf(i), z) | |
| 161 | return row | |
| 162 | } | |
908833aa 2026-09-28 Pavel Safronov | 163 | const first = sideAt(z0) |
| 164 | const last = sideAt(z1) | |
ab6ae94f 2026-09-28 Pavel Safronov | 165 | const firstC = coverAt(z0) |
| 166 | const lastC = coverAt(z1) | |
908833aa 2026-09-28 Pavel Safronov | 167 | // An end face: from the window's edge out to the side, bulging a little outward so the |
| 168 | // rim reads as rock heaped round the glass rather than a lathe-turned ring. | |
ab6ae94f 2026-09-28 Pavel Safronov | 169 | const endFace = (edge: Float32Array, c: Float32Array, zEnd: number, dir: number, outward: boolean) => { |
908833aa 2026-09-28 Pavel Safronov | 170 | for (let k = 0; k < END_RINGS; k++) { |
| 171 | const t = outward ? k / END_RINGS : 1 - k / END_RINGS | |
| 172 | rings.push({ | |
| 173 | r: (i) => R + (edge[i]! - R) * t, | |
| 174 | z: (i) => zEnd + dir * Math.sin(Math.PI * t) * 0.35 * (edge[i]! - SKIN), | |
ab6ae94f 2026-09-28 Pavel Safronov | 175 | c, |
908833aa 2026-09-28 Pavel Safronov | 176 | }) |
| 177 | } | |
| 178 | } | |
ab6ae94f 2026-09-28 Pavel Safronov | 179 | endFace(first, firstC, z0, -1, true) |
908833aa 2026-09-28 Pavel Safronov | 180 | for (let j = 0; j <= ALONG; j++) { |
| 181 | const z = z0 + ((z1 - z0) * j) / ALONG | |
| 182 | const row = j === 0 ? first : j === ALONG ? last : sideAt(z) | |
ab6ae94f 2026-09-28 Pavel Safronov | 183 | rings.push({ r: (i) => row[i]!, z: () => z, c: j === 0 ? firstC : j === ALONG ? lastC : coverAt(z) }) |
908833aa 2026-09-28 Pavel Safronov | 184 | } |
ab6ae94f 2026-09-28 Pavel Safronov | 185 | endFace(last, lastC, z1, 1, false) |
| 186 | rings.push({ r: () => R, z: () => z1, c: lastC }) | |
908833aa 2026-09-28 Pavel Safronov | 187 | |
| 188 | const pos = new Float32Array(rings.length * AROUND * 3) | |
ab6ae94f 2026-09-28 Pavel Safronov | 189 | const cov = new Float32Array(rings.length * AROUND) |
908833aa 2026-09-28 Pavel Safronov | 190 | rings.forEach((ring, j) => { |
| 191 | for (let i = 0; i < AROUND; i++) { | |
| 192 | const a = thetaOf(i) | |
| 193 | const r = ring.r(i) | |
| 194 | pos.set([r * Math.cos(a), r * Math.sin(a), ring.z(i)], (j * AROUND + i) * 3) | |
ab6ae94f 2026-09-28 Pavel Safronov | 195 | cov[j * AROUND + i] = ring.c[i]! |
908833aa 2026-09-28 Pavel Safronov | 196 | } |
| 197 | }) | |
| 198 | const idx = new Uint32Array((rings.length - 1) * AROUND * 6) | |
| 199 | let n = 0 | |
| 200 | for (let j = 0; j + 1 < rings.length; j++) { | |
| 201 | for (let i = 0; i < AROUND; i++) { | |
| 202 | const a = j * AROUND + i | |
| 203 | const b = j * AROUND + ((i + 1) % AROUND) | |
| 204 | const c = a + AROUND | |
| 205 | const d = b + AROUND | |
| 206 | // Wound so the faces point outward, as the metal skin's do. | |
| 207 | idx.set([a, b, c, b, d, c], n) | |
| 208 | n += 6 | |
| 209 | } | |
| 210 | } | |
| 211 | const g = new BufferGeometry() | |
| 212 | g.setAttribute('position', new BufferAttribute(pos, 3)) | |
ab6ae94f 2026-09-28 Pavel Safronov | 213 | if (cover) g.setAttribute('aCover', new BufferAttribute(cov, 1)) |
908833aa 2026-09-28 Pavel Safronov | 214 | g.setIndex(new BufferAttribute(idx, 1)) |
| 215 | g.computeVertexNormals() | |
| 216 | g.computeBoundingSphere() | |
ab6ae94f 2026-09-28 Pavel Safronov | 217 | return new Mesh(g, rockMaterial(!!cover)) |
908833aa 2026-09-28 Pavel Safronov | 218 | } |
| 219 | ||
| 220 | /** | |
| 221 | * Regolith: dark grey-brown, matte, not metal. Albedo is licensed upward the way the fill | |
| 222 | * lights are (design.md §2.3) — a real C-type asteroid is as dark as charcoal, and would | |
| 223 | * be a silhouette here. | |
| 224 | */ | |
ab6ae94f 2026-09-28 Pavel Safronov | 225 | function rockMaterial(covered: boolean): MeshStandardMaterial { |
908833aa 2026-09-28 Pavel Safronov | 226 | const m = new MeshStandardMaterial({ color: new Color(0x80776d), roughness: 1, metalness: 0 }) |
| 227 | m.onBeforeCompile = (shader) => { | |
ab6ae94f 2026-09-28 Pavel Safronov | 228 | if (covered) { |
| 229 | shader.vertexShader = `#define ROCK_COVERED\n${shader.vertexShader}` | |
| 230 | shader.fragmentShader = `#define ROCK_COVERED\n${shader.fragmentShader}` | |
| 231 | } | |
908833aa 2026-09-28 Pavel Safronov | 232 | shader.vertexShader = shader.vertexShader |
ab6ae94f 2026-09-28 Pavel Safronov | 233 | .replace('#include <common>', '#include <common>\nvarying vec3 vRock;\n#ifdef ROCK_COVERED\nattribute float aCover;\nvarying float vCover;\n#endif') |
| 234 | .replace('#include <begin_vertex>', '#include <begin_vertex>\nvRock = position;\n#ifdef ROCK_COVERED\nvCover = aCover;\n#endif') | |
908833aa 2026-09-28 Pavel Safronov | 235 | shader.fragmentShader = shader.fragmentShader |
ab6ae94f 2026-09-28 Pavel Safronov | 236 | .replace('#include <common>', `#include <common>\n${ROCK_GLSL}\n${BUMP_GLSL}\n#ifdef ROCK_COVERED\nvarying float vCover;\n#endif`) |
| 237 | .replace('#include <clipping_planes_fragment>', `#include <clipping_planes_fragment> | |
| 238 | #ifdef ROCK_COVERED | |
| 239 | // The edge of a bank, broken up below the mesh's spacing so it reads as a ragged | |
| 240 | // shore of rock on the plating rather than a line joining vertices. | |
| 241 | if (vCover < ${ROCK_COVER.toFixed(2)} + 0.05 * rockNoise(vRock / 1.3) + 0.03 * rockNoise(vRock / 0.4)) discard; | |
| 242 | #endif`) | |
908833aa 2026-09-28 Pavel Safronov | 243 | .replace('#include <map_fragment>', `#include <map_fragment> |
| 244 | float rockH = rockHeight(vRock); | |
| 245 | // Mottling: lighter on the highs, darker in the pits, and patches of warmer and colder | |
| 246 | // rock at a larger scale, as ejecta lies over whatever it landed on. | |
| 247 | float ejecta = rockNoise(vRock / 9.0); | |
| 248 | diffuseColor.rgb *= mix(0.78, 1.12, clamp(0.5 + rockH * 0.9, 0.0, 1.0)); | |
| 249 | diffuseColor.rgb *= mix(vec3(0.9, 0.95, 1.02), vec3(1.08, 1.0, 0.9), clamp(0.5 + ejecta, 0.0, 1.0));`) | |
| 250 | .replace('#include <normal_fragment_maps>', `#include <normal_fragment_maps> | |
6368edcf 2026-09-28 Pavel Safronov | 251 | normal = bumpNormal(-vViewPosition, normal, vec2(dFdx(rockH), dFdy(rockH)), faceDirection);`) |
908833aa 2026-09-28 Pavel Safronov | 252 | } |
ab6ae94f 2026-09-28 Pavel Safronov | 253 | // Three caches programs by onBeforeCompile's source, which is the same for both. |
| 254 | m.customProgramCacheKey = () => `rock|${covered}` | |
908833aa 2026-09-28 Pavel Safronov | 255 | return m |
| 256 | } | |
| 257 | ||
| 258 | const ROCK_GLSL = /* glsl */` | |
| 259 | varying vec3 vRock; | |
| 260 | ||
| 261 | float rockHash(vec3 p) { | |
| 262 | p = fract(p * 0.1031); | |
| 263 | p += dot(p, p.zyx + 31.32); | |
| 264 | return fract((p.x + p.y) * p.z); | |
| 265 | } | |
| 266 | // Value noise, −0.5..0.5, smooth. | |
| 267 | float rockNoise(vec3 p) { | |
| 268 | vec3 i = floor(p); | |
| 269 | vec3 f = fract(p); | |
| 270 | f = f * f * (3.0 - 2.0 * f); | |
| 271 | float a = mix(mix(rockHash(i), rockHash(i + vec3(1, 0, 0)), f.x), | |
| 272 | mix(rockHash(i + vec3(0, 1, 0)), rockHash(i + vec3(1, 1, 0)), f.x), f.y); | |
| 273 | float b = mix(mix(rockHash(i + vec3(0, 0, 1)), rockHash(i + vec3(1, 0, 1)), f.x), | |
| 274 | mix(rockHash(i + vec3(0, 1, 1)), rockHash(i + vec3(1, 1, 1)), f.x), f.y); | |
| 275 | return mix(a, b, f.z) - 0.5; | |
| 276 | } | |
| 277 | ||
| 278 | /** | |
| 279 | * Fine relief in kilometres, from 3 km lumps down to 50 m grit. Each octave fades out | |
| 280 | * once a pixel spans a good part of it: detail finer than a pixel is not detail, it is | |
| 281 | * the sparkle that makes a far surface crawl as it turns. | |
| 282 | */ | |
| 283 | float rockHeight(vec3 p) { | |
| 284 | float px = length(fwidth(p)); | |
| 285 | float h = 0.0; | |
| 286 | float amp = 0.35; | |
| 287 | float freq = 1.0 / 3.0; | |
| 288 | for (int o = 0; o < 6; o++) { | |
| 289 | float w = 1.0 - smoothstep(0.15, 0.5, px * freq); | |
| 290 | // Ridged: sharp crests and soft troughs, like weathered rock rather than cloud. | |
| 291 | float n = 0.5 - abs(rockNoise(p * freq + float(o) * 17.3)); | |
| 292 | h += amp * w * (n - 0.25); | |
| 293 | amp *= 0.5; | |
| 294 | freq *= 2.1; | |
| 295 | } | |
| 296 | return h; | |
| 297 | } | |
| 298 | ||
6368edcf 2026-09-28 Pavel Safronov | 299 | ` |
| 300 | ||
| 301 | export const BUMP_GLSL = /* glsl */` | |
908833aa 2026-09-28 Pavel Safronov | 302 | /** |
| 303 | * Bump mapping from screen-space derivatives (Mikkelsen 2010), as Three's own | |
| 304 | * bumpmap_pars_fragment does it — except the screen-space tangents are not normalised, | |
| 305 | * so the tilt is the relief's true slope in kilometres per kilometre, the same at every | |
| 306 | * distance, rather than a slope per pixel that steepens as the camera backs away. | |
| 307 | */ | |
6368edcf 2026-09-28 Pavel Safronov | 308 | vec3 bumpNormal(vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection) { |
908833aa 2026-09-28 Pavel Safronov | 309 | vec3 vSigmaX = dFdx(surf_pos.xyz); |
| 310 | vec3 vSigmaY = dFdy(surf_pos.xyz); | |
| 311 | vec3 vN = surf_norm; | |
| 312 | vec3 R1 = cross(vSigmaY, vN); | |
| 313 | vec3 R2 = cross(vN, vSigmaX); | |
| 314 | float fDet = dot(vSigmaX, R1) * faceDirection; | |
| 315 | vec3 vGrad = sign(fDet) * (dHdxy.x * R1 + dHdxy.y * R2); | |
| 316 | return normalize(abs(fDet) * surf_norm - vGrad); | |
| 317 | } | |
| 318 | ` |