Propagating Orbital Bodies in Unity Gaming Engine

This post builds on the concepts contained in “Generating the Positions and Velocities of Orbital Bodies.”

Context

For the past few months, we have been working on generating an orbital hierarchy of a solar system. We then took this abstract hierarchy and ascribed it with a set of Keplerian Orbital Elements. Finally, we used these orbital elements to build up hard position and velocity data for our orbital bodies.

We now have everything we need in order to render and propagate these orbital bodies in Unity Gaming Engine.

Scope

This post is not going to delve into the minutiae of creating things like GameObjects, meshes and shaders in Unity. Numerous tutorials exist that can help a curious developer work out how to create nice looking stars, intuitive camera movements, etc. This post assumes that the user has an environment, camera, and their objects all set up and ready to go, and gets right into propagation.

What we mean by “Propagation”

“Propagation” is a bit of an overloaded word that has a few different meanings.

On Earth, when we take observations of satellite positions and velocities in the sky, we can then estimate their future states by the technique of Orbit Propagation.

In physics, propagation is the process by which a disturbance or a wave is transmitted through a medium. Radio waves propagate.

In the context of PISES, when we say that we are planning on propagating an orbital body, we mean that we are going to move an orbital body through the pre-calculated positions of its orbital path at pre-calculated speeds.

Plan of Attack

Unity contains a number of executive methods like Awake, Start and Update.

Update is a method that is called once per frame, and is typically where you might monitor the user’s behavior, inputs and actions. It’s also where we might articulate the movement of GameObjects.

Each of our orbital bodies, which we shall represent as a GameObject, has a set of both positions and velocities, which we generated in the last post. At any point in time, this GameObject should be moving from one position index to the next, at a corresponding velocity.

Essentially, on each call to Update, we should check to see if our GameObject has reached its destination (the next position in its position array). If the GameObject has not reached its destination, we leave it alone, and let it travel.

If the GameObject has reached this destination, then we want to set not only a new destination for it, but a new velocity; send it on its way, and wait until it reaches that destination.

Setup

First, in whatever script we are using to create this scene, we need to curate:

  1. A list of all OrbitalNode objects that we want to propagate. If this doesn’t sound familiar, read up on OrbitalNodes here.
  2. A mapping of OrbitalNodes to GameObjects. Each Orbital Node will be associated with a GameObject representing the orbital body in unity.
  3. A mapping of OrbitalNodes to their target positions.
  4. A mapping of OrbitalNodes to their current position-velocity index. This is the index in the position and velocity arrays that the Orbital Node is currently traveling through.
  5. A mapping of OrbitalNodes to their perifocal reference frames. More on this in a moment.
// A collection of the objects that we wish to propagate.
private List<GameObject> allPropagatingBodies;

// A mapping of orbital nodes to their associated GameObjects.
private Dictionary<OrbitalNode, GameObject> orbitalNodeToGameObject;

// A mapping of orbital nodes to their target positions.
private Dictionary<OrbitalNode, Vector3> nodeToTargetPosition;

// A mapping of orbital nodes to the index of their target in the elliptical position and velocity points. 
private Dictionary<OrbitalNode, int> nodeToEllipticalIndex;

// A mapping of orbital nodes to their reference frames.
private Dictionary<OrbitalNode, GameObject> nodeToReferenceFrame

You might be thinking that some of these mappings are a little bit unnecessary, and that a lot of this data could be contained in the OrbitalNode class, and you’re right – it could be, and under ordinary circumstances, I might have done this – but I have an overarching mandate when working on PISES that all backend code must be cleanly and totally separated from Unity code. On the backend, we calculate all of the positions and velocities of our orbital bodies, and that’s where I draw the line between PISES and Unity. The minutiae of moving the bodies, of where the bodies physically are in their movement progress – this all belongs to Unity, not to PISES.

Sidequest: Harnessing GameObjects as Convenient Perifocal Reference Frames

