Geocentric Celestial Mechanics

Keir Finlow-Bates
11 min readFeb 22, 2023

“The moon’s an arrant thief, And her pale fire she snatches from the sun.”

― William Shakespeare

When I was young, my father had a telescope. Not a little thing with lenses, but a huge reflector telescope — a tube nearly two meters long and half a meter in diameter. The stand was configured for Australian latitudes, so after we moved to Europe it required some skill to track celestial objects in the sky manually, constantly twisting knobs to keep them in view. Mars, the Andromeda galaxy, or whatever we were looking at would drift off over a period of a few seconds. We spent a lot of my childhood nights out in the cold looking at the night sky.

And so, on creating a voxel world (namely the Orthoverse) I knew that at some point I would give in, and spend time implementing some activity in the virtual heavens.

This article examines what I have done to provide clouds, the sun, and the moon in that imaginary world. I have taken the simpler approach of putting the sun and moon on circular orbits around the world.

Introduction

The Orthoverse uses an open source Minecraft/Roblox-like system called PrismarineJS, and written from the ground up in JavaScript to create the world, and within PrismarineJS the open source 3D rendering library THREE is used to produce the graphics. Generating celestial objects in your virtual world therefore requires nothing more than coding in those two systems, with some simple high-school trigonometry and vector calculations thrown in.

To begin with, some terminology:

A voxel is the three dimensional equivalent of a pixel. It has an x (or east/west), y (or up/down), and z (or north/west) co-ordinate. Just as pixel is derived from the words “picture element”, the word ‘voxel’ is a combination of “volume element”.

In PrismarineJS the world is 256 voxels high — this is a column. 256 columns arranged in a 16 by 16 grid form a chunk. And if you set your rendering distance to the maximum value, namely five chunks, the visible world consists of 11 by 11 chunks with you in the center.

Five chunks to the horizon: count them

That means that the world visible to a player is 176 voxels wide, and up to 256 voxels high.

Conveniently for the Orthoverse, each land owned by someone consists of six by six chunks, with each chunk representing a feature, such as a mountain, a lake, or a small forest.

Skyboxes and clouds

To add the illusion that there is something beyond the horizon we use a skybox. This is a cube big enough to contain the visible world, with jpgs or pngs pasted onto it that show a distant scene.

The skybox is also going to rotate around the x axis to give the illusion that the clouds are moving. Therefore it needs to be big enough to clear the corners of the visible world as it rotates around it.

Using Pythagoras’ theorem, we can see that the width of the skybox has to be at least 250.

But the box is going to travel with us as our avatar moves through the world — otherwise if you walked in one direction long enough you would walk through it, which would be weird. And that means we could be standing at the very bottom of the world looking up, or at the very top looking down, and so the height has to be at least twice the height of the world, which is 2 times 256, or 512 units.

For the Orthoverse I downloaded six creative commons 3.0 licensed images from Elyvisions, edited out the ground, and stretched them someone to provide more cloud. Because the images are square, the skybox has to have square faces too, and so we go with a cube using the computed height of 512 plus a bit extra.

Here is the code to make the skybox:

  const skyGeo = new THREE.BoxGeometry(520, 520, 520)
const feature = 'sh'

const loader = new THREE.TextureLoader()
const skyMaterials = [
new THREE.MeshBasicMaterial({
map: loader.load('extra-textures/background/' + feature + '_ft.png'),
transparent: true,
side: THREE.DoubleSide,
}), // WS
new THREE.MeshBasicMaterial({
map: loader.load('extra-textures/background/' + feature + '_bk.png'),
transparent: true,
side: THREE.DoubleSide,
}), // ES
new THREE.MeshBasicMaterial({
map: loader.load('extra-textures/background/' + feature + '_up.png'),
transparent: true,
side: THREE.DoubleSide,
}), // Up
new THREE.MeshBasicMaterial({
map: loader.load('extra-textures/background/' + feature + '_dn.png'),
transparent: true,
side: THREE.DoubleSide,
}), // Down
new THREE.MeshBasicMaterial({
map: loader.load('extra-textures/background/' + feature + '_rt.png'),
transparent: true,
side: THREE.DoubleSide,
}), // NS
new THREE.MeshBasicMaterial({
map: loader.load('extra-textures/background/' + feature + '_lf.png'),
transparent: true,
side: THREE.DoubleSide,
}), // SS
]

const skybox = new THREE.Mesh(skyGeo, skyMaterials)

