Drawing the Real Night Sky in a BrowserSection 5 of 10
Rendering the Atmosphere and the Stars
The midday sky that came out pitch black when a physical atmosphere model met the browser, and the law that turns a point source into a star the eye believes.
This part of the article answers two questions. Why does a physically correct model of Sunlight bouncing off the molecules and particles of the air and spreading in every direction. Blue light scatters most, which is why the daytime sky is blue, and a low Sun, whose light crosses far more air, turns red. produce a pitch-black first frame in a browser? And what, concretely, is the calculation that turns a star — a point source with no angular size at all — into something that looks like the naked-eye night sky?
The Sky Is Not Our Own Code
The colour of the sky is drawn by a library, @takram/three-atmosphere. It carries precomputed tables for the wavelength-dependent scattering off air molecules (why the sky is blue) and off aerosols (why there is a white smear around the Sun), and returns the The physical measure of how much light leaves a surface in a given direction. It is what a camera or an eye actually receives, so physically based rendering computes it. It has no ceiling: a daytime sky runs far above the 0-to-1 range a display is given. of the sky for a given observer position and Sun direction. For the sky itself — The stretch of time when the Sun is below the horizon but the sky is still lit. It deepens as the Sun sinks, so an evening runs civil twilight, down to 6 degrees below the horizon, then nautical to 12, then astronomical to 18. A morning takes the same stages in reverse, from astronomical through nautical and civil to sunrise. colour, the dimming near the horizon — not one line of colour maths lives in Asterarium.
The library constrains Asterarium in three ways: the choice of rendering backend, the coordinate system, and how The step that maps the wide range of brightness held in real ratios into the narrow range a display can actually show. The shape of the curve decides how colour and highlights read. is handled. Its 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. are The language in which WebGL shaders, the small programs that run on the GPU, are written. It has C-like syntax and built-in vector arithmetic.-only, so they will not run on The standard JavaScript library layered over WebGL, the browser 3D interface. It expresses a picture as scenes, cameras, geometries and materials instead of raw draw calls.'s newer WebGPU backend. Its coordinate system is an Earth-fixed one, so something has to bridge it to a world coordinates — The coordinate system that places objects in Asterarium's 3D scene: one unit is about a metre, the origin is the observer, and the axis convention is called NUE, for North, Up and East. Distances here are drawing conveniences, not real ones: stars go on a sphere of radius 1000 (about 1 km), and the Sun, Moon and planets sit outside it at two million (about 2,000 km). The layer of air the sky model draws is some 60,000 units, or 60 km, thick. whose origin is the observer; that 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. is in the section "Coordinate Frames and the Orientation of the Sky". And its materials — a material being the three.js object that collects how a thing is drawn — are marked toneMapped: false, so they emit raw High dynamic range: keeping brightness as real ratios instead of clamping it into a 0-to-1 range, so values many powers of ten apart can coexist in one image. radiance and bypass the renderer's tone mapping. That third one, bypassing tone mapping, is where the incident starts. To keep a future swap local, exactly one component, the one that assembles the atmosphere, may call the library at all.
The lookup table — A precomputed array of values that is read instead of recomputed. On the GPU it is held as an image, turning an expensive calculation into a single fetch. are self-hosted. The library defaults to raw file serving on GitHub, which is unacceptable in production on availability, on latency, and on CORS. So four An image format that stores brightness as real ratios instead of squeezing it into a 0-to-1 range. Its full name is OpenEXR, and it is the standard interchange format in film and CG work. images totalling about 7.7 MB are served by us and handed over as loaded texture objects; if handed a URL string instead, the library would fetch them from GitHub itself. These four do not go through the object storage on a separate domain that carries the big assets, described in the section "Delivery and the Standalone Pages": they sit on the same origin as the page. Self-hosting them costs nothing against this article's running constraint of having no code that runs on a server: an EXR is a static file that is served just by being there, so it rides the same delivery path as the page itself with nothing added.
The library's own star rendering is not used: it places the roughly 9,100 stars of its bundled data, from the Yale Bright Star Catalog, as points of one size and one intensity — no per-star size or colour, no constellation lines, no Milky Way, no labels. In the early prototype it was deliberately left on, though, as a cross-check: the way it rotates its stars from One of the reference epochs that say when a set of astronomical coordinates applies: noon Universal Time on 1 January 2000. Most star catalogues give their positions in it. into the Earth-fixed frame told us whether this app's own 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. turned the same way.
The Incident: A Pitch-Black Midday Sky
One caveat before the story: this is not an account of a cause found and fixed. Why the screen came out black is still unexplained. What was established is the arrangement that renders correctly instead, and what follows is how that arrangement was found and pinned down.
The very first prototype tried the most obvious arrangement: drop in the library's sky and let three.js's renderer do One of the curves used to fit brightness into what a display can show. It lets highlights lose their colour gradually, giving a roll-off closer to photographic film than the curves used before it. tone mapping as usual. The result, under the condition that should have produced the brightest possible image, was a black frame. That condition is a midday Sun at 75 degrees The angle of an object above the observer's horizon: 0 degrees at the horizon, 90 degrees straight overhead, negative when the object is below it., close to the roughly 78 degrees the Sun reaches at noon on the summer solstice in Tokyo.
The chain up to this point can be followed step by step. The library's materials output radiance directly, and daytime sky values run well above 1.0. Because they are toneMapped: false, three.js never injects its tone-mapping code into their shaders, so the renderer's AgX setting was never in effect at all. The destination is the 8-bit display buffer — one integer from 0 to 255 per channel — which can represent only values already mapped into 0 to 1. Write radiance there without passing through any compression step and the ratios between light and dark are gone.
The mechanism just described predicts a blown-out white frame, values pinned at the top of the range. What actually appeared was pitch black: that is the discrepancy flagged at the start of this part, the one never worked out. What was established is that changing the exposure — the multiplier applied to radiance before the tone curve — moved not a single 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., which means the tone-mapping stage was not being entered at all. That is not an inference from the toneMapped: false flag but an experiment. The multiplier that was moved is the same one the query parameter ?exp= overrides for tuning during development.
The arrangement that works needs four pieces together. The code below is JSX: A library that lets a three.js scene be written as React components, so React reconciles 3D objects rather than HTML elements. components plus the postprocessing library. One of the four, flat on Canvas, does not appear in the excerpt: Canvas is the drawing surface the whole scene is mounted on, and it sits one level outside the compositing code shown here. The four notes below describe each piece's role in an arrangement that works.
<EffectComposer frameBufferType={HalfFloatType}>
<AerialPerspective
sky={sky}
sun={sky}
moon={false}
sunLight={false}
skyLight={false}
transmittance={sky}
inscatter={sky}
/>
<ToneMapping mode={ToneMappingMode.AGX} />
</EffectComposer>- The
frameBufferTypesetting makes the intermediate buffer 16-bit floating point. Without it, radiance is clipped at 1.0 before it ever reaches tone mapping: the daytime sky and the cores of bright stars both flatten out, leaving nothing for the curve to roll towards white. AerialPerspectivedraws the atmosphere as 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.. It applies, to whatever was already in the buffer, both atmospheric transmittance (how much of the light from behind survives the air) and in-scatter (the light the air scatters into the line of sight along the way). Stars dim naturally through twilight without a single line of code on the star side. That dimming is physical extinction by the air, and it is a different thing from 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., which the star shader applies itself. Without the aerial-perspective pass nothing paints the sky at all, and even noon has a dark background.- The
ToneMappingpass, in AgX mode, compresses HDR into displayable range. It is the counterpart oftoneMapped: false. An arrangement without it certainly breaks, and what it produced was the black screen. - The
flatattribute goes onCanvas, the component that wraps a three.js drawing surface as a React element. It disables the renderer's own tone mapping. Since the pass above already compressed once, compressing again lowers the contrast of everything bright.
On the right-hand side, sky is a variable: a boolean for whether there is an atmosphere at all. The same variable goes to four attributes — the sky, the solar disc, transmittance and in-scatter — so one toggle switches between the ground view, with air, and the view from space with the atmosphere off. The latter is the "Atmosphere" switch in the menu's "Display settings" panel, which simply stops drawing the air; the observer does not move. sun follows sky so the library draws its physically correct solar disc, with our own code adding only a The spill of brightness around a very bright source, caused by light scattering inside an eye or a lens. It spreads wider than the source itself. on top. moon alone is always off, because we draw our own Moon and would otherwise get two. With the atmosphere off the background is filled with a near-black colour and the night factor is pinned to 1, so the stars are visible against it even at noon, and because tone mapping still runs their relative brightness is preserved as well.
sunLight and skyLight light the objects of a scene with sunlight and with the light of the sky. There is nothing here to light. Lighting also needs to know which way each surface faces — its normal — and the library gathers those in an extra full-screen pass of its own. That would be one more pass spent lighting nothing, so both stay off at all times.
The Star Law: Brightness Is Not Disc Area
The naive implementation — a brighter star gets a bigger disc — is wrong both physically and perceptually. A star is a point source, with no angular extent to the naked eye. Sirius nevertheless looks "big" because of atmospheric turbulence and because the optics of the eye spread a bright point. What the eye receives is a small, saturated core with a faint glare around it, so extra flux goes into radiance, not area.
One thing to say first: stars are drawn along two paths. Ordinary stars are drawn as a set of points, and bright ones as billboard — A four-cornered flat mesh turned so that it always faces the camera. Because it sets its own size it can spread wider than a point drawn by the GPU, which is what a broad glow needs. — flat quad — A single four-cornered flat surface, two triangles at its simplest. It is what a texture is pasted onto, or what covers the screen for a full-screen pass. kept facing the camera. Where the split falls, and why there is one, comes later on this page, under "Two Paths, Mutually Exclusive, and the Tuning Knobs". The law is used in three places: the celestial sphere drawn as points, the brightest stars drawn as billboards, and the planets. Move the boundary A number for how bright an object looks: smaller is brighter, and a difference of one magnitude is a factor of about 2.512 in brightness. Under a dark sky the naked eye reaches roughly 6 to 6.5. so that a star is handed off from point to billboard, or put a star next to a planet, and differing size laws would show as a visible step. So the GLSL text and its default constants live in a single shared module of plain strings and numbers, touching neither three.js nor React. The interstellar flight page — a 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. that flies between stars placed at their true distances — reads that one module too. These pages are meant to ship none of each other's JavaScript, so that none of the eight standalone 3D pages carries the others' code and loads slowly. This module pulls in no three.js, which places it inside the exception the rule makes. The rule, its exception and how the pages are split are described in the section "Delivery and the Standalone Pages". Two copies would satisfy the rule just as well. There is one because two would quietly drift until the same sky no longer matched itself. The GLSL excerpt follows.
float starSizePx(float intensity, float K2, float fovFactor, float dpr) {
return K2 * pow(intensity, uSizeExponent) * fovFactor * dpr;
}
float starCoreHDR(float intensity) {
return clamp(intensity, 1.0, uCoreMax);
}
float starPSF(float r) {
float core = exp(-r * r * 26.0);
float r0 = 0.10;
float rr = max(r, r0) / r0;
float skirt = uSkirtGain / pow(rr, uSkirtPow);
float psf = core + skirt;
psf *= 1.0 - smoothstep(0.75, 1.0, r);
return psf;
}The Size and Brightness Formulas
The three functions multiply together into one pixel. Pixel colour = star colour × core radiance × the The function describing how a point source of light is smeared out by an optical system. It is why a star looks like a small blur rather than a mathematical point. value × the per-star The opacity value carried alongside a colour: 0 is fully transparent, 1 fully opaque. × the night factor. The output alpha is the product of the last three of those: the point spread function value, the per-star alpha and the night factor. The blend is additive. The star colour is A way of holding colour in which the number is proportional to the amount of light. The sRGB values a display takes are bent to suit the eye, so light is added and multiplied in linear form and converted back at the very end. baked from the The difference between a star's brightness measured through a blue filter and through a yellow one. A larger value means a redder star and a smaller one a bluer star, which makes it a proxy for surface temperature. the catalogue carries, described in the section "The Data Pipeline". Because the blend is additive, that output alpha is never a compositing weight. It is used as a test instead: a pixel whose alpha falls below 0.003 hits discard, so it writes no depth either — a pixel that did write depth would be shielded from the pass that paints the sky, leaving a black hole in the daytime sky. How that was found out is in the section "Depth Rules and the Bugs Behind Them". Three factors are multiplied into the per-star alpha: the sub-pixel correction, which stops stars smaller than a pixel from flickering; the light-pollution fade, which removes faint stars as the sky brightens; and the twinkle. Each is described further down this page.
What follows is implementation detail. The names in the code above, uSizeExponent and uSkirtPow among them, are uniform — A value handed into a shader, the small program running on the GPU, that stays constant for the whole draw. The current time or the direction of the Sun are typical. handed in from outside. K2 is not: it is an argument of the function, and its value arrives from the uniform uSizeK, the name used for that gain from here on. There are six defaults: the size gain uSizeK is 3.0, the exponent uSizeExponent 0.25, the core cap uCoreMax 12.0, the skirt gain uSkirtGain 0.14, the skirt exponent uSkirtPow 2.6, and the amount of sparkle added around bright stars 0.25. The sparkle amount is the one default that does not appear in the excerpt above: it is applied in the shader that draws bright stars as billboards, outside the shared code shown here. The two remaining factors do separate jobs. dpr is the pixel density of the device, applied so that stars do not thin out on a high-density screen. fovFactor grows as the field of view narrows, taken as 1 at a 60-degree field. Since the drawn size is fixed in pixels, without it a star would shrink relative to the scene as you zoom in, so it cancels that out. What it holds steady is apparent size on screen; it is not a claim that the star has an angular size.
The entry point is magnitude. The The GPU program that decides where each vertex of a shape lands on screen. It runs once per vertex. computes intensity — a dimensionless brightness derived from magnitude, with magnitude 6.5 landing exactly on 1 — as pow(2.512, 6.5 - aMag), the definition of the magnitude scale itself, one step being a factor of about 2.512. The same formula lives in 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., as a general function that takes its reference magnitude as an argument: GLSL text and a TypeScript function cannot share code, so this one formula exists twice. The 6.5 itself does not: this sky and the interstellar flight page read one shared constant, and a test pins them to the same value. And 6.5 is a normalisation point, not a cutoff. The faintest star actually drawn is magnitude 6.8, under a sky with no light pollution — a little deeper than the naked-eye limit of about 6 to 6.5 in a dark sky. Nothing is cut off abruptly either: stars fade smoothly between magnitude 6.0 and 6.8, and are fully gone at 6.8.
That 6.8 limit holds at the upper quality tier — In Asterarium, a preset that changes the rendering load as a group. There are four, low, medium, high and ultra: low draws the 6,000 brightest stars, medium the 8,037 of the brighter catalogue, high and ultra all 38,168, and the tier also sets the pixel-density ceiling and whether stars twinkle. The level moves up or down on its own with the measured frame rate., the ones that load the full catalogue. The quality tier moves up and down automatically with the measured frame rate, and the lower tiers use a brighter catalogue that stops at magnitude 6.5, which then becomes the limit, and the lowest tier draws only the brightest 6,000 of those.
Size grows as intensity to the power 0.25 — the fourth root of flux. In this catalogue Sirius is magnitude -1.09, an intensity of about 1,100, which is more than 40 times the flux of a magnitude-3 star at about 25; the fourth root leaves its drawn diameter only about 2.5 times larger. Had area been proportional to flux, the diameter ratio would have been over 6 and the result an obvious blob. What is held back goes into radiance: the core is the intensity clamped into the range 1.0 to 12.0, and because the intermediate buffer is HDR the excess above 1.0 survives for the final AgX curve to roll towards white. The perception of "so bright it goes white" comes from the shape of the curve, not from painting the colour white. The lower bound of 1.0 is not there for faint stars: a star fainter than magnitude 6.5 still gets a core of radiance 1.0, and what makes it disappear is alpha, never radiance.
How the light spreads is decided by the point spread function, called starPSF in the code. Here r is the radius normalised to 0 at the sprite centre and 1 at its edge; the first term is a tight gaussian core, the second a power law — A shape in which a quantity falls off as some power of distance. It does not vanish as abruptly as an exponential, so it keeps a long tail, which is how the spill of light around a bright source behaves. glare skirt. In the glare models of Spencer et al. 1995 and the CIE, the veiling light scattered inside the eye falls off roughly as the inverse square of angular distance from a bright source. Those two are cited as the origin of the shape, not as physical justification for what is drawn. The exponent uSkirtPow is 2.6, steeper than the model's 2 so that the skirt does not spread too far. And r is a position within the sprite, not an angular distance. The r0 of 0.10 in the formula keeps the skirt from blowing up where r reaches 0 at the centre: everything inside radius 0.10 is treated as 0.10, which caps the skirt at uSkirtGain. A final line ramping the whole function down to zero is needed because a power law decays slowly and never reaches zero: about 0.0004 of the core is still there at the edge, and cutting it off there would end the light on the square boundary of the sprite. Ramping the whole function to zero between radius 0.75 and 1 ends it as a circle instead.
Faint stars need one more correction — the sub-pixel correction named above. Once the computed size drops below a pixel, whether the star is drawn at all is left to the luck of the pixel grid, and it flickers. So the drawn size is raised to a floor of 1.5 pixels and alpha is multiplied by the square of the ratio between the true diameter and that floor. The square of a diameter ratio is a ratio of areas, so the total light emitted is unchanged and faint stars fade out smoothly.
Light Pollution and Twinkle
Light pollution and twinkling are applied per star as well. The "Light pollution" slider in the menu's "Display settings" panel selects one of nine steps, 0 through 8, on a scale of our own that is not mapped to any published one. The limiting magnitude of 6.8 belongs to step 0, a sky with no light pollution; each step drops it by 0.55 magnitudes, reaching 2.4 at step 8. Step 8 stands, loosely, for a city-centre sky, but nothing was measured to establish that match. The fading band moves with it, so at step 8 stars fade between magnitude 1.6 and 2.4 and are gone at 2.4. Twinkle amplitude is scaled by altitude, near full at the horizon and nearly still at the zenith, because the longer the slanted path through the air, the stronger the scintillation.
Two Paths, Mutually Exclusive, and the Tuning Knobs
Stars are drawn along two paths: ordinary stars as a set of points, and bright stars as billboards. The split is at magnitude 1.7, and the catalogue holds exactly thirty stars brighter than that. The reason is hardware. Every GPU caps how large a point may be drawn, and the The web standard that lets a page draw 3D graphics with the GPU, the chip built for rendering, straight from the browser. No plugin is involved. specification guarantees only one pixel; real devices go far beyond that, but how far varies by model. A point also fills its whole square, core and skirt alike, so the larger it gets the more pixels it costs. The cap of 28 pixels across was not measured off any device: it is where the fill cost and the look were traded off. It does rely on more than the one pixel the specification guarantees, but on a device whose GPU cap falls short of 28 pixels the point size is simply clamped at that device's cap and grows no further. The glare skirt of Sirius or Vega, though, spreads wider than 28 pixels, which is why the brightest stars are drawn as billboards rather than points. Since those take the billboard path, a lower device cap can only affect the apparent size of the less bright stars. A billboard sizes its own quad, so the same computed value is clamped to a range of 6 to 30 pixels and used as the distance from centre to edge: up to about 60 pixels across, skirt included, twice what a point can reach.
The irregular sparkle around bright stars is added on billboards only, as five to seven streaks generated from a stable per-star random seed. The streaks stand in for the radial smear the eye itself produces when light scatters inside it around a bright point.
The problem is guaranteeing that no star is drawn twice. Rather than keeping a separate exclusion list, the approach feeds in a value that the existing draw law already renders invisible. Exactly one function answers which stars are in the bright set; the billboard path draws those, and the point path calls the same function and replaces their magnitudes with an impossible 99.
const magForPoints = data.mag.slice()
for (const i of brightStarIndices(data)) magForPoints[i] = 99
g.setAttribute('aMag', new BufferAttribute(magForPoints, 1))The intensity of magnitude 99 is essentially zero. The size law returns a sub-pixel size, the energy-conserving correction drives alpha to nearly zero, and the The GPU program that computes the colour of each individual pixel. Its cost grows with the screen area a shape covers, so large shapes are expensive. reaches its discard. With exactly one function making the call, moving the 1.7-magnitude boundary makes both paths follow at once.
Every threshold and coefficient above — the alpha cut at 0.003, the skirt exponent of 2.6, the 28-pixel cap on points, the magnitude-1.7 split between the two paths, the number of streaks, the limiting magnitude of 6.8 under a pristine sky and the 0.55 per step — was settled by eye against a live picture during development. Two of them — the exposure and the star law — can be overridden from the URL query string. ?exp= sets the exposure multiplier applied before the AgX curve, relative to a default of 1.0, so /?exp=1.5 renders half again as bright. ?starlaw= takes a comma-separated list that overrides the six defaults of the star law (size gain, size exponent, core cap, skirt gain, skirt exponent and sparkle amount, in that order) in one go.