Overview of Staxels Risers

In staxel, every entity has 3 collision boxes. The hitboxes are as follows: The Collider, the Riser and the Interactor.

An image showing Staxel's collision boxes.
White = Collider, Purple = Riser, Green = Interactor

The Collider: Collides with terrain and stops the entity walking through walls.

The Riser: Collides with terrain, but instead of stopping player movement, it will try to move the player upwards until it no longer collides.

The Interactor: Does not collide with terrain or other entities, used for various things like allowing the player to select an NPC.

This tutorial will focus on the 2nd collision box, and recreating it within Unity.


Why would you want to use this system?

An image showing a complex hitbox

In Staxel, every tile in the game was created out of voxels. The game was made to be moddable with tiles requiring only a model and some stats defining how it would work within the world.

This had lead to auto-generating boxy collision models for all tiles, thus meaning the hitboxes are complicated.

Now in your normal game, this probably isn’t going to be much of an issue. You can control how smooth hitboxes are and can control how the player moves on/around objects.

But in cases of user-designed material (i.e. Voxels etc) it can be handy to have a simple way to have a player smoothly go over any object.


As an example, here is a Capsule Collider trying to get up a set of stairs.

Pill Collider heading up Stairs.

As opposed to a Staxel Riser:

Pill Collider heading up Stairs.

Notice how much smoother that is? That is the goal of this tutorial.


Quick Notes

Here are some quick notes on the effectiveness of this method.

Upsides:

  • Can deal with practically all types of collision, simple to complex.
  • Platforming is very forgiving with this method. (Lets players cross gaps larger than they think would be possible.)
  • Movement is smoothed out. Add in some additional camera lerping and you should be set.
  • Slots into the existing Unity Physics system without major adjustments.

Neutral:

  • Gravity needs to be turned on/off via this script.
  • This method lets players squeeze into places which is smaller than the character model

Downsides:

  • The character will no longer physically touch the ground. (Friction needs to be adjusted.)
  • Uses more physics calculations than standard methods.
  • Using non-standard colliders (i.e. Mesh Collider) requires a lot more effort.


Recreating the Riser

Note: For the purposes of this tutorial, this will be created in 3D. However all techniques will work in 2D.

Note: This tutorial assumes you are using Capsule Colliders that remain vertically straight. Adjustments will be needed if that is not the case.

Step 1: Setting up Unity

Your first step, as always, is to create a unity project.

You will want to make a level that will be good for testing movement. It doesn’t need to be complex, just enough to test things out.

An image showing a simple Unity level.
You will probably want Walls, Ramps, and Stairs for testing.

Next create the gameobject for your character, for this tutorial it will show a cylinder, but use whatever you want. Now set it up like the following:

Unity Character Setup

Note: Try to keep the trigger hitbox to a smaller radius than the collider hitbox. If it is equal to or larger, the character could start climbing up walls they come in contact with.

For the purpose of this tutorial, we are going to go with a dead simple character controller. Just to make it easier to debug if anything goes wrong.