Note that the materials are set to transparent (because we are going to put the sun and moon beyond them), and double sided, otherwise the cloud images would only be visible outside the box.

There is a handy library called TWEEN, which provides smooth transitions when you move rendered THREE objects around. Using that I can make the skybox rotate slowly with just four lines:

  new TWEEN.Tween(skybox.rotation)
.to({y: "-" + (2 * Math.PI) }, 2000000)
.repeat(Infinity)
.start()

Each repetition rotates the box a total of 2π radians, namely a full revolution, every two million milliseconds (about half an hour), and then we just do it again, forever.

Now you will understand why I edited out the ground: it will rotate too, and that would just look odd.

The sun

To make that big fiery ball in the sky we need a sphere.

  const sunGeo = new THREE.SphereGeometry(46, 24, 24)
const sunMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 })
const sun = new THREE.Mesh(sunGeo, sunMaterial)
const sunDist = 750

With a bit of experimentation I worked out that at a distance of 750 units, a sphere of radius 46 units looks decent.

In PrismarineJS, and hence the Orthoverse, the day is 24000 ticks long, and time starts at 0 ticks and progresses from there. Note that 24000 ticks take 20 Earth minutes to pass — this is to make the shift from day to night and back to day more likely to occur in any given game session). We need a function to calculate where the sun should be at any given time.

