tower / cascadia / files / src / views / interior.ts
Branch cascadia · default branch · all branches
interior.ts
| 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 | */ |
| 20 | import type { Texture } from 'three' |
| 21 | import { |
| 22 | AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide, |
| 23 | Mesh, PerspectiveCamera, RepeatWrapping, RingGeometry, |
| 24 | Scene, ShaderMaterial, TextureLoader, Vector3, WebGLRenderer, |
| 25 | } from 'three' |
| 26 | import { |
| 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' |
| 33 | import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' |
| 34 | import type { Place } from '../world/schema.js' |
| 35 | import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js' |
| 36 | import { |
| 37 | MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture, |
| 38 | } from './cap-pattern.js' |
| 39 | import { |
| 40 | type Surface, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M, |
| 41 | RASTER_MIN_HEIGHT_M, RASTER_WIDTH, rasterSet, |
| 42 | } from './raster.js' |
| 43 | |
| 44 | const KM = 1000 |
| 45 | const R = RADIUS_M / KM |
| 46 | const L = LENGTH_M / KM |
| 47 | const BEAM_R = BEAM_RADIUS_M / KM |
| 48 | /** Wall depth in km. The far cap's window is set this far into its own frame. */ |
| 49 | const HULL_D = HULL_DEPTH_M / KM |
| 50 | /** The collector, in km: 122 km of radius flying 320 km ahead of the sun cap. */ |
| 51 | const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 |
| 52 | const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM |
| 53 | /** Width of the sunlit edge structure, in km (D-054). */ |
| 54 | const 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 | */ |
| 64 | const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM |
| 65 | const 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. */ |
| 69 | const 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 | */ |
| 80 | const HAZE_DAY = 0x9aa3ad |
| 81 | const HAZE_DUSK = 0x8d8792 |
| 82 | |
| 83 | |
| 84 | /** Shell tessellation. 0.7 km triangles: distant relief is real, your feet are not. */ |
| 85 | const SEG_AROUND = 512 |
| 86 | const 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. */ |
| 99 | const 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 | |
| 104 | export 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 |
| 111 | export type Lighting = keyof typeof LIGHTING |
| 112 | export const isLighting = (v: unknown): v is Lighting => |
| 113 | typeof v === 'string' && v in LIGHTING |
| 114 | |
| 115 | const STATE_KEY = 'tower.interior' |
| 116 | const STATE_SAVE_MS = 400 |
| 117 | |
| 118 | export 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 | */ |
| 134 | const HAZE_GLSL = /* glsl */` |
| 135 | uniform vec3 uCam; |
| 136 | uniform vec3 uK; |
| 137 | uniform vec3 uHaze; |
| 138 | uniform float uExposure; |
| 139 | const float A = ${DENSITY_A.toExponential(8)}; |
| 140 | const float RR = ${(R * R).toFixed(4)}; |
| 141 | |
| 142 | vec3 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 | |
| 156 | const GROUND_VERT = /* glsl */` |
| 157 | #include <common> |
| 158 | #include <logdepthbuf_pars_vertex> |
| 159 | uniform sampler2D uHeight; |
| 160 | uniform vec2 uTexel; |
| 161 | uniform float uMppKm; |
| 162 | varying vec2 vUv; |
| 163 | varying vec3 vWorld; |
| 164 | varying vec3 vNormal2; |
| 165 | |
| 166 | /** The raster is stretched across a declared working range, not 0..anything. */ |
| 167 | float 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 | |
| 172 | void 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 | |
| 195 | const GROUND_FRAG = /* glsl */` |
| 196 | #include <common> |
| 197 | #include <logdepthbuf_pars_fragment> |
| 198 | uniform sampler2D uColor; |
| 199 | uniform float uFrontZ; |
| 200 | uniform float uFrontWidth; |
| 201 | uniform float uBelowFront; |
| 202 | uniform float uAboveFront; |
| 203 | varying vec2 vUv; |
| 204 | varying vec3 vWorld; |
| 205 | varying vec3 vNormal2; |
| 206 | ${HAZE_GLSL} |
| 207 | |
| 208 | /** Cheap value noise, to keep the ground from being flat colour underfoot. */ |
| 209 | float hash(vec2 p) { |
| 210 | return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); |
| 211 | } |
| 212 | float 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 | |
| 219 | void 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 | |
| 265 | const FLAT_VERT = /* glsl */` |
| 266 | #include <common> |
| 267 | #include <logdepthbuf_pars_vertex> |
| 268 | varying vec3 vWorld; |
| 269 | void 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 | */ |
| 295 | const STAR_GLSL = /* glsl */` |
| 296 | uniform float uSpin; |
| 297 | uniform float uStarGain; |
| 298 | |
| 299 | float 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 | } |
| 304 | vec2 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. |
| 312 | const float STAR_SCALE = 46.0; |
| 313 | |
| 314 | vec3 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. */ |
| 349 | const REVEAL_FRAG = /* glsl */` |
| 350 | #include <common> |
| 351 | #include <logdepthbuf_pars_fragment> |
| 352 | uniform vec3 uTint; |
| 353 | varying vec3 vWorld; |
| 354 | ${HAZE_GLSL} |
| 355 | void 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 | */ |
| 378 | const SUN_CAP_FRAG = /* glsl */` |
| 379 | #include <common> |
| 380 | #include <logdepthbuf_pars_fragment> |
| 381 | uniform vec3 uTint; |
| 382 | uniform sampler2D uGlazing; |
| 383 | varying vec3 vWorld; |
| 384 | ${HAZE_GLSL} |
| 385 | ${STAR_GLSL} |
| 386 | void 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 | */ |
| 435 | const GLAZED_CAP_FRAG = /* glsl */` |
| 436 | #include <common> |
| 437 | #include <logdepthbuf_pars_fragment> |
| 438 | uniform vec3 uTint; |
| 439 | uniform sampler2D uGlazing; |
| 440 | varying vec3 vWorld; |
| 441 | ${HAZE_GLSL} |
| 442 | ${STAR_GLSL} |
| 443 | void 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 | |
| 478 | const BEAM_VERT = /* glsl */` |
| 479 | #include <common> |
| 480 | #include <logdepthbuf_pars_vertex> |
| 481 | varying vec3 vWorld; |
| 482 | varying vec3 vNormal2; |
| 483 | void 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 | */ |
| 496 | const BEAM_FRAG = /* glsl */` |
| 497 | #include <common> |
| 498 | #include <logdepthbuf_pars_fragment> |
| 499 | uniform vec3 uBeam; |
| 500 | varying vec3 vWorld; |
| 501 | varying vec3 vNormal2; |
| 502 | ${HAZE_GLSL} |
| 503 | void 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 | |
| 517 | export 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 | } |