This is Part 2 of a series of Blog Posts. Find the Previous one here: Sci-fi Racing P1: The First Controller
Hovering as defined by Cambridge:
- to stay in one place in the air, usually by moving the wings quickly
- to stand somewhere, especially near another person, eagerly or nervously waiting for their attention
- to stay at or near a particular level
- to put the cursor on a computer screen in a particular place without clicking on it
The ones that sounds most like what I'm aiming for is 1 and 4 but they are not quite qualifying for what I'm thinking about when I say "Hovercar" for example. At least for me a hovercar is a vehicle, usually analogous to a normal car, that "floats" or "levitates" just above ground whether the car is moving or not.
The goal here is to make a hovering setup for the hovervehicles (as it might not be a car, could be a bike too or something third) which is modular and adjustable such that we could attach any number of hover thrusters or engines to a vehicle to get different interesting behaviours which could let us introduce vehicle classes later down the line. But what does that look like exactly? The way I see it, I should look at what makes a car and then try to transfer the concepts I want to a thruster of sorts. To achieve this we should make use of Unity's Physics system and substitute in our own physics where applicable.
Looking at cars there are a couple of properties to consider to not be dragged to the ground and those we are most concerned with:
For now we'll not concern ourselves with the weight of the vehicle. Although weight can have some interesting implications for handling the car we will at first focus on just getting some kind of system that, using physics, can keep some sort of body from hitting the ground in a stable manner. Stable in this context means that it won't spontaneously wobble or change directions if no other forces are introduced. Second is suspension. While we don't have "suspension" in the traditional sense of a car, we do want a similar effect to what suspension provides for a normal car. That being shock absorption from the road regardless of changing elevation. Lastly, we have the upwards force. This allows the car to stay above the road and not drag its body across the ground as you drive. Most of this upwards force is handeled by the tires of the car (more specifically the pressure inside the tires) and the rigidity of the materials used. We can simulate the upwards force easily with thruster-like logic and in that same go also achieve something that feels like suspension.
Luckily for us, given that it's a hovervehicle we are making, we don't have to care about friction as the vehicle doesn't touch the ground, or at least not while it's operational. Instead we'll have to deal with air drag, which we will look at in a later post.
The bad news is that car physics are hard to do right. Just look inside any of Unity's Car examples and you'll find that conventional wisdom tells you to never touch the Wheel Colliders and their logic and just adjust the numbers instead. The good news is that, we don't have to actually implement real car physics here. We might get to that in future if that makes sense to more closely mimicks the parts we want to sell the illusion, but for my idea right now we could try a more simple approach that is still Physics driven and see how that works in practice. The two ideas I have are one big thruster that keeps the body above ground and the other idea is to modularise the thrusters such that each of them have a different center of mass and thus can affect the vehicle in different ways, the parts coming together to form a sum of the total Physics calculation to affect the body.
For the purposes of testing out the above ideas I have made a scene that I call the TestLab so we can setup different scenarios and code to see how it acts before we commit to an approach. This also allows us to iterate more rapidly as instead of making an entire vehicle's code-base we are only concerned with the thrust that keeps it above ground. Now I said I had two approaches I wanted to try, so we'll start with the simplest one: a single thruster that keeps a cube off the ground when dropped into a level using Physics. The script is simple and looks similar to the one we had in the previous post because it's taken from the same codebase to try and build on.
using UnityEngine;
namespace Game.TestLab.Version1
{
[RequireComponent(typeof(Rigidbody))]
public class HoverThruster : MonoBehaviour
{
[SerializeField] private float Force;
[SerializeField] private float Gravity;
[SerializeField] private float MaxGroundDistance;
[SerializeField] private LayerMask GroundLayer;
private Rigidbody rb;
bool isOnGround;
private Ray groundRay;
private RaycastHit groundHit;
private void Start()
{
rb = GetComponent<Rigidbody>();
rb.useGravity = false;
}
private void FixedUpdate()
{
groundRay = new Ray(transform.position, -transform.up);
isOnGround = Physics.Raycast(groundRay, out groundHit, MaxGroundDistance, GroundLayer);
if (isOnGround)
{
float height = groundHit.distance;
Vector3 groundNormal = groundHit.normal.normalized;
Vector3 force = Force * Time.deltaTime * groundNormal;
Vector3 gravity = Gravity * height * Time.deltaTime * -groundNormal;
rb.AddForce(gravity, ForceMode.Acceleration);
rb.AddForce(force, ForceMode.Acceleration);
}
}
}
}
This is a little less than 50 lines of code and what it does is nothing ground breaking. Every Physics Update it attempts to see if we are close enough to the ground and if we are, find the ground normal, apply a force that pushes us up and apply gravity to keep us from ascending forever. The force is 68, the Gravity is 80 and the MaxGroundDistance is 100.
The result of that looks like this:
The box falls but is caught by the upwards forces before it returns up and keeps going until it stabilises. In isolation at least, this is a decent start. However I'm not super happy with how long it takes for the "suspension" to kick in and ensure that the vehicle stops moving up and down. It would also have a hard time recovering if the cube fell from a greater height as the upwards force would not be enough to stop it. However, remember in the previous post when I introduced the PIDController
Unity made? It can actually be really useful here as it exactly is introduced to ensure the power required to hover above the ground is always relatively proportional to the current forces being applied.
To make room for the PIDController
and still letting us use the same file we introduce a boolean UsePid
and if it's on we make use of the PIDController
to adjust the upwards force. The code now looks like this:
using UnityEngine;
namespace Game.TestLab.Version1
{
[RequireComponent(typeof(Rigidbody))]
public class HoverThruster : MonoBehaviour
{
[SerializeField] private float Force;
[SerializeField] private float Gravity;
[SerializeField] private float MaxGroundDistance;
[SerializeField] private bool UsePid;
[SerializeField] private LayerMask GroundLayer;
private Rigidbody rb;
private PIDController pidController;
bool isOnGround;
private Ray groundRay;
private RaycastHit groundHit;
private void Start()
{
rb = GetComponent<Rigidbody>();
rb.useGravity = false;
pidController = new();
}
private void FixedUpdate()
{
groundRay = new Ray(transform.position, -transform.up);
isOnGround = Physics.Raycast(groundRay, out groundHit, MaxGroundDistance, GroundLayer);
if (isOnGround)
{
float height = groundHit.distance;
Vector3 groundNormal = groundHit.normal.normalized;
Vector3 force;
if (UsePid)
{
float forcePercentage = pidController.Seek(groundHit.point.y + 1f, height);
force = (Force * forcePercentage) * Time.deltaTime * groundNormal;
}
else
{
force = Force * Time.deltaTime * groundNormal;
}
Vector3 gravity = Gravity * height * Time.deltaTime * -groundNormal;
rb.AddForce(gravity, ForceMode.Acceleration);
rb.AddForce(force, ForceMode.Acceleration);
}
}
}
}
A rather simple if statement has been introduced and so, with this new change our new test looks like this:
Now this looks much closer to what you'd expect from a suspension system without actually being one! The body is dropped from the same height as the previous example, however some things did change. The Force
we need with the PIDController
is moved up from 68 to 250 because we are no longer fighting a real gravity when we adjust for the ground, we are adjusting to the artificially adjusted value from PIDController
so the Force
needs to be higher. But the result is a lot better feeling! Less floaty (ironically) and more impactful.
So now we can move on to the Modular version.
This sounds harder than it is to achieve. Instead of having one thruster that covers the entire body at once, we will attempt to attach multiple thrusters that all contribute to the Rigidbody's overall physics forces. The good thing about an approach like that is it will unlock more interesting vehicles in future and it will allow for other possibilities like having individual thrusters be damaged during the race and whatnot. The first attempt is using a lot of the same code as is used in the previous examples. We will make a version that does not use the PIDController
and one that does.
What mostly changes here is where the Thruster gets the information about the Gravitational force. It has been moved to a separate component called PhysicsSettings
for ease of use:
using UnityEngine;
namespace Game.TestLab.Version1
{
public class PhysicsSettings : MonoBehaviour
{
public float Gravity => gravity;
[SerializeField] private float gravity;
}
}
And then in the script:
using UnityEngine;
namespace Game.TestLab.Version1
{
public class HoverThrusterModular : MonoBehaviour
{
[SerializeField] private PhysicsSettings settings;
[SerializeField] private Rigidbody rb;
[SerializeField] private Vector3 centerOfMass;
[SerializeField] private float Force;
[SerializeField] private float MaxGroundDistance;
[SerializeField] private bool UsePid;
[SerializeField] private LayerMask GroundLayer;
private PIDController pidController;
bool isOnGround;
private Ray groundRay;
private RaycastHit groundHit;
private void Start()
{
rb.useGravity = false;
pidController = new();
}
private void FixedUpdate()
{
groundRay = new Ray(transform.position, -transform.up);
isOnGround = Physics.Raycast(groundRay, out groundHit, MaxGroundDistance, GroundLayer);
if (isOnGround)
{
float height = groundHit.distance;
Vector3 groundNormal = groundHit.normal.normalized;
Vector3 force;
if (UsePid)
{
float forcePercentage = pidController.Seek(groundHit.point.y + 1f, height);
force = (Force * forcePercentage) * Time.deltaTime * groundNormal;
}
else
{
force = Force * Time.deltaTime * groundNormal;
}
Vector3 gravity = settings.Gravity * height * Time.deltaTime * -groundNormal;
rb.AddForce(gravity, ForceMode.Acceleration);
rb.AddForceAtPosition(force, rb.transform.TransformPoint(centerOfMass), ForceMode.Acceleration);
}
}
}
}
Fairly straight forward changes. Now what also changes is how we apply the Thrust itself. Instead of applying the Thrist to the center of the Rigidbody like the Singular Thruster example, we instead give each Thruster a "Center of Mass" point that's used when applying Force to the Rigidbody: rb.AddForceAtPosition(force, rb.transform.TransformPoint(centerOfMass), ForceMode.Acceleration);
.
With the first example being with the PIDController
flag set to false we get the following result:
This seems a bit like the very first example, fairly promising however then after a while you realise that this system is Unstable. It will very gradually, at first almost not at all, add a bit of error value to the force calculation and eventually it reaches a breaking point where the body can no longer stay upright and instead topples to one side. However as we will see in a minute, this does not apply to the PID version:
So it appears that the PID controller is superior again! It falls to the ground quickly and stabilises quickly as well while staying stable, something that's harder with multi-thruster systems. Case closed right? Well...not quite.
So far we have tested all the four versions of the implementation on stationary surfaces. The ground doesn't move and no forces have been applied in any other direction of the thruster. We will keep not applying forward thrust for a while longer, but we can certainly add a bit of testing to the environment to see how the implementations fair when the environment is no longer stationary. To do this, I created a script that made the plane the thruster is above rise and fall like a Sine curve.
using UnityEngine;
namespace Game.Scripts.TestLab
{
public class SineMove : MonoBehaviour
{
[SerializeField] private float Speed;
[SerializeField] private bool Pause;
private float elapsed;
public void Update()
{
if (!Pause)
{
elapsed += Time.deltaTime;
float sin = Mathf.Sin(elapsed);
sin *= Speed * Time.deltaTime;
transform.position = new Vector3(transform.position.x, transform.position.y + sin, transform.position.z);
}
}
}
}
We apply this to the platform that's underneath the Thruster implementations and we get this scene:
As we can see here, the 1st from the left and the 3rd from the left actually have the exact same problem. They can't stabilise themselves when the elevation manages to touch the body itself resulting in error being introduced and the Rigidbody being rotated, no longer upright whereas the 2nd and 4th examples from the left are both stable at changing elevations. So now is it a shut case that the PID controllers are superior?? Weeeell...
We have one more test I want to make to be absolutely sure that this "thruster suspension" system we have is worth moving on with. So to make the ultimate test I deviced a little method that takes a small Unity terrain and then morphs it in real time to simulate an ever changing never-quite-straight-plane terrain. This means that we still don't introduce more forces, we just make the ground move instead of the body! How do we achieve this? Some of you might be thinking "vertex shader!" or "cloth simulation?" however, there is a much simpler (yet not as computationally good but that's okay for the test) way we can do this instead! Simply modify a small terrain tile and offset the generation of it every frame so that the environment "moves". I found one of the old Brackey's tutorials on procedural terrain generation which led me to this video. The short version is that we just want the code from the video and are not super interested in the general application for it that Brackeys was aiming for.
We also have to ensure a stable framerate as recalculating a lot of height maps in real-time is very expensive every frame. Luckily for us we want the exact same terrain modification of all the test surfaces so that we know all the thruster implementations are put through the same conditions. To achieve this, I made one terrain morphing script and then had all the terrains share that data.
Here is the script to generate the heightmaps:
using UnityEngine;
namespace Game.TestLab
{
public class TerrainMoveSharedSettings : MonoBehaviour
{
public TerrainData Data => terrainData;
[SerializeField] private MoveAxis Axis = MoveAxis.XY;
[SerializeField] private float MoveSpeed;
[SerializeField] private int Width;
[SerializeField] private int Height;
[SerializeField] private int ResolutionFactor = 1;
[SerializeField] private float Depth;
[SerializeField] private float Scale;
[SerializeField] private bool Paused;
public enum MoveAxis { X, Y, XY }
private TerrainData terrainData;
private float offsetX;
private float offsetY;
private void Update()
{
if (!Paused)
{
if (terrainData == null) return;
if (Axis == MoveAxis.X || Axis == MoveAxis.XY) offsetX += MoveSpeed * Time.deltaTime;
if (Axis == MoveAxis.Y || Axis == MoveAxis.XY) offsetY += MoveSpeed * Time.deltaTime;
terrainData = GenerateTerrain(terrainData);
}
}
public void SetupSharedTerrain(TerrainData data)
{
if (terrainData != null) return;
terrainData = data;
}
private TerrainData GenerateTerrain(TerrainData terrainData)
{
terrainData.size = new Vector3(Width, Depth, Height);
terrainData.SetHeights(0, 0, GenerateHeights());
return terrainData;
}
private float[,] GenerateHeights()
{
float[,] heights = new float[Width * ResolutionFactor, Height * ResolutionFactor];
for (int x = 0; x < Width * ResolutionFactor; x++)
{
for (int y = 0; y < Height * ResolutionFactor; y++)
{
heights[x, y] = CalculateHeight(x, y);
}
}
return heights;
}
private float CalculateHeight(int x, int y)
{
float xCoord = (float)x / Width * Scale + (Axis == MoveAxis.X || Axis == MoveAxis.XY ? offsetX : 0f);
float yCoord = (float)y / Height * Scale + (Axis == MoveAxis.Y || Axis == MoveAxis.XY ? offsetY : 0f);
return Mathf.PerlinNoise(xCoord, yCoord);
}
}
}
And here is the script that morphs the terrains I'm using every frame:
using UnityEngine;
namespace Game.TestLab
{
[RequireComponent(typeof(Terrain))]
public class TerrainMoveShared : MonoBehaviour
{
[SerializeField] private TerrainMoveSharedSettings settings;
private Terrain terrain;
private void Start()
{
terrain = GetComponent<Terrain>();
settings.SetupSharedTerrain(terrain.terrainData);
}
private void Update()
{
if (settings.Data.Equals(null)) return;
terrain.terrainData = settings.Data;
}
}
}
And with all that work we get:
Look at the Single Thrusters! They can be in an everchanging Terrain environment just fine. No instability whatsoever. And that's both with or without PID. The modular ones are struggling both with and without PID. So clearly there is an answer somewhere in the middle of our implementations for a version 2. We need something that is as stable as the Single Thruster with PID assistance both when Stationary and Moving, but it needs to be Modular like the PID assisted modular controller when the environment is changing height with the Sine curve.
PID assistance is vital to get the right feel for our hover thrusters. Our current implementations are imitating dependent suspension when really they should be imitating independent suspension like in the example found in the Research part of the post. We want the qualities of a Single Thruster system to handle any bumps in the road but modular. So there is a bit of a ways to go yet, but this is a really nice start!