I spent a couple of days investigating some new approaches regarding celestial mechanics in 2D (i.e. orbits and planetary motion), and I wanted to share some observations and techniques I’ve developed.

First of all, it is relatively easy to implement a complex system of bodies, including moons, moons of moons, and stellar systems where two stars orbit around a common center of mass, even for a junior. However, if you want a stable simulation (and you do unless you want chaos), each gravity pair should be treated in isolation, so that the whole system is set up as a group of independent two-body interactions.

Second, I’m obviously talking about simplified models, not true astrophysics. Things can be intentionally exaggerated, scales can be non-realistic, distances shrunk etc.

To implement such physics, semi-implicit Euler method seems to be the easiest and a very robust approach. It’s superior to Verlet imo, and can be implemented by rookies. It is, however, sensitive to sudden changes in framerate and time steps should be done in FixedUpdate for improved numerical stability.

Semi-implicit Euler differs from the usual Euler integration such that instead of doing (pseudo-code)

```
velocity[t+1] = velocity[t] + acceleration * deltaTime;
position[t+1] = position[t] + velocity[t] * deltaTime;
```

you do

```
velocity[t+1] = velocity[t] + acceleration * deltaTime;
position[t+1] = position[t] + velocity[t+1] * deltaTime;
```

It’s a trivial modification but has the effect of diminishing translational errors, while conserving the total energy of the system, thus it provides physical consistency throughout.

Such a simulation can be neatly time-scaled on the fly (the errors are negligible unless you scale it to values greater than multiple frames, because this will inevitable scale up the vectors to large magnitudes), and velocities, positions and relationships can be neatly configured and even predicted during edit mode. The way I’ve implemented it has a serialized (and fixed aka “non-reorderable”) list of properties for each body, where I can set the mass, the color, etc. and when it comes to picking the parent body, you just set its index. But before the simulation starts, I build a tree out of this data so that it recursively updates from the top down. I.e. Sun → planet1 → its moons → planet2 → planet 3 → its moon X.

I simulate each body as if it was relative to the world origin, and once its velocity and position are updated internally, I then position the body relative to where its parent truly lies in space (which is always a correct position thanks to iterating top to bottom). Because each branch is relatively short (1 to 3 steps at most, if we ignore moons of moons which are only theoretically possible according to sciences) I integrate the body’s absolute position on the fly, through backward propagation (basically if I need to refresh the moon X’s world position, I first find where planet 3 is, but to do that I need to find where the Sun is … Once I hit “I have no parent” I return everything added up, just like transform.position works).

With all that said, the system is rich enough that you may introduce more than just circular orbits. Elliptical, parabolical, and even hyperbolical orbits (technically all conic sections) all arise naturally, depending on the initial values. It is also easy to come up with a system of naturally arising gravitational resonances (Kepler’s 3rd law). Kepler’s 1st and 2nd law (accelerating near the Sun/focus when following eccentric orbits) are also obeyed as a by-product of simulating actual gravitational acceleration.

This is the formula which is plugged in as an ‘acceleration’ just like any other continuous force you might add to the system

```
G_constant * parent_mass / distance^3 * 2d_vector_from_body_to_parent
```

I simply ignore the mass of the pulled object, the masses in my model are strictly one-way, so there is no tidal effect, for performance and simplicity.

G constant is *big G* in physics, and realistically it’s a very small (empirical) value (it’s **not** 9.81, that’s *small g*), but for gaming purposes and simple simulations you may keep it at 1, because you can always tweak the masses and velocities, so it can be totally ignored. Similarly, masses are only relative to each other, you don’t have to bother yourself with realistic units. Though, conveniently, Earth’s mass is around 6 petagrams (5.927x10^12 kgs), so you might base everything on some coarse convention. **It’s not pentagrams**, goddamnit.

https://www.google.com/search?q=petagram

Finally, if you want to simulate dozens of body in your game, constantly, while also doing other game stuff, it makes sense to think of a system that will simulate the motion up to a certain point (perhaps only during development), and then “bake” that into deterministic paths to simply remove rigid and typically immovable objects (such as planets) from having to compute complex formulas all the time. Maybe do that only for ships and other small bodies subjected to merciless gravity of true celestial objects?

If you do want to “bake” your paths, however, you then need to find a way to create actual parametric orbital representations. For example, a circle has a center and radius, and that’s usually more than enough to come up with all its points, it’s tangents, all kinds of distances, chords, angles, circumference etc.

This also allows for more complex phenomena, like orbital precession. This is extremely hard to simulate based on a naively plugged Newton’s formula for gravitational attraction, especially if the system is designed to isolate all interactions into two-body problems.