In our simplex solar hierarchy, all stellar orbital bodies are represented by Orbital Nodes. Each Orbital Node has (potentially) a parent, child and sibling. All planetary bodies have a parent (the star they orbit), a potential child (any lunar bodies) and in rare cases a sibling (if they are part of a binary planet system).

When calculating the positions of a child orbital body, you might imagine that it would be extremely complicated to sum its orbital motion around its parent with the orbital motion of the parent around its respective parent. That is, if the parent body is also in motion, when we calculate the positions and velocities of the child, we would have to factor in the movements of its parent – and its parent’s parent, and that grandparent’s parent, and so forth.

A classic example is to consider the example of our own moon’s actual path around the sun, as opposed to its elliptical path around the Earth:

An exaggerated depiction of the moon’s path around the sun.

In PISES, we do not want to, and do not need to calculate the actual path of the object: Unity can actually do this for us, if we’re smart about it. All that our OrbitalSystem should store is the nature of the elliptical path around the parent body.

In order to achieve this, we can make use of the parent field of the unity GameObject’s transform.

For every GameObject that we instantiate, representing an orbital body, we will create a second, invisible GameObject, representing its perifocal reference frame. If the term perifocal reference frame doesn’t sound familiar to you, refer back to this previous post.

We will orient this perifocal reference frame with its up, right and forward vectors in line with the orbital body’s plane of the ecliptic.

Any orbital body that is a child of this orbital body will have its transform’s parent field set to that of the parent body. When we set this parent-child relationship, we set “world space” to false. That’s because all of the position and velocity data of the child object is relative to the parent.

Below is an example of setting the parent reference frame for two orbital bodies, and then creating two new reference frames of their own, for their children to point to.

// Assign the parent transform for these two child bodies. 
c1OrbitalBody.transform.SetParent(referenceFrame.transform, false);
c2OrbitalBody.transform.SetParent(referenceFrame.transform, false);

// Create new reference frames for each orbital body. 

// This reference frame is offset from the parent reference frame.
// It is oriented at the orbital system's perifocal vectors and centered on the orbital body.
GameObject child1ReferenceFrame = new GameObject();
child1ReferenceFrame.transform.position = c1Position;
child1ReferenceFrame.transform.right = Conversions.convertSystemVector3ToUnityVector3(child1.orbitalSystem.P); // right = X
child1ReferenceFrame.transform.up = Conversions.convertSystemVector3ToUnityVector3(child1.orbitalSystem.Q); // up = Y
child1ReferenceFrame.transform.forward = Conversions.convertSystemVector3ToUnityVector3(child1.orbitalSystem.W); // forward = Z
child1ReferenceFrame.transform.SetParent(referenceFrame.transform, false);

GameObject child2ReferenceFrame = new GameObject();
child2ReferenceFrame.transform.position = c2Position;
child2ReferenceFrame.transform.right = Conversions.convertSystemVector3ToUnityVector3(child2.orbitalSystem.P); // right = X
child2ReferenceFrame.transform.up = Conversions.convertSystemVector3ToUnityVector3(child2.orbitalSystem.Q); // up = Y
child2ReferenceFrame.transform.forward = Conversions.convertSystemVector3ToUnityVector3(child2.orbitalSystem.W); // forward = Z
child2ReferenceFrame.transform.SetParent(referenceFrame.transform, false);

// Map the orbital nodes to their reference frames.
nodeToReferenceFrame.Add(child1, child1ReferenceFrame);
nodeToReferenceFrame.Add(child2, child2ReferenceFrame);

Instantiating the Orbital Bodies as Game Objects

Before we can propagate our orbital bodies, we have to instantiate them. Above, we explained and provided an example of how we are harnessing parent transforms as unity reference frames. Now, let’s get into the minutiae of climbing down the tree of the orbital hierarchy and generating GameObjects.

First, we need to start with the root node, the orbital node at the tip of the orbital hierarchy. In a Unary system, this will be a star. In a 2+ system, this will be a barycenter.

