3D Gravity Tech Demo

Understanding Universal Gravitation

Recently, I started a project to create a 3D gravity system in Unity. Initially, the project began as a realistic simulation of Newton’s Law of Universal Gravitation, however, I quickly realized why this is not the standard method of implementing gravity in games and simulations that do not need to be scientifically accurate.

Newton’s Law of Universal Gravitation is a mathematical equation for determining the gravitational force that two masses will apply on each other. The equation can be written F=G(m1*m2)/(r^2), where F is the gravitational force (N), G is the gravitational constant equal to 6.6743 x 10^-11 N*m^2/kg^2, m1 is the mass (kg) of one of the objects, m2 is the mass (kg) of the other object, and r is the distance (m) between the two objects. Essentially, this law tells us that gravity is the force of the mass of every object in the universe pulling on every other object in the universe.

Having every mass apply force on every other mass in the scene is computationally expensive, but more importantly, it is chaotic. I ran into the three-body problem, which is “the problem of determining the motion of three celestial bodies moving under no influence other than that of their mutual gravitation. No general solution of this problem (or the more general problem involving more than three bodies) is possible, as the motion of the bodies quickly becomes chaotic.”1 Chaotic behavior is unpredictable by definition and while it is realistic, it is not what I want for this project.

Super Mario Galaxy

I decided to go back to the first game that I had played with such a 3D gravity system, Super Mario Galaxy, for guidance. Super Mario Galaxy pioneered the concept of using gravity as a game mechanic in three dimensions, and as I revisited it, I realized just how brilliant the game was. My goal changed from creating a realistic gravity simulation to making something that would be fun for a player; I wanted to recreate Super Mario Galaxy‘s gravity system.

Super Mario Galaxy‘s gravity system appears to be wildly inconsistent. Gravity pulls in all different directions and there are no clear rules as to when or where gravity will behave a certain way, but this video from Jasper on YouTube provided a detailed breakdown of how the game’s gravity works.2

While the rules aren’t explicitly given to the player, they do exist. Instead of applying gravity universally, there are invisible colliders called gravitational fields. These fields define a set of rules for how gravity will behave within the collider. Super Mario Galaxy has eight primitive field types: disk, cone, parallel, sphere, cube, torus, cylinder, and wire.

Implementation

For my system, I have simplified this even further down to just four gravity field types: mesh, point, spline, and vector. The mesh gravity field applies gravity towards the nearest point to the player on the ground surface collider. This is the most computationally expensive, but allows for the most freedom in level design and easiest implementation. The point gravity field works just like the sphere gravity field in Super Mario Galaxy; it pulls towards a point at the center of the field. The spline gravity field allows the level designer to identify a Bézier curve, as opposed to a collider or point. Gravity will be applied towards the nearest point on the spline. Lastly, the vector field works like the parallel field in Super Mario Galaxy. It applies gravity in any given direction at all points within the field. These fields can be combined to create infinite unique levels. Any spline or mesh can be turned into a center of gravity for a field, and all of Super Mario Galaxy‘s primitive field types can be replicated with just these four types.

Point Gravity Field
Spline Gravity Field
Mesh Gravity Field
Vector Gravity Field

The torus-shaped field is actually made of points on a spline. While I could have used the mesh field, this would be more computationally expensive than just creating a simple 2D circle-shaped spline and placing it inside of the torus model.

IGravitationalField Interface

using UnityEngine;

public interface IGravitationalField
{
    public float GravitationalForce { get; set; }
    public void OnTriggerEnter(Collider other);
    public Vector3 GetDown(Vector3 position);
}

PointField Class

using UnityEngine;

public class PointField : MonoBehaviour, IGravitationalField
{
    public float GravitationalForce { get; set; } = 0.25f;

    public Vector3 GetDown(Vector3 position) => transform.position;