DumbCharacterController.cs (C#)

using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class DumbCharacterController : MonoBehaviour {
    public float MovementForce = 10;

    private Rigidbody _rigidBody;

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

    private void Update() {
        var input = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
        _rigidBody.AddForce(input * MovementForce);
    }
}

Note: You can use your own character controller if you have one already. This controller was aimed at keeping things simple so there is less to debug if things go wrong.

Step 2: Implementing the Riser

The first thing to do is to create the script which will control the Riser collision.

RiserController.cs (C#)

using UnityEngine;

public class RiserController : MonoBehaviour {
  public Rigidbody CharacterRigidbody;
  public CapsuleCollider MainCollider;
  public CapsuleCollider RiserCollider;

  public float RiserVelocityMultiplier = 1.5f;
  public float RiserVerticalVelocityMultiplier = 0.25f;

  private const float max_riser_iterations = 6;
  private const float trivial_length = 0.0001f;

  private Vector3 _positionLastFrame;
}

CharacterRigidbody, MainCollider and RiserCollider will need to be attached to their respective components.

RiserVelocityMultiplier and RiserVerticalVelocityMultiplier will control how fast the riser will move compared to the Player’s movement speed.

max_riser_iterations and trivial_length are constants for optimisation. These can be left alone.

Next, lets add in the collision check to this file:

RiserController.cs (C#)

//Note: This function does not handle the Capsule Collider being angled or scaled.
private bool CheckForCollision(CapsuleCollider checkingCollider, float heightOffset) {
  Vector3 start = CharacterRigidbody.position + checkingCollider.center;
  start.y += heightOffset;
  Vector3 end = start;

  if (checkingCollider.height > checkingCollider.radius * 2) {
    float additionalOffset = (checkingCollider.height / 2) - checkingCollider.radius;
    start.y -= additionalOffset;
    end.y += additionalOffset;
  }

  foreach (var overlap in Physics.OverlapCapsule(start, end, checkingCollider.radius)) {
    if (overlap.attachedRigidbody == CharacterRigidbody)
      continue;
    return true;
  }

  return false;
}

This collision check helps convert a CapsuleCollider into the parameters needed for Physics.OverlapCapsule. And then checks to see if it overlaps any other collider that is not our own rigidbody.

As noted, it does not handle the collider being scaled or rotated, as for this tutorial that is not needed. Additional adjustments will need to be made if this is the case.


Alright enough beating around the bush. Lets add the riser part.

RiserController.cs (C#)

private void FixedUpdate() {
  if (CheckForCollision(RiserCollider, 0)) {
    //The character is standing on ground. Remove all downwards momentum to make sure they don't sink into the ground.
    CharacterRigidbody.useGravity = false;
    if (CharacterRigidbody.velocity.y < 0)
      CharacterRigidbody.velocity = new Vector3(CharacterRigidbody.velocity.x, 0, CharacterRigidbody.velocity.z);

    //This makes sure that the player doesn't fall through the floor if they are falling downwards.
    var movementOffset = transform.position - _positionLastFrame;
    float verticalRiserMovement = movementOffset.y <= 0 ? movementOffset.y * movementOffset.y * RiserVerticalVelocityMultiplier : 0;

    //Note: distanceToRise should be capped, if you have fast movement.
    var distanceToRise = Mathf.Sqrt(verticalRiserMovement + movementOffset.x * movementOffset.x + movementOffset.z * movementOffset.z) * RiserVelocityMultiplier;
    if (distanceToRise > 0) {
      var riserStep = 1.0f;
      var totalOffset = 0f;
      for (var i = 0; i < max_riser_iterations; i++) {
        riserStep /= 2.0f; //Perform smaller and smaller movements

        if (riserStep * distanceToRise < trivial_length)
          break;

        var distance = totalOffset + riserStep * distanceToRise;
        if (CheckForCollision(RiserCollider, distance) && !CheckForCollision(MainCollider, distance))
          totalOffset = distance;
      }

      if (totalOffset > 0f)
        CharacterRigidbody.position += new Vector3(0, totalOffset, 0);
    }
  }
  else
    CharacterRigidbody.useGravity = true; //Reactivate gravity as the character is no longer touching the ground.

  _positionLastFrame = transform.position;
}

This function is complex, so let’s break down what it does step-by-step:

  1. Check to see if the riser is colliding with anything. If not, activate gravity and exit.
  2. Deactivate gravity and remove all downwards momentum. This helps to stop the player sinking into the ground.
  3. Calculate how far the player should rise, depending on the difference from the last fixed timestep.
  4. Start iterating to calculate how high the Player can actually rise.
    • Cut the check distance in half. (Large steps first helps settle to the correct spot faster.)
    • If the check distance is too short, just exit as it ain’t worth it to check.
    • Check to see if the new position will still collide with the RiserCollider, and nothing is in the way of the MainCollider. If so, set the totalOffset.
  5. And if the totalOffset is non-zero. Adjust the rigidbody.

And… thats it! Try out the code and see if you like how the character moves around now.


And for reference, the final complete file is this:

RiserController.cs (C#)

using UnityEngine;

public class RiserController : MonoBehaviour {
  public Rigidbody CharacterRigidbody;
  public CapsuleCollider MainCollider;
  public CapsuleCollider RiserCollider;

  public float RiserVelocityMultiplier = 1.5f;
  public float RiserVelocityVerticalMultiplier = 0.25f;

  private const float max_riser_iterations = 6;
  private const float trivial_length = 0.0001f;

  private Vector3 _positionLastFrame;

  private void FixedUpdate() {
    if (CheckForCollision(RiserCollider, 0)) {
      //The character is standing on ground. Remove all downwards momentum to make sure they don't sink into the ground.
      CharacterRigidbody.useGravity = false;
      if (CharacterRigidbody.velocity.y < 0)
        CharacterRigidbody.velocity = new Vector3(CharacterRigidbody.velocity.x, 0, CharacterRigidbody.velocity.z);

      //This makes sure that the player doesn't fall through the floor if they are falling downwards.
      var movementOffset = transform.position - _positionLastFrame;
      float verticalRiserMovement = movementOffset.y <= 0 ? movementOffset.y * movementOffset.y * RiserVerticalVelocityMultiplier : 0;

      //Note: distanceToRise should be capped, if you have fast movement.
      var distanceToRise = Mathf.Sqrt(verticalRiserMovement + movementOffset.x * movementOffset.x + movementOffset.z * movementOffset.z) * RiserVelocityMultiplier;
      if (distanceToRise > 0) {
        var riserStep = 1.0f;
        var totalOffset = 0f;
        for (var i = 0; i < max_riser_iterations; i++) {
          riserStep /= 2.0f;

          if (riserStep * distanceToRise < trivial_length)
            break;

          var distance = totalOffset + riserStep * distanceToRise;
          if (CheckForCollision(RiserCollider, distance) && !CheckForCollision(MainCollider, distance))
            totalOffset = distance;
        }

        if (totalOffset > 0f)
          CharacterRigidbody.position += new Vector3(0, totalOffset, 0);
      }
    }
    else
      CharacterRigidbody.useGravity = true; //Reactivate gravity as the character is no longer touching the ground.

    _positionLastFrame = transform.position;
  }

  private bool CheckForCollision(CapsuleCollider checkingCollider, float offset) {
    //Note: This function does not handle the Capsule Collider being angled.

    var start = CharacterRigidbody.position + checkingCollider.center;
    start.y += offset;

    var end = start;

    if (checkingCollider.height > checkingCollider.radius * 2) {
      var heightOffset = (checkingCollider.height / 2) - checkingCollider.radius;
      start.y -= heightOffset;
      end.y += heightOffset;
    }

    //Would be better to use the NonAlloc version of this
    var overlaps = Physics.OverlapCapsule(start, end, checkingCollider.radius);

    foreach (var overlap in overlaps) {
      if (overlap.attachedRigidbody == CharacterRigidbody)
        continue;
      return true;
    }

    return false;
  }
}


FAQ

Why check that the Riser stays in the ground?

This was added to ensure that the player always stays grounded, and doesn’t end up hovering over the ground.

Removing this check will cause the player to jitter up and down as they move.

The Player starts inside the ground? How do I fix this.

The way this was fixed in staxel, was to rise the player out of the ground on startup. A way to do this would be to add the following:

RiserController.cs (C#)

private const float forced_riser_distance_per_second = 2f;
private bool _forceRise;

private void Start() {
    _forceRise = true;
}

private void FixedUpdate() {
  ...

    var distanceToRise = Mathf.Sqrt(verticalRiserMovement + movementOffset.x * movementOffset.x + movementOffset.z * movementOffset.z) * RiserVelocityMultiplier;
    if (_forceRise)
      distanceToRise = Math.Max(distanceToRise, Time.fixedDeltaTime * forced_riser_distance_per_second);

  ...

    if (totalOffset > 0f)
      CharacterRigidbody.position += new Vector3(0, totalOffset, 0);
    else
      _forceRise = false;

  ...
}

This does mean that players will stay stuck in the ground for a small bit. But this can be hidden by loading screens if need be.

Why not use OnTriggerEnter, OnTriggerExit etc?

This is mainly because of the following section:

RiserController.cs (C#)

for (var i = 0; i < max_riser_iterations; i++) {
  riserStep /= 2.0f;

  if (riserStep * distanceToRise < trivial_length)
    break;

  var distance = riserStep * distanceToRise;
  if (CheckForCollision(RiserCollider, distance) && !CheckForCollision(MainCollider, distance))
    totalOffset += riserStep * distanceToRise;
}

There are two collision checks here, one to ensure that the trigger is still touching the ground, and another to make sure we don’t raise the player too high into other collision.

This cannot be replicated with OnTriggerEnter/OnTriggerExit as they typically do not trigger multiple times a frame.