// Render Stars ///////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////
// Renders all stars of this SolarSystem.
private void RenderStars()
{
    // Obtain the Root Node.
    OrbitalNode rootNode = solarSystem.rootNode;

    // Track the root node.
    allStellarNodes.Add(rootNode);

    // If this is a star system, then we are rendering a unary system.
    if (rootNode.starSystem != null)
    {
        // Instantiate the star.
        // This instantiation method simply creates a GameObject using the star type to select a prefab model,
        // the radius to determine the object's size, and the position to place the object.  I leave these simple tasks
        // to the reader to work out.
        GameObject star = InstantiateStar(rootNode.starSystem.type, rootNode.starSystem.solarRadius, rootNode, new Vector3(0,0,0));
        
        // Map node to orbital body.
        orbitalNodeToGameObject.Add(rootNode, star);
        gameObjectToOrbitalNode.Add(star, rootNode);
    }

    // Otherwise, this is a barycenter and this is an n-ary system.
    else 
    {
        // Instantiate the barycenter.
        // Another simple instantiation method like the one above, except we simply instantiate a tiny point of light to represent the barycenter.
        GameObject barycenter = InstantiateBarycenter(rootNode, new Vector3(0,0,0));

        // Map node to orbital body.
        orbitalNodeToGameObject.Add(rootNode, barycenter);
        gameObjectToOrbitalNode.Add(barycenter, rootNode);
        allBarycenters.Add(barycenter);

        // Create the L0 reference frame.
        GameObject L0ReferenceFrame = new GameObject();
        L0ReferenceFrame.transform.position = new Vector3(0,0,0);
        L0ReferenceFrame.transform.right = new Vector3(1,0,0); // right = X
        L0ReferenceFrame.transform.up = new Vector3(0,1,0); // up = Y
        L0ReferenceFrame.transform.forward = new Vector3(0,0,1); // forward = Z

        // The L0 reference frame is where we'll establish the solar ecliptic grid.
        // We can talk about the Ecliptic Grid in another post.
        solarEclipticGrid = EclipticGrid.GenerateEclipticGrid(L0ReferenceFrame, solarSystem.maximumSeparation * 10, gridMaterial);
        solarEclipticGrid.name = "Solar Ecliptic Grid";
        
        // Map the node to its reference frame.
        nodeToReferenceFrame.Add(rootNode, L0ReferenceFrame);

        // Continue down the hierarchy.
        RenderSiblingPair(rootNode.child1, L0ReferenceFrame);
    }
}

Now that we have generated the root node and its reference frame, we can advance recursively down the hierarchy with the method, RenderSiblingPair(). It is very similar to the previous method.

