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 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 of the sky for a given observer position and Sun direction. For the sky itself — 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 is handled. Its are -only, so they will not run on 's newer WebGPU backend. Its coordinate system is an Earth-fixed one, so something has to bridge it to a whose origin is the observer; that 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 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 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 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 into the Earth-fixed frame told us whether this app's own 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 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 , 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 , 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: 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>
Compositing the atmosphere and tone mapping (excerpt)
  • The frameBufferType setting 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.
  • AerialPerspective draws the atmosphere as a . 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 , 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 ToneMapping pass, in AgX mode, compresses HDR into displayable range. It is the counterpart of toneMapped: false. An arrangement without it certainly breaks, and what it produced was the black screen.
  • The flat attribute goes on Canvas, 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 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 — flat 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 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 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 star law (GLSL, excerpt)

The Size and Brightness Formulas

The three functions multiply together into one pixel. Pixel colour = star colour × core radiance × the value × the per-star × 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 baked from the 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 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 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 , 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 , 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 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 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))
Excluding the bright stars from the point path

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 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.