And while circles are simple enough, ellipses are a HELL to work with. They’re not that much different from the circles – VISUALLY – you only need one more parameter, right, and that’s the horizontal stretching. Oh but now you also need rotation because stretching breaks symmetry. But the real problem lies in the fact that the simulation can give you only points, so it’s a matter of fitting a parametric ellipse to existing data.

Implicit quadratic form of an ellipse has 6 polynomial coefficients (ax^2+2bxy+cy^2+2dx+2fy+g=0) and solving a system of equations which emerges when trying to fit existing data, requires knowledge about Hilbertian matrices, eigenvalues/eigenvectors, null space, and yes it’s true, I’m just throwing funny words at you. The only legit code I’ve been able to scour is either GIS, data visualization, image recognition and similarly ultra-dedicated jaw-dropping pieces of someone’s academic work, typically relying on heavy libraries designed for hard core linear algebra solvers and complex plane shenanigans. Not to mention that all of it is either OpenCV (cpp; relatively intelligible but not useful), NumPy (python; shit I can’t read this AT ALL).

So what do we do? Well, we do it ourselves lol. Let’s do some shmackademic work!

Truth be told, we have a trick in our sleeve, because what we do isn’t fitting to some noisy data sample resembling a curve. We actually have a neat elliptic path defined through **ordered** points. But that’s not all! Remember that the bodies always revolve around the world origin in the simulation? This means that we can easily produce an absolute orbital path for each body, instead of having to analyze all kinds of floral designs and Lissajous patterns of relative prograde/retrograde motion (video). By Kepler’s 1st law, it just happens that the parent body (the gravity well) sits exactly in one focus of the ellipse (or it’s the center of a circle, but I wouldn’t bet on it, as true circles are **rare**). So we know that one focus at all times, and if we could only find the true center, we would have it all.

What we need are the following 2D parameters:

a) true elliptic center (right in-between the two foci),

b) axis lines,

c) rotation,

d) semi-minor axis length,

e) semi-major / semi-minor axis ratio (aka the stretching factor, analog to eccentricity, but much easier to work with).

First we need a method to analytically discern circles and ellipses from parabolas and hyperbolas. How? Well I don’t know. That part is surely solvable but not really that simple or even necessary. Just make sure your paths are closed if you want parametrization. Only circles and ellipses exhibit indefinite periodic motion anyway and that’s what we’re after.

Ok then, once we know the path is closed, we can proceed by naively finding the average of all points (aka the finite points centroid), by summing them all up and dividing by their number. This will get us the center. Whoa, that was easy.

Now we know that the two foci always lie on the semi-major axis. We can use this to find the line of this axis. Any line can be defined by a point (vector) and a slope (scalar), however to make it more robust, I say let’s do this with a point (vector) and a direction (unit vector). We know the “point” part has to be the center. I called these parameters p and k (don’t ask me why).

```
void estimateAxisLines(Vector2 center, Vector2 focus, out (Vector2 p, Vector2 k) major, out (Vector2 p, Vector2 k) minor) {
major = ( center, center.To(focus).normalized ); // where a.To(b) equals (b - a)
minor = ( center, major.k.Perp(normalize: false) ); // axes are always orthogonal to each other
}
```

where Perp looks like this, and because we know that k was already unit, we can bypass normalization.

```
static public Vector2 Perp(this Vector2 v, bool normalize = true)
=> normalize? new Vector2(-v.y, v.x).normalized
: new Vector2(-v.y, v.x);
```

Generally in 2D I rotate stuff like this (we don’t need it tho, at least not for the simulation per se, but you might find it useful, it’s faster than quaternions)

```
static public Vector2 Rotated(this Vector2 point, float radians)
=> point.Rotated(Vector2.zero, radians);
static public Vector2 Rotated(this Vector2 point, Vector2 center, float radians) {
Vector2 trig = Polar(radians), c2p = point - center;
return center + new Vector2(c2p.PerpDot(trig), c2p.Dot(trig));
}
static public Vector2 Polar(float theta) => new Vector2(MathF.Cos(theta), MathF.Sin(theta));
static public float PerpDot(this Vector2 lhs, Vector2 rhs)
=> lhs.x * rhs.y - lhs.y * rhs.x;
static public float Dot(this Vector2 lhs, Vector2 rhs)
=> lhs.x * rhs.x + lhs.y * rhs.y;
```

(Technically multiplying by 2x2 matrix {{cos(a), -sin(a)}, {sin(a), cos(a)}} with some origin shifting.)

