fuzzyworld

tower / cascadia / files / src / views / interior.ts

Branch cascadia · default branch · all branches

interior.ts

49.9 KB · history · blame

1/**
2 * The interior view: standing inside Tower. See design.md §1.5, §1.8, §2.4.
3 *
4 * Scene units are kilometres, as in the exterior view, so the numbers in the code
5 * are the numbers in canon. The observer stands on the inner surface with the axis
6 * overhead; nothing in this scene rotates, because the observer, the ground and
7 * the air all co-rotate together. That is not a simplification — it is the point.
8 * Inside a sealed cylinder the spin is genuinely invisible, and the only thing that
9 * gives it away is what falls (src/canon/motion.ts).
10 *
11 * Three things carry the illusion, and all three are computed rather than styled:
12 *
13 * - the ground curving up on both sides and closing overhead, which is just the
14 * real cylinder seen from the inside;
15 * - haze, integrated per fragment through Tower's rotational density profile, so
16 * the far side reads clearer than the ground at the same distance (§1.8);
17 * - the beam, a self-luminous shaft 3.65° wide and the only thing in the sky that
18 * is not land.
19 */
20import type { Texture } from 'three'
21import {
22 AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide,
23 Mesh, PerspectiveCamera, RepeatWrapping, RingGeometry,
24 Scene, ShaderMaterial, TextureLoader, Vector3, WebGLRenderer,
25} from 'three'
26import {
27 BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, COLLECTOR_DIAMETER_M, COLLECTOR_STANDOFF_M,
28 COLLECTOR_RIM_M, DAY_LENGTH_S, DAY_SWEEP_FRACTION, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2,
29 BRIGHTNESS_TWILIGHT_W_M2, G0, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M,
30 RAYLEIGH_FLOOR_PER_M,
31 SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, gravityAtHeight,
32} from '../canon/constants.js'
33import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js'
34import type { Place } from '../world/schema.js'
35import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js'
36import {
37 MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture,
38} from './cap-pattern.js'
39import {
40 type Surface, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M,
41 RASTER_MIN_HEIGHT_M, RASTER_WIDTH, rasterSet,
42} from './raster.js'
43
44const KM = 1000
45const R = RADIUS_M / KM
46const L = LENGTH_M / KM
47const BEAM_R = BEAM_RADIUS_M / KM
48/** Wall depth in km. The far cap's window is set this far into its own frame. */
49const HULL_D = HULL_DEPTH_M / KM
50/** The collector, in km: 122 km of radius flying 320 km ahead of the sun cap. */
51const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2
52const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM
53/** Width of the sunlit edge structure, in km (D-054). */
54const COLLECTOR_RIM = COLLECTOR_RIM_M / KM
55
56/**
57 * Rayleigh extinction per kilometre at floor density, per channel.
58 *
59 * Canon quotes 550 nm (green). Scattering goes as lambda^-4, so red survives about
60 * twice as far as green and blue about half as far — which is the whole reason
61 * distant land in Tower goes blue rather than merely grey, and it costs nothing to
62 * carry three numbers instead of one.
63 */
64const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM
65const K_RGB: [number, number, number] = [
66 K_GREEN * (550 / 650) ** 4, K_GREEN, K_GREEN * (550 / 440) ** 4,
67]
68/** Density falls as exp(-a(R^2 - s^2)) — the rotational profile, in 1/km^2. */
69const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_K)) * KM ** 2)
70
71/**
72 * Airlight: the radiance the haze itself adds. It is deliberately NEUTRAL.
73 *
74 * The obvious move is to make it sky blue, and it is wrong: blue already scatters
75 * 2.4x harder than green in K_RGB, so a blue source colours the haze twice and
76 * every distant thing turns mauve regardless of what it is made of. With a neutral
77 * source the blue falls out of the physics, and the far side of the world stays
78 * recognisably forest and desert at the 78% canon says it should be.
79 */
80const HAZE_DAY = 0x9aa3ad
81const HAZE_DUSK = 0x8d8792
82
83
84/** Shell tessellation. 0.7 km triangles: distant relief is real, your feet are not. */
85const SEG_AROUND = 512
86const SEG_ALONG = 768
87
88/**
89 * The five settings, darkest first.
90 *
91 * `dark` is the beam shut down entirely and is the only one that is genuinely
92 * black — with 54 TW off there is no airlight, no lit ground and no rod overhead,
93 * and the only thing left in the world is the sky through the far cap. `night` is
94 * the beam banked rather than killed, which is what night has to be here: §1.3
95 * gives the glow to the beam and the air rather than to the diffusers, so aiming
96 * the swarm away would not darken anything (D-046).
97 */
98/** The pinwheel's period, the one interval in Tower you can time against the sky. */
99const fmtSpin = (): string => {
100 const s = (2 * Math.PI) / OMEGA_RAD_S
101 return `${Math.floor(s / 60)} min ${Math.round(s % 60)} s`
102}
103
104export const LIGHTING = {
105 dark: 0,
106 night: BRIGHTNESS_NIGHT_W_M2,
107 twilight: BRIGHTNESS_TWILIGHT_W_M2,
108 temperate: BRIGHTNESS_TEMPERATE_W_M2,
109 full: BRIGHTNESS_FULL_W_M2,
110} as const
111export type Lighting = keyof typeof LIGHTING
112export const isLighting = (v: unknown): v is Lighting =>
113 typeof v === 'string' && v in LIGHTING
114
115const STATE_KEY = 'tower.interior'
116const STATE_SAVE_MS = 400
117
118export interface InteriorCallbacks {
119 onPickPlace: (id: string) => void
120 onLook: () => void
121}
122
123// ---------------------------------------------------------------------- shaders
124
125/**
126 * Transmittance from `a` to `b` through Tower's air, per channel.
127 *
128 * The integral has no closed form worth having — the exponent is a(s^2 - R^2) with
129 * s^2 quadratic along the ray — so it is sampled. The integrand is smooth and
130 * monotonic on each side of the ray's closest approach to the axis, so 24 midpoint
131 * samples land within a percent of the 512-sample version the instruments use, and
132 * the two agree on the canon table in design.md §1.8.
133 */
134const HAZE_GLSL = /* glsl */`
135uniform vec3 uCam;
136uniform vec3 uK;
137uniform vec3 uHaze;
138uniform float uExposure;
139const float A = ${DENSITY_A.toExponential(8)};
140const float RR = ${(R * R).toFixed(4)};
141
142vec3 towerTransmittance(vec3 from, vec3 to) {
143 vec3 d = to - from;
144 float len = length(d);
145 const int N = 24;
146 float tau = 0.0;
147 for (int i = 0; i < N; i++) {
148 float t = (float(i) + 0.5) / float(N);
149 vec2 p = from.xy + d.xy * t;
150 tau += exp(-A * (RR - dot(p, p)));
151 }
152 return exp(-uK * (tau * len / float(N)));
153}
154`
155
156const GROUND_VERT = /* glsl */`
157#include <common>
158#include <logdepthbuf_pars_vertex>
159uniform sampler2D uHeight;
160uniform vec2 uTexel;
161uniform float uMppKm;
162varying vec2 vUv;
163varying vec3 vWorld;
164varying vec3 vNormal2;
165
166/** The raster is stretched across a declared working range, not 0..anything. */
167float elevation(vec2 at) {
168 return ${(RASTER_MIN_HEIGHT_M / KM).toFixed(6)}
169 + texture2D(uHeight, at).r * ${(RASTER_HEIGHT_SPAN_M / KM).toFixed(6)};
170}
171
172void main() {
173 vUv = uv;
174 vec3 radial = normalize(vec3(position.xy, 0.0));
175 vec3 up = -radial;
176 // Spinward: z-hat x s-hat. The floor's other tangent is the axis itself.
177 vec3 spinward = vec3(-radial.y, radial.x, 0.0);
178 vec3 deepward = vec3(0.0, 0.0, 1.0);
179
180 float h = elevation(uv);
181 // Central differences on the raster, in km of rise per km of run.
182 float dz = (elevation(uv + vec2(uTexel.x, 0.0)) - elevation(uv - vec2(uTexel.x, 0.0)))
183 / (2.0 * uMppKm);
184 float dt = (elevation(uv + vec2(0.0, uTexel.y)) - elevation(uv - vec2(0.0, uTexel.y)))
185 / (2.0 * uMppKm);
186 vNormal2 = normalize(up - deepward * dz - spinward * dt);
187
188 vec3 p = position + up * h;
189 vWorld = p;
190 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
191 #include <logdepthbuf_vertex>
192}
193`
194
195const GROUND_FRAG = /* glsl */`
196#include <common>
197#include <logdepthbuf_pars_fragment>
198uniform sampler2D uColor;
199uniform float uFrontZ;
200uniform float uFrontWidth;
201uniform float uBelowFront;
202uniform float uAboveFront;
203varying vec2 vUv;
204varying vec3 vWorld;
205varying vec3 vNormal2;
206${HAZE_GLSL}
207
208/** Cheap value noise, to keep the ground from being flat colour underfoot. */
209float hash(vec2 p) {
210 return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
211}
212float vnoise(vec2 p) {
213 vec2 i = floor(p), f = fract(p);
214 f = f * f * (3.0 - 2.0 * f);
215 return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x),
216 mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
217}
218
219void main() {
220 #include <logdepthbuf_fragment>
221 // The raster is an sRGB image and every uniform Colour here is already linear,
222 // so the texture is the one thing that has to be brought into the working space.
223 // Three inserts that decode into its own materials and never into a custom one.
224 vec3 base = pow(texture2D(uColor, vUv).rgb, vec3(2.2));
225
226 /**
227 * The raster is 264 m per pixel, so within a few hundred metres of the observer
228 * one texel fills the screen. This is texture, not terrain: it breaks up the
229 * flat block without claiming there is anything there. It fades out past 4 km,
230 * where the raster is carrying real detail again.
231 */
232 float near = 1.0 - smoothstep(0.4, 4.0, distance(uCam, vWorld));
233 // Unrolled surface coordinates. World xyz shears the pattern into radial streaks
234 // exactly where the ground is most grazing, which is most of the view.
235 vec2 surf = vec2(vWorld.z, atan(vWorld.y, vWorld.x) * ${R.toFixed(3)});
236 // Rotated and off-harmonic octaves. Axis-aligned value noise at one scale reads
237 // as a woven diamond lattice, which is worse than the flat colour it replaced.
238 vec2 rot = vec2(surf.x * 0.86 - surf.y * 0.51, surf.x * 0.51 + surf.y * 0.86);
239 float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45;
240 base *= 1.0 + (grain - 0.5) * 0.12 * near;
241
242 // The beam is a line source on the axis, so the light comes from straight up.
243 vec3 radial = normalize(vec3(vWorld.xy, 0.0));
244 float lambert = clamp(dot(vNormal2, -radial), 0.0, 1.0);
245 /**
246 * The dawn front (D-053). The beam's own glow comes up everywhere at once — it is
247 * one beam — but the diffusers hand the light down the axis, so the *ground*
248 * lights as a line running deepward at 225 m/s. Below the front and above it are
249 * different times of day, and this is the only place in Tower where that is true.
250 *
251 * Outside dawn and dusk both sides are 1.0 and this costs a mix.
252 */
253 float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
254 float daylight = mix(uBelowFront, uAboveFront, side);
255
256 // The rest of the world is lit land, and lit land is what fills the shadows.
257 vec3 lit = base * (0.34 + 0.80 * lambert) * uExposure * daylight;
258
259 vec3 T = towerTransmittance(uCam, vWorld);
260 gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
261 #include <colorspace_fragment>
262}
263`
264
265const FLAT_VERT = /* glsl */`
266#include <common>
267#include <logdepthbuf_pars_vertex>
268varying vec3 vWorld;
269void main() {
270 vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
271 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
272 #include <logdepthbuf_vertex>
273}
274`
275
276/**
277 * The sky through the far cap window (§1.4, D-045).
278 *
279 * Sampled by view direction, in an azimuthal-equidistant projection about +z.
280 *
281 * The projection is the whole trick. +z is the spin axis and therefore the
282 * celestial pole, so polar angle becomes radius and azimuth becomes angle, and
283 * adding the spin phase to the azimuth *is* the pinwheel: every star runs a circle
284 * about the axis once every 7 min 29 s and the one on the axis does not move.
285 *
286 * Two projections were wrong before this one. Gnomonic (xy/z) blows up as the ray
287 * goes perpendicular to the axis, which is the ordinary grazing view from the foot
288 * of the cap. Painting the field on the cap plane instead is stable but has no
289 * fixed angular scale — from a kilometre away the whole 60° field lands inside one
290 * star cell, so the sky comes out empty. Equidistant has neither failure: it is
291 * defined for every direction but exactly sunward, and stars stay the same angular
292 * size however far away the window is, because they are at infinity and that is
293 * what being at infinity means.
294 */
295const STAR_GLSL = /* glsl */`
296uniform float uSpin;
297uniform float uStarGain;
298
299float starHash(vec2 p) {
300 vec3 q = fract(vec3(p.xyx) * 0.1031);
301 q += dot(q, q.yzx + 33.33);
302 return fract((q.x + q.y) * q.z);
303}
304vec2 starHash2(vec2 p) {
305 vec3 q = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
306 q += dot(q, q.yzx + 33.33);
307 return fract((q.xx + q.yz) * q.zy);
308}
309
310// Cells per radian. ~1.2 deg cells, which at 22% occupancy lands near the naked-eye
311// sky's real density of one star per 7 square degrees.
312const float STAR_SCALE = 46.0;
313
314vec3 starField(vec3 dir) {
315 vec3 d = normalize(dir);
316 float th = acos(clamp(d.z, -1.0, 1.0));
317 float ph = atan(d.y, d.x) + uSpin;
318 vec2 p = vec2(cos(ph), sin(ph)) * th * STAR_SCALE;
319 vec2 cell = floor(p);
320 vec2 f = p - cell;
321 vec3 sum = vec3(0.0);
322 for (int j = -1; j <= 1; j++) {
323 for (int i = -1; i <= 1; i++) {
324 vec2 o = vec2(float(i), float(j));
325 vec2 g = cell + o;
326 float mag = starHash(g);
327 if (mag < 0.78) continue; // most of the sky is empty
328 float b = pow(max(0.0, 1.0 - length(o + starHash2(g + 11.7) - f) / 0.09), 4.0)
329 * ((mag - 0.78) / 0.22);
330 // A few warm ones among the cool. Without it the field reads as noise.
331 sum += b * mix(vec3(0.72, 0.80, 1.0), vec3(1.0, 0.86, 0.68), starHash(g + 5.1));
332 }
333 }
334 /**
335 * The pole star, placed rather than left to the hash. §1.4 promises one
336 * motionless star at the centre of the pinwheel; it is the fixed point the whole
337 * phenomenon is read against, and a sky that happened not to roll one there
338 * would be missing the only part of it anybody in Tower would have named.
339 *
340 * It lands at the observer's own radius on the window — the axis is horizontal
341 * from the floor, so this is a star on the horizon, not overhead.
342 */
343 sum += pow(max(0.0, 1.0 - th * STAR_SCALE / 0.11), 4.0) * vec3(1.0, 0.95, 0.86) * 1.6;
344 return sum * 2.6 * uStarGain;
345}
346`
347
348/** The bore between the world's end and the glazing. Plain structure, hazed. */
349const REVEAL_FRAG = /* glsl */`
350#include <common>
351#include <logdepthbuf_pars_fragment>
352uniform vec3 uTint;
353varying vec3 vWorld;
354${HAZE_GLSL}
355void main() {
356 #include <logdepthbuf_fragment>
357 vec3 T = towerTransmittance(uCam, vWorld);
358 gl_FragColor = vec4(uTint * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0);
359 #include <colorspace_fragment>
360}
361`
362
363/**
364 * The sun cap: also a window, and what fills it is the back of the collector.
365 *
366 * §1.4 gave this cap "heavy glare management" and no view, on the assumption that
367 * it faces the sun. It does not face the sun — it faces the **collector**, which is
368 * 41.8° across from here against the sun's 0.53° and sits squarely between the two.
369 * Tower is inside its umbra permanently, so no direct sunlight has ever reached
370 * this cap and there is nothing here to manage (D-050).
371 *
372 * What there is instead is a 244 km disc of unlit structure. Its front is a mirror
373 * pointed at the sun; its back radiates the collector's own waste heat at about
374 * 245 K, which is infrared and therefore black. So the sky through this window is a
375 * black disc with stars around the edge of it — and the disc does not turn with
376 * them, because the collector flies in formation rather than spinning.
377 */
378const SUN_CAP_FRAG = /* glsl */`
379#include <common>
380#include <logdepthbuf_pars_fragment>
381uniform vec3 uTint;
382uniform sampler2D uGlazing;
383varying vec3 vWorld;
384${HAZE_GLSL}
385${STAR_GLSL}
386void main() {
387 #include <logdepthbuf_fragment>
388 vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5;
389 vec4 frame = texture2D(uGlazing, uv);
390 vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure;
391
392 vec3 dir = normalize(vWorld - uCam);
393 vec3 sky = starField(dir);
394 /**
395 * Cast on through the window to the collector's plane. Anything landing on the
396 * disc sees its unlit back, which is as black as the sky is not — and the edge,
397 * which is the opposite: structure standing proud of the mirror plane with the
398 * sun striking it side-on (D-054). So the collector reads the way any backlit
399 * thing does, as a hole in the stars with a lit rim, rather than as an absence.
400 */
401 float t = (${COLLECTOR_Z.toFixed(1)} - vWorld.z) / min(dir.z, -1e-4);
402 if (t > 0.0) {
403 float r = length(vWorld.xy + dir.xy * t);
404 sky = mix(sky, vec3(0.0), step(r, ${COLLECTOR_R.toFixed(2)}));
405 // Tight core for the structure itself, and a wide dim skirt for the scatter
406 // around it. One exponential alone reads as a drawn outline rather than light.
407 float d = abs(r - ${COLLECTOR_R.toFixed(2)});
408 float rim = exp(-d / ${COLLECTOR_RIM.toFixed(3)})
409 + 0.10 * exp(-d / ${(COLLECTOR_RIM * 3).toFixed(3)});
410 /**
411 * Not scaled by uExposure — the sun does not dim when Tower banks its beam.
412 * It does fade as the eye adapts, like the stars, but from a much higher floor:
413 * this is sunlit structure, and it stays visible at noon where a star does not.
414 */
415 sky += vec3(1.0, 0.93, 0.78) * rim * (0.3 + 0.7 * uStarGain) * 0.42;
416 }
417
418 vec3 face = mix(sky, mullion, frame.a);
419 vec3 T = towerTransmittance(uCam, vWorld);
420 gl_FragColor = vec4(face * T + uHaze * uExposure * (1.0 - T), 1.0);
421 #include <colorspace_fragment>
422}
423`
424
425/**
426 * The far cap: a window, and the frame holding it.
427 *
428 * `uGlazing`'s alpha is the mask — opaque where there is a mullion, clear where
429 * there is a pane. Through a pane is deep anti-sun space (§1.4): black, with stars
430 * in it, and the haze in front doing the rest. By day the airlight term swamps
431 * everything and the cap is simply blue, which is what §1.5 already said would
432 * happen to anything 540 km away. At night the airlight collapses with the beam
433 * and what is left is the sky.
434 */
435const GLAZED_CAP_FRAG = /* glsl */`
436#include <common>
437#include <logdepthbuf_pars_fragment>
438uniform vec3 uTint;
439uniform sampler2D uGlazing;
440varying vec3 vWorld;
441${HAZE_GLSL}
442${STAR_GLSL}
443void main() {
444 #include <logdepthbuf_fragment>
445 vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5;
446 vec4 frame = texture2D(uGlazing, uv);
447 vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure;
448 /**
449 * The stars are NOT scaled by uExposure. Nothing Tower does to its own beam
450 * changes how bright Vega is — the exposure models the beam lighting the world,
451 * and the sky outside is not lit by it. uStarGain carries the other half of the
452 * same fact: the eye adapts, so the stars are there at noon and unseeable.
453 */
454 vec3 sky = starField(vWorld - uCam);
455 vec3 face = mix(sky, mullion, frame.a);
456 /**
457 * The bullseye mirror (D-048) is returning 1.7 MW/m² back up the column, so it
458 * is lit by the beam rather than by the room: it blazes at noon, banks at night
459 * and goes out entirely when the beam does. The stars behind it do none of
460 * those, which is the whole difference between the two things in this shader.
461 *
462 * It is on the axis, but that is NOT the centre of the pinwheel: starField turns
463 * about the direction parallel to the axis, which lands on the window at the
464 * camera's own radius rather than at the cap's middle. The two are ~10° apart
465 * from the floor and coincide only for an observer standing on the axis.
466 */
467 float rc = length(vWorld.xy) / ${R.toFixed(1)};
468 float mirror = 1.0 - smoothstep(${(MIRROR_UV_RADIUS * 0.86).toFixed(5)},
469 ${MIRROR_UV_RADIUS.toFixed(5)}, rc);
470 face += mirror * vec3(1.0, 0.96, 0.88) * uExposure * 2.6;
471 vec3 T = towerTransmittance(uCam, vWorld);
472 gl_FragColor = vec4(face * T + uHaze * uExposure * (1.0 - T), 1.0);
473 #include <colorspace_fragment>
474}
475`
476
477
478const BEAM_VERT = /* glsl */`
479#include <common>
480#include <logdepthbuf_pars_vertex>
481varying vec3 vWorld;
482varying vec3 vNormal2;
483void main() {
484 vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
485 vNormal2 = normalize(mat3(modelMatrix) * normal);
486 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
487 #include <logdepthbuf_vertex>
488}
489`
490
491/**
492 * The beam is glowing air, not a surface, so it is brightest where the sightline
493 * passes through the most of it — down the middle — and fades at the limb. A tube
494 * shaded the usual way does the exact opposite and reads as a chrome pipe.
495 */
496const BEAM_FRAG = /* glsl */`
497#include <common>
498#include <logdepthbuf_pars_fragment>
499uniform vec3 uBeam;
500varying vec3 vWorld;
501varying vec3 vNormal2;
502${HAZE_GLSL}
503void main() {
504 #include <logdepthbuf_fragment>
505 vec3 view = normalize(vWorld - uCam);
506 // Additive white clips long before the limb, so the falloff has to start early
507 // or the shaft renders as a hard-edged slab instead of a glowing rod.
508 float through = pow(clamp(abs(dot(vNormal2, view)), 0.0, 1.0), 1.1);
509 vec3 T = towerTransmittance(uCam, vWorld);
510 gl_FragColor = vec4(uBeam * uExposure * through * T, 1.0);
511 #include <colorspace_fragment>
512}
513`
514
515// ------------------------------------------------------------------------- view
516
517export class InteriorView {
518 private renderer: WebGLRenderer
519 private scene = new Scene()
520 private camera: PerspectiveCamera
521 private ground: Mesh
522 private groundMat: ShaderMaterial
523 private surface: Surface = 'canon'
524 private shared: ShaderMaterial[] = []
525 private running = false
526 private lastFrame = 0
527 private dragging = false
528 private lastPointer = { x: 0, y: 0 }
529 private saveTimer: ReturnType<typeof setTimeout> | null = null
530 /** The height raster, read back so the observer can stand on the ground. */
531 private heights: { data: Uint8ClampedArray; w: number; h: number } | null = null
532
533 /** Where the observer stands. Default is the capital, at the centre of the world. */
534 at: SurfacePoint = { theta: 0, z: LENGTH_M / 2 }
535 /** Eye height above the floor, in metres. */
536 eyeM = EYE_MIN_M
537 /** Heading, radians clockwise from deepward (+z). */
538 yaw = 0
539 /** Elevation angle, radians. Clamped just short of straight up and down. */
540 pitch = 0
541 fovDeg = 60
542 lighting: Lighting = 'temperate'
543 /** Phase of the star pinwheel, radians. The only thing in the view that turns. */
544 private spin = 0
545 /** Time of day, 0..1. 0 is midnight, 0.5 is noon. */
546 dayPhase = 0.5
547 /** Whether the clock is running, and how many world seconds pass per real one. */
548 cycling = false
549 cycleRate = 900
550 /**
551 * Whether the clock, rather than the Light menu, is deciding the light. Set by
552 * the panel: ticking the box or touching the time slider hands control over.
553 */
554 clockDriven = false
555
556 private labels: { place: Place; el: HTMLSpanElement }[] = []
557
558 constructor(
559 private canvas: HTMLCanvasElement,
560 private cb: InteriorCallbacks,
561 private labelHost?: HTMLElement,
562 ) {
563 this.renderer = new WebGLRenderer({ canvas, antialias: true, logarithmicDepthBuffer: true })
564 this.renderer.setClearColor(HAZE_DAY)
565 /**
566 * Half a metre to 1,500 km is a depth range no fixed-point buffer survives, and
567 * both ends are required: the near plane has to clear an eye height of 1.7 m,
568 * and the far plane has to reach the far cap 540 km away. Hence the logarithmic
569 * depth buffer above.
570 */
571 this.camera = new PerspectiveCamera(this.fovDeg, 1, 0.0005, 1500)
572
573 this.groundMat = this.buildGroundMaterial()
574 this.ground = this.buildGround()
575 this.scene.add(this.ground)
576 this.buildCaps()
577 this.buildBeam()
578
579 this.restoreState()
580 this.applyLighting()
581 this.attach()
582 this.place()
583 this.resize()
584 // The canvas is hidden until this view is opened, so its box is zero-sized at
585 // construction time when the view is restored on load. Same trap as D-029.
586 new ResizeObserver(() => this.resize()).observe(canvas)
587 }
588
589 // ------------------------------------------------------------------ geometry
590
591 private buildGroundMaterial(): ShaderMaterial {
592 const m = new ShaderMaterial({
593 vertexShader: GROUND_VERT,
594 fragmentShader: GROUND_FRAG,
595 side: BackSide,
596 uniforms: {
597 uColor: { value: null }, uHeight: { value: null },
598 uTexel: { value: [1 / RASTER_WIDTH, 1 / RASTER_HEIGHT] },
599 uMppKm: { value: L / RASTER_WIDTH },
600 ...this.sharedUniforms(),
601 },
602 })
603 this.shared.push(m)
604 void this.loadRasters(m)
605 return m
606 }
607
608 private sharedUniforms(): Record<string, { value: unknown }> {
609 return {
610 uCam: { value: new Vector3() },
611 uK: { value: new Vector3(...K_RGB) },
612 uHaze: { value: new Color(HAZE_DAY) },
613 uExposure: { value: 1 },
614 // Only the glazed cap reads these, but every material carries them so the
615 // per-frame update can walk one list instead of knowing who wants what.
616 uSpin: { value: 0 },
617 uStarGain: { value: 0 },
618 // Only the ground declares these; carried here so one loop can set them.
619 uFrontZ: { value: 0 },
620 uFrontWidth: { value: 24 },
621 uBelowFront: { value: 1 },
622 uAboveFront: { value: 1 },
623 }
624 }
625
626 private buildGround(): Mesh {
627 const g = new CylinderGeometry(R, R, L, SEG_AROUND, SEG_ALONG, true)
628 g.rotateX(Math.PI / 2)
629 g.translate(0, 0, L / 2)
630 /**
631 * The rasters run x = z and y = theta from the map's cut; Three's cylinder runs
632 * u around and v along. Deriving theta from vertex positions instead would smear
633 * a column of triangles across the image, because the seam vertices are
634 * duplicated and atan2 gives both the same angle (D-023).
635 *
636 * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle
637 * in the XY plane is t - pi/2; canon theta is measured from +X.
638 */
639 const uv = g.attributes['uv']!
640 const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI)
641 for (let i = 0; i < uv.count; i++) {
642 uv.setXY(i, uv.getY(i), uv.getX(i) - shift)
643 }
644 uv.needsUpdate = true
645 return new Mesh(g, this.groundMat)
646 }
647
648 /**
649 * Swap the ground you are standing on. See raster.js for what a surface is.
650 *
651 * Every surface shares the raster dimensions and the height window, so nothing
652 * here has to rebuild the shader or the mesh — only the two textures and the
653 * CPU-side height copy the camera stands on change.
654 */
655 setSurface(surface: Surface): void {
656 if (surface === this.surface) return
657 this.surface = surface
658 void this.loadRasters(this.groundMat)
659 }
660
661 private async loadRasters(m: ShaderMaterial): Promise<void> {
662 const load = (name: string): Promise<Texture> =>
663 new Promise((resolve, reject) => {
664 new TextureLoader().load(`${import.meta.env.BASE_URL}${name}`, (tex) => {
665 tex.wrapS = RepeatWrapping
666 tex.wrapT = RepeatWrapping
667 // uv.y is computed rather than read from the file's row order, so no flip.
668 tex.flipY = false
669 tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
670 resolve(tex)
671 }, undefined, reject)
672 })
673 try {
674 const want = this.surface
675 const set = rasterSet(want)
676 const [color, height] = await Promise.all([load(set.color), load(set.height)])
677 // The reader may have switched surfaces while these were in flight; the
678 // height raster in particular must never disagree with the one readHeights
679 // has already put on the CPU, or the camera stands on the wrong ground.
680 if (want !== this.surface) { color.dispose(); height.dispose(); return }
681 m.uniforms['uColor']!.value = color
682 m.uniforms['uHeight']!.value = height
683 m.needsUpdate = true
684 this.readHeights(height)
685 this.place()
686 // The panel's elevation readout was drawn before any of this existed.
687 this.cb.onLook()
688 } catch {
689 // No rasters yet. The shell still renders; it is simply blank.
690 }
691 }
692
693 /**
694 * Read the height raster back onto the CPU.
695 *
696 * The shell is displaced on the GPU, which is fine until somebody has to stand on
697 * it: the camera would otherwise sit 1.7 m above the *undisplaced* cylinder, which
698 * is underground everywhere the land is higher than that — which is everywhere.
699 * From inside the rock the ground is invisible (its underside is a front face, and
700 * the shell is drawn back-side only), so the view showed sky below the horizon and
701 * nothing else. There is no way to ask the GPU where the ground is, so the answer
702 * has to be computed here from the same data the shader displaces by.
703 */
704 private readHeights(tex: Texture): void {
705 const img = tex.image as CanvasImageSource & { width: number; height: number }
706 const canvas = document.createElement('canvas')
707 canvas.width = img.width
708 canvas.height = img.height
709 const ctx = canvas.getContext('2d', { willReadFrequently: false })
710 if (!ctx) return
711 ctx.drawImage(img, 0, 0)
712 try {
713 const { data } = ctx.getImageData(0, 0, img.width, img.height)
714 this.heights = { data, w: img.width, h: img.height }
715 } catch {
716 // A tainted canvas would only mean standing on the reference cylinder again.
717 }
718 }
719
720 /** Bilinear sample of the height raster, in km. Wraps in theta, clamps in z. */
721 private sampleHeightKm(u: number, v: number): number {
722 const src = this.heights
723 if (!src) return 0
724 const x = Math.min(src.w - 1.001, Math.max(0, u * src.w - 0.5))
725 const y = ((v * src.h - 0.5) % src.h + src.h) % src.h
726 const x0 = Math.floor(x), y0 = Math.floor(y)
727 const fx = x - x0, fy = y - y0
728 const at = (px: number, py: number): number =>
729 src.data[((py % src.h) * src.w + Math.min(src.w - 1, px)) * 4]! / 255
730 const top = at(x0, y0) * (1 - fx) + at(x0 + 1, y0) * fx
731 const bot = at(x0, y0 + 1) * (1 - fx) + at(x0 + 1, y0 + 1) * fx
732 return (RASTER_MIN_HEIGHT_M + (top * (1 - fy) + bot * fy) * RASTER_HEIGHT_SPAN_M) / KM
733 }
734
735 /**
736 * Height of the rendered ground under a surface point, in km.
737 *
738 * This has to agree with the *mesh*, not with the raster: the shell's cells are
739 * 0.7 km across and the raster's are 0.26 km, so the surface actually drawn is the
740 * raster sampled at the cell corners and stretched flat between them. Sampling the
741 * raster directly would put the observer's feet a few tens of metres off in rough
742 * country, and underground is a far worse error than hovering — so the four corners
743 * are interpolated, and half the spread across the cell is added as clearance. On
744 * the plains that clearance is nil; on a ridge it lifts you clear of the facet.
745 */
746 private groundKm(p: SurfacePoint): number {
747 if (!this.heights) return 0
748 const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI)
749 const jf = (p.z / LENGTH_M) * SEG_ALONG
750 const iF = (normalizeTheta(p.theta - mapThetaOrigin()) / (2 * Math.PI) + shift) * SEG_AROUND
751 const j0 = Math.max(0, Math.min(SEG_ALONG - 1, Math.floor(jf)))
752 const i0 = Math.floor(iF)
753 const fj = Math.min(1, Math.max(0, jf - j0))
754 const fi = iF - i0
755 const corner = (di: number, dj: number): number =>
756 this.sampleHeightKm((j0 + dj) / SEG_ALONG, ((i0 + di) / SEG_AROUND - shift))
757 const c = [corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)]
758 const lo = c[0]! * (1 - fj) + c[2]! * fj
759 const hi = c[1]! * (1 - fj) + c[3]! * fj
760 return lo * (1 - fi) + hi * fi + (Math.max(...c) - Math.min(...c)) / 2
761 }
762
763 /** Ground elevation under the observer, in metres. Reported by the panel. */
764 get elevationM(): number {
765 return this.groundKm(this.at) * KM
766 }
767
768 private glazedCapMaterial(): ShaderMaterial {
769 const m = new ShaderMaterial({
770 vertexShader: FLAT_VERT, fragmentShader: GLAZED_CAP_FRAG, side: DoubleSide,
771 uniforms: {
772 uTint: { value: new Color(0xbcc2cc) },
773 uGlazing: { value: capGlazingTexture() },
774 ...this.sharedUniforms(),
775 },
776 })
777 this.shared.push(m)
778 return m
779 }
780
781 private sunCapMaterial(): ShaderMaterial {
782 const m = new ShaderMaterial({
783 vertexShader: FLAT_VERT, fragmentShader: SUN_CAP_FRAG, side: DoubleSide,
784 uniforms: {
785 uTint: { value: new Color(0xffffff) },
786 uGlazing: { value: sunCapGlazingTexture() },
787 ...this.sharedUniforms(),
788 },
789 })
790 this.shared.push(m)
791 return m
792 }
793
794 private revealMaterial(tint: number): ShaderMaterial {
795 const m = new ShaderMaterial({
796 vertexShader: FLAT_VERT, fragmentShader: REVEAL_FRAG, side: BackSide,
797 uniforms: { uTint: { value: new Color(tint) }, ...this.sharedUniforms() },
798 })
799 this.shared.push(m)
800 return m
801 }
802
803 /**
804 * The caps close the world at both ends. From the midpoint they are 270 km away
805 * and 4% transmitted (§1.8), so they are all but pure haze — which is the correct
806 * and slightly unnerving result, and worth drawing precisely because it is.
807 *
808 * They are not the same object any more. The sun cap faces the sun and the
809 * collector and keeps its checker; the far cap looks into deep anti-sun space and
810 * is glazed (D-045), set a kilometre into its own frame so the wall depth of
811 * D-044 has somewhere to be visible from.
812 */
813 private buildCaps(): void {
814 // The tint is a multiplier over the pattern, not the colour of the cap.
815 const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.sunCapMaterial())
816 this.scene.add(sun)
817
818 const far = new Mesh(new CircleGeometry(R, 128), this.glazedCapMaterial())
819 far.position.z = L + HULL_D
820 this.scene.add(far)
821
822 // The reveal: a kilometre of bore standing between the end of the world and
823 // the glass. BackSide, because it is only ever seen from inside.
824 const bore = new CylinderGeometry(R, R, HULL_D, 128, 1, true)
825 bore.rotateX(Math.PI / 2)
826 bore.translate(0, 0, L + HULL_D / 2)
827 this.scene.add(new Mesh(bore, this.revealMaterial(0x9aa0aa)))
828 }
829
830 private buildBeam(): void {
831 const g = new CylinderGeometry(BEAM_R, BEAM_R, L, 48, 1, true)
832 g.rotateX(Math.PI / 2)
833 g.translate(0, 0, L / 2)
834 const m = new ShaderMaterial({
835 vertexShader: BEAM_VERT, fragmentShader: BEAM_FRAG,
836 uniforms: { uBeam: { value: new Color(0xfff2d6).multiplyScalar(0.85) }, ...this.sharedUniforms() },
837 transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide,
838 })
839 this.shared.push(m)
840 this.scene.add(new Mesh(g, m))
841 }
842
843 // -------------------------------------------------------------------- camera
844
845 /** Basis at the observer: up is toward the axis, deepward is +z, spinward is theta-hat. */
846 private basis(): { up: Vector3; spinward: Vector3; deepward: Vector3; eye: Vector3 } {
847 const { theta } = this.at
848 const radial = new Vector3(Math.cos(theta), Math.sin(theta), 0)
849 // Up is toward the axis, so standing on high ground means a *smaller* radius.
850 const s = R - this.groundKm(this.at) - this.eyeM / KM
851 return {
852 up: radial.clone().negate(),
853 spinward: new Vector3(-Math.sin(theta), Math.cos(theta), 0),
854 deepward: new Vector3(0, 0, 1),
855 eye: new Vector3(radial.x * s, radial.y * s, this.at.z / KM),
856 }
857 }
858
859 /** Put the camera where the observer is, pointing where they are looking. */
860 private place(): void {
861 const { up, spinward, deepward, eye } = this.basis()
862 const dir = deepward.clone().multiplyScalar(Math.cos(this.pitch) * Math.cos(this.yaw))
863 .addScaledVector(spinward, Math.cos(this.pitch) * Math.sin(this.yaw))
864 .addScaledVector(up, Math.sin(this.pitch))
865 this.camera.position.copy(eye)
866 this.camera.up.copy(up)
867 this.camera.lookAt(eye.clone().add(dir))
868 this.camera.fov = this.fovDeg
869 this.camera.updateProjectionMatrix()
870
871 for (const m of this.shared) (m.uniforms['uCam']!.value as Vector3).copy(eye)
872 }
873
874 /**
875 * Go and stand at a place.
876 *
877 * Selecting something moves the observer rather than turning them toward it: at
878 * ground level almost everything in Tower is over the haze horizon, so turning to
879 * face a place usually just points at haze. `facing` is for places with extent —
880 * stand at the head of a river and look down it — and is null for a point, where
881 * arriving *is* the answer and there is nothing to aim at.
882 */
883 visit(p: SurfacePoint, facing: SurfacePoint | null = null): void {
884 this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) }
885 if (facing) this.yaw = this.headingTo(facing)
886 // Arriving somewhere looking at your feet is never what was wanted.
887 this.pitch = 0
888 this.place()
889 this.queueSave()
890 }
891
892 /** Heading from where the observer stands to a surface point, as a yaw. */
893 private headingTo(p: SurfacePoint): number {
894 const dz = (p.z - this.at.z) / KM
895 const dTheta = normalizeTheta(p.theta - this.at.theta)
896 const around = (dTheta > Math.PI ? dTheta - 2 * Math.PI : dTheta) * R
897 return Math.abs(around) + Math.abs(dz) < 1e-6 ? this.yaw : Math.atan2(around, dz)
898 }
899
900 /**
901 * Where the day is, as a light level and a front position.
902 *
903 * Dawn and dusk are the interesting part and they are not a dimmer. The beam
904 * glows by itself (§1.3, D-046), so its power — and with it the sky, the airlight
905 * and the rod overhead — has to come up globally; there is no way to make one end
906 * of the axis brighter than the other. What the swarm *can* do is choose where to
907 * put the light once it is there, and it hands it down the world as a front. So a
908 * Tower morning is a bright sky over dark ground, with a line of daylight coming
909 * toward you at 225 m/s.
910 */
911 private sampleDay(): { watts: number; frontZ: number; below: number; above: number; kind: 'dawn' | 'dusk' | null } {
912 const p = ((this.dayPhase % 1) + 1) % 1
913 const sweep = DAY_SWEEP_FRACTION
914 const DAWN = 0.25
915 const DUSK = 0.75
916 const smooth = (e0: number, e1: number, x: number): number => {
917 const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)))
918 return t * t * (3 - 2 * t)
919 }
920 // Daylight everywhere, ignoring the front: up through dawn, down through dusk.
921 const up = smooth(DAWN, DAWN + sweep, p)
922 const down = smooth(DUSK, DUSK + sweep, p)
923 const day = up - down
924 // Noon is Earth clear-sky full; the rest of the day sits nearer temperate.
925 const shape = day * (0.62 + 0.38 * Math.sin(Math.PI * Math.min(1, Math.max(0, (p - DAWN) / (DUSK - DAWN)))))
926 const watts = BRIGHTNESS_NIGHT_W_M2 + (BRIGHTNESS_FULL_W_M2 - BRIGHTNESS_NIGHT_W_M2) * shape
927
928 /**
929 * The front only exists while it is crossing. `below`/`above` are multipliers on
930 * the ground's share of the light, so the side that has not had its morning yet
931 * stays dark under a sky that is already bright.
932 */
933 let frontZ = -1e6, below = 1, above = 1
934 let kind: 'dawn' | 'dusk' | null = null
935 const nightShare = 0.04
936 if (p >= DAWN && p < DAWN + sweep) {
937 frontZ = ((p - DAWN) / sweep) * L
938 below = 1
939 above = nightShare
940 kind = 'dawn'
941 } else if (p >= DUSK && p < DUSK + sweep) {
942 frontZ = ((p - DUSK) / sweep) * L
943 below = nightShare
944 above = 1
945 kind = 'dusk'
946 }
947 return { watts, frontZ, below, above, kind }
948 }
949
950 /**
951 * Where the dawn line is relative to the observer, when there is one. Naming the
952 * distance is the point: a front 90 km deepward arrives in about seven minutes,
953 * and that is a thing you can wait for.
954 */
955 private frontNote(): string {
956 const { frontZ, kind } = this.sampleDay()
957 if (!kind) return ''
958 /**
959 * Sign matters and is easy to get backwards: z counts deepward from the sun
960 * cap, and the front always travels deepward. So a front at a *lower* z than
961 * the observer is sunward of them and still coming; a higher one has already
962 * gone past.
963 */
964 const d = frontZ - this.at.z / KM
965 if (Math.abs(d) < 1) return ` · the ${kind} line is here`
966 return d < 0
967 ? ` · ${kind} ${(-d).toFixed(0)} km sunward, coming`
968 : ` · ${kind} passed, ${d.toFixed(0)} km deepward`
969 }
970
971 /** Human clock, for the readout. Tower keeps Earth's 24 hours (D-053). */
972 get clock(): string {
973 const secs = (((this.dayPhase % 1) + 1) % 1) * DAY_LENGTH_S
974 const h = Math.floor(secs / 3600)
975 const m = Math.floor((secs % 3600) / 60)
976 return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
977 }
978
979 /**
980 * Put the clock at a given time of day. Works whether or not the cycle is
981 * running — the front and the light level are a function of the phase, not of
982 * anything animating, so scrubbing is the same operation as ticking.
983 */
984 setDayPhase(p: number): void {
985 this.dayPhase = ((p % 1) + 1) % 1
986 this.applyLighting()
987 this.cb.onLook()
988 this.queueSave()
989 }
990
991 setCycling(on: boolean): void {
992 this.cycling = on
993 if (on) this.clockDriven = true
994 if (on) this.applyLighting()
995 else {
996 // Hand the manual settings back an unswept world.
997 for (const m of this.shared) {
998 m.uniforms['uBelowFront']!.value = 1
999 m.uniforms['uAboveFront']!.value = 1
1000 }
1001 this.applyLighting()
1002 }
1003 this.queueSave()
1004 }
1005
1006 private applyLighting(): void {
1007 /**
1008 * Eyes adapt, so twilight is not 6.7x darker than temperate to look at. The
1009 * exponent compresses the canon watts into something a screen can show while
1010 * keeping the ordering and the ratios honest.
1011 */
1012 const day = this.clockDriven ? this.sampleDay() : null
1013 const watts = day ? day.watts : LIGHTING[this.lighting]
1014 if (day) {
1015 for (const m of this.shared) {
1016 m.uniforms['uFrontZ']!.value = day.frontZ
1017 m.uniforms['uBelowFront']!.value = day.below
1018 m.uniforms['uAboveFront']!.value = day.above
1019 }
1020 }
1021 const e = (watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35
1022 /**
1023 * The other half of "eyes adapt": the stars are always exactly as bright as
1024 * they are, and whether you can see them depends entirely on what your eyes
1025 * are currently set to. Full at night and below, gone by noon — 2% at
1026 * temperate, which is the honest answer to why nobody looks out at lunchtime.
1027 */
1028 const starGain = (BRIGHTNESS_NIGHT_W_M2 / Math.max(watts, BRIGHTNESS_NIGHT_W_M2)) ** 0.6
1029 const haze = new Color(watts <= BRIGHTNESS_TWILIGHT_W_M2 ? HAZE_DUSK : HAZE_DAY)
1030 for (const m of this.shared) {
1031 m.uniforms['uExposure']!.value = e
1032 m.uniforms['uStarGain']!.value = starGain
1033 ;(m.uniforms['uHaze']!.value as Color).copy(haze)
1034 }
1035 // Scaled, so `dark` clears to black rather than to a dusk-coloured sky that
1036 // nothing in the scene is bright enough to justify.
1037 this.renderer.setClearColor(haze.clone().multiplyScalar(e), 1)
1038 }
1039
1040 setLighting(l: Lighting): void {
1041 this.lighting = l
1042 this.applyLighting()
1043 this.queueSave()
1044 }
1045
1046 // ------------------------------------------------------------------- labels
1047
1048 setPlaces(places: readonly Place[]): void {
1049 if (!this.labelHost) return
1050 this.labelHost.replaceChildren()
1051 this.labels = places.map((place) => {
1052 const el = document.createElement('span')
1053 el.innerHTML = `${place.name.toUpperCase()}<small>${place.kind}</small>`
1054 el.style.pointerEvents = 'auto'
1055 el.style.cursor = 'pointer'
1056 el.onclick = () => this.cb.onPickPlace(place.id)
1057 this.labelHost!.append(el)
1058 return { place, el }
1059 })
1060 }
1061
1062 private placePoint(p: Place): SurfacePoint | null {
1063 const g = p.geometry
1064 if (g.type === 'point') return { theta: g.theta, z: g.z }
1065 if (g.type === 'band') return null
1066 const first = g.points[0]
1067 return first ? { theta: first[0], z: first[1] } : null
1068 }
1069
1070 /**
1071 * Captions for what is in view. Distant things are dropped once the haze has
1072 * eaten them, so the labels agree with what can actually be seen rather than
1073 * naming a mountain that is not there.
1074 */
1075 private updateLabels(): void {
1076 if (!this.labels.length) return
1077 const { width, height } = this.canvas.getBoundingClientRect()
1078 const eye = this.camera.position
1079 const v = new Vector3()
1080 const placed: { x: number; y: number }[] = []
1081 for (const { place, el } of this.labels) {
1082 const at = this.placePoint(place)
1083 if (!at) { el.style.display = 'none'; continue }
1084 const s = R
1085 v.set(s * Math.cos(at.theta), s * Math.sin(at.theta), at.z / KM)
1086 const dist = v.distanceTo(eye)
1087 v.project(this.camera)
1088 const x = ((v.x + 1) / 2) * width
1089 const y = ((1 - v.y) / 2) * height
1090 let visible = v.z < 1 && Math.abs(v.x) < 1 && Math.abs(v.y) < 1 && dist > 0.3
1091 // Roughly: green extinction at the mean density along the ray. Exact enough
1092 // to decide whether a caption is honest.
1093 if (visible && Math.exp(-K_GREEN * dist * 0.55) < 0.06) visible = false
1094 if (visible && placed.some((q) => Math.abs(q.x - x) < 150 && Math.abs(q.y - y) < 30)) {
1095 visible = false
1096 }
1097 el.style.display = visible ? 'block' : 'none'
1098 if (!visible) continue
1099 placed.push({ x, y })
1100 el.style.left = `${x}px`
1101 el.style.top = `${y}px`
1102 el.style.opacity = String(Math.max(0.35, Math.min(1, 1 - dist / 260)))
1103 }
1104 }
1105
1106 // --------------------------------------------------------------- interaction
1107
1108 private attach(): void {
1109 this.canvas.addEventListener('pointerdown', (e) => {
1110 this.dragging = true
1111 this.lastPointer = { x: e.clientX, y: e.clientY }
1112 this.canvas.setPointerCapture(e.pointerId)
1113 })
1114 this.canvas.addEventListener('pointermove', (e) => {
1115 if (!this.dragging) return
1116 const dx = e.clientX - this.lastPointer.x
1117 const dy = e.clientY - this.lastPointer.y
1118 this.lastPointer = { x: e.clientX, y: e.clientY }
1119 // Scale with the field of view, so zoomed in does not mean flung around.
1120 const rate = (this.fovDeg / 60) * 0.0032
1121 this.yaw -= dx * rate
1122 this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate))
1123 this.place()
1124 this.cb.onLook()
1125 this.queueSave()
1126 })
1127 const end = (e: PointerEvent) => {
1128 this.dragging = false
1129 this.canvas.releasePointerCapture?.(e.pointerId)
1130 }
1131 this.canvas.addEventListener('pointerup', end)
1132 this.canvas.addEventListener('pointercancel', end)
1133 this.canvas.addEventListener('wheel', (e) => {
1134 e.preventDefault()
1135 this.fovDeg = Math.max(3, Math.min(90, this.fovDeg * (e.deltaY > 0 ? 1.08 : 1 / 1.08)))
1136 this.place()
1137 this.cb.onLook()
1138 this.queueSave()
1139 }, { passive: false })
1140 window.addEventListener('pagehide', () => this.save())
1141 }
1142
1143 setEyeHeight(m: number): void {
1144 this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, m))
1145 this.place()
1146 this.queueSave()
1147 }
1148
1149 // -------------------------------------------------------------------- state
1150
1151 private queueSave(): void {
1152 if (this.saveTimer) clearTimeout(this.saveTimer)
1153 this.saveTimer = setTimeout(() => this.save(), STATE_SAVE_MS)
1154 }
1155
1156 private save(): void {
1157 if (this.saveTimer) clearTimeout(this.saveTimer)
1158 this.saveTimer = null
1159 try {
1160 localStorage.setItem(STATE_KEY, JSON.stringify({
1161 theta: this.at.theta, z: this.at.z, eye: this.eyeM,
1162 yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg,
1163 }))
1164 } catch {
1165 // Not persisting where you stood is survivable.
1166 }
1167 }
1168
1169 /** Every field is checked: a bad entry here would strand the view (D-030). */
1170 private restoreState(): void {
1171 try {
1172 const raw = localStorage.getItem(STATE_KEY)
1173 if (!raw) return
1174 const s = JSON.parse(raw) as Record<string, unknown>
1175 const num = (v: unknown, lo: number, hi: number): number | null =>
1176 typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null
1177 const theta = num(s['theta'], 0, 2 * Math.PI)
1178 const z = num(s['z'], 0, LENGTH_M)
1179 if (theta !== null && z !== null) this.at = { theta, z }
1180 this.eyeM = num(s['eye'], EYE_MIN_M, EYE_MAX_M) ?? this.eyeM
1181 this.yaw = num(s['yaw'], -100, 100) ?? this.yaw
1182 this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch
1183 this.fovDeg = num(s['fov'], 3, 90) ?? this.fovDeg
1184 if (isLighting(s['lighting'])) {
1185 this.lighting = s['lighting']
1186 }
1187 } catch {
1188 // Unreadable storage just means standing in the capital again.
1189 }
1190 }
1191
1192 // -------------------------------------------------------------------- readout
1193
1194 /** What the instruments say from here. Rendered by the app into the overlay. */
1195 report(): { standing: string; lines: string[] } {
1196 const deg = (r: number) => ((r * 180) / Math.PI)
1197 const heading = normalizeTheta(this.yaw)
1198 const compass = ['deepward', 'spinward', 'sunward', 'antispinward'][
1199 Math.round(deg(heading) / 90) % 4
1200 ]!
1201 const elevation = this.elevationM
1202 const overhead = RADIUS_M - elevation - this.eyeM
1203 const beamAngle = 2 * Math.atan(BEAM_RADIUS_M / overhead)
1204 const eyeText = this.eyeM < KM
1205 ? `${this.eyeM.toFixed(1)} m` : `${(this.eyeM / KM).toFixed(1)} km`
1206 return {
1207 standing: `θ ${deg(this.at.theta).toFixed(1)}° z ${(this.at.z / KM).toFixed(1)} km`,
1208 lines: [
1209 `ground ${elevation.toFixed(0)} m above the datum`,
1210 `eye ${eyeText} · ${(gravityAtHeight(elevation + this.eyeM) / G0).toFixed(4)} g`,
1211 `looking ${deg(heading).toFixed(0)}° ${compass}, ${deg(this.pitch) >= 0 ? '+' : ''}${deg(this.pitch).toFixed(0)}°`,
1212 `field ${this.fovDeg.toFixed(0)}°`,
1213 `beam ${deg(beamAngle).toFixed(2)}° wide overhead`,
1214 ...(this.clockDriven ? [`time ${this.clock}${this.frontNote()}`] : []),
1215 // The pinwheel replaced the rain as the spin's evidence here (D-049), so
1216 // the readout names the period you can actually time against the sky.
1217 `sky turns once in ${fmtSpin()}, about the axis`,
1218 ],
1219 }
1220 }
1221
1222 resize(): void {
1223 const { width, height } = this.canvas.getBoundingClientRect()
1224 if (!width || !height) return
1225 this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
1226 this.renderer.setSize(width, height, false)
1227 this.camera.aspect = width / height
1228 this.camera.updateProjectionMatrix()
1229 }
1230
1231 start(): void {
1232 if (this.running) return
1233 this.running = true
1234 this.lastFrame = performance.now()
1235 const tick = (now: number) => {
1236 if (!this.running) return
1237 const dt = Math.min(0.1, (now - this.lastFrame) / 1000)
1238 this.lastFrame = now
1239 /**
1240 * The pinwheel. Nothing inside Tower rotates — you, the ground and the air
1241 * all turn together — but the sky through the far cap does not, so it wheels
1242 * once every 7 min 29 s about the one motionless star on the axis. It is the
1243 * only unambiguous evidence of the spin anywhere in the view (D-045).
1244 */
1245 if (this.cycling) {
1246 this.dayPhase = (this.dayPhase + (dt * this.cycleRate) / DAY_LENGTH_S) % 1
1247 this.applyLighting()
1248 this.cb.onLook()
1249 }
1250 this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI)
1251 for (const m of this.shared) m.uniforms['uSpin']!.value = this.spin
1252 this.renderer.render(this.scene, this.camera)
1253 this.updateLabels()
1254 requestAnimationFrame(tick)
1255 }
1256 requestAnimationFrame(tick)
1257 }
1258
1259 stop(): void {
1260 this.running = false
1261 }
1262}