    public void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent(out Mass mass))
        {
            if (mass.GravitationalField == this) return;
            mass.GravitationalField = this;
            mass.GravitationalFieldEntered(GravitationalForce);
        }
    }
}

MeshField Class

using UnityEngine;

public class MeshField : MonoBehaviour, IGravitationalField
{
    public float GravitationalForce { get; set; } = 0.25f;
    [SerializeField] private Collider _shape;

    public Vector3 GetDown(Vector3 position) => _shape.ClosestPoint(position);

    public void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent(out Mass mass))
        {
            if (mass.GravitationalField == this) return;
            mass.GravitationalField = this;
            mass.GravitationalFieldEntered(GravitationalForce);
        }
    }
}

SplineField Class

using Dreamteck.Splines;
using UnityEngine;

public class SplineField : MonoBehaviour, IGravitationalField
{
    [SerializeField] private SplineComputer _spline;
    public float GravitationalForce { get; set; } = 0.25f;

    public Vector3 GetDown(Vector3 position) => _spline.Project(position).position;

    public void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent(out Mass mass))
        {
            if (mass.GravitationalField == this) return;
            mass.GravitationalField = this;
            mass.GravitationalFieldEntered(GravitationalForce);
        }
    }
}

VectorField Class

using UnityEngine;

public class VectorField : MonoBehaviour, IGravitationalField
{
    public float GravitationalForce { get; set; } = 0.25f;
    [SerializeField] private Vector3 _direction;

    public Vector3 GetDown(Vector3 position) => position + _direction.normalized;

    public void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent(out Mass mass))
        {
            if (mass.GravitationalField == this) return;
            mass.GravitationalField = this;
            mass.GravitationalFieldEntered(GravitationalForce);
        }
    }
}

Mass Class

using UnityEngine;

public class Mass : MonoBehaviour
{
    [SerializeField] private Rigidbody _rigidbody;
    [SerializeField] private Transform _visual;

    public IGravitationalField GravitationalField;
    [SerializeField] private SplineField _startField;

    private bool _isTouchingPlanetSurface = false;
    [SerializeField] private float _raycastLength;

    [SerializeField] private float _rotationForceMagnitude;
    [SerializeField] private float _moveForceMagnitude;
    [SerializeField] private float _gravitationalForceMagnitude;
    [SerializeField] private float _jumpForceMagnitude;

    private const float GRAVITATIONAL_CONSTANT = 1f;

    private float _tempRotationForceMagnitude;
    private float _tempGravitationalForceMagnitude;

    private RaycastHit[] _hits;
    private Vector3 _gravitationalForceDirection;
    private Vector3 _normalForceDirection;
    
    private bool _canJump = true;
    private bool _slowDown = false;

    private void Awake()
    {
        GravitationalField = _startField;
        _tempGravitationalForceMagnitude = _gravitationalForceMagnitude;
        _tempRotationForceMagnitude = _rotationForceMagnitude;
    }

    private void FixedUpdate()
    {
        ApplyGravity();
        ApplyPlanetRotation();
    }

    private void ApplyGravity()
    {
        if (GravitationalField == null) return;

        _hits = Physics.RaycastAll(transform.position, -transform.up, _raycastLength);

        if (_hits.Length == 0)
        {
            _hits = Physics.RaycastAll(transform.position, transform.forward, _raycastLength);
        }

        if (_hits.Length == 0)
        {
            _hits = Physics.RaycastAll(transform.position, -transform.forward, _raycastLength);
        }

        if (_hits.Length == 0)
        {
            _hits = Physics.RaycastAll(transform.position, transform.right, _raycastLength);
        }

        if (_hits.Length == 0)
        {
            _hits = Physics.RaycastAll(transform.position, -transform.right, _raycastLength);
        }

        if (_hits.Length == 0)
        {
            _gravitationalForceDirection = GravitationalField.GetDown(transform.position) - transform.position;
            _hits = Physics.RaycastAll(transform.position, _gravitationalForceDirection, _raycastLength);
        }

        GetGravityNormal();
        _rigidbody.AddForce(_normalForceDirection.normalized * GRAVITATIONAL_CONSTANT * _gravitationalForceMagnitude, ForceMode.Acceleration);
        _hits = new RaycastHit[0];
    }