function sunPositionCalculation (age) {
const time = age % 24000

Great start. We take the age of the world, and work out what time of day it is using modulo 24000.

    const theta =
Math.PI / 4 +
(
((
Math.floor(bot.time.day / 180) / 2 === Math.floor(Math.floor(bot.time.day / 180) / 2)
? bot.time.day % 180 + 1
: 180 - (bot.time.day % 180)
) / 180) *
Math.PI / 4
)

This is a bit more complicated. I wanted the sun to fluctuate between traversing the sky to the south with different elevations at different times. This equation means that the sun starts at π/4 radians above the horizon, which is a low 45∘, and to progress daily up to π/2 radians so that eventually she passes overhead. This takes a total of 180 days, and then over the next 180 days she returns back to orbiting low across the sky.

That way we have a year lasting 360 days. An unusual year, in that the shortest day is still twice as long as the longest night, but that’s the way you want it to be in a virtual world — who wants to be building castles or spaceship ports in the dark…

Now to calculate the position of the sun relative to the avatar:

    const rads = ((time / 24000) * 2 * Math.PI)
const sunX =
bot.entity.position.x +
Math.sin(rads) * sunDist
const sunY =
(-1 * Math.cos(rads) * Math.sin(theta) * sunDist)
const sunZ = bot.entity.position.z - (Math.cos(theta) * Math.cos(rads) * (sunDist + sunH))

I calculated rads, the number of radians around the orbit the sun has traveled, by dividing the time of day by 24000 (the number of ticks in a day) and multiplying by 2π (a full revolution around a circle).

And then I added the x position of the avatar to the sun’s x position, and the z position to the sun’s z position. This ensures that the sun “runs away” from you as you move towards it. I removed adding the y position, because it just looked odd when you jump, as the sun bounced up from the horizon and back down. This does mean that if you build a tower to the top of the world you get closer to the sun and it looks bigger … but you can’t get everything to be perfect.

I also added code to change the color of the sun when close to the horizon to get a nice red sunrise or sunset:

  // sun color at sunset
const red = 0xff
const green = Math.floor(0xff * (intensity))
const blue = Math.floor(0xff * (intensity / 2))
const color = (red * 0x10000) + (green * 0x100) + blue

Then I determined one final problem — having the day the same length as the night is annoying, because it does not give enough time to build in daylight. The trick is to move the center of the orbit up above the head of the avatar:

If I want the sunrise to happen at 4000 ticks instead of 6000, then simple trigonometry tells me that the amount to raise h = R * cos(α). And I know R — it’s the radius of the circular orbit.

I should probably throw in an extra θ into that equation if I always want the day to be exactly the same length, but having some variation is fun. Days where the sun is low on the horizon at midday will simply be longer.

  const sunDist = 610
const moonDist = 600
// orbit raise factor
const sunH = Math.cos(2 * Math.PI * 4000 / 24000) * sunDist
const moonH = Math.cos(4000 * Math.PI / 24000) * moonDist

Then I just add sunH to the value for sunY three code blocks up.

The moon

Positioning the moon is done in the same manner as for the sun, with a few diferences:

  • The moon in the Orthoverse orbits faster than the sun, going around once every 20000 ticks. This ensures she is sometimes in the sky at the same time as the sun, and sometimes at night.
  • The θ angle for the moon will fluctuate between π/6 and -π/6 every 28 days, so the moon will never be as far south as the sun can be, but she can be to the north.

Other than those differences, the function for computing the moon position is very similar to the sun one.

  function moonPositionCalculation (age) {
const time = age % 20000
if (typeof bot.entity.position.x === 'undefined') {
return { pos: {x: 0, y: -1 * moonDist, z: 0 } }
}
const theta =
Math.PI / 6 +
(
((
Math.floor(bot.time.day / 14) / 2 === Math.floor(Math.floor(bot.time.day / 14) / 2)
? bot.time.day % 14 + 1
: 14 - (bot.time.day % 14)
) / 14)
* Math.PI / 2
)
const rads = ((time / 20000) * 2 * Math.PI)
const moonX =
bot.entity.position.x +
Math.sin(rads) * moonDist
const moonY =
(-1 * Math.cos(rads) * Math.sin(theta) * moonDist)
+ moonH
// + bot.entity.position.y
const moonZ = bot.entity.position.z - (Math.cos(theta) * Math.cos(rads) * (moonDist + moonH))
return { pos: { x: moonX, y: moonY, z: moonZ } }
}

There are two extra issues for the moon.

  1. During the day she should be paler, almost translucent like a silver bubble
  2. She needs phases, so there can be nights with a full moon or a crescent moon, or anything in-between

Point 1 is easily done by blending the silver base color of the moon with the current color of the sky and setting the moon sphere to that color.

Point 2 requires another shape — a hemisphere, slightly larger than the moon, with the same color as the sky. Furthermore, the hemisphere needs to always point away from the sun.

Phases are important to me, because of Lord of the Rings by J.R.R. Tolkien. I had read that book about four or five times before I noticed that there are frequent mentions of the moon and whether he is full or half-full. Furthermore, the mentions correlate properly across both time and scenes, meaning they can be used to determine when events are happening at the same time for different characters in different places.

I am not going to add all the code for the moon phases here — you can find the full file for calculating moon and sun stuff in the celestial.js file in the Orthoverse voxel world Github repository, and look for it there.

Eclipses

I have not calculated whether there will ever be an eclipse of the sun, but given the different orbit times I don’t see why not. I imagine they will be very infrequent, however.

What I did do, is set the sun and moon to orbit on the same circle in a test run, and add code to provide dimming of the sky if the moon ever fully passes in front of the sun:

  // how bright should the sky be
let intensity = intensityCalc(bot.time.timeOfDay)

// calculate eclipse conditions
const playerPos3 = new THREE.Vector3(
bot.entity.position.x,
bot.entity.position.y,
bot.entity.position.z
)
const sunDir = (new THREE.Vector3().subVectors(playerPos3, sun.position)).normalize()
const moonDir = (new THREE.Vector3().subVectors(playerPos3, moon.position)).normalize()
const angle = moonDir.angleTo(sunDir)

// eclipse affects daylight if angle between sun and moon is < 0.02
if (angle < 0.02) {
intensity = intensity * Math.abs((angle * 50)) * Math.abs((angle * 50))
}

const adjustedSkyColor = new THREE.Color(
darkenSky(skyColor, intensity)
)

viewer.scene.background = adjustedSkyColor

In this, I look at the direction from the avatar to the moon, and to the sun, and if the angle between the two is less than 0.02 radians, I dim the sky proportionally by the square of how close the angle is to 0. This mimics real eclipses, where the sun has to be almost completely covered by the moon before we notice significant darkness.

I think the easiest way to determine eclipse times is to do it empirically, with a loop counting upwards and using the position functions to determine if, at any given time, there is an eclipse. But if someone else wants to determine whether the orbit equations are dependent or independent and if it turns out to be the latter case compute the first time value when there is an eclipse, they are more than welcome to.

Conclusion

The moon and sun are deployed in the Orthoverse, and it all seems to work. I’m sure I’ll find more bugs and have to tweak a few things, but that’s basically it.

Update

I ended up calculating the eclipse timings myself only to discover that they happen at night, which is clearly no good. A small adjustment ensures there are two visible eclipses every voxel world year:

  function moonPositionCalculation (age) {
const time = (age + 6000) % 20000

--

--

Keir Finlow-Bates
Keir Finlow-Bates

Written by Keir Finlow-Bates

I walk through the woods talking about blockchain

Responses (1)