fuzzyworld

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

rock-hull.ts

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

908833aa 2026-09-28 Pavel Safronov1/**
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 */
19import {
20 BufferAttribute, BufferGeometry, Color, Mesh, MeshStandardMaterial,
21} from 'three'
22import { HULL_DEPTH_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
23import { mulberry32, simplex } from '../terrain/noise.js'
24
25const KM = 1000
26const R = RADIUS_M / KM
27const L = LENGTH_M / KM
28const HULL_D = HULL_DEPTH_M / KM
29/** The skin the rock sits on. */
30const SKIN = R + HULL_D
31/** The thinnest the rock ever gets, km: always a little proud of the metal. */
32export const ROCK_MIN_KM = 0.2
33
34const SEED = 0x70c4
35/** Vertices around, and rows along, the side. ~0.7 km apart around, ~0.8 km along. */
36const AROUND = 480
37const ALONG = 680
38/** Rings across each end face, from the window's edge out to the side. */
39const END_RINGS = 6
40
41interface Crater { theta: number; z: number; r: number }
42
ab6ae94f 2026-09-28 Pavel Safronov43/**
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 */
48export type RockCover = (theta: number, z: number) => number
49export 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 */
55const COVER_LIFT = 1.6
56
57/** A patch dug clear of rock: round a thruster pod, whose flames would otherwise hit it. */
58export 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 */
64export 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
81const 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 Safronov86/**
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 */
94export 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 Safronov138 *
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 Safronov142 */
ab6ae94f 2026-09-28 Pavel Safronov143export 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 Safronov150 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 Safronov158 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 Safronov163 const first = sideAt(z0)
164 const last = sideAt(z1)
ab6ae94f 2026-09-28 Pavel Safronov165 const firstC = coverAt(z0)
166 const lastC = coverAt(z1)
908833aa 2026-09-28 Pavel Safronov167 // 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 Safronov169 const endFace = (edge: Float32Array, c: Float32Array, zEnd: number, dir: number, outward: boolean) => {
908833aa 2026-09-28 Pavel Safronov170 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 Safronov175 c,
908833aa 2026-09-28 Pavel Safronov176 })
177 }
178 }
ab6ae94f 2026-09-28 Pavel Safronov179 endFace(first, firstC, z0, -1, true)
908833aa 2026-09-28 Pavel Safronov180 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 Safronov183 rings.push({ r: (i) => row[i]!, z: () => z, c: j === 0 ? firstC : j === ALONG ? lastC : coverAt(z) })
908833aa 2026-09-28 Pavel Safronov184 }
ab6ae94f 2026-09-28 Pavel Safronov185 endFace(last, lastC, z1, 1, false)
186 rings.push({ r: () => R, z: () => z1, c: lastC })
908833aa 2026-09-28 Pavel Safronov187
188 const pos = new Float32Array(rings.length * AROUND * 3)
ab6ae94f 2026-09-28 Pavel Safronov189 const cov = new Float32Array(rings.length * AROUND)
908833aa 2026-09-28 Pavel Safronov190 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 Safronov195 cov[j * AROUND + i] = ring.c[i]!
908833aa 2026-09-28 Pavel Safronov196 }
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 Safronov213 if (cover) g.setAttribute('aCover', new BufferAttribute(cov, 1))
908833aa 2026-09-28 Pavel Safronov214 g.setIndex(new BufferAttribute(idx, 1))
215 g.computeVertexNormals()
216 g.computeBoundingSphere()
ab6ae94f 2026-09-28 Pavel Safronov217 return new Mesh(g, rockMaterial(!!cover))
908833aa 2026-09-28 Pavel Safronov218}
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 Safronov225function rockMaterial(covered: boolean): MeshStandardMaterial {
908833aa 2026-09-28 Pavel Safronov226 const m = new MeshStandardMaterial({ color: new Color(0x80776d), roughness: 1, metalness: 0 })
227 m.onBeforeCompile = (shader) => {
ab6ae94f 2026-09-28 Pavel Safronov228 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 Safronov232 shader.vertexShader = shader.vertexShader
ab6ae94f 2026-09-28 Pavel Safronov233 .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 Safronov235 shader.fragmentShader = shader.fragmentShader
ab6ae94f 2026-09-28 Pavel Safronov236 .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 Safronov243 .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 Safronov251 normal = bumpNormal(-vViewPosition, normal, vec2(dFdx(rockH), dFdy(rockH)), faceDirection);`)
908833aa 2026-09-28 Pavel Safronov252 }
ab6ae94f 2026-09-28 Pavel Safronov253 // Three caches programs by onBeforeCompile's source, which is the same for both.
254 m.customProgramCacheKey = () => `rock|${covered}`
908833aa 2026-09-28 Pavel Safronov255 return m
256}
257
258const ROCK_GLSL = /* glsl */`
259varying vec3 vRock;
260
261float 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.
267float 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 */
283float 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 Safronov299`
300
301export const BUMP_GLSL = /* glsl */`
908833aa 2026-09-28 Pavel Safronov302/**
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 Safronov308vec3 bumpNormal(vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection) {
908833aa 2026-09-28 Pavel Safronov309 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`