Drawing the Real Night Sky in a BrowserSection 6 of 10

Depth Rules and the Bugs Behind Them

The sky is painted as a post-processing pass keyed on depth, so every depth decision shows up on screen. The bugs that appeared, and the rules they left behind.


Most of the rendering rules in Asterarium were derived backwards, from bugs that actually appeared on screen. The reason comes down to one fact: here the sky is not a background object drawn first, but a that runs after the scene is drawn and reads the . That single fact very nearly dictates how stars, the Moon and the planets must treat . Everything here is about — the night-sky scene that opens at the top of the site, called that throughout this article to keep it apart from the eight . The standalone 3D pages, such as the Moon globe, run under rules of their own, and they are covered at the end of this page, under "The Standalone Pages Follow Different Rules".

The Sky Paints Every Pixel Left at Far Depth

The mechanism underneath is this. The atmosphere, drawn by the library @takram/three-atmosphere, is a single post-processing pass that reads the depth buffer by pixel. A pixel still at the far depth is taken to be infinitely distant and its colour is replaced outright by the of the sky. That is a replacement, not an addition: whatever had been drawn there is gone. Every other pixel is dimmed by the air between the camera and the point at that depth, and has the light scattered into that stretch added to it. The top of the atmosphere is fixed at 60 km above the surface, and a point beyond it saturates at the value for the top, so pushing it further out barely changes the result. That is why the Sun, the Moon and the planets, parked about 2,000 km away, receive very nearly the scattered light that the sky beside them is painted with.

The bar for "still at the far value" sits right at the end of the depth scale: only pixels whose is at or above 1.0 - 1e-8 get repainted as sky. One part in a hundred million is finer than a single step of the depth buffer, so the test effectively means "pixels whose depth is still exactly 1.0". For compositing scattered light that is a reasonable rule; for the objects drawn in front of it, it is a hard constraint.

The rule that follows comes in a pair. A celestial object must write depth, and any nearly transparent whose contribution is negligible must be thrown away with a . One without the other does not work. Skip the depth write and the sky simply overwrites those pixels. In practice, that is exactly what happened once: the whole star field vanished. Write depth without discarding the near-transparent fragments and you get the opposite: depth is written where nothing was drawn, so the sky is never composited there and a black rectangle is left in the daytime sky. It stays black because the pixel is never repainted as sky, and because at a radius of 1,000 — about a kilometre, one sixtieth of the 60 km top of the atmosphere — the atmosphere library reads it as a point with almost no air in front of it and adds almost no scattered light. On this page, that symptom is called a dark box.

  float alpha = psf * vAlpha * uVisibility;

  // …
  if (alpha < 0.003) discard;
The star shader (excerpt)

The excerpt comes from near the end of the star shader. psf is the value of the , vAlpha is the derived from that star's brightness, and uVisibility is the . Why, then, does a depth-writing star not itself leave a dark hole in daylight? Because by day its alpha is zero: the discard above throws it away and no depth is written. The star shader multiplies its final alpha by the night factor, which is 0 whenever the Sun is at or above the horizon. By day every star therefore falls below the threshold, and the sky paints those pixels normally. Constellation lines and the Milky Way share the same night factor. There is one exception: the view from space with the atmosphere off, where the night factor is pinned to 1 whatever the of the Sun. The name says space, but it is only a display toggle that stops the air from being drawn; the observer never moves.

The standard advice in 3D is that additively blended transparent objects should not write depth, because doing so unfairly rejects other transparent objects drawn later. Here that advice is deliberately broken. Between stars it is safe: the that make up the bulk of the field and the used for the thirty or so brightest stars are all placed on the same sphere of radius 1,000, and they blend additively, so which of the two draws first cannot change the result. The also keeps its default comparison, so stars sharing a depth never reject one another. Between objects at different radii, that unfair rejection does occur. The draw order of the Sun's , the planets and the stars is therefore pinned by hand with renderOrder, the number that tells when to draw an object.

The discard threshold differs per object. Most of them look at alpha directly. The Milky Way looks at the luminance of its colour — brightness as the eye weights the colour components — multiplied by the night factor. That colour has already been dimmed for light pollution, the glow of town lights that washes out faint objects. The horizon glow in the table below, the band those same lights lay faintly along the horizon, is judged on the brightness of that band itself. None of the numbers in the table came from theory: each was settled on screen, as the lowest value that left no holes and no flicker.

