This is Part 3 of a series of Blog Posts. Find the Previous one here: Sci-fi Racing P2: Hovering

Refining the Thruster

In the previous post I had a couple of different approaches to thrusters using PID controllers and I had thought to myself that perhaps those would be worth further looking into and refining to work as I needed them to. However on further inspection it turns out that my approach was inherently unstable because of some complicated PID theory that simply goes over my head. So I had to look elsewhere for my idea. I knew that magnets have been used to create hover travel of sorts when acting as super conductors. Perhaps I could find something less PID dependent in that realm?

Semantics

I was looking into Hovering for the first part of this and after looking for some papers on the topic and generally just topics online I found surprisingly little. So instead I thought of synonyms and came up with levitation. Levitation and Hovering are not all that different. Interestingly the definition for Levitation uses the word "Hovering" to describe it. However when I switched up my search I found a bunch of stuff instead. As with any research it's about knowing the correct thing to search for.

Magnets

This search led me to a paper on ResearchGate called Repulsive Magnetic Levitation Force Calculation For A High Precision 6-DoF Magnetic Levitation Positioning System (just rolls off the tongue doesn't it?). In this paper there is a lot of science and math that tries to describe a simplified way (read: faster) to determine a magnetic system's ability to levitate in a controlled environment. Games are just smoke and mirrors anyway so this might be a good starting point for me. A lot of game mechanics, when they need to simulate or fake something from real life, could be derived from learning how to read academic papers. It's not easy, however after you've read a couple you start picking up on what parts are important to you.

In that paper I found the following snippet:

That's a lot of words and scary math symbols!

At this point your eyes might be glazing over, or you are excited! Whichever of the two, the important part is the formular J = ((N • l) / (bai • h)) • ey which results in a vector that represents the force we wish to have to levitate. Now of course we don't have any coils or current or even a cross sectional area as the formular describes. But we don't need it! We can fake our way through that. So after a friend helped me understand the above computation I wrote a script to represent this fake magnet and we get:

public class Magnet : MonoBehaviour
{
    [SerializeField] public int coilTurns;
    [SerializeField] public float current;
    [SerializeField] public float gravityForce;
    [SerializeField] public float crossSectionalArea;
    [SerializeField] public Rigidbody RBody;
    [SerializeField] public LayerMask groundLayer;

    bool isOnGround;

    private Ray groundRay;
    private RaycastHit groundHit;
    private float maxGroundDistance = 100f;
    private PIDController pidController;

    private float currentHeight;
    private float pidForce;
    private Vector3 groundNormal;
    private Vector3 force;
    private Vector3 gravity;

    private void Start()
    {
        pidController = new();
    }

    private void FixedUpdate()
    {
        groundRay = new Ray(transform.position, -transform.up);
        isOnGround = Physics.Raycast(groundRay, out groundHit, maxGroundDistance, groundLayer);

        if (isOnGround)
        {
            currentHeight = groundHit.distance;
            groundNormal = groundHit.normal.normalized;
            pidForce = pidController.Seek(groundHit.point.y + 0.75f, currentHeight);
            force = coilTurns * current * pidForce / crossSectionalArea * groundNormal * Time.deltaTime;
            gravity = gravityForce * currentHeight * Time.deltaTime * -groundNormal;

            RBody.AddForce(gravity, ForceMode.Acceleration);
            RBody.AddForce(force, ForceMode.Acceleration);
        }
    }
}

Which when run side by side with the last thruster implementation looks like this:

Magnets....how do they work?

Look at how graciously the magnet gently flows just above the curvature of what is directly underneath it. Looks great right? But....there are a couple of problems.

Proportional Integral Derivative Controllers

First of all this representation of a magnet is...quite lacking because this is not how magnets actually work. They don't even mesh well with PID theory despite how nicely it follows the curvature of the underlying shifting landscape (damn it). This becomes especially clear when you try to modularize this approach out into multiple magnets working together.

public class MagnetEngine : MonoBehaviour
{
    public bool IsOnGround { get; private set; }
    public float CurrentHeight {  get; private set; }

    [Header("Engine")]
    [SerializeField] private float gravityForce;
    [field: SerializeField] public float DistanceToGround { get; private set; }
    [field: SerializeField] public Rigidbody RBody { get; private set; }
    [field: SerializeField] public float MaxGroundDistance { get; private set; }
    [field: SerializeField] public LayerMask GroundLayer {  get; private set; }
    [field: SerializeField] public PIDController PidController { get; private set; }
    [Header("Magnet Settings"), Space(5)]
    [SerializeField] private MagnetModular[] magnets;
    [field: SerializeField] public float CoilTurns;
    [field: SerializeField] public float Current;
    [field: SerializeField] public float CrossSectionalArea;

    private Ray groundRay;
    private RaycastHit groundHit;

    private Vector3 groundNormal;
    private Vector3 gravity;

    private void Start()
    {
        RBody.useGravity = false;
        if (magnets != null && magnets.Length > 0)
        {
            foreach (MagnetModular magnet in magnets)
            {
                if(magnet) magnet.Setup(this);
            }
        }
    }

    private void FixedUpdate()
    {
        groundRay = new Ray(transform.position, -transform.up);
        IsOnGround = Physics.Raycast(groundRay, out groundHit, MaxGroundDistance, GroundLayer);

        if (IsOnGround)
        {
            CurrentHeight = groundHit.distance;
            groundNormal = groundHit.normal.normalized;
            gravity = gravityForce * CurrentHeight * Time.deltaTime * -groundNormal;

            RBody.AddForce(gravity, ForceMode.Acceleration);
        }
    }
}

And then the magnet itself:

public class MagnetModular : MonoBehaviour
{
    [SerializeField] private Vector3 centerOfMass;

    private MagnetEngine engine;

    private Ray groundRay;
    private RaycastHit groundHit;

    private float pidForce;
    private Vector3 groundNormal;
    private Vector3 force;

    public void Setup(MagnetEngine engine) => this.engine = engine;

    private void FixedUpdate()
    {
        if (!engine) return;

        if (engine.IsOnGround)
        {
            groundRay = new Ray(transform.position, -transform.up);
            Physics.Raycast(groundRay, out groundHit, engine.MaxGroundDistance, engine.GroundLayer);

            groundNormal = groundHit.normal.normalized;
            float distance = (engine.CurrentHeight - groundHit.distance) + engine.DistanceToGround;
            if (groundHit.distance < distance)
            {
                pidForce = engine.PidController.Seek(distance, groundHit.distance);
                force = engine.CoilTurns * engine.Current * pidForce / engine.CrossSectionalArea * groundNormal * Time.deltaTime;
            }
            else
            {
                force = engine.CoilTurns * engine.Current / engine.CrossSectionalArea * groundNormal * Time.deltaTime;
            }

            engine.RBody.AddForceAtPosition(force, engine.RBody.transform.TransformPoint(centerOfMass), ForceMode.Acceleration);
        }
    }

    private void OnDrawGizmos()
    {
        Ray ray = new(transform.position, force.normalized);
        Gizmos.DrawLine(ray.origin, ray.origin + (ray.direction * force.magnitude));
        Gizmos.DrawSphere(ray.origin + (ray.direction * force.magnitude), 0.05f);
    }
}

In this next video you will see how I go frame by frame at first and notice (via the visible gizmos) how each magnet is creating force and in what direction that force is pulling the magnet. As you will see the magnets are actively fighting each other because each individual magnet believes it's the only PID controller despite that PID controller being shared with all the magnets. Could you fix this by having individual PID controllers for each magnet? It's possible, however that's also a lot of computation which might bog down the game.

Magnet Supremacy War

Clearly a different approach is necessary.

More Research

I went looking for even more research on this to see what other approaches I might have missed. I found a couple of resources:

But none of it actually helped me achieve what I wanted to do which was have an upwards levitation thruster that could work like suspension systems does in cars. But then I was lucky to catch a break! Because on Freya's discord server there was a user (named weasel) who told me what I attempted to do could most likely be accomplished by using a simple Harmonic Oscillator representation instead. It's not exactly how physics work but that's okay as long as it conveys the right feel and the resulting calculation we use can still be represented as a force. Nice!

Harmonic Oscillator Supremacy

So after being told what kind of calculation we are talking about to get that to work, this is as simple as it gets: force = groundRay.direction * 2 * (groundHit.distance - desiredGroundDistance). That's it. That formular is all it takes. You can then expand it to include a controllable force coefficient (which I will do later) to increase the effectiveness of the oscillator. The code for this version looks like like this:

[SerializeField] private float gravityForce = 80f;
[SerializeField] private float desiredGroundDistance = 1f;
[SerializeField] private float maxGroundDistance = 30f;
[SerializeField] private LayerMask groundLayer;

private bool isOnGround;
private Rigidbody rbody;
private Ray groundRay;
private RaycastHit groundHit;
private Vector3 force;
private Vector3 gravity;

private void Start()
{
    rbody = GetComponent<Rigidbody>();
}

private void FixedUpdate()
{
    groundRay = new Ray(transform.position, -transform.up);
    isOnGround = Physics.Raycast(groundRay, out groundHit, maxGroundDistance, groundLayer);
    if (isOnGround)
    {
        force = groundRay.direction * 2 * (groundHit.distance - desiredGroundDistance);
        gravity = gravityForce * Time.deltaTime * groundRay.direction;
        rbody.AddForce(gravity, ForceMode.Acceleration);
        rbody.AddForce(force, ForceMode.Acceleration);
    }
}

And here is the magnet approach (left) and the harmonic oscillator approach (right) side by side. In this example I showcase a slow terrain shifting followed by a 10x faster version to showcase another problem with the PID Magnet more clearly:

Magnet (left) and Harmonic Oscillator (right)

What we notice right away is how much bounce the new approach has before it comes closer to a resting position. This is much more natural feeling than the magnet approach because it doesn't feel nearly as stiff. You will also notice that when the speed of the terrain shifting is increased the magnet approach follows the ground variation much more closely. In fact too closely. It makes the magnet feel incredibly stiff whereas the Harmonic Oscillator moves very little, even when the landscape is shifting fast. Lastly here are all the four approaches side by side. The difference is quite noticeable once you set them up like this:

From left to right: PID Thruster, Magnet, Modular Magnet and Harmonic Oscillator

We do still have one problem to solve though, which the other thrusters have not had a chance to be tested for because they were too unstable; falling from great heights. In this racing game I envision that you can fall from rather great heights while racing and whatever thruster implementation I go with that needs to be something it can handle. The height may be any arbitrary height so the forces involved need to be able to handle anything. I should implement some kind of cap to the fall velocity though to make that calculation easier on the physics engine and to limit how big the error margin can get. To help this I introduce a thrusterForce value making our calculation from earlier into this: force = groundRay.direction * 2 * (groundHit.distance - desiredGroundDistance) * thrusterForce;. This ensures that the system has more power when I need it to.

Additionally the Unity Rigidbody already has linear velocity dampening built-in so we just need to crank that value up to control the oscillation that happens from falling (basically getting to a resting point faster) and to avoid collision with the ground from great heights and after fiddling with values we get:

Slowdown as it gets close to the ground and no collision with the terrain

An elegant landing when falling from about 30 units up into the air. So that works fairly well! The body falls quickly, slows down when getting close to the ground as the thruster logic kicks in and then ensures that it won't collide with the underlying terrain causing unwanted unpredictable forces and rotation. There is one problem here though (there is always something...) which is that 30 units up isn't actually all that high and if the body is dropped from 60, 80 or 150 units up the body cannot catch itself in time without fiddling with settings again, which is something we wish to avoid. We need something that can somewhat self correct with minimum effect on the thruster itself.

More Oscillators is more better

One last effort to try and make use of this type of thruster before further testing I decided to modularize it like I have the other options to see how that fairs in a simple setup. The "engine" script is what carries most of the numbers and applies gravity to the Rigidbody whereas each individual thruster, without knowledge of the others, simply applies upwards force independently.

The engine script:

public class HarmonicOscillatorEngine : MonoBehaviour
{
    public Rigidbody RBody => rbody;
    [field: SerializeField] public float ThrusterForce { get; private set; } = 2f;
    [field: SerializeField] public float GravityForce { get; private set; } = 80f;
    [field: SerializeField] public float DesiredGroundDistance { get; private set; } = 1f;
    [field: SerializeField] public float MaxGroundDistance { get; private set; } = 30f;
    [field: SerializeField] public LayerMask GroundLayer { get; private set; }

    private Rigidbody rbody;
    private bool isOnGround;
    private Ray groundRay;
    private Vector3 gravity;

    private void Start()
    {
        rbody = GetComponent<Rigidbody>();
    }

    private void FixedUpdate()
    {
        groundRay = new Ray(transform.position, -transform.up);
        isOnGround = Physics.Raycast(groundRay, out RaycastHit _, MaxGroundDistance, GroundLayer);
        if (isOnGround)
        {
            gravity = GravityForce * Time.deltaTime * groundRay.direction;
            rbody.AddForce(gravity, ForceMode.Acceleration);
        }
    }
}

and the thruster script:

public class HarmonicOscillatorModular : MonoBehaviour
{
    [SerializeField] private HarmonicOscillatorEngine engine;

    private bool isOnGround;
    private Ray groundRay;
    private RaycastHit groundHit;
    private Vector3 force;

    private void FixedUpdate()
    {
        if (engine == null) return;

        groundRay = new Ray(transform.position, -transform.up);
        isOnGround = Physics.Raycast(groundRay, out groundHit, engine.MaxGroundDistance, engine.GroundLayer);
        if (isOnGround)
        {
            force = groundRay.direction * 2 * (groundHit.distance - engine.DesiredGroundDistance) * engine.ThrusterForce;
            engine.RBody.AddForceAtPosition(force, transform.position, ForceMode.Acceleration);
        }
    }
}

And after placing four thrusters on the cube like the others we get the following:

Four Harmonic Oscillator Thrusters

Even when dropped from high up this version might hit the ground but because there are four oscillators working in tandem like this they can easily correct themselves from the resulting rotational forces that would have otherwise sent it spinning. This is great as it's unlikely any of the vehicles would ever only have one thruster to keep them levitating. To illustrate the problem I've set these two harmonic oscillator bodies up so that they fall from 400 units up in the air and have the same rigidbody and thruster settings.

Blink and you'll miss it!

What you'll notice right away is that the example on the right which has multiple correction thrusters can regain stability after the fall and start following the curvature again whereas the one on the left not only can't reach the minimum height off the ground it's supposed to it also can't stabilize because there is nothing to correct the rotational forces it got from the impact. You can solve this by upping the Linear Dampening to double it's current value on the 1 thruster example, however then this becomes a tedious tweaking task with every single setup which isn't as interesting of an exercise as being able to place many thrusters and play with Center of Mass to get different feeling cars.

Testing on a Car

At this point I thought "Why not test the thrusters on the car model I have?" just to see how it does. To my surprise it works fairly well with a non-cube model as you can see below:

Disabling and Enabling thrusters

Even though it wouldn't work exactly as you'd expect with thrusters in real life the effect is actually most of the way there that we need to sell the effect. I can put thrusters in different places to get different forces and it somewhat conforms to the shape of the car I've put the thrusters on. But...there is a problem here which wasn't as clear with the Cube model because of it's uniformity. Since the system only has a single rigidbody and the thrusters are fighting to adjust the to the ground, something funny happens when you let the car fall from higher up (doesn't even need to be that high up):