    private void ApplyPlanetRotation()
    {
        Quaternion targetRotation = Quaternion.FromToRotation(transform.up, _normalForceDirection) * transform.rotation;
        transform.rotation = Quaternion.Lerp(transform.rotation, targetRotation, _rotationForceMagnitude * Time.fixedDeltaTime);
        if (_isTouchingPlanetSurface && _canJump)
            _rotationForceMagnitude = _tempRotationForceMagnitude;
    }

    public void GravitationalFieldEntered(float gravitationalForceMultiplier)
    {
        float velocityMultiplier = .5f;
        float rotationMultiplier = .1f;
        float restoreGravityDelay = .5f;

        _gravitationalForceMagnitude = _tempGravitationalForceMagnitude * gravitationalForceMultiplier;
        _rigidbody.velocity *= velocityMultiplier;
        _rotationForceMagnitude = _tempRotationForceMagnitude * rotationMultiplier;

        _slowDown = true;
        _canJump = false;

        Invoke(nameof(RestoreGravity), restoreGravityDelay);
    }

    public void RestoreGravity()
    {
        _gravitationalForceMagnitude = _tempGravitationalForceMagnitude;
        _canJump = true;
        _slowDown = false;
    }

    private void GetGravityNormal()
    {
        if (GravitationalField == null) return;

        _normalForceDirection = (transform.position - GravitationalField.GetDown(transform.position)).normalized;
        for (int i = 0; i < _hits.Length; i++)
        {
            if (_hits[i].transform == GravitationalField)
            {
                _normalForceDirection = _hits[i].normal.normalized;
                break;
            }
        }
    }

    public void Jump()
    {
        if (!_canJump) return;

        float gravitationalMultiplier = .5f;
        float velocityMultiplier = 0f;
        float rotationMultiplier = .5f;
        float restoreGravityDelay = 1f;

        _rigidbody.velocity *= velocityMultiplier;
        _rigidbody.AddForce(_normalForceDirection * _jumpForceMagnitude, ForceMode.Impulse);
        _gravitationalForceMagnitude = _tempGravitationalForceMagnitude * gravitationalMultiplier;

        Invoke(nameof(RestoreGravity), restoreGravityDelay);

        _canJump = false;
        _rotationForceMagnitude = _tempRotationForceMagnitude * rotationMultiplier;
    }

    public void Move(Vector3 direction)
    {
        float velocityMultiplier = .5f;

        Vector3 moveDir = (transform.forward * direction.z + transform.right * direction.x);
        Vector3 currentNormalVelocity = Vector3.Project(_rigidbody.velocity, _normalForceDirection.normalized);
        _rigidbody.velocity = currentNormalVelocity + (moveDir * _moveForceMagnitude);

        if (moveDir != Vector3.zero)
            _visual.localRotation = Quaternion.LookRotation(direction);

        if (_slowDown)
            _rigidbody.velocity *= velocityMultiplier;

    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.transform == GravitationalField)
        {
            _isTouchingPlanetSurface = true;
        }
    }

    private void OnTriggerExit(Collider other)
    {
        if (other.transform == GravitationalField)
        {
            _isTouchingPlanetSurface = false;
        }
    }
}
  1. Britannica, The Editors of Encyclopaedia. “three-body problem”. Encyclopedia Britannica, 27 Sep. 2024, https://www.britannica.com/science/three-body-problem. Accessed 16 November 2024. ↩︎
  2. JasperRLZ. “How Spherical Planets Bent the Rules in Super Mario Galaxy.” YouTube, YouTube, 2020, www.youtube.com/watch?v=QLH_0T_xv3I. ↩︎