Drawing the Real Night Sky in a BrowserSection 10 of 10
Design Principles, in Summary
The five principles that run through decisions as different as astronomy and asset delivery, and how each of them connects back to one constraint: no server-side code.
The nine sections before this one — the big picture, computing star positions, coordinate frames, generating the data, drawing the atmosphere and the stars, In rendering, the distance from the camera to whatever is seen at a given pixel. Comparing these values is how a renderer works out what hides what. conventions, state management, delivery, verification — deal with problems that have little to do with one another.
The question here is a single one. Seen across all of them, how many principles do those decisions reduce to? The answer is five, and they come below in this order. Principle 1: where state lives is decided by how often it changes. Principle 2: exactly one actor may write any given value. Principle 3: dependencies between layers point one way. Principle 4: duplication is accepted where it keeps pages isolated. Principle 5: every bug met is written into the code, the docs and the tests. Principles 3 and 4 are close enough in subject that they share one heading; the rest have one each.
The constraint they start from is that Asterarium has no server-side code: no program runs in response to a request, and all the delivery servers hand back are files finished at build time. Principles 1, 3 and 4 — where state lives, the direction of dependencies, the isolation of pages — follow directly from that constraint. Principles 2 and 5, the single owner and the writing down of incidents, gained their weight under it. Without a single owner, the rule about where a value lives is overwritten by whichever writer runs last and becomes a formality; without incidents written down, the copies deliberately kept apart for isolation drift out of step unnoticed. So without principles 2 and 5, principles 1, 3 and 4 do not hold up in practice.
Principle 1: Where state lives is decided by how often it changes
State lives in four places, and the axis of classification is not what a value means but how often it changes. Settings that change on user action go into a A small state-management library for React: it builds the store, the single place where shared state lives. Each part of the interface declares which slice of that state it uses, so it is redrawn only when that slice changes. store; values that change frame — One image of a moving picture. Smooth motion needs roughly 60 of them a second, which leaves about 16 milliseconds to build each one. A coordinate frame is a different use of the same word. go into module singletons; the one scene as it stands at the moment of each share goes into the URL query string; and settings to carry into the next visit go into localStorage. Which value goes where, and how each is handed across, is the subject of the section "State and the Frame Loop".
Why the line matters is best shown by what happens when it is crossed. Put a per-frame value in React state and every frame triggers a re-render. At 60 frames a second a frame has about 16.7 milliseconds to spend (a division by 60, not a measurement), and the judgement that a re-render inside each of them is heavy rests on general reasoning, not on a measurement. Put a rarely-changing setting in a singleton, on the other hand, and the UI has to invent its own way of hearing about changes.
So the clock singleton that holds simulation time lives outside React, and the UI reads it through a In Asterarium, a way of carrying a value that changes every frame into the interface at a fixed low rate. To subscribe is to ask to be told whenever a value changes; here the interface side, the one being told, names the rate it wants, such as twice a second. The clock readout re-reads twice a second by default, so it is not rebuilt once per frame.. No timer of its own sits behind it. The per-frame call that advances the time is itself what drives the notifications, and that call comes from useFrame, the A library that lets a three.js scene be written as React components, so React reconciles 3D objects rather than HTML elements. hook that runs once a frame. A subscriber whose interval has not yet elapsed since its last notification is skipped for that frame.
useSyncExternalStore, React's own hook for subscribing to an outside store, is unusable here. It requires that reading an unchanged value hand back the very same object every time (the comparison is Object.is), and the clock returns a fresh object on every read, so the pair causes an endless re-render. In its place, the first value is read once in a useState initialiser and every later one arrives by calling setState from the subscription.
The principle is not "put everything in a singleton". On the interstellar flight, one of the standalone 3D page — In Asterarium, a 3D page that shares no scene code with the main sky or with the other such pages, and there are eight of them: the Moon globe, the moonwalk, the Mars globe, the deep field, the orrery, the interstellar flight, the eclipse viewer and the projector. Each one ships as its own bundle of code, so a visitor downloads only the page they opened., only position and attitude, that is, which way the ship points, live in the singleton; the selected star and the loading progress stay in React state. Low-frequency values belong in React, and that half of the line is as much a part of the rule as the other.
Principle 2: One owner, and a protocol for asking
For any value, exactly one actor may write it, and everyone else goes through the API that owner publishes. Camera orientation is the clearest case. Drags and wheel gestures are handled by the owner itself, and four further purposes ask for the camera to move: aiming from a search result or a share link, the auto-pan of In Asterarium, a full-screen state that hides every control and shows nothing but the sky. The interface fades out after a few seconds without input and comes back when the viewer moves., the device orientation sensor, and In Asterarium, a saved sequence of scenes that plays back on its own, each scene recording a place, a moment, a camera direction and the display settings. One scene is called a step, and every step carries its own transition and dwell time. playback. If those four wrote the angles directly they would overwrite one another within the same frame, and which one won would depend on the accident of execution order among React components.
The answer is to make the component that actually moves the camera its sole owner, and to let the four outsiders do nothing but call the entry points that owner publishes. Those requests have two properties.
- A request is a request, not a command. A smooth move started by the request that swings the view to a given direction (
cameraBus.slewTo) is cancelled the instant the user drags, scrolls or presses a key, and sensor input is ignored while a drag is in progress. However many automatic movers are added, direct user input always outranks them. - Handover of ownership is explicit. Simulation time is normally owned by the clock singleton, but during a tour transition that fast-forwards time the playback engine drives it. The engine drives the clock by interpolation, deriving the current time from nothing but the start time, the end time and the fraction elapsed, so a dropped frame or a jittery wall clock still replays the same scene the same way. That situation is marked by a flag named
driven, and while it is set the per-frame call that would advance time (clock.advance) does nothing at all. Rather than leaving it to implicit timing, the conflict is made into state and given a name.
The same shape appears in the section "Coordinate Frames and the Orientation of the Sky": the A rectangular array of numbers. In 3D it holds a coordinate transform, a rotation or a displacement, as one object, and multiplying transforms together collapses a whole chain of them into a single matrix. that orients the In astronomy, an imaginary sphere centred on the observer, used to describe the direction in which something appears. It carries no distance at all: it keeps the direction to an object and discards how far away it really is. In this article the word also names the container of 3D objects standing in for it, and since one scene unit is about a metre, the stars sit on a sphere of 1000 units, a kilometre in radius. is assembled in one place each frame, and all 38,000-odd stars turn by that single matrix. With one writer, nobody ever has to ask who wrote a value last.
Principle 3 and principle 4: One-way dependencies, and duplication chosen for isolation
Both of these are rules about which code may know about which. Principle 3 fixes the direction between layers; principle 4 accepts duplication in order to hold a boundary between pages. Principle 3 first, the one-way dependency. The In Asterarium, the modules that reference neither three.js, nor React, nor any state store: the astronomy maths and the code around it. They run without a browser, so testing them is a matter of handing in an input and checking the value that comes back. sits upstream, the scene layer and the UI layer downstream import it, and nothing points back the other way. Because the direction is fixed, upstream code can be tested without launching a browser. The testing structure described in the section "Verification", where that pure layer runs under a plain Node environment, only works because of it. The shared navigation, which every page includes and which imports no 3D code at all, is the same rule seen from another angle.
Principle 4 is the decision to accept duplication when duplication is what keeps the isolation. As described in the section "Delivery and the Standalone Pages", In this article, the name for the night-sky scene that opens on the site's front page. It draws the stars, constellation lines, the Milky Way, the Sun, Moon and planets through atmospheric scattering, seen from an observer standing on the ground. The name is there to tell it apart from the eight standalone 3D pages, separate pages such as the Moon globe and the projector which share no 3D code with it or with each other. scene, that is, the night sky on the front page, and the eight standalone 3D pages share none of their 3D scene code with each other. The page chrome, that is, the navigation and other parts every page carries, is the exception. Several implementations therefore exist in near-identical copies, and the reason for not sharing differs from case to case, as the three below show.
- The singleton behind a throttled subscription is written separately for the sky clock and for each standalone page. Here the reason is the bundle boundary, and which page loads which bundle is settled at build time. The comment atop the singleton holding the interstellar flight state says so outright: the shape of the sky clock is copied, not imported, because the sky store must never enter that bundle. One import line would drag the main store, and everything hanging off it, into what that page ships.
- URL encoding and decoding exists once in the module that reads and writes the main share link, and once per standalone page. This time the reason is not the bundle but the parameters: each page carries different ones. The A mechanical model that shows the planets circling the Sun. Asterarium borrows the name for a standalone page that looks down on the planetary orbits and where each planet is now. has its own clock and its own closed list of bodies to focus on, nothing the main sky could share a shape with. What they do share is only the idiom: clamp values into range, ignore garbage instead of throwing, omit anything left at its default.
- For the deep field, a standalone 3D page that flies among galaxies photographed by JWST, the projection that turns a The longitude-like angle on the sky, measured eastward along the celestial equator, which is Earth's equator projected outward, starting from the vernal equinox where the Sun's yearly path crosses it. It is conventionally written in hours, where 24 hours is 360 degrees. and The latitude-like angle on the sky, measured from the celestial equator, which is Earth's equator projected outward: positive to the north, negative to the south, up to 90 degrees. on the celestial sphere into a position on the flat image, together with the binary-format constants, is implemented separately on the runtime side and in the data-generation script. Two reasons here. The script reaches for Node-only facilities to read files and fetch source data, which the pure computation layer on the runtime side is not allowed to use. And a shared implementation would put the same mistake on both sides, where comparing them would still agree, whereas two independent ones surface a mistake as a disagreement. What this cannot catch is a mistake common to both, which is what happens when one person writes both from the same formula.
Accepting duplication means accepting the risk that two copies drift apart. That risk is managed by checking rather than by unifying the two implementations. For the duplicated projection, a test drives both with 1,000 pairs of right ascension and declination generated from a fixed seed and asks that the two results differ by less than 1e-12 arcsecond — A unit of angle equal to one 3600th of a degree. The full Moon is about 1800 arcseconds across., a trillionth of an arcsecond. That bound comes out of double-precision rounding. One rounding step in double precision is about 2.2e-16 times the value itself. What the test compares is not a sky coordinate but an offset from the field centre, and it keeps every point within 0.3° — about 1,000 arcseconds — of that centre, which puts a step at roughly 2e-13 arcseconds, so 1e-12 arcseconds is five of them: a few rounding steps wide, which is what showing the same arithmetic looks like. The number of pairs is simply a figure that keeps the test instantaneous. For the bundle boundaries, a script reads the JavaScript in the finished Building the whole site into HTML and JavaScript files ahead of time, so nothing is assembled per request; delivery is only the handing out of files that already exist. and looks, byte by byte, for each page's marker strings inside code that other pages load. How the markers are chosen, and how the check is kept from quietly passing on nothing once a marker is renamed, is described in the section "Delivery and the Standalone Pages".
Principle 5: Burn incidents into the code, the docs and the tests
The last principle is not about how code is structured but about what is written down in it. The camera The near clipping distance of a camera: nothing closer is drawn. The nearer it is placed, the less precision is left for distant objects, so its position decides how reliably far things sort. discussed in the section "Depth Rules and the Bugs Behind Them" is the model case: a single number, near, carries five lines of comment. What is recorded is not only the right value but the wrong one, the symptom, the cause, and where the full account lives. Its opening, the part that gives the reason for the value, reads:
// near=2 (NOT 0.1): every depth-writing far body (Moon/Sun/planets at
// 2e6) must keep near/z ≥ 1e-6 so its 24-bit MSAA depth never
// quantizes to 1.0In one sentence: the sky is a post-processing — A full-screen pass applied to the image after the scene has been drawn. Fitting brightness to what a display can show, and adjusting colour, both happen at this stage. In this article the sky itself is drawn as one of these passes. that runs after the scene is drawn, and it repaints only those pixel — One of the small squares a screen is divided into; a picture is the whole array of them. Colour is decided one pixel at a time, so the cost of drawing follows roughly from how many of them have to be filled. whose depth is still at the farthest value. The Moon is placed about 2,000 km from the camera. If it sat any closer than the top of the atmosphere, fixed at 60 km above the surface, almost no scattered light would be applied to it, and it would turn into a black disc against the daytime sky. Right at the top the scattering has not yet flattened out, so the body is pushed to more than thirty times that height. The real Moon is about 384,000 km away, far outside the The far clipping distance of a camera: nothing beyond it is drawn. Its ratio to the near clipping distance decides how finely distant objects can be told apart in depth, so it is set as tight as the scene allows. — the outer bound of what is drawn — so that distance is unusable; 2,000 km is a drawing distance that matches the apparent direction only. With the near plane left at 0.1, the depth of the Moon at that distance rounds until it can no longer be told apart from the farthest value, so the lunar surface is repainted as sky and flickers. Raising the near plane to 2 opens the gap and the symptom goes. That black-disc bug, the size of the rounding step, and the distances the stars and the Sun, Moon and planets are each placed at, are set out in the section "Depth Rules and the Bugs Behind Them".
There are three places to put it, and they play different parts. Two of them record. A comment in the code puts the history where anyone about to change the setting will see it. The design docs note, for instance, that the cut-off for discarding pixels that contribute almost nothing ranges from 0.0015 to 0.01 across objects. The quantity tested against it is usually The opacity value carried alongside a colour: 0 is fully transparent, 1 fully opaque., and for the Milky Way luminance times the In Asterarium, a number from 0 to 1 saying how far into night the sky has gone: 0 with the Sun on the horizon, 1 once it is 18 degrees below and the sky is fully dark. It fades the stars, the constellation lines, the Milky Way and the deep-sky markers together. The Moon stays an opaque disc, but its brightness follows this number squeezed into the range 0.25 to 1, so it shows faintly by day, while the planets come out on a separate threshold in solar altitude of their own.; the spread is recorded as the result of measured tuning against holes and flicker, which stops a later reader from tidying the values into one. The remaining place, an automated test that drives a real browser, does not record but prevents: it fails, and so blocks the change, when the symptom returns. Comments and docs can explain a regression; they cannot stop one.
From a constraint to principles, from principles to decisions
All five principles lead back to the constraint stated at the top of this page: no server-side code. What makes it concrete is one line of Next.js configuration, output: 'export', after which the build does nothing but emit finished static files into out/. Why that choice was made is the subject of the section "The Big Picture: No Server-Side Code". From there the chain runs as follows, keeping the split made at the top: where state lives, one-way dependencies and the isolation of pages follow directly from the constraint, while the single owner and the writing down of incidents are the two that gained weight under it.
- First, placing state by how often it changes: even with something running on a server, a round trip per frame would never arrive in time. Nor is there a route for precomputing the answers, since the combinations of place, time and viewpoint are endless and could never be shipped as static files. Every value that moves while the viewpoint or the time does therefore gathers inside the browser. Avoiding React re-renders therefore came first, which moved per-frame values outside React, and that widened into the discipline of placing state by how often it changes: a principle that follows directly from the constraint.
- Second, the single owner and its request protocol: once the per-frame writer sits outside React, the framework no longer decides who may write a value. Ownership has to be assigned by hand, which is what gave that protocol its extra weight under the constraint.
- Third, one-way dependencies: with no option of putting a layer on the server, every seam between computation and rendering has to be drawn inside the browser, and the direction between layers is then the only thing that still lets the computation be lifted out and checked on its own, so this rule too follows directly from the constraint.
- Fourth, duplication chosen for isolation: what ships is a prebuilt set of static files, and which code loads on which page is decided at build time by the configuration of the bundler alone. Nothing else guarantees that pages stay independent, so the constraint produced both the acceptance of duplication and the byte-level check over the built output directly.
- Fifth, writing incidents into the code, the docs and the tests: running entirely in the browser means there are no server logs to read when something breaks. Understanding comes only from reproduction and reading code, and having to keep that understanding is what gave writing it into the code, the docs and the tests its extra weight under the constraint.
One constraint thus selects five principles, and those five set the centre of gravity for the individual decisions. That chain is what gives code as different as astronomy, shader — A small program that runs on the GPU, the chip built for rendering, to compute where a vertex goes or what colour a pixel takes. It runs once for every vertex or every pixel it is handed. and deployment scripts a consistent feel. Choosing to run no server-side code narrowed what was possible: no accounts, and no public gallery of shared sky tours, because both would need storage that lives on a server. In exchange, it kept the basis for every decision down to a single thread. That is the end of the article; the contents of all ten sections, and the glossary of astronomy and rendering terms, are on its front page (/about/tech/).