// Render Sibling Pair ////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////
// Renders an orbital node and its sibling within a given reference frame.
// This method should not be called on the root node; it is the only node in an n-ary system that doesn't have a sibling. 
private void RenderSiblingPair(OrbitalNode child1, GameObject referenceFrame)
{
    // Determine Geometry /////////////////////////////////////////
    //////////////////////////////////////////////////////////////

    // Obtain the sibling.
    OrbitalNode child2 = child1.sibling;

    allStellarNodes.Add(child1);
    allStellarNodes.Add(child2);

    // Generate the Elliptical Points
    child1.orbitalSystem.GenerateEllipticalPoints(ORBITAL_RESOLUTION);
    child2.orbitalSystem.GenerateEllipticalPoints(ORBITAL_RESOLUTION);

    // Determine the starting anomaly.
    int anomaly = Numericals.randomInt(0, ORBITAL_RESOLUTION);
    System.Numerics.Vector3 c1syspos = child1.orbitalSystem.ellipticalPoints[anomaly];
    System.Numerics.Vector3 c2syspos = child2.orbitalSystem.ellipticalPoints[anomaly];

    // Translate position into unity units
    Vector3 c1Position = new Vector3(c1syspos.X * AU_TO_UU, c1syspos.Y * AU_TO_UU, c1syspos.Z * AU_TO_UU);
    Vector3 c2Position = new Vector3(c2syspos.X * AU_TO_UU, c2syspos.Y * AU_TO_UU, c2syspos.Z * AU_TO_UU);

    // Set the target index of the two stars for propagation.
    // This is a setup step for propagation.
    nodeToEllipticalIndex.Add(child1, anomaly);
    nodeToEllipticalIndex.Add(child2, anomaly);

    // Set the first target position to the current position; that way at propagation start, we'll jump to the next target position.
    // This is a setup step for propagation.
    nodeToTargetPosition.Add(child1, c1Position);
    nodeToTargetPosition.Add(child2, c2Position);

    // Instantiate and Orient Orbital Bodies //////////////////////
    //////////////////////////////////////////////////////////////

    // Instantiate the orbital bodies, relative to the parental reference frame.
    GameObject c1OrbitalBody;
    if (child1.starSystem != null)
    {
        c1OrbitalBody = InstantiateStar(child1.starSystem.type, child1.starSystem.solarRadius, child1, c1Position);
    }
    else 
    {
        c1OrbitalBody = InstantiateBarycenter(child1, c1Position);
        allBarycenters.Add(c1OrbitalBody);
    }

    // Map node to orbital body.
    orbitalNodeToGameObject.Add(child1, c1OrbitalBody);
    gameObjectToOrbitalNode.Add(c1OrbitalBody, child1);

    // Instantiate Child 2
    GameObject c2OrbitalBody;
    if (child2.starSystem != null)
    {
        c2OrbitalBody = InstantiateStar(child2.starSystem.type, child2.starSystem.solarRadius, child2, c2Position);
    }
    else
    {
        c2OrbitalBody = InstantiateBarycenter(child2, c2Position);
        allBarycenters.Add(c2OrbitalBody);
    }

    // Map node to orbital body.
    orbitalNodeToGameObject.Add(child2, c2OrbitalBody);
    gameObjectToOrbitalNode.Add(c2OrbitalBody, child2);

    // Assign these bodies to the parent reference frame.
    c1OrbitalBody.transform.SetParent(referenceFrame.transform, false);
    c2OrbitalBody.transform.SetParent(referenceFrame.transform, false);

    // Instantiate and Orient Ellipses ////////////////////////////
    //////////////////////////////////////////////////////////////

    // This block of code warrants its own post entirely.  We will come back to the Orbital Ellipses another day.
    // For now, let's just focus on the orbital bodies.

    // ...
    // ...
    // ...

    // Create new reference frames for each orbital body. /////////
    //////////////////////////////////////////////////////////////

    // This reference frame is offset from the parent reference frame.
    // It is oriented at the orbital system's perifocal vectors and centered on the orbital body.
    GameObject child1ReferenceFrame = new GameObject();
    child1ReferenceFrame.transform.position = c1Position;
    child1ReferenceFrame.transform.right = Conversions.convertSystemVector3ToUnityVector3(child1.orbitalSystem.P); // right = X
    child1ReferenceFrame.transform.up = Conversions.convertSystemVector3ToUnityVector3(child1.orbitalSystem.Q); // up = Y
    child1ReferenceFrame.transform.forward = Conversions.convertSystemVector3ToUnityVector3(child1.orbitalSystem.W); // forward = Z
    child1ReferenceFrame.transform.SetParent(referenceFrame.transform, false);

    GameObject child2ReferenceFrame = new GameObject();
    child2ReferenceFrame.transform.position = c2Position;
    child2ReferenceFrame.transform.right = Conversions.convertSystemVector3ToUnityVector3(child2.orbitalSystem.P); // right = X
    child2ReferenceFrame.transform.up = Conversions.convertSystemVector3ToUnityVector3(child2.orbitalSystem.Q); // up = Y
    child2ReferenceFrame.transform.forward = Conversions.convertSystemVector3ToUnityVector3(child2.orbitalSystem.W); // forward = Z
    child2ReferenceFrame.transform.SetParent(referenceFrame.transform, false);

    // Map the orbital nodes to their reference frames.
    nodeToReferenceFrame.Add(child1, child1ReferenceFrame);
    nodeToReferenceFrame.Add(child2, child2ReferenceFrame);

    // If these nodes have children, render them.
    // Otherwise, if they have worlds, render them.
    if (child1.child1 != null)
    {RenderSiblingPair(child1.child1, child1ReferenceFrame);}
    else if (child1.starSystem != null && child1.starSystem.numWorlds > 0)
    {RenderWorlds(child1, child1ReferenceFrame);}

    if (child2.child1 != null)
    {RenderSiblingPair(child2.child1, child2ReferenceFrame);}
    else if (child2.starSystem != null && child2.starSystem.numWorlds > 0)
    {RenderWorlds(child2, child2ReferenceFrame);}
}