Three things can still be drawn by day: the planets, the horizon glow and the ground disc. That none of them leaves a dark box follows from the same rules. (The Moon sits outside the atmosphere, and is covered under the next heading.) The planets have visibility 0 while the Sun is more than 2° above the horizon, so not one pixel of them is drawn. The planets' own visibility curve, which is not the night factor the stars use, runs from +2° down to −10° — more permissive than the stars' night-factor threshold, which is what lets Venus hold up against a bright sky. The horizon glow takes its strength from the amount of light pollution times the night factor, so by day it too is 0 and every fragment falls under the threshold. Of the three, only the ground disc actually puts pixels on screen by day, and it is opaque geometry inside the atmosphere — a disc of radius 2,000, sunk by 3 units below the horizontal plane. It is never a pixel to be repainted as sky but one that gets the dimming and the scattered light of the air in front of it, which is why it takes on the colour of the sky at sunset.

ObjectDepth writeDiscard when
Stars (point sprites and billboards)yesalpha < 0.003
Planetsyesoutside the disc, or alpha < 0.003
Sun glareyesoutside the disc, or alpha < 0.004
Moonyesnever (opaque disc)
Milky Wayyesluminance * night factor < 0.0015
Constellation linesyesalpha < 0.003
Deep-sky objectsyesalpha < 0.01
Horizon glowyesband brightness < 0.004
Labelsnonever (handled separately)

Bodies Above the Atmosphere, and a Near Plane of 2

The next bug was that the Sun, the Moon and the planets appeared as dark holes against a bright daytime sky — the Moon a dark disc, Venus a dark dot. The cause is that the atmosphere library derives the length of the path through the air from each pixel's depth. At the time the Sun, the Moon and the planets sat on the same sphere as the stars, radius 1,000, about a kilometre out. A kilometre against 60 km of atmosphere is almost no air at all, so almost no scattered light is applied to those pixels. The surrounding sky, at far depth, gets the full depth of the air and turns bright; the pixels of the body do not.

The fix was to move the bodies above the atmosphere. One unit in this scene stands for one metre, and they were moved from radius 1,000 out to 2,000,000. The pixels of a body are then treated as a point outside the air, and receive very nearly the scattered light the surrounding sky receives. The distance went up by a factor of 2,000 and each body is drawn 2,000 times larger to match, so its size on screen is unchanged. The camera is the eye of the observer, and it sits at the origin. Ordered by distance from it, with the clipping planes and the objects listed together, the scene now runs as follows.

  • The : 2, about two metres. Nothing closer is drawn
  • The ground disc: 3 units, about three metres, measured when you look straight down (a disc of radius 2,000, sunk by 3 units below the horizontal plane)
  • The label sprites: radius 990. The constellation lines and the : radius 995
  • The carrying the stars and the Milky Way: radius 1,000, about a kilometre
  • The top of the atmosphere: 60km above the surface
  • The Sun, the Moon and the planets: 2,000,000, about 2,000km, more than thirty times further out than the top of the atmosphere
  • The : 1e7 units, ten million units, about 10,000km

One distance is missing from that list: the real distance to the Moon, about 384,000 km. It falls far outside the far plane, so it cannot be used, and pushing the plane out that far would only make depth precision worse. The price of the fix is that the bodies now lie outside the sphere the stars sit on, so in depth order the stars are in front. At night, faint stars bleed across the disc of the Moon, which is about 0.5° across. That was judged negligible next to compositing daytime and twilight correctly, and accepted at first. It was solved later, without depth, by a geometric test, described under "Not Every Overlap Is a Depth Problem" later on this page.

That distance of 2,000,000 then caused a bug of its own. Zoomed to a 2° field of view, the Moon showed black gaps across its face, flickering whenever the camera moved. The gaps were triangular because a GPU draws a surface as a mesh of triangles, and the repainting happened one triangle at a time. The cause was depth , and the fix was to raise the camera near plane from 0.1 to 2. That was all. Where those two numbers come from is the rest of this story.

The far plane sits far beyond the near plane, so the normalised depth of a point at camera distance z is well approximated by 1 − near/z. The near plane was then 0.1, and at z = 2,000,000 that makes near/z equal to 5e-8. But the smallest step a 24-bit depth buffer can represent is about 5.96e-8, which is one part in 2^24. The gap between the depth of the lunar surface and the far value of 1.0 was smaller than a single step, so it rounded to 1.0 exactly — and the sky test, reading that as infinity, repainted the lunar surface. With the near plane at 2, near/z becomes 1e-6, about seventeen times that smallest step: a comfortable margin.

        dpr={[1, 2]}
        gl={{ antialias: true, powerPreference: 'high-performance' }}
        // …
        camera={{ fov: 60, near: 2, far: 1e7 }}
