Drawing the Real Night Sky in a BrowserSection 3 of 10
Coordinate Frames and the Orientation of the Sky
Why three coordinate frames coexist, why the observer sits at the world origin, and where the single matrix that turns the celestial group comes from.
Suppose the astronomy has told you which direction a celestial object lies in — its The angle measured along the observer's horizon eastward from due north: east is 90 degrees, south 180, west 270. and 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.. Where exactly do you put it in a 3D scene? This section answers four questions. Why does the project need three coordinate frames at once? Why is the observer, rather than the centre of the Earth, placed at the scene origin? Why is the rotation that turns the night sky borrowed from the atmosphere library instead of being derived from scratch? And why can the steps of the per-frame loop not be reordered?
Three Coordinate Frames, Side by Side
Three coordinate systems live in the scene at the same time, each with its own job. Mixing them up is a reliable source of bugs, so the boundaries between them are drawn explicitly. The 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. equatorial frame that star catalogues use takes its axes from the The point where the Sun's yearly path crosses the celestial equator, the projection of Earth's equator onto the sky, moving from south to north. Longitude-like angles on the sky are all measured from it. and the north celestial pole. 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. and The name of the axis convention Asterarium's 3D scene uses. With the observer at the origin, the X axis points true north, the Y axis to the zenith and the Z axis true east. The name is the initials of North, Up and East. are two names for one thing: the first names its role, the second names how its axes are chosen. North, up and east as X, Y and Z form a right-handed set — the arrangement you get by pointing the thumb, index finger and middle finger of your right hand along X, Y and Z — which is the handedness 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. assumes.
| Frame | Origin | Axes | Time-dependent? | Used by |
|---|---|---|---|---|
| J2000 equatorial | Formal only (directions, not places) | +X to the vernal equinox, +Z to the north celestial pole | No (frozen at the start of year 2000) | Star catalogue data, constellation lines, Milky Way, equatorial grid |
| ECEF (Earth-centred, Earth-fixed) | Centre of the Earth | Fixed to the Earth, turning with its rotation | Yes | Internals of the atmospheric-scattering library @takram/three-atmosphere |
| NUE (north, up, east) world frame | The observer | +X due north, +Y the zenith, +Z due east | No (as long as the observing site is unchanged) | The three.js scene and camera: everything on screen |
Two of the three come from outside: star catalogues are published in J2000 A coordinate system built by extending Earth's equator and rotation axis outward onto the sky. A position is given as two angles: right ascension, which works like longitude, and declination, which works like latitude. It does not turn with the Earth, so a star's values stay nearly fixed., and the atmosphere library does its work in Earth-Centered, Earth-Fixed: a rectangular coordinate system locked to the turning Earth, with its origin at Earth's centre, its Z axis along the rotation axis and its X axis towards longitude zero., neither of which we can change. Only the third, the coordinate system the scene and camera live in, was ours to design, and it is NUE: north, up and east as the axes, with the observer at the origin. The horizon is then the plane Y=0, azimuth is atan2(z, x) and altitude is asin(y).
The Observer Sits at the Origin for Floating-Point Reasons
The scene origin is the observer, not the centre of the Earth, in order to protect the precision of the coordinates sent to the GPU. One scene unit here is one metre: the stars sit on a sphere of radius 1000 (1 km) and the Sun, Moon and planets sit at a distance of 2,000,000 (2,000 km). The stars are therefore nearer than the Moon, the reverse of reality, which looks odd until you see what each placement is for: a star only has to carry a direction, whereas the Sun, Moon and planets have to sit outside the layer of air the atmosphere library computes scattering through — inside it they show up as black discs against a bright daytime sky. The library takes the length of the path through air from the 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. of each 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. and adds the light scattered along it, so an object with a short path picks up almost none of that light. The section "Depth Rules and the Bugs Behind Them" works this through in full. The stars do stay inside that layer, and it costs nothing, because stars are only ever drawn at night. Anything inside the layer picks up scattered daylight and extinction — the dimming of light on its way through air — during the day, and by then the stars, faded by 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. through 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., are gone. The extinction they pick up during twilight is used on purpose, as the natural fading of stars (see the section "Rendering the Atmosphere and the Stars").
Put the origin at the centre of the Earth and the ground under your feet can only be placed in steps of about half a metre. The reason is that three.js hands vertex positions to the GPU as 32-bit floats, a format whose representable values are spaced apart. Call the gap between two neighbouring representable values the step: at 2,000,000 (2,000 km), where the bodies sit, the step is 0.125 m, and at 6,370,000, one Earth radius, it is 0.5 m. The step is a length, an absolute quantity, which is why it grows coarser as the coordinate grows. The derivation runs as follows. The format keeps only 24 bits of The part of a floating-point number that carries its significant digits. Its width is fixed, so the spacing between representable values grows in proportion to the size of the number; a 32-bit float has 24 bits of it. — the part of a floating-point number that carries its significant digits — so taken as a fraction of the value the step is nearly constant everywhere, between one part in 8.4 million (2^-23) and one part in 16.8 million (2^-24). Within each power-of-two interval the step is constant and equals the lower end of that interval divided by 2^23. And 2,000,000 falls between 2^20 (about 1.05 million) and 2^21, while 6,370,000 falls between 2^22 and 2^23.
That half-metre hurts most right under your feet, because the ground, the nearest thing in the scene, is the thing carrying the largest coordinates: one Earth radius from the origin. The camera itself and the geometry near it — the ground disc, the region around the horizon — carry coordinates of about 6,370,000 (6,370 km). Once vertex positions can only be expressed in half-metre steps, the surfaces built from those vertices shift by that much, and once a surface shifts, so does the judgement of which surface is in front. The closer a surface is to the camera, the larger a half-metre step is compared with the gaps between surfaces, so a surface that should be in front can get judged as behind. Two surfaces swapping places from frame to frame like this is known as The flicker seen where two surfaces sit at almost the same distance and the judgement of which one is in front swaps from pixel to pixel, or from frame to frame. The cause is a difference in distance that cannot be represented: it happens when depth, the distance from the camera, is stored in coarse steps, and also when vertex coordinates are so large that rounding error grows.. The z-fighting meant here is not a matter of the precision of the GPU memory that records, for every pixel, the distance to whatever is currently drawn there. It is what lets a near object hide a far one. What it holds is not the distance itself but a normalised depth, mapped onto 0 to 1, with 1.0 meaning nothing nearer than the far limit., the per-pixel store of depth values. Even with depth recorded finely enough, the vertex coordinates themselves are rounded to a coarse grid, and that alone is enough to swap which surface is in front.
Distant objects, by contrast, stop being a concern once the origin moves to the observer, because they are placed as a direction vector times a distance, which turns a spacing in coordinates into a spacing in angle. The 0.125 m spacing at 2,000 km subtends about 0.013 arcsecond — A unit of angle equal to one 3600th of a degree. The full Moon is about 1800 arcseconds across.. The deepest zoom the app allows is a vertical field of view of 1 degree, so on a screen 1,000 pixels tall one pixel covers about 3.6 arcseconds: the error stays below a hundredth of a pixel. As a bonus, azimuth and altitude map straight onto the axes, so converting a horizontal coordinate into a scene direction needs no rotation at all.
The price is one coordinate-frame conversion between the scene and the atmosphere library. That conversion is worldToECEFMatrix, the world-to-ECEF 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., determined entirely by the observer latitude, longitude and elevation. It is built in two steps: convert the geodetic coordinates — A place on Earth given as latitude, longitude and elevation. The elevation is measured not from a true sphere but from an ellipsoid of revolution, the slightly flattened shape used to approximate the Earth. (a place given as latitude, longitude and elevation) to an ECEF point on the WGS84 ellipsoid — the standard geodetic model that approximates the slightly flattened Earth as an ellipsoid of revolution — then build a matrix whose basis is north, up and east with that point as its origin. Asterarium computes this matrix itself: it is assembled once per observing site and copied into the matrix of the same name inside the atmosphere library at the start of each per-frame update. From there on the read path runs through the library, but the value originates on our side. The library republishes that matrix, together with the matrix it derives from the date and the direction of the Sun, as the state it exposes for the frame — so 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. the scene reads back the very matrix it wrote.
function computeWorldToECEF(observer: Observer, result: Matrix4): Matrix4 {
const position = new Geodetic(
(observer.lonDeg * Math.PI) / 180,
(observer.latDeg * Math.PI) / 180,
observer.elevationM,
).toECEF(new Vector3())
// Basis columns (north, up, east), origin at the observer's ECEF position.
return Ellipsoid.WGS84.getNorthUpEastFrame(position, result)
}
// … and once per frame, before the date is advanced:
api.worldToECEFMatrix.copy(worldToECEF)That matrix does not depend on time, so it is recomputed only when the observing site changes. What every frame actually needs is the opposite direction: a rotation from ECEF into the world frame. Inverting a general matrix is real work, but here it is enough to extract the rotation part and transpose it. This works because a matrix whose columns are the mutually perpendicular unit vector — A vector whose length is exactly 1, so that it carries a direction and nothing else. It is the natural way to hold a quantity like where a star appears, which is a direction without a distance. north, up and east is orthogonal, and the inverse of an A matrix whose axes are mutually at right angles and all of length 1. Its inverse is simply its transpose, the same numbers with rows and columns swapped, so the reverse transform costs nothing to obtain. is its transpose. Of the constraints that come with treating the transpose as the inverse, the only one a person has to uphold is that the columns stay perpendicular. The other one — that no scaling may ever be baked into this matrix — is enforced by the implementation, since the three.js call that extracts the rotation, extractRotation, normalises each column to unit length. Nothing checks perpendicularity; it holds by convention alone. The translation part — the observer position in ECEF, about 6,370 km — is dropped, because what is being transformed is a direction, not a place.
Borrowing the Matrix That Turns the Celestial Group
The orientation of the entire star field collapses into a single matrix, rewritten once per frame. The stars, constellation lines, Milky Way, deep-sky object — A collective name for the diffuse things beyond the solar system that are not single stars: nebulae, star clusters and galaxies. Most of them need binoculars or a telescope. and equatorial grid are all children of the In Asterarium's 3D scene, the three.js Group, a container that moves its children as one, standing in for the celestial sphere. The stars, the constellation lines, the Milky Way and the deep-sky markers are its children, so overwriting the matrix that holds its orientation turns all of them together., the three.js Group that stands in for 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., so setting that group matrix turns all of them together. Its value is the product of two rotations: ECEF into the world frame, and J2000 into ECEF. three.js treats coordinates as column vectors — a coordinate written as a single column of three numbers — so the rightmost matrix applies first: a J2000 position is turned into ECEF, and that result is then turned into the world frame. The second rotation, inertialToECEFMatrix, is borrowed as-is from the atmosphere library, which computes it from the current date. The inertial in that name is the J2000 inertial frame: a coordinate system fixed with respect to the stars, which does not turn with the Earth.
The per-frame work is a handful of lines, and three names run through it. api is the handle the atmosphere library component exposes: it is what the earlier excerpt wrote worldToECEFMatrix into. transient bundles up the three things read back off that api every frame — two matrices and the direction of the Sun — whose values the library refreshes each frame. extractRotation is the three.js call that then works on a matrix taken out of transient. scratch holds the matrices and vectors allocated once at mount and reused thereafter, and celestial is the handle on the celestial group. setMatrix is a thin in-house wrapper that overwrites the matrix of the underlying three.js group; automatic matrix updates are switched off, so the scene owns that value outright.
const { worldToECEFMatrix, inertialToECEFMatrix, sunDirection } = transient
// ECEF-to-world ROTATION only (strip the ~6.37e6 m observer translation).
scratch.ecefToWorld.extractRotation(worldToECEFMatrix).transpose()
// Orient the celestial group.
if (celestial) {
scratch.groupMatrix
.copy(scratch.ecefToWorld)
.multiply(inertialToECEFMatrix)
celestial.setMatrix(scratch.groupMatrix)
}Deriving the rotation ourselves was a genuine option. 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. — the astronomy modules that reference neither three.js nor React — already provides local Earth's rotation angle expressed as a clock, measured against the vernal equinox, the crossing of the celestial equator and the Sun's yearly path, rather than against the Sun. Because it is an angle written in units of time, 24 hours is 360 degrees and one hour is 15 degrees. In almanac terms, where a star appears is set by this value rather than by solar time, though whether a given implementation uses it directly is another question. (Greenwich sidereal time taken from the library plus the observer longitude), and the spherical trigonometry involved is a few dozen lines. The reason for borrowing instead is not accuracy but consistency.
Two kinds of rendered output share the screen: the sky drawn by the library (daytime blue, the gradient of twilight, the darkness of night) and the star field drawn by Asterarium itself. The first is governed by the direction of the Sun, which the library derives from the date. If the diurnal motion of the stars were computed by a second, independent implementation, the two could drift apart over details such as the The slow circling of Earth's rotation axis, one turn in about 26,000 years. It carries the vernal equinox, the zero point of sky coordinates, along with it, so every star's coordinates drift as well. model or the choice between mean and apparent sidereal time. Pick the same models and the gap is small — but update only one side, or differ in some implementation detail, and the direction in which the sky brightens stops agreeing with the direction in which stars set. That is the kind of bug whose cause is hard to reach from the symptom. For the orientation of the celestial group, at least, a single implementation decides it, so no such disagreement arises.
The direction of the Sun is obtained along two separate paths, for two different purposes. The positions of the Sun, Moon and planets as drawn on screen come from the astronomy Asterarium computes itself and include atmospheric refraction — The bending of light by Earth's atmosphere, which makes an object appear higher than it really is. The lift is largest near the horizon, where it reaches about 34 arcminutes, or 0.57 degrees. — the shift that makes an object appear higher than it is, caused by the bending of light in air. The solar altitude used to fade the stars through twilight comes instead from the direction of the Sun published by the atmosphere library, and carries no refraction. The paragraphs below take each in turn.
The Sun, Moon and planets live outside the celestial group. Each frame they are computed as A coordinate system based on the observer's own horizon, giving a position as two angles: azimuth, measured round from north, and altitude, measured up from the horizon. The same star takes different values from another place or at another time. that already include refraction, and altitude and azimuth turn directly into a world direction vector. No J2000 rotation is involved; putting them inside the group would apply its matrix a second time and wreck their positions.
The atmosphere library does expose its own ECEF directions for the Sun and Moon, but those are purely geometric and refraction-free. Refraction is about 34 arcminute — A unit of angle equal to one 60th of a degree, that is, 60 arcseconds. (0.57 degrees) near the horizon. The Sun moves roughly 15 arcminutes per minute of time along its daily path; how fast it actually sinks is a fraction of that, depending on latitude and season, and around sunset at mid-latitudes such as Japan it comes to some 10 arcminutes per minute. Divide 34 arcminutes by that rate: using the library directions as they are would put sunset about three minutes off. The separate path is a deliberate choice made in order to include refraction. The gap between the two paths is the sum of that deliberate refraction and the difference between the A table of where a body is at each moment, or the model that computes those positions. Two different ephemerides give slightly different positions for the same moment. models the two implementations use. That second difference should be on the order of arcseconds, since both follow modern ephemerides.
One quantity is taken from the library anyway: the solar altitude used to fade the stars through twilight. How the stars disappear should track how bright the sky is, so it follows the Sun as seen by whatever is drawing that sky. The cost is that the Sun on screen (refracted) and the reference for the fade (unrefracted) stay about 34 arcminutes apart. The fade follows the solar altitude from 0 down to -18 degrees, so the shift it causes is of the same order as the one at sunset: a few minutes. Agreeing with the brightness of the sky was judged the more important of the two.
The Per-Frame Order Is Fixed by Dependencies
A library that lets a three.js scene be written as React components, so React reconciles 3D objects rather than HTML elements. offers useFrame, a React hook whose callback runs once for every frame drawn. Exactly one component in this scene uses it, and the steps inside it cannot be reordered. In order: the update of the 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., the advance of simulation time, the atmosphere update, the orientation of the celestial group, and finally the night factor together with the placement of the Sun, Moon and planets.
- The tour update runs before the clock advances so that the absolute time a tour jump writes is visible to the atmosphere and the celestial group within the same frame. Reversed, the change would show up one frame late.
- The atmosphere cannot update until the time is settled: both the solar and lunar directions and the J2000-to-ECEF rotation take the date as input.
- The orientation of the celestial group reads that rotation matrix, so running it before the atmosphere update would use last frame's value.
- The night factor and body placement depend on both the settled time and the freshly updated solar direction.
Alongside the ordering there is a second rule: this loop creates no new objects. Matrices and vectors are allocated once at mount and reused, and the helper that converts horizontal coordinates into a world direction writes into a vector passed to it rather than returning a new one. Piling up objects that are thrown away every frame invites garbage collection to run in a burst and drop a frame. Until the atmosphere materials — the settings that describe how a surface is drawn — are ready, the call that reads the library state returns null, and the loop simply does no work and waits for the next frame. That "still null" answer is also the project-wide signal that the scene is not ready.
In short, the only freedom left between two coordinate systems that could not be changed was the definition of the world frame, and choosing an observer-centred NUE frame bought floating-point precision, a direct handling of horizontal coordinates, and a single matrix for the whole celestial group in one decision. None of this follows directly from the constraint of having no server-side code. But since every calculation happens in the browser, the precision problem lands there too, and it showed up here as a choice of origin made to suit a GPU that takes its coordinates as 32-bit floats.