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 and . 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 equatorial frame that star catalogues use takes its axes from the and the north celestial pole. and 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 assumes.

FrameOriginAxesTime-dependent?Used by
J2000 equatorialFormal only (directions, not places)+X to the vernal equinox, +Z to the north celestial poleNo (frozen at the start of year 2000)Star catalogue data, constellation lines, Milky Way, equatorial grid
ECEF (Earth-centred, Earth-fixed)Centre of the EarthFixed to the Earth, turning with its rotationYesInternals of the atmospheric-scattering library @takram/three-atmosphere
NUE (north, up, east) world frameThe observer+X due north, +Y the zenith, +Z due eastNo (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 , and the atmosphere library does its work in , 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 of each 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 through , 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 — 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 z-fighting meant here is not a matter of the precision of the , 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 . 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 , determined entirely by the observer latitude, longitude and elevation. It is built in two steps: convert the (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 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)
The wrapper component around the atmosphere library (excerpt)

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 north, up and east is orthogonal, and the inverse of an 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, and equatorial grid are all children of the , the three.js Group that stands in for the , 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)
}
The component that owns the per-frame work (excerpt)

Deriving the rotation ourselves was a genuine option. The — the astronomy modules that reference neither three.js nor React — already provides local (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 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 — 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 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 (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 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

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