This PerpDot thing is a peculiar thing, specific to 2D, similar to how Cross (⨯) is specific to 3D. In fact, Cross IS a component-wise combination of PerpDots per each plane (YZ, ZX, XY in that order). It’s like a weird cousin to Dot (⋅), where the original vector is orthogonalized prior to operation. We’ll need that again later.

But for now we have the axis lines, and we can easily find rotation by passing semi-major axis’s k parameter to Atan2. In my case I had to flip the sign of X, but that’s probably my sloppy math.

```
void someFunc() {
Vectx.ComputePolar(_estMajor.k.ScaledBy(x: -1f), out _estRotation);
}
// where
static public Vector2 ScaledBy(this Vector2 vec, float x = 1f, float y = 1f)
=> new Vector2(vec.x * x, vec.y * y);
static public bool ComputePolar(this Vector2 v, out float polar) {
polar = Mathx.Atan2(v.y, v.x);
if(polar.IsNaN()) { polar = 0f; return false; } // this is the case only if
// input was NaN, but I've also made ComputeSpherical
// and these two share the same general design principle
// (don't judge me)
return true;
}
```

(The reason why I call this polar is because it’s one half of polar coordinates *θ*, the other half *r* being the magnitude of the vector, but I don’t need that, I just want that Atan2 out of sight. The reason behind this is because I’ve developed a style where I intentionally push grittiness of the numeric algebra below a certain line, as this makes the code much easier to parse and harder to produce a typo with.)

Now comes the interesting part. We can find the **rotated** bounding box of the ellipse if we take one axis line (say semi-major) and project all the points left and right onto it. That’s easy to do, here’s the math (when the line is considered as infinite and defined by a point and a direction).

```
static public Vector2 ClosestPointOnLine(Vector2 lp, Vector2 dir, Vector2 point)
=> lp.Directed(dir, lp.To(point).Dot(dir));
static public Vector2 Directed(this Vector2 v, Vector2 dir, float length)
=> v + length * dir;
```

I then find the point farthest from the center. I then take this point and find the point farthest from it. For this I use custom utilities I’ve developed over the years (earlier version of this can be found in this thread ). All of this boils down to this kind of code

```
int fIndex;
List<Vector2> proj = getProjected(_estMajor, _pts);
_estCenter.FarthestOf(out fIndex, proj); // gives us the index of the farthest point
proj[fIndex].FarthestOf(out _estMajorDist, proj); // gives us the extreme distance
```

Voila, we get our semi-major length estimation.

Do the same thing for the semi-minor axis, and now you know the sides of a rotated bounding box which is guaranteed to perfectly contain the ellipse.

However, if I did I good job here of visualizing how orbits work, you’ve probably noticed that there is one problem with this approach – Kepler’s 3rd law basically ruins everything for me because it introduces a velocity gradient along the path. If the body accelerates when near the gravity well focus (periapsis) and slows down when away from it (apoapsis) this means our simulation does not produce evenly distributed points along the path. This messes up our naive approach to finding the center of the ellipse, because averaging favors the side with more densely packed points, and introduces a hefty bias.

Let’s do something else then. Instead of averaging the points, let’s find a true centroid of a simple polygon. “Simple” means it’s not intersecting with itself (that would be a “complex” polygon), and what I’ve been calling “an ellipse” all this time is honestly just a convex polygon made out of ordered and finite points, which is why this works.

It’s a little bit more involved, but not too much. We previously had this (wiki)

```
Vector2 estimateCloudCentroid(List<Vector2> pts) {
var sum = Vector2.zero;
for(int i = 0; i < pts.Count; i++) sum += pts[i];
return sum / pts.Count;
}
```

and now we can upgrade to this (wiki)

```
Vector2 computePolygonCentroid(List<Vector2> pts) {
var sum = Vector2.zero;
var area = 0f; // aka area accumulator
for(int i = 0, j = pts.Count - 1; i < pts.Count; i++) {
var t = pts[j].PerpDot(pts[i]);
area += t;
sum += t * (pts[i] + pts[j]);
j = i;
}
if(area.IsZero(1E-7f)) return estimateCloudCentroid(pts);
return sum / (3f * area);
}
static public bool IsZero(this float value, float epsilon = kEPSILON_32)
=> Abs(value) < epsilon;
```

Notice the perpdot? This particular application is called *a shoelace formula*.

I keep my kEPSILON_32 at 1E-5f btw, but it could be finer. This is to avoid issues with 32-bit floating point precision.