In this case we can't spin to win, sadly.

It starts to spin rather out of control as it falls. The problem is that the thrusters are trying to act while falling and the center of mass is a bit off so the thrusters don't work quite right. This is fairly easy to solve though! First we add a way for the thruster to only work when it's close enough to the ground so that the car shouldn't spin out of control even if the thrusters are on when high in the air.

isOnGround = Physics.Raycast(groundRay, out groundHit, engine.DesiredGroundDistance, engine.GroundLayer);

All we do here is change the engine.MaxGroundDistance value with the engine.DesiredGroundDistance. So "on the ground" for a thruster means we need to be very close whereas the engine can be much farther off the ground to apply gravity. Gravity should always be applied regardless of how far away the car is from the ground, however the trickier part for this game is making sure gravity is applied in the direction of the ground regardless of how the vehicle is rotated. But we can tweak that later. For now, we need to solve another problem. The vehicle should rotate such that it aligns with whatever is considered the ground to ensure stable play when levitating rather than flying. It should be as simple as adding this to the engine:

Vector3 cross = Vector3.Cross(groundHit.normal, transform.forward);
rbody.AddTorque(cross * 5f, ForceMode.Acceleration);

And it is implented as such in the engine:

private void FixedUpdate()
{
    groundRay = new Ray(transform.position, -transform.up);
    isOnGround = Physics.Raycast(groundRay, out groundHit, MaxGroundDistance, GroundLayer);

    if (isOnGround)
    {
        gravity = GravityForce * Time.deltaTime * groundRay.direction;
        if (Vector3.Dot(rbody.transform.up, groundHit.normal) < 0.98f)
        {
            Vector3 cross = Vector3.Cross(groundHit.normal, transform.forward);
            rbody.AddTorque(cross * 5f, ForceMode.Acceleration);
        }
    }
    else
    {
        gravity = GravityForce * Time.deltaTime * Vector3.down;
    }
    rbody.AddForce(gravity, ForceMode.Acceleration);
}

