This is the fifth installment of a tutorial series about controlling the movement of a character. It covers replacing standard gravity with a custom approach, through which we support walking on a sphere.

This tutorial is made with Unity 2019.2.21f1. It also uses the ProBuilder package.

Variable Gravity

Up to this point we have always worked with a fixed gravity vector: 9.81 straight down. This is sufficient for most games, but not all. For example, walking on the surface of a sphere that represents a planet isn't currently possible. So we're going to add support for custom gravity, which needn't be uniform.

Before we get complex, let's start by simply flipping the gravity and see what happens, by making the Y component of the gravity vector positive, via the project settings. This effectively turns it into antigravity, which should make our sphere fall upward.

Fighting antigravity.

It turns out that our sphere does fly upward, but it initially clings to the ground. That's because we're snapping it to the ground and our code assumes normal gravity. We have to change it so it can work with any gravity vector.

Up Axis We relied on the up axis always being equal to the Y axis. To let go of this assumption we have to add an up axis field to MovingSphere and use that instead. To support gravity that can change at any time we'll have to set the up axis at the start of FixedUpdate . It points in the opposite direction that gravity pulls, so it's equal to the negated normalized gravity vector. Vector3 upAxis; … void FixedUpdate () { upAxis = -Physics.gravity.normalized; … } Now we have to replace all usage of Vector3.up with the new up axis. First, in UpdateState when the sphere is in the air and we use it for the contact normal. void UpdateState () { … else { contactNormal = upAxis ; } } Second, in Jump when biasing the jump direction. void Jump () { … jumpDirection = (jumpDirection + upAxis ).normalized; … } And we also have to adjust how we determine the jump speed. The idea is that we counteract gravity. We used −2 times the gravity Y component, but this no longer works. Instead we have to use the magnitude of the gravity vector, regardless of its direction. This means that we have to remove the minus sign as well. float jumpSpeed = Mathf.Sqrt( 2f * Physics.gravity .magnitude * jumpHeight); Finally, when probing for the ground in SnapToGround we have to replace Vector3.down with the up axis, negated. bool SnapToGround () { … if (!Physics.Raycast( body.position, -upAxis , out RaycastHit hit, probeDistance, probeMask )) { return false; } … }

Dot Products We can also no longer directly use the Y component of a normal vector when we need a dot product. We have to invoke Vector3.Dot with the up axis and normal vector as arguments. First in SnapToGround , when checking if we found ground. float upDot = Vector3.Dot(upAxis, hit.normal); if ( upDot < GetMinDot(hit.collider.gameObject.layer)) { return false; } Then in CheckSteepContacts to see whether we're wedged in a crevasse. bool CheckSteepContacts () { if (steepContactCount > 1) { steepNormal.Normalize(); float upDot = Vector3.Dot(upAxis, steepNormal); if ( upDot >= minGroundDotProduct) { … } } return false; } And in EvaluateCollision to check what kind of contact we have. void EvaluateCollision (Collision collision) { float minDot = GetMinDot(collision.gameObject.layer); for (int i = 0; i < collision.contactCount; i++) { Vector3 normal = collision.GetContact(i).normal; float upDot = Vector3.Dot(upAxis, normal); if ( upDot >= minDot) { groundContactCount += 1; contactNormal += normal; } else if ( upDot > -0.01f) { steepContactCount += 1; steepNormal += normal; } } } Our sphere can now move around no matter which direction is up. It's also possible to change the gravity direction while in play mode and it will immediately adjust to the new situation. Flipping gravity halfway.

Relative Controls However, although flipping gravity upside down works without issue, any other direction makes it harder to control the sphere. For example, when gravity aligns with the X axis we can only control movement along the Z axis. Movement along the Y axis is out of our control, only gravity and collisions can affect it. The X axis of our input gets eliminated because we still define our control in the world-space XZ plane. We have to define the desired velocity in a gravity-aligned plane instead. Lost X axis control when gravity pulls left. As gravity can vary we also have to make the right and forward axes relative. Add fields for them. Vector3 upAxis , rightAxis, forwardAxis ; We need to project directions on a plane to make this work, so let's replace ProjectOnContactPlane with a more general ProjectDirectionOnPlane method that works with an arbitrary normal and also performs the normalization at the end. //Vector3 ProjectOnContactPlane (Vector3 vector) { // return vector - contactNormal * Vector3.Dot(vector, contactNormal); //} Vector3 ProjectDirectionOnPlane (Vector3 direction, Vector3 normal) { return (direction - normal * Vector3.Dot(direction, normal)).normalized; } Use this new method in AdjustVelocity to determine the X and Z control axes, feeding it the variable axes and contact normal. void AdjustVelocity () { Vector3 xAxis = ProjectDirectionOnPlane(rightAxis, contactNormal) ; Vector3 zAxis = ProjectDirectionOnPlane(forwardAxis, contactNormal) ; … } The gravity-relative axes are derived in Update . If a player input space exists then we project its right and forward vectors on the gravity plane to find the gravity-aligned X and Z axes. Otherwise we project the world axes. The desired velocity is now defined relative to these axes, so the input vector need not be converted to a different space. void Update () { … if (playerInputSpace) { rightAxis = ProjectDirectionOnPlane(playerInputSpace.right, upAxis); forwardAxis = ProjectDirectionOnPlane(playerInputSpace.forward, upAxis); } else { rightAxis = ProjectDirectionOnPlane(Vector3.right, upAxis); forwardAxis = ProjectDirectionOnPlane(Vector3.forward, upAxis); } desiredVelocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed; //} desiredJump |= Input.GetButtonDown("Jump"); } This still doesn't solve the problem that a control axis gets eliminated when it aligns with gravity, but when using the orbit camera we can orient it such that we regain full control. Using the orbit camera.