Centroids in general are supposed to be imaginary points where you can support the shape with a pencil and it will stay in balance (assuming its weight is evenly distributed over area). With convex shapes, it is guaranteed that the centroid will be contained by the shape. Our naive approach had a bias, however with polygons we take the area into account, so it doesn’t matter how well distributed the points are.

And that’s all. You have now successfully parametrized a physically simulated elliptic path. The only thing that’s left is to mark up the velocity gradient, so that the body can pretend to be affected by gravity as it orbits. You are now free to accelerate (or decelerate) your simulation as much as you need to and it will also resist any sudden spikes in performance. Of course, we’ve sacrificed the dynamic part of the simulation, but for many practical purposes, no one really needs to move entire planets off course, right?

Well, sometimes.

[edit]

Oh, and I forgot to show how to actually sample a parametrized ellipse.

First we start with the obvious. It’s kind of circular, so there must be some polar coordinates involved. *t* is a simple interpolant 0…1 that corresponds to *θ*, but saves us from having to think about radians. We can think about revolutions instead. A full revolution of 1 lands on the same place as 0, so this value is also modular.

```
Vector2 getPoint(float radius, float t) => radius * Polar(t.Mod(1f) * TAU);
```

Right now this is just a circle centered at world origin. To turn it into an ellipse we change radius to semiMinorAxis and introduce a stretching factor (a ratio). The rules of my parametrization is that ratio can never get below 1 (practically semiMinorAxis is always the minor one, or they are both equal).

```
// ratio must be >= 1
Vector2 getPoint(float semiMinorAxis, float ratio, float t)
=> (semiMinorAxis * Polar(t.Mod(1f) * TAU)).ScaledBy(y: majorRatio);
```

Mod or ‘modulo’ (not the same thing as `%`

) can be defined as

```
// m is supposed to be > 0
static public float Mod(this float n, float m)
=> (m <= 0f)? 0f : (n %= m) < 0f? n + m : n;
```

(This provides plenty of benefits when working with cyclic structures, which are typical for all kinds of modular geometry (vertices, edges, loops, angles etc.), but also with data arrays. For example 7.Mod(7) would yield 0, allowing you to cross the boundary and start the same sequence again, but there is also -1.Mod(7) which would yield 6, making it easy to refer to a last element without having to invoke an upper bound etc.)

Now we should introduce both rotation and translation so that it becomes independent from the world origin. But if we think about it, we already did scale, and intend to do rotation and translation. That’s standard transformation.

So to make this more universal (and conveniently separable if we ever need the straightforward non-transformed geometry for some reason), let’s add TRS method (short for translation/rotation/scale). By convention/intuition the three operations are performed in reversed order, so first go the scalar multipliers, followed by matrix rotation, then we offset the whole thing. (A great benefit when doing this on your own is that you can easily extend the process to batch transform a load of points, with precomputed trigonometry.)

```
// rotation in radians
Vector2 trs(Vector2 pt, Vector2 scale, float rotation, Vector2 translate)
=> translate + pt.ScaledBy(scale).Rotated(rotation);
// rotation in radians; ratio must be >= 1
Vector2 getPoint(Vector2 center, float semiMinorAxis, float ratio, float rotation, float t)
=> trs((semiMinorAxis * Polar(t.Mod(1f) * TAU)), new Vector2(1f, ratio), rotation, center);
```

Finally, here’s how to get the two foci points.

```
// rotation in radians; ratio must be >= 1
(Vector2 focus1, Vector2 focus2) getFoci(Vector2 center, float semiMinorAxis, float ratio, float rotation) {
if((ratio - 1f).IsZero()) return ( center, center ); // this must be a circle
var f = MathF.Sqrt((ratio * semiMinorAxis).Sqr() - semiMinorAxis.Sqr());
return ( // the scaling is already integrated above for better precision
trs(new Vector2(0f, -f), Vector2.one, rotation, center),
trs(new Vector2(0f, +f), Vector2.one, rotation, center)
);
}
```

[/edit]

Btw here are some more details on the actual semi-implicit Euler implementation.

This is the core update routine, for example.

```
void FixedUpdate() {
recurseUpdate(_bt, Time.fixedDeltaTime * _timeScale); // _bt is the root of 'body tree'
}
void recurseUpdate(BodyNode body, float deltaTime) {
updateMotionOf(body, deltaTime);
if(body.satellites != null)
for(int i = 0; i < body.satellites.Count; i++)
recurseUpdate(body.satellites[i], deltaTime);
}
void updateMotionOf(BodyNode body, float deltaTime) {
if(body.parent != null) { // only localPos is affected
body.vel += g_acc(-body.localPos, body.parent.mass) * deltaTime;
body.localPos += body.vel * deltaTime;
}
// true global pos will integrate the parents' offsets up the chain
body.gameObject.transform.position = body.pos.ToVector3_XZ();
}
```