After calling RenderSiblingPair, a recursive method, all orbital bodies should be both instantiated, linked to their parent reference frames, and mapped to their own reference frames.

The method “RenderWorlds” follows the same paradigm as the previous two methods, just for planets and moons: it does not need to be notated here.

The Propagator

At this point, we’re ready to begin mobilizing these orbital bodies. If you recall from the previous section, we have already done some setup to prepare for this task during the instantiation process.

// Propagate all orbital nodes and their associated reference frames.
private void PropagateOrbitalNodes()
{
    // Propagate all orbital nodes save for the root node.
    foreach(OrbitalNode node in nodesToPropagate)
    {
        // Do not propagate the root node.
        if (node.depth == 0) {continue;}

        // Get the desired position and velocity of the game object, in unity values.
        GameObject go = orbitalNodeToGameObject[node];
        Vector3 currentLocalUnityPosition = go.transform.localPosition; // "localPosition" is relative to the parent transform.
        Vector3 targetLocalUnityPosition = nodeToTargetPosition[node];
        float vel = node.orbitalSystem.velocityMagnitudes[nodeToEllipticalIndex[node]] * PROPAGATION_SPEED;
        float step = vel * Time.deltaTime;

        // Get the reference frame of the parent.
        GameObject parentFrame = nodeToReferenceFrame[node.parent];

        // If this node's game object has arrived at the target position,
        float dist = Vector3.Distance(currentLocalUnityPosition, targetLocalUnityPosition);
        if (Vector3.Distance(currentLocalUnityPosition, targetLocalUnityPosition) <= 0.001f) // .001f seems to be the smallest we can get before we start "missing" the target position.
        {
            // Set a new target index.  Loop back to 0 if we hit the end of our indices.
            int oldTargetIndex = nodeToEllipticalIndex[node];
            int newTargetIndex = (oldTargetIndex + 1) % ORBITAL_RESOLUTION;
            nodeToEllipticalIndex[node] = newTargetIndex;

            // Set the new target position.
            System.Numerics.Vector3 newSysPos = node.orbitalSystem.ellipticalPoints[newTargetIndex];
            targetLocalUnityPosition = new Vector3(newSysPos.X * AU_TO_UU, newSysPos.Y * AU_TO_UU, newSysPos.Z * AU_TO_UU);
            nodeToTargetPosition[node] = targetLocalUnityPosition;

            // Set a new velocity.
            vel = node.orbitalSystem.velocityMagnitudes[newTargetIndex] * PROPAGATION_SPEED;

            // Set movement step 
            step = vel * Time.deltaTime;
        }

        // Mobilize node.
        go.transform.localPosition = Vector3.MoveTowards(currentLocalUnityPosition, targetLocalUnityPosition, step);

        // Adjust alpha gradient of orbital ellipse.
        PropagateOrbitalEllipse(node);

        // If the stellar data panel is active, propagate it
        PropagateStellarMouseoverPanel();

        // If the planetary data panel is active, propagate it
        PropagatePlanetaryMouseoverPanel();

        // If the selection indicator is active, propagate it
        PropagateSelectionIndicator();

        // Mobilize Perifocal Reference frame.
        GameObject referenceFrame = nodeToReferenceFrame[node];
        referenceFrame.transform.localPosition = Vector3.MoveTowards(currentLocalUnityPosition, targetLocalUnityPosition, step);
    }
}