Camera setup for the sky scene (excerpt)

What the rule states is not that computed factor of seventeen but the round lower bound of 1e-6. The two textbook remedies for depth precision are a logarithmic depth buffer and reversed-Z, and neither was available here. A logarithmic depth buffer — storing depth on a logarithmic scale to even out steps that otherwise coarsen with distance — has the rewrite the depth value itself. The pass that paints the sky reads that value back and turns it into a distance, so on a rewritten scale neither the length of the path through the air nor the test for sky comes out right. And reversed-Z — swapping the far value to 0 and the near value to 1 so that the fine steps of floating point land far away — is not supported by the build of the atmosphere library. What remains is a one-line rule: any depth-writing distant body must keep near/z at or above 1e-6. A near plane of 2 also means that nothing within two metres of the camera is drawn. That costs nothing here, because the closest object is the ground disc that hides stars below the horizon, and it is sunk by 3 units, deliberately deeper than the near plane, so looking straight down never opens a hole in it.

Not Every Overlap Is a Depth Problem

Pushing the bodies outside the sphere of the stars meant stars showed through the lunar disc at night. Solving that with depth would conflict with the placement rule just established. So it is solved without depth, by a geometric test inside the shader. the direction and the of the Moon and of the Sun are handed to the ; if the of a star's world direction with that of the body exceeds the cosine of that radius, the star lies inside the disc and its alpha is set to 0. The decision is made in the vertex shader, the disappearance happens in the fragment shader: an alpha of 0 is passed straight through and the final threshold test discards it, writing neither colour nor depth. Every direction is passed as a by convention, so a dot product can be compared directly against a cosine. When no direction for the Moon or the Sun is available, the convention is to pass a zero vector, and dot(uMoonDir, uMoonDir) > 0.5 — the dot product of the vector with itself — is the test for whether one was supplied: 1 for a unit vector, 0 for a zero vector, with 0.5 as a threshold placed between them. Doing that check and the comparison against the star for each of the two bodies costs at most four dot products per star. The field holds about 38,000 of them — the whole slice taken from the catalogue, everything brighter than 8 — so that comes to at most about 150,000 dot products per frame.

  if (dot(uMoonDir, uMoonDir) > 0.5 && dot(worldDir, uMoonDir) > uMoonCosR) {
    alpha = 0.0;
  }
  if (dot(uSunDir, uSunDir) > 0.5 && dot(worldDir, uSunDir) > uSunCosR) {
    alpha = 0.0;
  }
The star shader: hiding stars that fall inside the lunar or solar disc

The apparent radius is padded by a factor of 1.15. A star is not drawn as a mathematical point but as a small image spread by its point spread function, so one just outside the limb, the edge of the disc, would still smear its wings onto the disc. The 1.15, like the thresholds in the table, was settled by eye on screen. Inside the Sun's glare, stars are not hidden but dimmed, by a separate expression: the closer a star lies to the centre of the glare the more smoothly it fades, scaled by how bright the glare currently is, so on a glare-free night the star field is untouched. That reproduces how the eye sees a bright bloom swallow its neighbours, and it also hides a mismatch. A star at a distance of 1,000 and the glare at a distance of 2,000,000 receive their atmospheric correction separately, so the star would otherwise sit inside the glare as a hard, badly matched dot.

The first dark box was the one under the previous heading: the Sun, the Moon and the planets left dark against a bright daytime sky. The second appeared around Venus. Planets are drawn as square point sprites. The wings of the point spread fall off as a , so for a point as extreme as Venus — magnitude −4.5 at its brightest — the alpha stays above the 0.003 threshold all the way to the corners of the square. Those corners then write depth and the sky cannot composite there. The fix clips the square sprite to a circle — cutting off what falls outside it — with a discard at the top of the fragment shader for anything beyond the unit disc.

The third was not a dark box but a missing glare: inside the rectangle of a star's sprite, the Sun's glare dropped out. It shows up in the two situations where stars and glare share the screen. One is the view from space with the atmosphere off, described earlier: turning the air off does not stop the glare billboard from being drawn — in that view the same billboard also draws the solar disc itself — and the night factor is pinned to 1, so both are always on screen. The other is twilight, and that window is narrow: stars only appear once the Sun is below the horizon, and the strength of the glare reaches exactly zero at an altitude of −1°. So it is the one degree between 0° and −1° during which the wings of the bloom, reaching above the horizon, share the screen with stars. The glare is a single billboard placed at the position of the Sun, spreading out to eight times the apparent solar radius, roughly 2°.