This is how my data is organized (ymmv). I start with linearly serializable BodyDef structs

```
[SerializeField] [NonReorderable] BodyDef[] _bodies;
// ...
[Serializable]
private struct BodyDef {
public BodyDefType type;
public bool showProjection; // I use this to selectively toggle path gizmos in edit mode
public int parent; // just an index
[Min(1f)] public float mass; // in petagrams?
[ColorUsage(showAlpha: false)] public Color color;
public Vector2 position; // initial position, some coordinate relative to the parent
public Vector2 velocity; // typically tangential velocity, or the body would free-fall
public BodyDef(BodyDefType type, int parent, float mass, Color color, Vector2 position, Vector2 velocity)
=> (this.type, this.showProjection, this.parent, this.mass, this.color, this.position, this.velocity)
= (type, false, parent, mass, color.ToColor(alpha: 1f), position, velocity);
}
private enum BodyDefType {
Barycenter,
Stellar,
Substellar
}
```

This data is then reorganized to a tree structure

```
private class BodyNode {
public GameObject gameObject;
public bool isBarycenter; // I don't instantiate a prefab if the gravity well is a mathematical point
public bool isEmissive; // I use this to configure the sun's material
public Color color;
public BodyNode parent; // actual hierarchy is replicated
public List<BodyNode> satellites;
public float mass;
public Vector2 localPos; // the value that is updated by the sim
public Vector2 pos => parent is null? localPos : localPos + parent.pos; // the true position is computed on the fly
public float scale => ComputeLogScale(mass);
public Vector2 vel; // the value that is updated by the sim
// I use this just to visualize the wildly ranging masses
// Here are some examples of masses: The Sun = 230, The Earth = 12, The Moon = 1
static public float ComputeLogScale(float val) => (1f + Mathx.Log(val)) * .1f;
}
```

When converting from an array to a tree I also need to make sure I didn’t accidentally made a cyclic graph. Trees are acyclic by definition, and besides, running a loop over a cyclic graph would halt Unity. This was just an experiment, so I’m not too strict about this, but here’s one piece of code that tries to prevent this from happening. This method behaves recursively, but is written in a non-recursive manner, and attempts to find a world position of an object while in edit mode, because I draw gizmos and whatnot, which lets me inspect some behaviors before I set everything in motion.

```
bool getPositionOf(int index, out Vector2 pos) {
pos = Vector2.zero;
int count = 0;
while(true) { // <- usually a tell-tale sign that dragons dwell inside
if(index == -1) return true; // -1 is a special signal used by the top-level body, this means it's finished
if(index < 0 || index >= _bodies.Length) return false; // index was out of bounds, report failure
pos += _bodies[index].position; // accumulate local positions up the hierarchy
index = _bodies[index].parent; // re-index in order to process the parent next
if(++count > 20) break; // this prevents a deadlock; I like to keep safety mechanisms at the bottom
// do..while also makes sense
}
throw new InvalidOperationException("Cyclic graph error.");
}
```

This is how I build the tree

```
List<BodyNode> recurseTree(BodyDef[] defs, BodyNode parent = null, int target = -1) {
if(target < -1) throw new NotSupportedException($"Invalid index '{target}'.");
var result = new List<BodyNode>();
for(int i = 0; i < defs.Length; i++) {
if(defs[i].parent == target) {
var node = makeFromDef(defs[i]);
node.parent = parent;
node.satellites = recurseTree(defs, node, i);
result.Add(node);
}
}
// here we make sure this isn't a so-called forest, but a single tree
if(target == -1 && result.Count > 1)
throw new NotSupportedException("System can't have more than one dominant gravity well.");
return result;
}
BodyNode makeFromDef(BodyDef def)
=> new BodyNode() {
isBarycenter = def.type == BodyDefType.Barycenter,
isEmissive = def.type == BodyDefType.Stellar,
color = def.color,
mass = def.mass,
localPos = def.position,
vel = def.velocity
};
```

This is all put into motion by doing

```
void OnEnable() => _bt = recurseTree(_bodies)[0];
```

I then recursively instantiate my prefabs in Start, similar to how Update works. At this point you’ve seen all the relevant pieces of the puzzle.

That’s all, hopefully this research helps you make a fantastic game.