Celestial Model
How AstraTerra turns a world clock into positions in the sky. Every renderer — starfield, constellation overlay, sky grid, sextant, astrolabe — reads from this one pipeline, so a sign error here moves the entire sky at once. So it is important we are aligned. Somethings may diverg from vanilla when not defined, and making the assumptions we are in a spherical world.
Coordinate Conventions
Get these wrong and everything downstream is subtly mirrored.
| Quantity | Convention |
|---|---|
| Altitude | Degrees above the horizon. 0 is the horizon, +90 the zenith. |
| Azimuth | Degrees clockwise from north. North 0, east 90, south 180, west 270. |
| World X | East is +X. |
| World Y | Up is +Y. |
| World Z | North is -Z, south is +Z. |
A horizontal coordinate becomes a world direction as:
That negated Z is the whole reason north maps to -Z. StarRenderModel,
SkyCoordinateGridModel, and SkyBodyModel all use this identical form, and
SkyBodyModel.FromWorldDirection is its exact inverse.
The Pipeline
1. Local sidereal angle
CelestialMath.GetVanillaAlignedLocalSiderealAngle converts the world clock into the angle the sky
has turned:
solarLongitude = turns since the March equinox * 360
solarRightAscension = eclipticToEquatorial(solarLongitude, obliquity).rightAscension
localDayFraction = wrap(totalDays + longitude / 360)
solarHourAngle = (localDayFraction - 0.5) * 360
sidereal = solarRightAscension + solarHourAngle
The sun transits at local noon, so at that instant sidereal time equals the sun's right ascension — not its ecliptic longitude. Those two angles agree at the equinoxes and solstices but differ by as much as about 2.47° between them at Earth's obliquity. Treating longitude as right ascension rotated the stellar sky almost ten clock minutes away from the sun at the worst point in the year.
The daily term is a fraction of a complete rotation rather than 15° per named clock hour. A
16-hour world and a 30-hour world both turn through 360° per world day, put the sun on the meridian
at their own local noon, and apply longitude as the same fraction of one rotation.
Sidereal time must increase with time
Hour angle is sidereal - rightAscension,
so if the sidereal angle decreases as the day goes on, the hour angle decreases too and the
entire sky — stars, constellations, deep-sky objects, grid lines — rotates west to east.
If you would like to make a custome world, solar system you can modify to have the place rotate in a different direction.
The seasonal term also means the sidereal day is slightly shorter than the solar day. Solar right
ascension gains a full extra turn over one world year. Its instantaneous rate varies slightly with
season because the obliquity transform is nonlinear; code that needs a position evaluates the
sidereal angle at the requested timestamp rather than advancing it with a fixed 15° rate.
The visible sun is the measurement authority
CelestialMath.GetVanillaAlignedSolarEquatorialCoordinates describes the survival sun in the same
equatorial frame as the stars. Its right ascension comes from the ecliptic-to-equatorial rotation.
Its declination mirrors Vintage Story's survival formula, tilt * sin(solarLongitude), with the
same seasonal phase and shared axial tilt.
That declination is intentionally the one the game draws, rather than a second solar ephemeris.
IGameCalendar.GetSunPosition remains the authority for the sun's actual world direction, and the
sextant and astrolabe continue to measure that vector directly. Projecting the shared equatorial sun
with the sidereal angle is tested against the survival model to within the brass sextant's
one-arcminute scale across seasons, latitudes, longitudes, day lengths, and year lengths.
This is visual and instrumental agreement with Vintage Story's solar model, not a claim that its uniform seasonal orbit is a precision Earth ephemeris. In particular, the survival declination sinusoid differs from the exact obliquity rotation by up to about 0.26°, and the uniformly advancing season omits the real orbit's equation of centre. Replacing the visible sun with a higher-order ephemeris would be a separate compatibility decision; the star field must not do so by itself.
2. Hour angle to horizontal coordinates
CelestialMath.GetHorizontalCoordinates is standard spherical astronomy:
H = sidereal - rightAscension
alt = asin( sin(dec) sin(lat) + cos(dec) cos(lat) cos(H) )
az = atan2( -sin(H), tan(dec) cos(lat) - sin(lat) cos(H) )
A negative hour angle puts an object east of the meridian and climbing; a positive one puts it west and sinking. That single fact drives rising/setting classification and the astrolabe's transit countdown, which counts down from right ascension to sidereal time:
3. Horizontal coordinates to a drawable body
SkyProjection.Project is the last leg, and it is deliberately generic: it takes an
EquatorialCoordinates and a visual magnitude and knows nothing about what kind of object it is
placing. It applies the horizon cutoff, the fade band, the magnitude curve, and the world direction,
and returns a RenderedBody.
Everything that has a position in the sky goes through it. RenderedStar is that body plus a HIP
number, a colour temperature and a guide-star flag; a planet or comet is that body plus its own name
and tint. Deep-sky plates share the horizon handling and the direction but not the magnitude curve,
because their brightness is authored per object — a nebula is a surface brightness spread over
degrees, not a point source.
One projection, or the sky drifts apart
A body that moves is drawn against the fixed stars, so the two must be placed by the same arithmetic. A second copy of the horizon fade or the magnitude curve for planets would look right on its own and wrong next to a star at the same altitude — and nothing would fail.
SkyProjectionTests.A_Moving_Body_Lands_Exactly_Where_A_Star_At_The_Same_Position_Does
pins the two paths together.
Bodies That Move
A catalog star's right ascension and declination are constants. A planet's, a comet's and a radiant's are functions of world time, and that is the only difference between them.
ISkyEphemeris is that function, and nothing more:
Implementations live in Astronomy/ and stay pure, which is what lets an orbit be checked against
published positions for real dates without a running game. FixedEphemeris covers anything that does
not move, so a caller collecting bodies to sight or to forecast never has to know which kind it holds.
Positions are geocentric: they do not depend on where the observer stands. Latitude and longitude enter afterwards, in the projection.
From the ecliptic to the equator
Planets and comets are computed in the plane they orbit in, not in the plane the sky is drawn in.
CelestialMath.EclipticToEquatorial rotates between the two by the obliquity of the ecliptic,
CelestialMath.MeanObliquityDeg (23.4392911°, Earth's mean tilt at J2000).
It rotates a unit vector rather than using the textbook
atan2(sin l cos e - tan b sin e, cos l). Same rotation, but that form divides by zero at the
ecliptic poles — which planets never approach and a steeply inclined comet can.
The same constant is what the sun's seasonal declination needs, so both should read it from here rather than each carrying a tilt of their own.
The planets
PlanetEphemeris solves two orbits per sample — the planet's and the observer's — and subtracts
them. Retrograde motion is not scripted anywhere; it is what that subtraction does when the world
overtakes a slower planet on the inside, and a player charting Mars over a couple of world weeks will
watch it happen.
Elements come from JPL's Approximate Positions of the Major Planets, six per body plus per-century
rates, accurate to arcminutes across 1800–2050. KeplerianOrbit implements JPL's own formulation:
advance the elements, take the mean anomaly, solve Kepler's equation by Newton–Raphson, place the
body in its orbital plane, rotate that plane into the ecliptic.
Magnitude is H + 5 log10(r · delta) plus a linear phase term. Venus swings by more than a magnitude
over a world year, which is the point of modelling it at all.
The star brightness curve saturates before the planets do
SkyProjection.GetBrightnessFromMagnitude is deliberately compressed for stars and reaches full
brightness at magnitude 0.4. Every planet but Saturn spends most of its time brighter than that,
so left alone, Venus at -4.9 and Mars at its dimmest would be drawn identically.
PlanetRenderModel.GetBrilliance keeps responding across the range planets occupy, and the
renderer spends it on the glow rather than the core. Anything else added to the sky brighter than
magnitude 0.4 needs the same treatment, or the sky flattens out at the bright end.
What a telescope resolves
A planet is a point of light to the naked eye and a disc through a glass, and those are two different
drawing problems. PlanetRenderModel answers the first; PlanetDiscRenderModel answers the second,
and it runs only while the scope is raised — nothing below is computed for a player looking up.
It reads three things off the same ephemeris the sprite already uses:
| Quantity | Where it comes from | What it produces |
|---|---|---|
| Distance | GetGeocentricPosition(t).Length |
The disc's angular width, so Mars swells towards opposition |
| Phase angle | PhaseAngleAt(t) |
Which authored face is drawn: full, half or crescent |
| Sun direction | The calendar's own sun | Which way the crescent points |
Faces are pictures, not shaded models, and each is drawn lit from the left. The quad is built with its horizontal running away from the sun's projected direction, so a crescent Venus turns its lit limb towards the sun wherever the sun happens to be.
The moons of Jupiter and Saturn ride on circular orbits in their parent's equatorial plane, which is
within a few degrees of the ecliptic — so they string out along the ecliptic tangent at the planet,
opened into an ellipse by moonPlaneTiltDeg for Saturn and left nearly edge-on for Jupiter. Their
periods are quoted in real days, the same clock WorldEpoch puts the planets on, so Io still
laps Callisto nine times whatever the world's day length. The third component of the orbit is depth,
and its sign is why it is computed at all: a moon on the far side of the swing, inside its parent's
disc, is dropped rather than drawn — so the count beside Jupiter changes over a night.
Everything resolved is drawn 7.5x larger than life
PlanetDiscRenderModel.DiscExaggeration is the one departure from the real sky in this pass, and
it applies to every body and every orbit alike, so the relationships hold: Saturn's globe stays a
third of its ring span, Callisto stays four times as far out as Io. The factor is fixed by the
eyepiece rather than by taste — any larger and Callisto's ten-arcminute swing leaves the precision
telescope's field, which is exactly when a player can no longer count the moons.
A moon is exempt in one direction only: enlarged with everything else it would still be under a
pixel, so it is held at MinimumMoonWidthDeg, the size the stars beside it draw at.
A planet with a disc drops the sprite that stood in for one — at any magnification worth using, the sprite is several times wider than the planet and would erase it. What is left is a halo just outside the disc, which is both what an eyepiece shows and what keeps the planet findable at low power.
The world epoch
Orbits are published against Julian centuries past J2000, so a world day has to be worth something
in real time. Two decisions in WorldEpoch fix it, and both are visible in the sky:
| Decision | Value | Consequence |
|---|---|---|
| A world year is a Julian year | RealDaysPerWorldYear = 365.25 |
Jupiter takes ~12 world years on a 12-day world and a 360-day world alike |
| World time zero is the start of the world year | WorldZeroOffsetDaysFromJ2000 = 78.816 - 0.2226 x 365.25 |
The planets share the seasons the rest of the mod already keeps |
The epoch follows the world year, not the equinox and not J2000
GetSolarLongitudeDegrees reads zero at the March equinox, and that equinox falls
CelestialMath.SpringEquinoxYearFraction (about 22%) into a world year rather than at its start.
Anchoring the planets anywhere else starts them that far round Earth's orbit from where the world
says its own sun is — every planet in the wrong season, a body at opposition drawn near the sun,
and nothing failing anywhere.
The two suns still differ by up to about 4° of ecliptic longitude, because the seasonal model
advances at a constant rate while the real world speeds up at perihelion. That is the equation of
centre, it is worth about a quarter of an hour of transit timing, and
PlanetEphemerisTests.The_Sun_The_Planets_Are_Measured_From_Tracks_The_Sun_The_Seasons_Use
holds it there.
Sampling: once a world minute, not once a frame
An orbit costs real arithmetic; a planet moves by arcminutes over a world hour. CachedSkyEphemeris
wraps any ephemeris and quantizes the sample to the world minute, the same trick
AstrolabePlannerRenderer uses for its sunrise search.
Two differences from that one are worth knowing:
- The world minute is the whole key. The sky clock also buckets on player position, because sunrise depends on latitude. A geocentric position does not, so there is nothing to add here.
- The sample is taken at the top of the minute, not at the instant asked for, so every caller inside a minute is told the same thing whoever asked first.
Only one sample is remembered. A caller that interleaves times — the astrolabe forecast scrolling
ahead while the sky renders the present — should hold its own instance rather than share one, or
every call misses. That is not hypothetical: AstrolabePlannerRenderer and SextantReadingRenderer
each build and keep their own, precisely because the astrolabe asks about forecast times while the
sky asks about now.
Instruments point at an ephemeris, not at a position
AstrolabeTarget carries an ISkyEphemeris rather than a right ascension and declination, because
the instrument exists to answer questions about other times: it scrolls hours and days ahead, and a
planet is somewhere else by then. A recorded constellation supplies a FixedEphemeris and behaves
exactly as it did before.
One approximation is left in deliberately. hoursUntilTransit holds the target's right ascension
where the reading found it, rather than solving the transit against the ephemeris. A planet drifts
under half a degree a day, so a night's countdown is a couple of minutes out — below anything the
instruments can show. A comet near perihelion would not be, and that is where the solve has to become
iterative.
Season-Anchored Events
Anything that should happen at the same point in the year — a meteor shower peak, a seasonal constellation window — must be anchored to solar longitude, never to a day of the year.
CelestialMath.GetSolarLongitudeDegrees is the seasonal term of the sidereal angle, extracted so the
two cannot drift apart. It is the sun's right ascension at local noon, which is what makes it stand
in for solar longitude:
solarLongitude = ((totalDays - equinoxDay) mod daysPerYear) / daysPerYear * 360
equinoxDay = daysPerYear * SpringEquinoxYearFraction
Day zero of a world year is the first of January, not the equinox
Vintage Story runs its own sun as -tilt x cos(2*pi*(yearRel + 10/365))
(SurvivalCoreSystem.GetSolarSphericalCoords), so the sun crosses the equator northward about
22% of the way into the world year — the same place the calendar starts calling the season
spring. CelestialMath.SpringEquinoxYearFraction = 0.25 - 10/365 is that anchor.
Anchoring the year at day zero instead put the whole sky about 80° of solar longitude ahead of
the world it is drawn over: Betelgeuse came to the meridian at midnight on day 269 — late
September on a 360-day world — instead of mid-December.
CelestialMathTests.Betelgeuse_Comes_To_The_Meridian_At_Midnight_In_December pins it.
A day of the year is not a season
daysPerYear is world configuration. Vintage Story's default is 108, but a world can be
created with 12 or 360. An event pinned to "day 224" therefore lands in a different season on
every world, while 140° of solar longitude is late summer on all of them.
`MeteorShowerActivityTests` pins it across 12-, 108- and 360-day
years, and those tests fail loudly if day-of-year anchoring is reintroduced.
Since testing is not automated yet, this kinda error can lead correct on the world you happened to test and wrong everywhere else. Specially if you test in a flat world.
Measuring distance from an anchor
Use CelestialMath.ShortestAngularDistanceDegrees, never a plain subtraction. A window straddling
0°/360° otherwise reads as nearly a full turn wide rather than a few degrees, and the event silently
never fires — or fires all year.
A short year compresses every window
Because windows are angular, their length in days scales with daysPerYear. A 10° window is about
3 days on a 108-day year but 0.33 of a day on a 12-day year, where it can fall entirely in
daylight and never be observable. That is inherent to anchoring on the angle rather than a defect,
but anything user-facing built on this should account for a window that may be shorter than a single
night.
Turning a shower rate into visible streaks
MeteorShowerActivity.ReadAll is the runtime boundary between the world clock and the client visual:
it combines solar-longitude proximity, radiant altitude, moon phase brightness, and the same natural
darkness used by the starfield.
A published ZHR is meteors per hour of real watching, so MeteorShowerVisualModel spends it over
3600 real seconds rather than over a world hour. An observed rate of 120 therefore averages one
streak every 30 real seconds — watching a shower is meant to be a patient thing. The two clocks
differ by more than a factor of twenty: a default world hour passes in roughly two real minutes, so
spending the rate over the world hour instead would produce a meteor every second or so and read as
a storm on every ordinary night. DebugMeteorRateMultiplier in the client config exists to compress
that wait during development, and it scales only how often meteors spawn, never which shower they
come from.
Streak positions are generated around the radiant on the celestial sphere. Their tangent always points away from it, and angular length grows with radiant separation. The renderer turns each frame into a tapered four-section ribbon on the sky sphere and submits all active streaks in one mesh. Transient positions are client-local visual state; the shared astronomical conditions remain fully deterministic.
The Milky Way
The band is the one part of the sky that is not a position. Everything else in the model — a star, a
plate, a planet, a meteor radiant — is projected one body at a time through SkyProjection. The
Milky Way is the light of the stars the catalog stops short of, spread over a quarter of the sky at
once, so it is drawn as the sphere itself rather than as an object on it.
Galactic coordinates, once
GalacticFrame holds the J2000 rotation between galactic and equatorial coordinates, and it is the
only place in the mod that knows about it. MilkyWayRenderModel.ProjectBand walks a coarse grid in
galactic longitude and latitude, rotates each vertex into the equatorial frame, and hands it to the
same horizontal-coordinate and world-direction code every other body uses.
Two landmarks pin the rotation, in GalacticFrameTests: galactic (0, 0) has to land on Sagittarius A*
at 17h 45m, -29°, and galactic (180, 0) on the anticentre at 05h 46m, +29°. Get the rotation wrong by
a sign and the band still looks like a band — it simply rises in the wrong season, over the wrong
horizon, and nothing else in the sky disagrees with it.
The glow is a texture, the mesh is only a sphere
Every feature of the band — the bulge, the Great Rift, the clouds, the reddening — lives in
assets/astraterra/textures/environment/milky-way.png, an equirectangular map in galactic
coordinates. So the mesh carries none of it: 72 by 36 cells is enough that a great circle does not
read as a polygon and that the horizon fade is smooth along it, and the texture is sampled per pixel
regardless.
The map is generated, not photographed. tools/milkywaygen integrates an exponential disc with four
logarithmic arms and a flattened bulge along every line of sight from the Sun's place in it, dimmed
by a thinner, flatter dust layer in front, and tone-maps the result. That keeps the asset ours, and
it means the band's shape can be argued with in parameters — a scale height, a dust opacity — rather
than repainted.
It answers to the night, not to the star bias
MilkyWayVisibility.CalculateOpacity is deliberately not the star pass's brightness bias. A star is
a point source and survives a bright sky; surface brightness is the first thing any sky glow takes
away. So the band is gone below TwilightFloor darkness, and a full moon removes most of what is
left. A Milky Way that ignored either would read as a decal painted on the sky.
MilkyWayBrightness in ModConfig/astraterra.json scales the result, and zero switches the band off
entirely.
The Sun And The Moon
The moon is not modelled by AstraTerra, and the sun's latitude and seasonal motion still come from
Vintage Story. Both are read through IGameCalendar.GetSunPosition and GetMoonPosition, so the mod
measures the bodies the game actually draws rather than maintaining a second visible-sun model. The
moon is read at the observer's own instant rather than the world's wherever AstraTerra draws it —
see the moon's picture for why longitude cannot reach it
any other way.
When longitudeAwareSun is enabled, AstraTerra wraps the solar delegate that Vintage Story's
survival mod installed. The wrapper changes only dayRel, by longitude / 360 of one rotation, and
then calls the captured delegate with the original position and year. The survival delegate therefore
continues to own latitude, axial tilt and seasonal declination while local solar noon shifts with X.
The server's astraterra.json is authoritative for this flag and sends the decision to every client;
a remote client's local copy cannot make its sun disagree with the server's daylight calculation.
Both return a unit vector in the same world space described above, with Y = sin(altitude).
Vanilla's vector looks mirrored but is not
GetSunPosition builds Z as sin(zenith) * cos(azimuth) with no negation, which reads as if
vanilla referenced its azimuth to +Z (south). It does not. Its zenith angle is
2pi - acos(sin(altitude)), which lands in the fourth quadrant where sine is negative, and
that factor supplies the minus sign. Expanded, vanilla returns exactly
```text
X = cos(altitude) * sin(azimuth)
Y = sin(altitude)
Z = -cos(altitude) * cos(azimuth)
```
— the same convention as the table above, with azimuth already measured clockwise from north.
`SkyBodyModel.FromWorldDirection` therefore needs no rotation, and the same `atan2(X, -Z)`
serves both vanilla bodies and the mod's own star directions.
Do not "fix" this by rotating vanilla's azimuth by 180°. Checked against the survival mod's
`GetSolarSphericalCoords` at 45°N: the sun reads 92.5° (east) at 06:30, 180.0° (south) at noon,
and 267.5° (west) at 17:30.
Both accept a totalDays argument, so positions can be sampled into the future. The astrolabe
relies on this to move its clock along with its forecast.
Replaceable solar motion and startup order
IGameCalendar.OnGetSolarSphericalCoords is a public settable delegate. The calendar constructor
starts with a degenerate placeholder, the survival mod replaces it during client LevelFinalize and
server GameReady, and any other mod may replace it again.
AstraTerra registers for those same ready stages and defers its assignment until the stage's handlers have finished. It then captures and chains the delegate it found. Client and server use separate installer instances, including in integrated single-player, and disabling or disposing the wrapper restores the captured delegate only when AstraTerra's own wrapper is still installed.
This is cooperative rather than exclusive ownership. A sun mod already installed at the ready stage
is preserved and receives the longitude-shifted dayRel; a mod that assigns the property later wins
and clobbers AstraTerra's wrapper. In the latter case the sextant and astrolabe still follow the newly
visible sun because they call GetSunPosition, and the star field returns to universal time on the
next frame rather than drifting away from it: ObserverLongitude asks the installer whether the
calendar is still holding AstraTerra's own wrapper, so losing it removes the longitude term from the
whole sky at once. Passing both the shifted time and the original posX also means a
third-party delegate that already applies longitude would apply it twice; such a mod should disable
longitudeAwareSun on the server or coordinate delegate ownership explicitly.
The base survival delegate's seasonal declination is retained by the wrapper. The separate solar-declination issue tracks what, if anything, AstraTerra's independent sidereal approximation should say about that motion.
The moon's picture, and only its picture
MoonDiscRenderer draws the moon from the surface portrait the player asked for — pixel art, a
photograph of the real one, or Vintage Story's own disc — and asks the game to put its disc away
except when vanilla was chosen, through the same moonScale suppression a moon world uses. Nothing
else changes:
where the moon is, what phase it is in, how bright the night is and how the phase advances are still
the calendar's, read fresh each frame — so anything that moves the game's moon moves this one. The
planets keep SolarSystemArt; the moon overhead is MoonArt. The surface mesh stays level while
per-vertex lighting turns independently towards the sun. That separation keeps Tycho fixed in the
portrait and gives intermediate calendar phases their own curved terminator instead of rotating and
snapping a pre-phased photograph.
| Question | Answered by |
|---|---|
| Where | GetMoonPosition(pos, LocalMoonTime.MoonTotalDays(...)) |
| Surface | The full portrait selected by MoonArt |
| Phase | Continuous VanillaMoonPhase.ExactAt lighting in MoonDiscMeshBuilder |
| Which way up | The portrait stays level through MoonDiscModel.SurfaceRightAxis; only its light turns sunward |
| How wide | AngularDiameterDeg: 7°, matching Vintage Story's own disc |
The moon is the one body the longitude-aware sun cannot take with it
IGameCalendar.GetMoonPosition(pos, totalDays) forwards only pos.Z, and there is no moon
counterpart to OnGetSolarSphericalCoords. Left alone, a world with the wrapper installed would
keep the moon on universal time while the sun, the daylight and the star field moved off it:
about an hour of drift per 8,300 blocks travelled east at the default polarEquatorDistance,
which is a full moon rising near noon after a long enough walk, and a moon standing against the
wrong constellations.
The wrapper's shift is a pure offset in time, so LocalMoonTime.MoonTotalDays reads the game's
own moon a fraction of a day later instead — position and phase together, through the calendar's
own GetMoonPhase, with VanillaMoonPhase interpolating the game's brightness table at that
same instant so the sextant offers the moon it is actually drawing. That is only done where
AstraTerra draws the moon itself. Under
moonArt=vanilla the game draws its own disc, so the mod leaves the moon on world time and the
sextant goes on measuring the body actually in the sky; the moon then keeps vanilla's single
time zone while the rest of the sky does not, which is the cost of not owning the disc.
Moonlight stays on the world's moon, and so do eclipses
GetDayLightStrength takes its sun from the longitude-aware delegate but its moon from the
cached GetMoonPosition(z), which no observer position reaches. Night brightness therefore
follows the world's moon while the disc overhead is the observer's, and vanilla's eclipse
darkening compares the local sun against the universal moon. Moving those would mean patching
the calendar's cached moon angles themselves — which also moves vanilla's own disc, and has no
single answer on a server with players at different longitudes. Tracked separately.
Vintage Story's own moon is about 7° across — 256 quad units at moonScale * 1.1, hung at distance
50. AngularDiameterDeg = 7.0 keeps the replacement photograph at that familiar apparent size. This
is about thirteen times the real moon's half-degree diameter, and it overflows the precision
telescope's field at high magnification, so a scope raised on it shows a crop of the lunar surface.
The night side is dark, and darkness is a hole in a daytime sky
The mesh dims the unlit side to faint earthshine, which is what the moon is: after dark it is drawn opaque and takes a bite out of the star field behind it. By day the same dark disc would be a disc of night punched into a blue sky, so the pass crossfades to additive blending as daylight comes up — darkness adds almost nothing, and only the lit face survives, which is also what a daytime moon looks like.
Two draws of one quad, and the reason the moon is a pass of its own rather than part of the star pass: the star pass stops at dawn, and the moon does not.
The Sky Clock
SkyClock.Read reports the hour, the phase of the day, and how long until the next horizon
crossing. It takes the sun altitude as a delegate rather than computing one, which keeps it pure and
testable while letting callers feed in Vintage Story's real sun.
| Phase | Sun altitude |
|---|---|
Day |
above 0° |
Dusk / Dawn |
between -6° and 0°, split by whether the sun is climbing |
Night |
below -6° (civil twilight) |
Sunrise and sunset are found by walking forward at most one world day in five-minute steps for a horizon crossing, then bisecting the bracketing interval. The finer sweep matters near the polar circles, where the sun can rise and set again inside the old 15-minute interval. Both results are nullable: at a polar day or polar night no crossing exists, and the astrolabe says so rather than inventing an hour.
Clock time on the astrolabe is local apparent solar time at the observer's longitude, as long as
that longitude reaches the sky at all: ObserverLongitude.ForObserver reads zero whenever the
visible sun is not the longitude-aware one, and every reading collapses to the world's single solar
time with it. The world's
internal clock stays universal; CelestialMath.GetUniversalSolarTimeHours reads that clock and
CelestialMath.GetLocalSolarTimeHours shifts it by (longitude / 360) * hoursPerDay. Which hour the
character panel shows is configured separately via displayedClockTime in astraterra.json:
local (the default), universal, or zones. Zoned time divides the circumference into one
whole-clock-hour step per zone, so custom world-day lengths keep integer clock readings.
Longitude is real in the sky and on the displayed clock
AstraTerra installs its own OnGetSolarSphericalCoords wrapper that shifts the sun — and
therefore daylight — with world X, chaining whatever delegate the survival mod installed rather
than replacing it outright. The star field already shifted with longitude; the sun now matches.
Set longitudeAwareSun to false in astraterra.json to keep vanilla's single time zone —
the star field, the instruments and the displayed clock all fall back with the sun, because
every one of them reads its longitude through ObserverLongitude.ForObserver, which answers
zero unless AstraTerra's wrapper is the delegate the calendar is holding at that moment.
See Latitude And Longitude for the world-config scale and the observer-position pipeline.
What The Render Thread May Do
The sky pass runs inside Vintage Story's sun/moon render, on the render thread, every frame it draws. Five thousand stars go through it, so the ordinary costs of comfortable code are not affordable here.
| Rule | Why |
|---|---|
| No LINQ on the per-frame path | Select/Where/OrderBy over the catalog cost 12 ms and 440 KiB per frame — a stutter by itself, and about a gigabyte of garbage a minute |
| Project into reused buffers | StarRenderModel.ProjectVisibleStars(..., List<RenderedStar> destination) fills a caller-owned list and sorts it in place; the allocating overload is for callers that are not per-frame |
| Rendered bodies are structs | RenderedBody and RenderedStar are readonly record struct, so a visible sky is not three thousand heap objects a frame |
| Redo work only when it shows | The projection refreshes when the sky has turned or the observer moved StarRefreshThresholdDeg (0.05°) — a tenth of a star sprite. At default time speed the sky turns about 0.25° a second, so that is a few refreshes a second rather than sixty |
| Parse nothing per frame | A journal book's JSON is deserialized only when the written text changes |
| Batch, do not loop draw calls | Every sky sprite goes through SkyBillboardMeshBuilder: constellation marks (~3700) into one mesh, stars and planets into one mesh per sprite — three at most, since only a bright, a faint and a planet sprite are ever in play. Around 6700 draw calls a frame became about four |
| Colour rides on vertices | A per-body colour uniform forces a draw call per body. The batches carry colour in the mesh, which is what collapses them. Note this shades a star once rather than twice — see the warning below |
Vec4f is a class |
A tint built per body per frame is thousands of heap objects a second. The shader uploads its uniform the moment it is assigned, so the draw loops keep one mutable instance |
Recurring logs go to VerboseDebug |
Notification lands in client-main.log; a line every five seconds for every skipped frame flooded players' logs |
Who may draw the sky, and who must ask permission
| Pass | Stage / order | Occluded by terrain? | Gate |
|---|---|---|---|
| Stars, planets, constellation marks, meteors | Opaque 0.3 (inside vanilla's sun/moon render) | Yes — terrain paints over it at 0.37 | none, by design |
| Sky coordinate grid | Opaque 0.96 | No | SkyExposure |
| Constellation overlay | Ortho | No | SkyExposure |
| Sextant readout | Ortho | No | SkyExposure |
The star pass must not be gated on sky exposure
Vanilla's own starfield settles this: SystemRenderNightSky draws at Opaque 0.1 with the
depth test off and no exposure check of any kind, and is still not visible from inside a cave.
The mod's star pass sits in the same part of the frame and gets the same occlusion for free.
A whole-sky gate cannot express "sky visible in that direction", so it can only be wrong one way or the other. Both have shipped: a rain-map check took the sky away from anyone standing under a tree (#71), and a light-level threshold took it away from a player standing indoors looking straight out of a window. What survives is deliberately weak — any sunlight at the eye counts — because skylight and line of sight come through the same openings, and it now guards only the passes that draw over finished terrain.
.stars debug reports the numbers behind the verdict, so the next report of this comes back as
skyExposure=blocked; eyeY=…; rainMapY=…; sunlightAtEye=… rather than a screenshot.
Measuring it
Every claim above is checkable from inside the game, in one session, without a rebuild:
.stars render # what is drawing now
.stars render constellations off # switch one path off
.stars render stars off # ...and another
.stars render all on # put it back
The toggles are session-scoped on purpose — a measurement tool, not a setting — and cover stars
(with the planets that share its billboard path), constellations, deepsky, meteors, comets
and milkyway. Switching
both stars and constellations off also skips the projection they share, so its cost shows up too
rather than hiding behind a draw that no longer happens.
Every 30 seconds the debug log then reports what the pass actually cost:
AstraTerra sky cost: frames=1800; ms/frame=0.31 (peak 2.44); drawCalls/frame=2989 (peak 3011);
meshUploads=1; meshUpdates=7; paths=stars=on; constellations=off; deepsky=on; meteors=on
Draw calls and mesh uploads are counted at the GL call itself, in SkyPassMetrics, not inferred from
list lengths — the old line reported "how many dots were built", which stopped being the number of
draw calls the moment those dots were batched. Peaks sit next to means because they answer different
questions: the mean is what the pass costs, the peak is what the player felt.
Batching changed how a star is tinted
The old per-star path set the tint as both RgbaTint and RgbaLightIn, and the standard
shader computes rgbaTint * applyLight(ambient, rgbaLightIn) * vertexColour — so the tint was
applied twice, once directly and once through the light mix, which desaturates it. The batch sets
both uniforms to white and carries the colour on the vertices, applying it once.
Star colours therefore read slightly more saturated than they did. That is a look change, not a
correctness one, and it is the one part of the batching work that has to be judged by eye rather
than by a counter. Reproducing the old shading exactly would mean re-implementing applyLight on
the CPU, including its point-light term, which would drift with any change to Vintage Story's
shaders.
This is a player-visible contract, not a micro-optimisation
A player reported the mod eating memory, stuttering while moving, and flooding the client log —
with OpenAL failing to allocate sound sources alongside it. All of it traced back to the star
projection running the full LINQ chain every frame.
StarRenderModelTests.ProjectVisibleStars_Into_A_Reused_Buffer_Allocates_Nothing_Per_Frame pins
the allocation, because nothing else fails when it comes back.
Where The Invariants Are Pinned
| Invariant | Test |
|---|---|
| Sky turns east to west | CelestialMathTests.Stars_Travel_East_To_West_Across_The_Night |
| Sidereal time advances | CelestialMathTests.VanillaAlignedSidereal_AdvancesWithTimeOfDay |
| Sidereal time gains going east | CelestialMathTests.VanillaAlignedSidereal_AppliesLongitudeOffset |
| The drawn moon follows the sun east | LocalMoonTimeTests.An_Eastward_Observer_Reads_The_Moon_Later_In_The_Day |
| A vanilla moon is measured where it is drawn | LocalMoonTimeTests.A_Vanilla_Moon_Is_Left_On_The_Worlds_Own_Time |
| The sky drops longitude when the sun does | ObserverLongitudeTests.The_Registered_Installer_Decides_Every_Time_It_Is_Asked |
| Only the sun's wrapper maps longitude itself | SkyLongitudeWiringTests.Only_The_Sun_Wrapper_Maps_Longitude_Without_Asking_Whether_The_Sun_Follows_It |
| A clobbered wrapper stops counting as installed | LongitudeAwareSunControllerTests.The_Sky_Only_Counts_The_Wrapper_As_Installed_While_The_Calendar_Still_Holds_It |
| Transit counts down | AstrolabeServiceTests.Read_Counts_Down_To_The_Next_Transit |
| Sidereal day is shorter than solar | AstrolabeServiceTests.SiderealCycle_Runs_Slightly_Shorter_Than_The_Solar_Day |
| Rising is east of the meridian | AstrolabeServiceTests.Read_Uses_Live_Sky_Direction_To_Distinguish_Rising_And_Setting |
| Azimuth is north-referenced | SkyBodyModelTests.Azimuth_Is_Measured_Clockwise_From_North |
| Moving and fixed bodies share one projection | SkyProjectionTests.A_Moving_Body_Lands_Exactly_Where_A_Star_At_The_Same_Position_Does |
| The ecliptic is tilted the right way | CelestialMathTests.EclipticToEquatorial_Places_The_North_Ecliptic_Pole_In_Draco |
| An ephemeris is sampled once a world minute | SkyEphemerisTests.A_Cached_Body_Is_Sampled_Once_Per_World_Minute_However_Many_Frames_Pass |
| Planets are where they really were | PlanetEphemerisTests.A_Planet_Is_Where_It_Really_Was_At_A_Historic_Opposition |
| World time zero is the March equinox | PlanetEphemerisTests.The_World_Clock_Starts_At_The_March_Equinox |
| An inner planet stays near the sun | PlanetEphemerisTests.An_Inner_Planet_Never_Strays_Far_From_The_Sun |
| Retrograde motion falls out of the maths | PlanetEphemerisTests.Mars_Turns_Back_On_Itself_Without_Anyone_Scripting_It |
| Jupiter takes ~12 world years on any world | PlanetEphemerisTests.Jupiter_Takes_About_Twelve_World_Years_Whatever_A_World_Year_Is |
| Element rates match their semi-major axes | PlanetCatalogAssetTests.Every_Orbit_Obeys_Kepler_Third_Law |
| Vanilla vectors need no rotation | SkyBodyModelTests.Recovers_The_Angles_Vintage_Story_Encoded |
| The per-frame sky path allocates nothing | StarRenderModelTests.ProjectVisibleStars_Into_A_Reused_Buffer_Allocates_Nothing_Per_Frame |
| Constellation marks are one batched draw | ConstellationDotMeshBuilderTests, BootstrapSmokeTests.Telescope_Deep_Sky_Plates_Render_In_Front_Of_Catalog_Stars |
| Stars batch by sprite, not one draw each | SkyBillboardMeshBuilderTests, BootstrapSmokeTests.Stars_Are_Drawn_As_Batches_Rather_Than_One_Quad_Each |
| A journal book can be carried in the off-hand | BookOffhandStorageTests |
| Draw loops do not allocate a tint per body | BootstrapSmokeTests.The_Star_And_Planet_Draw_Loops_Do_Not_Allocate_A_Tint_Per_Body |
| Cost numbers mean what they say | SkyPassMetricsTests |
| One path goes dark, the rest keep drawing | SkyRenderPathsTests |
| The band lands on Sagittarius and the anticentre | GalacticFrameTests |
| The band's glow map wraps without a seam | MilkyWayRenderModelTests.The_Seam_Column_Repeats_The_First_One_At_The_Far_Edge_Of_The_Map |
| The band goes before the stars do | MilkyWayVisibilityTests |
| A block overhead never hides the whole sky | SkyExposureTests.Any_Skylight_At_All_Keeps_The_Sky |
| Day phases and polar cases | SkyClockTests |
| Solar longitude is the sidereal seasonal term | CelestialMathTests.SolarLongitude_Is_The_Seasonal_Term_Of_The_Sidereal_Angle |
| A full turn per year on any year length | CelestialMathTests.SolarLongitude_Runs_A_Full_Turn_Over_A_World_Year_Whatever_Its_Length |
| Angular distance takes the short way round | CelestialMathTests.ShortestAngularDistance_Never_Exceeds_Half_A_Turn |
| A season survives a change of year length | MeteorShowerActivityTests.A_Shower_Keeps_Its_Season_On_A_Twelve_Day_Year_And_A_Three_Hundred_Sixty_Day_One |
| Windows straddling 0°/360° behave normally | MeteorShowerActivityTests.A_Window_Straddling_The_Wrap_Behaves_Exactly_Like_One_That_Does_Not |