You will notice that at the end of the propagation method, we call a number of submethods to propagate various UI elements around the screen. These submethods are fairly trivial and simply set the position of the element in question to some offset distance from the target object (eg. the Stellar Mouseover panel hovers beside a star that has been moused over; we set its transform to the star’s transform, at an offset).

Error, Drift and Periodic Correction in PISES and in Industry

The movements of the stars and planets in PISES are all discrete movements. In other words, we haven’t really set up a true elliptical arc or a genuine physics model for our bodies to follow, but rather a set of 360 discrete points and 360 discrete velocities. When we check to see if a body has reached its destination, we check to see if it’s within .001 UU of the destination (if we checked to see if was exactly at the destination, it almost never would be; the chances that the velocity of the object would deposit it at the exact position at the instant of the frame-set are very, very small).

What all of this means is that over time, the position and velocities of the bodies are going to start to drift and become inaccurate. This is an inevitable aspect of propagating orbital bodies, not just in PISES, but in industry. Even softwares that harness standard technologies like SGP4 have to correct for error periodically. The conventional Two Line Element Sets of satellites traditionally have a lifespan of about two weeks before perturbations and propagator error eventually render the TLE’s useless.

It is thus common practice to have a corrective period in orbital propagation software: an interval at which we “reset” the orbits and bump them back onto track. Hopefully, this interval is frequent enough that to a user, the “reset” is essentially invisible.

In PISES, the drift becomes apparent after about 600 years of propagation – and much less for highly eccentric systems that have moments of high velocity. If this sounds like it’s much more impressive than the industry tools like SGP4 I mentioned above, it isn’t: all of the orbits in PISES are perfect, ideal, elliptical orbits, with no perturbations whatsoever. As we’ve discussed in this previous post, perturbations are powerful forces that have very real, profound, short term effects on a satellite’s orbit. PISES is only able to stay “accurate” for so “long” because these orbits are hyper-idealized – not how they would ever appear in nature.

Software Footage: A Great Conjunction in PISES

In order to demonstrate the capabilities of the propagator, I thought it would be fun to attempt to capture a Great Conjunction in light of the astronomical event transpiring between Jupiter and Saturn tonight!

It turned out to be a little trickier than I thought to find a conjunction, but after about 30 minutes (and 600 years of propagation) I did manage to produce a (rough) triple-conjunction, which you can see in this video!

When the frame freezes around 30 seconds, we draw a vector connecting three planetary bodies in the video. This would be something like the Great Conjunction that’s about to take place between Jupiter and Saturn. From the vantage of the nearest (or farthest) planet, if you were to look up in the sky, the other two planets would appear to be overlapping, or conjoining. THIS is what it means, astronomically, when we talk about a conjunction!

However, the conjunction between Jupiter and Saturn is a little bit more rare than the one pictured in this video. The conjunction in the video would transpire during the daytime on the example planets. That means, if the worlds had similar atmospheric conditions and its denizens had similar ocular sensory capabilities, this conjunction would be invisible to them.

This actually happened on Earth in the 17th century; there was a Great Conjunction during the daytime, and nobody was able to see it. The last time a GC happened during the night on Earth was in the 13th century. So they’re pretty rare!

Another thing that makes this great conjunction even more interesting is that it’s happening on the Winter Solstice, which is when the Earth’s axis tilts the farthest away from the sun. The fact that these two events overlap is, of course, completely random – but amazing nonetheless!

Scaling:
– Solar Radii x 100
– Planetary Radii : Normalized [1 ER – 10 ER] x 600
– Distance scaling : 1:1- 1 Grid Unit = 1 AU

Next Steps

Now that we are able to instantiate and animate our orbital bodies, the next step will be fleshing out our Solar System Scope with an ecliptic grid and with orbital ellipses. In the next post, we will descend into the waking hell that is working with Unity’s LineRenderer tool.

One thought on “Propagating Orbital Bodies in Unity Gaming Engine

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s