The cause was sorting. three.js sorts transparent objects by the distance from the camera to the centre of each . But the stars sit on a sphere centred on the observer, and the camera sits at that centre, so the distance is zero and the order becomes arbitrary. When a star draws first, its square writes depth — and the brightest stars are drawn as billboards whose alpha stays above the discard threshold across much of the rectangle, so the area that writes depth is that much wider. Inside that area the far more distant glare failed the depth test. The fix was to state the order explicitly with renderOrder: lower values draw first, so the glare is at −2, the planets at −1, the stars at the default 0. The planets come after the glare because a planet near the Sun sits at the same radius of 2,000,000 as the glare does. Equal depths pass the depth test, but the same radius does not give exactly the same depth along the line of sight — the surface of a sphere is not a plane, so equal radius is not equal distance along a given ray — and which of the two comes out in front depends on where the camera is looking. Laying the glare down first and adding the planet on top removes any chance that a planet's depth write rejects the glare behind it.

Labels Moved to a Scene of Their Own

The labels that name stars and constellations reached their present form after two separate symptoms. In the first, constellation names floating in empty sky appeared while star names pinned to bright stars did not. Labels then sat on the same sphere as the stars, radius 1,000, with the depth test still on. A label therefore landed at all but the same depth as the depth-writing star beneath it, failed the test and vanished; in empty sky no depth had been written, so the constellation names came through. The code now carries two measures against that. The label radius is pulled in to 990, 0.99 of the star sphere, so labels are unambiguously in front; and the depth test on the label sprites is switched off altogether, because a label is an on-screen annotation rather than a celestial object and should always composite in front. Which of the two came first is not recorded. The one part of occlusion that still matters — hiding anchors below the horizon — is an explicit per-frame visibility check rather than anything to do with depth.

Labels do not write depth either: the sprite textures have transparent margins, and depth written there would punch holes in the sky. That, however, produced the second round of symptoms — in daylight, every label vanished. It is a direct consequence of the first rule of this section: a pixel left at far depth is painted over with sky. With no depth written, the label pixels stay at the far value and the atmosphere pass replaces them with sky radiance, and because it replaces rather than adds, the text drawn there is gone. At night the overwrite went unnoticed, since what it paints is a very nearly black sky; only against a bright daytime sky did the labels visibly disappear.

The final fix was to take the labels out of the post-effect chain altogether. The sprites live in a scene of their own, drawn with the same camera after the atmosphere has been composited. For that draw, autoClear — the three.js default of wiping both the colour and the depth buffer before every render — is switched off, and only the depth buffer is cleared. Clearing colour as well would erase the sky that had just been composited. The labels themselves neither read nor write depth, so they always land on top of what is already there. One side effect: the labels are no longer children of the , so they cannot inherit the rotation that stands in for the Earth's spin. They therefore copy the world matrix of the celestial group — the matrix that gives its final position and orientation once every parent has been applied — onto themselves each frame, and so turn with the stars from inside a separate scene.

The Standalone Pages Follow Different Rules

All of the above applies only to the night-sky scene. The same site hosts standalone 3D pages — such as a Moon globe, a deep-field viewer, an , an eclipse view — that run under their own rules. What separates them is whether the scene receives input. The sky scene takes raw radiance from the atmosphere library, so it needs a post-processing chain, and its depth rules follow from that. The Moon globe page has no atmosphere and no HDR light source; its shader applies exposure itself — scaling the light it has to a brightness the display can show — and writes display-ready colour. A near plane of 2, or bodies parked at 2,000 km, mean nothing to a scene holding a single sphere of radius 1.

The deep-field page is the instructive contrast. Its sprites are already baked to display-ready colour at build time, so at render time would be a second pass over the same pixels. Tone mapping is therefore switched off on the renderer, the part of three.js that actually issues the draw calls. The sky scene switches the renderer's tone mapping off as well, and for the opposite reason: there the curve is applied once at the end of the post-processing chain, so the renderer must not apply one of its own; here it has been applied already. What decides the convention is not the setting but the question behind it: what range of brightness does this scene take as input? Each bug described above, with its symptom, its cause and the alternatives that were rejected, is written down in the source, beside the constant that caused it. Why that is done is in the section "Design Principles, in Summary".