With all of this, we lastly change the Rigidbody itself to have a centerOfMass at (0,0,0.275) and in the engine script we set the Thruster force to 10 and the Gravity Force to 2000. That gives us the following result:

That feels pretty convincing!

This isn't perfect yet, a bit stilted, however it's all driven by physics! So now we need to further tweak and play with the settings to get the desired result.

Conclusion

There is more to do here, but we are already in a much better place than when we started! Next up I need to test some other things such as, what happens if I change the rotation of the underlying environment for example? Will the vehicle follow as expected and be "magnitised" to the ground? And if we can make that work, then we should look into forward thrusting as well. There is also one small issue right now of angular drifting. Essentially, when the underlying terrain changes it introduces a bunch of little forces on the levitation thrusters as you'd expect however if the underlying terrain changes fast enough then the vehicle will begin to drift in one direction. Either clockwise or counter-clockwise around the global Y axis. Something we could do about it is introduce a check for when the player gives forward thrusting input to not let torque affect the body as we wish for the players to have full control when they do exert control. But we also need to ensure that the rotational torque has very little effect on forward motion so if you do let go of forward thrusting, then the vehicle should still go in a straight line.

All in all we are getting closer to having a physically based controller that uses forces to drive everything about it which is really cool!

Always good to see the progress like this I feel

Previous Post Next Post