tower / files / src / views / rock-hull.ts
rock-hull.ts
| 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 | |
| 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 | |
| 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. |
| 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. |
| 142 | */ |
| 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 }[] = [] |
| 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 | } |
| 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 | } |
| 163 | const first = sideAt(z0) |
| 164 | const last = sideAt(z1) |
| 165 | const firstC = coverAt(z0) |
| 166 | const lastC = coverAt(z1) |
| 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. |
| 169 | const endFace = (edge: Float32Array, c: Float32Array, zEnd: number, dir: number, outward: boolean) => { |
| 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), |
| 175 | c, |
| 176 | }) |
| 177 | } |
| 178 | } |
| 179 | endFace(first, firstC, z0, -1, true) |
| 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) |
| 183 | rings.push({ r: (i) => row[i]!, z: () => z, c: j === 0 ? firstC : j === ALONG ? lastC : coverAt(z) }) |
| 184 | } |
| 185 | endFace(last, lastC, z1, 1, false) |
| 186 | rings.push({ r: () => R, z: () => z1, c: lastC }) |
| 187 | |
| 188 | const pos = new Float32Array(rings.length * AROUND * 3) |
| 189 | const cov = new Float32Array(rings.length * AROUND) |
| 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) |
| 195 | cov[j * AROUND + i] = ring.c[i]! |
| 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)) |
| 213 | if (cover) g.setAttribute('aCover', new BufferAttribute(cov, 1)) |
| 214 | g.setIndex(new BufferAttribute(idx, 1)) |
| 215 | g.computeVertexNormals() |
| 216 | g.computeBoundingSphere() |
| 217 | return new Mesh(g, rockMaterial(!!cover)) |
| 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 | */ |
| 225 | function rockMaterial(covered: boolean): MeshStandardMaterial { |
| 226 | const m = new MeshStandardMaterial({ color: new Color(0x80776d), roughness: 1, metalness: 0 }) |
| 227 | m.onBeforeCompile = (shader) => { |
| 228 | if (covered) { |
| 229 | shader.vertexShader = `#define ROCK_COVERED\n${shader.vertexShader}` |
| 230 | shader.fragmentShader = `#define ROCK_COVERED\n${shader.fragmentShader}` |
| 231 | } |
| 232 | shader.vertexShader = shader.vertexShader |
| 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') |
| 235 | shader.fragmentShader = shader.fragmentShader |
| 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`) |
| 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> |
| 251 | normal = bumpNormal(-vViewPosition, normal, vec2(dFdx(rockH), dFdy(rockH)), faceDirection);`) |
| 252 | } |
| 253 | // Three caches programs by onBeforeCompile's source, which is the same for both. |
| 254 | m.customProgramCacheKey = () => `rock|${covered}` |
| 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 | |
| 299 | ` |
| 300 | |
| 301 | export const BUMP_GLSL = /* glsl */` |
| 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 | */ |
| 308 | vec3 bumpNormal(vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection) { |
| 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 | ` |