tower / commit
feat(canon): the sun cap is a window too, and Tower never sees the sun
README.md | 6 ++-- decisions.md | 29 +++++++++++++++++++ design.md | 29 +++++++++++++++---- src/canon/constants.ts | 23 ++++++++++++++++ src/views/cap-pattern.ts | 19 ++++++++++++- src/views/exterior.ts | 5 ++-- src/views/interior.ts | 72 ++++++++++++++++++++++++++++++++++-------------- 7 files changed, 150 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index b8f3de8..d5b243e 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ Companion piece to a novel set inside **Tower** — a fictional smart cylinder o spun for 1 g, with an inner surface the size of Wisconsin, lit by 54 TW of sunlight piped down its axis and scattered by mirrors that make its days and nights. Its wall is a kilometre of sealed hull: no windows along all 540 km, and no view of the Earth -it orbits. The far cap is the one exception — a 100 km window at the deepward end, -and the only place in the world with a horizon that is not Tower. +it orbits. Both end caps are windows, though — 100 km of glass each, and the only +places in the world with a horizon that is not Tower. Nobody aboard can see the sun +either: the 244 km collector sits between Tower and it, 79 times its diameter, so +the sun cap looks out at a black disc and the stars around it. Explore Tower from outside, pick a spot on the inner surface, and stand there — with the ground curving up on both sides and the far side of the world hanging 100 km diff --git a/decisions.md b/decisions.md index e61ed48..7089902 100644 --- a/decisions.md +++ b/decisions.md @@ -1497,3 +1497,32 @@ in here, and the waterfall is still the compass. It is only the *rendering* that gone, along with the toggle, the seeded raindrop field, and the six Three imports that existed for it. The readout line it occupied now prints the pinwheel's period, which is the interval you can actually time against the sky. + +## D-050 — The sun cap is a window too, and Tower never sees the sun + +**§1.4 was wrong about this cap, and the number that shows it was already in canon.** +It demanded "heavy glare management" on the grounds that the sun cap faces the sun. +It does not face the sun. It faces the **collector**, which is 244 km across at 320 +km — **41.8° wide against the sun's 0.53°, seventy-nine times its diameter**. The +collector sits between Tower and the sun by construction, so it occults it entirely, +and the umbra is 120 km across where Tower's 51 km sits in it. There is no direct +sunlight on the sun cap and there never has been. Nothing to manage. + +So this cap is glazed on the same terms as the far one, and what fills it is the +**back of the collector**: a black disc, because its front is a mirror and its back +radiates 9.6 TW of waste heat at about 245 K, which is infrared. Around the edge of +it, stars. It does not turn with them — the collector flies in formation rather than +spinning — so the sun cap window is a still black disc against a wheeling sky. + +**Nobody in Tower can see the sun.** They already could not see Earth (§1.4, the +terminator orbit puts it out the side, which is solid hull). A habitat in orbit +around a planet it has never seen, lit by a star it has never seen. + +The occultation is computed in the shader rather than modelled: the fragment casts +its own ray on to the collector's plane and asks whether it lands on the disc. That +is the same trick the far cap uses for its stars, and it means the interior does not +have to carry a 244 km mesh 320 km outside the world to draw one black circle. + +The interior's checkered cap material is gone with this. Both caps are windows now, +and nothing inside was still using it — the checker survives as the exterior's sun +cap and as both windows' mullions. diff --git a/design.md b/design.md index bb2605d..576cff0 100644 --- a/design.md +++ b/design.md @@ -90,6 +90,21 @@ Sunlight enters through the sun-facing end cap and runs the full 540 km axis. sun-widths**, running the whole length of the sky. This glow is a property of the beam and the air, not of the diffusers — it would be there if the beam were empty. +#### 1.3.0 Tower never sees the sun + +The collector is **244 km across and flies 320 km ahead of the sun cap**, which puts +it **41.8° wide** in Tower's sky against the sun's 0.53° — **79 times the sun's +diameter**. It sits squarely between Tower and the sun, so it occults it completely, +and the umbra it casts is 120 km across where Tower's 51 km sits inside it. The +eclipse is total, permanent, and self-inflicted. + +So **nobody aboard can see the sun**, for the same reason nobody can see Earth +(§1.4). What they can see, through the sun cap window, is the *back* of the +collector: 46,938 km² of structure radiating its own waste heat at about 245 K — +infrared, and therefore black. It is the only object in Tower's sky that is neither +Tower nor a star, and it never moves, because the collector flies in formation +rather than spinning. D-050. + #### 1.3.1 The diffuser swarm The diffusers must be individually invisible, or the sky becomes a row of separate @@ -242,8 +257,10 @@ The only views out are at the **end caps**, and they are peculiar: window's **mullions**, on the same polar tessellation the checker used and at the same cell pitch, so the one object in the world with a known scale still is one: the rule now reads against the sky instead of against a plate. The **sun cap** - faces the sun and the collector, keeps its checker, and needs heavy glare - management rather than a view. + faces the collector rather than the sun, and is **glazed too** (D-050): §1.3.0 + retires the "heavy glare management" this section used to demand of it, because no + direct sunlight has ever reached it. Through it, a 244 km black disc, stars round + the edge of it, and the beam blazing out of the aperture at its centre. - **By day the window is simply blue.** §1.5 puts the far cap 100–200 km into the haze, so from anywhere but the deepward end the airlight in front of it swamps whatever is behind, and the cap reads as a disc of sky. The stars are there at @@ -728,12 +745,12 @@ and the pinwheel below does the job it was standing in for. The physics stays in `canon/motion.ts` and stays tested; it was only ever the rendering that could not carry it. -**The one thing that does turn is outside.** The sky through the far cap does not +**The one thing that does turn is outside.** The sky through the caps does not co-rotate, so it wheels once every 7 min 29 s about the motionless star on the axis (D-045). It is by a wide margin the plainest evidence of the spin in the view, and -since D-049 it is the only one — and it is only available at -night, at the deepward end, looking the one direction that has ever had anything -in it but Tower. +it is the only evidence left now that the rain is gone. Both caps are windows +(D-050), so it can be had looking either way along the axis — deepward into empty +space, or sunward past the black disc of the collector. The observer stands on the terrain, which means the height raster is read back onto the CPU and sampled against the mesh's own cell corners — the GPU cannot be asked diff --git a/src/canon/constants.ts b/src/canon/constants.ts index 98e75ab..0834653 100644 --- a/src/canon/constants.ts +++ b/src/canon/constants.ts @@ -61,9 +61,32 @@ export const BEAM_POWER_W = BEAM_AREA_M2 * BEAM_INTENSITY_W_M2 /** Thermodynamic ceiling on passive concentration at 1 AU (D-003). */ export const ETENDUE_LIMIT = 46_223 +/** The sun's disc from Tower's orbit — 0.53°, and the reason the eclipse is total. */ +export const SUN_ANGULAR_DIAMETER_RAD = 0.53 * Math.PI / 180 export const COLLECTOR_EFFICIENCY = 0.85 export const COLLECTOR_AREA_M2 = BEAM_POWER_W / SOLAR_CONSTANT_W_M2 / COLLECTOR_EFFICIENCY export const COLLECTOR_DIAMETER_M = 2 * Math.sqrt(COLLECTOR_AREA_M2 / Math.PI) +/** + * How far ahead of the sun cap the collector flies. Not fixed by canon, but it has + * to live here rather than in one view: both 3D views draw the collector now, and + * the interior needs it to decide which part of the sun cap window it covers. + */ +export const COLLECTOR_STANDOFF_M = 320_000 +/** + * The collector is **41.8° across from the sun cap — 79 times the sun's diameter**, + * so it occults the sun completely and Tower sits in its umbra permanently. Nobody + * aboard can see the sun, for the same reason nobody can see Earth (§1.4). From + * inside, the back of it is the only object in the sky that is neither Tower nor a + * star, and it never moves. D-050. + */ +export const COLLECTOR_ANGULAR_DIAMETER_RAD = + 2 * Math.atan(COLLECTOR_DIAMETER_M / 2 / COLLECTOR_STANDOFF_M) +/** + * Umbra radius at the sun cap. Tower's 51 km sits well inside it, which is what + * makes the eclipse total rather than partial. + */ +export const COLLECTOR_UMBRA_RADIUS_M = + COLLECTOR_DIAMETER_M / 2 - COLLECTOR_STANDOFF_M * (SUN_ANGULAR_DIAMETER_RAD / 2) // ---------------------------------------------------- diffuser swarm (design.md §1.3.1) export const MIRRORLET_EDGE_M = 0.012 diff --git a/src/views/cap-pattern.ts b/src/views/cap-pattern.ts index 5c39cd0..13fb19e 100644 --- a/src/views/cap-pattern.ts +++ b/src/views/cap-pattern.ts @@ -106,7 +106,7 @@ export function drawCapPattern(size = 2048): HTMLCanvasElement { * Alpha is the whole point of this canvas — opaque where there is structure, * clear where there is a pane. The colour channels carry the mullion's own tone. */ -export function drawCapGlazing(size = 2048): HTMLCanvasElement { +export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement { const c = document.createElement('canvas') c.width = size c.height = size @@ -167,6 +167,7 @@ export function drawCapGlazing(size = 2048): HTMLCanvasElement { * centre lands on the window at the observer's own radius — down at eye level — * while the mirror is an object on the axis, about 10° above it from 270 km. */ + if (!bullseye) return c const mirrorKm = FAR_CAP_MIRROR_RADIUS_M / 1000 ctx.fillStyle = MIRROR ctx.beginPath() @@ -223,3 +224,19 @@ export function capGlazingTexture(): Texture { glazingCached = tex return glazingCached } + +/** + * The sun cap's glazing: the same frame, without the bullseye. Its middle is the + * beam aperture — a hole in the ring, not a mirror — so drawing one there would put + * a sliver of false structure around the rim of the opening. + */ +let sunGlazingCached: Texture | null = null +export function sunCapGlazingTexture(): Texture { + if (sunGlazingCached) return sunGlazingCached + const tex = new CanvasTexture(drawCapGlazing(2048, false)) + tex.colorSpace = SRGBColorSpace + tex.anisotropy = 16 + tex.magFilter = LinearFilter + sunGlazingCached = tex + return sunGlazingCached +} diff --git a/src/views/exterior.ts b/src/views/exterior.ts index 89bd686..5681b74 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -18,7 +18,7 @@ import { } from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import { - BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, + BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_STANDOFF_M, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, } from '../canon/constants.js' import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' import type { Place } from '../world/schema.js' @@ -32,8 +32,7 @@ const BEAM_R = BEAM_RADIUS_M / KM /** Wall depth in km (D-044). The caps sit at the outer face of it. */ const HULL_D = HULL_DEPTH_M / KM const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 -/** How far ahead of the sun cap the collector flies. Not fixed by canon. */ -const COLLECTOR_STANDOFF = 320 +const COLLECTOR_STANDOFF = COLLECTOR_STANDOFF_M / KM /** How close to an end, in km, counts as being on a cap rather than on the wall. */ const CAP_TOLERANCE = 8 const isCapZ = (zKm: number): boolean => zKm < CAP_TOLERANCE || zKm > L - CAP_TOLERANCE diff --git a/src/views/interior.ts b/src/views/interior.ts index 8a4a569..3d7ab1b 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -24,7 +24,7 @@ import { Scene, ShaderMaterial, TextureLoader, Vector3, WebGLRenderer, } from 'three' import { - BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, + BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, COLLECTOR_DIAMETER_M, COLLECTOR_STANDOFF_M, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, BRIGHTNESS_TWILIGHT_W_M2, G0, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M, SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, gravityAtHeight, @@ -32,7 +32,9 @@ import { import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' import type { Place } from '../world/schema.js' import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js' -import { MIRROR_UV_RADIUS, capGlazingTexture, capPatternTexture } from './cap-pattern.js' +import { + MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture, +} from './cap-pattern.js' import { COLOR_RASTER, HEIGHT_RASTER, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M, RASTER_MIN_HEIGHT_M, RASTER_WIDTH, @@ -44,6 +46,9 @@ const L = LENGTH_M / KM const BEAM_R = BEAM_RADIUS_M / KM /** Wall depth in km. The far cap's window is set this far into its own frame. */ const HULL_D = HULL_DEPTH_M / KM +/** The collector, in km: 122 km of radius flying 320 km ahead of the sun cap. */ +const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 +const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM /** * Rayleigh extinction per kilometre at floor density, per channel. @@ -322,36 +327,61 @@ vec3 starField(vec3 dir) { } ` -const CAP_FRAG = /* glsl */` +/** The bore between the world's end and the glazing. Plain structure, hazed. */ +const REVEAL_FRAG = /* glsl */` #include <common> #include <logdepthbuf_pars_fragment> uniform vec3 uTint; -uniform sampler2D uPattern; varying vec3 vWorld; ${HAZE_GLSL} void main() { #include <logdepthbuf_fragment> - // Planar UV across the cap's bounding box — the same mapping Three gives the - // disc geometry, so the pattern lands identically here and in the exterior view. - vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5; - vec3 face = pow(texture2D(uPattern, uv).rgb, vec3(2.2)) * uTint; vec3 T = towerTransmittance(uCam, vWorld); - gl_FragColor = vec4(face * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0); + gl_FragColor = vec4(uTint * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0); #include <colorspace_fragment> } ` -/** The bore between the world's end and the glazing. Plain structure, hazed. */ -const REVEAL_FRAG = /* glsl */` +/** + * The sun cap: also a window, and what fills it is the back of the collector. + * + * §1.4 gave this cap "heavy glare management" and no view, on the assumption that + * it faces the sun. It does not face the sun — it faces the **collector**, which is + * 41.8° across from here against the sun's 0.53° and sits squarely between the two. + * Tower is inside its umbra permanently, so no direct sunlight has ever reached + * this cap and there is nothing here to manage (D-050). + * + * What there is instead is a 244 km disc of unlit structure. Its front is a mirror + * pointed at the sun; its back radiates the collector's own waste heat at about + * 245 K, which is infrared and therefore black. So the sky through this window is a + * black disc with stars around the edge of it — and the disc does not turn with + * them, because the collector flies in formation rather than spinning. + */ +const SUN_CAP_FRAG = /* glsl */` #include <common> #include <logdepthbuf_pars_fragment> uniform vec3 uTint; +uniform sampler2D uGlazing; varying vec3 vWorld; ${HAZE_GLSL} +${STAR_GLSL} void main() { #include <logdepthbuf_fragment> + vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5; + vec4 frame = texture2D(uGlazing, uv); + vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure; + + vec3 dir = normalize(vWorld - uCam); + vec3 sky = starField(dir); + // Cast on through the window to the collector's plane. Anything that lands on + // the disc is looking at its unlit back, which is as black as the sky is not. + float t = (${COLLECTOR_Z.toFixed(1)} - vWorld.z) / min(dir.z, -1e-4); + vec2 hit = vWorld.xy + dir.xy * t; + if (t > 0.0 && dot(hit, hit) < ${(COLLECTOR_R * COLLECTOR_R).toFixed(1)}) sky = vec3(0.0); + + vec3 face = mix(sky, mullion, frame.a); vec3 T = towerTransmittance(uCam, vWorld); - gl_FragColor = vec4(uTint * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0); + gl_FragColor = vec4(face * T + uHaze * uExposure * (1.0 - T), 1.0); #include <colorspace_fragment> } ` @@ -664,12 +694,12 @@ export class InteriorView { return this.groundKm(this.at) * KM } - private capMaterial(tint: number): ShaderMaterial { + private glazedCapMaterial(): ShaderMaterial { const m = new ShaderMaterial({ - vertexShader: FLAT_VERT, fragmentShader: CAP_FRAG, side: DoubleSide, + vertexShader: FLAT_VERT, fragmentShader: GLAZED_CAP_FRAG, side: DoubleSide, uniforms: { - uTint: { value: new Color(tint) }, - uPattern: { value: capPatternTexture() }, + uTint: { value: new Color(0xbcc2cc) }, + uGlazing: { value: capGlazingTexture() }, ...this.sharedUniforms(), }, }) @@ -677,12 +707,12 @@ export class InteriorView { return m } - private glazedCapMaterial(): ShaderMaterial { + private sunCapMaterial(): ShaderMaterial { const m = new ShaderMaterial({ - vertexShader: FLAT_VERT, fragmentShader: GLAZED_CAP_FRAG, side: DoubleSide, + vertexShader: FLAT_VERT, fragmentShader: SUN_CAP_FRAG, side: DoubleSide, uniforms: { - uTint: { value: new Color(0xbcc2cc) }, - uGlazing: { value: capGlazingTexture() }, + uTint: { value: new Color(0xffffff) }, + uGlazing: { value: sunCapGlazingTexture() }, ...this.sharedUniforms(), }, }) @@ -711,7 +741,7 @@ export class InteriorView { */ private buildCaps(): void { // The tint is a multiplier over the pattern, not the colour of the cap. - const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.capMaterial(0xffffff)) + const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.sunCapMaterial()) this.scene.add(sun) const far = new Mesh(new CircleGeometry(R, 128), this.glazedCapMaterial())