Diving and Surfacing
We can now move while swimming exactly like when on the ground or in the air, so controlled movement is constrained to the ground plane. Vertical movement is currently only due to gravity and buoyancy. To grant control over vertical motion we need a third input axis. Let’s support this by adding an UpDown axis to our input settings, by duplicating either Horizontal or Vertical. I used space—the same key used for jumping—for the positive button and X for the negative button. Then change the playerInput field to a Vector3 and set its Z component to the UpDown axis in Update when swimming and to zero otherwise. We have to use the ClampMagnitude version of Vector3 from now on.
Vector3 playerInput;
…
void Update () {
playerInput.x = Input.GetAxis("Horizontal");
playerInput.y = Input.GetAxis("Vertical");
playerInput.z = Swimming ? Input.GetAxis("UpDown") : 0f;
playerInput = Vector3.ClampMagnitude(playerInput, 1f);
…
}
Find the current and new Y velocity components and use them to adjust the velocity at the end of AdjustVelocity. This works the same as for X and Z, but is done only when swimming.
void AdjustVelocity () {
…
velocity += xAxis * (newX - currentX) + zAxis * (newZ - currentZ);
if (Swimming) {
float currentY = Vector3.Dot(relativeVelocity, upAxis);
float newY = Mathf.MoveTowards(
currentY, playerInput.z * speed, maxSpeedChange
);
velocity += upAxis * (newY - currentY);
}
}
Swimming up and down; buoyancy 1.
Climbing and Jumping
It should be hard to climb or jump while submerged. We can disallow both by ignoring the player’s input in Update while swimming. The desire for climbing has to be explicitly deactivated. Jumping resets itself. It’s still possible for climbing to be active while swimming if multiple physics steps take place before the next update, but that’s fine as that takes place during a transition to swimming so exact timing doesn’t matter. To climb out of water the player just has to swim up while pressing the climb button and climbing will activate at some point.
if (Swimming) {
desiresClimbing = false;
}
else {
desiredJump |= Input.GetButtonDown("Jump");
desiresClimbing = Input.GetButton("Climb");
}
While it’s possible to jump when standing in shallow water, it makes it a lot harder. We’ll simulate this by scaling down the jump speed by 1 minus the submergence divided by the swim threshold, to a minimum of zero.
float jumpSpeed = Mathf.Sqrt(2f * gravity.magnitude * jumpHeight);
if (InWater) {
jumpSpeed *= Mathf.Max(0f, 1f - submergence / swimThreshold);
}
Swimming in Moving Water
We won’t consider water currents in this tutorial, but we should deal with water volumes that move in their entirety because they’re animated, as that’s just like regular moving geometry that we’re stand or climbing on. To make that possible pass the collider to EvaluateSubmergence and use its attached rigid body for the connected body if we end up swimming. If we’re in shallow water we ignore it.
void OnTriggerEnter (Collider other) {
if ((waterMask & (1 << other.gameObject.layer)) != 0) {
EvaluateSubmergence(other);
}
}
void OnTriggerStay (Collider other) {
if ((waterMask & (1 << other.gameObject.layer)) != 0) {
EvaluateSubmergence(other);
}
}
void EvaluateSubmergence (Collider collider) {
…
if (Swimming) {
connectedBody = collider.attachedRigidbody;
}
}
If we’re connected to a water body then we shouldn’t replace it with another body in EvaluateCollision. In fact, we don’t need any connection information at all, so we can skip all the work in EvaluateCollision while swimming.
void EvaluateCollision (Collision collision) {
if (Swimming) {
return;
}
…
}
Inside animated water cube; swim acceleration 10.
Floating Objects
Now that our sphere can swim it would be nice if it had some floating objects to interact with. Once again we have to program this ourselves, which we’ll do by adding support for it to our existing component that already supports custom gravity.
Submergence
Add a configurable submergence offset, submergence range, buoyancy, water drag, and water mask to CustomGravityRigidbody, just like MovingSphere, except that we don’t need a swim acceleration, speed, nor threshold.
[SerializeField]
float submergenceOffset = 0.5f;
[SerializeField, Min(0.1f)]
float submergenceRange = 1f;
[SerializeField, Min(0f)]
float buoyancy = 1f;
[SerializeField, Range(0f, 10f)]
float waterDrag = 1f;
[SerializeField]
LayerMask waterMask = 0;
Submergence settings for cube with scale 0.25.
Next, we need a submergence field. Reset it to zero at the end of FixedUpdate if needed, before applying gravity. We also need to know the gravity when determining submergence, so keep track of it in a field as well.
float submergence;
Vector3 gravity;
…
void FixedUpdate () {
…
gravity = CustomGravity.GetGravity(body.position);
if (submergence > 0f) {
submergence = 0f;
}
body.AddForce(gravity, ForceMode.Acceleration);
}
Then add the required trigger methods along with an EvaluateSubmergence method, which works the same as before except that we calculate the up axis only when needed and don’t support connected bodies.
void OnTriggerEnter (Collider other) {
if ((waterMask & (1 << other.gameObject.layer)) != 0) {
EvaluateSubmergence();
}
}
void OnTriggerStay (Collider other) {
if ((waterMask & (1 << other.gameObject.layer)) != 0) {
EvaluateSubmergence();
}
}
void EvaluateSubmergence () {
Vector3 upAxis = -gravity.normalized;
if (Physics.Raycast(
body.position + upAxis * submergenceOffset,
-upAxis, out RaycastHit hit, submergenceRange + 1f,
waterMask, QueryTriggerInteraction.Collide
)) {
submergence = 1f - hit.distance / submergenceRange;
}
else {
submergence = 1f;
}
}
Even when floating the objects can still go to sleep. If this is the case then we can skip evaluating submergence. So don’t invoke EvaluateSubmergence in OnTriggerStay if the body is sleeping. We still do it in OnTriggerEnter because that guarantees a change.
void OnTriggerStay (Collider other) {
if (
!body.IsSleeping() &&
(waterMask & (1 << other.gameObject.layer)) != 0
) {
EvaluateSubmergence();
}
}
Floating
In FixedUpdate apply water drag and buoyancy if needed. In this case we apply buoyancy via a separate AddForce invocation instead of combining it with the normal gravity.
if (submergence > 0f) {
float drag =
Mathf.Max(0f, 1f - waterDrag * submergence * Time.deltaTime);
body.velocity *= drag;
body.AddForce(
gravity * -(buoyancy * submergence),
ForceMode.Acceleration
);
submergence = 0f;
}
We’ll also apply the drag to the angular velocity, so objects won’t keep spinning while floating.
body.velocity *= drag;
body.angularVelocity *= drag;
Floating cubes.
Floating objects can now end up with arbitrary rotations while floating. Often objects will float with their lightest side facing up. We can simulate this by adding a configurable buoyancy offset vector, set to zero by default.
[SerializeField]
Vector3 buoyancyOffset = Vector3.zero;
We then apply the buoyancy at this point instead of the object’s origin, by invoking AddForceAtPosition instead of AddForce, with the offset transformed to word space as a new second argument.
body.AddForceAtPosition(
gravity * -(buoyancy * submergence),
transform.TransformPoint(buoyancyOffset),
ForceMode.Acceleration
);
Because gravity and buoyancy now act at different points they create angular momentum that pushes the offset point to the top. A larger offset creates a stronger effect, which can cause rapid oscillation, so the offset should be kept small.
Slight buoyancy offset.
Interacting with Floating Objects
While swimming through water with floating objects in them the orbit camera will jerk back and forth because it tries to stay in front of the objects. This can be avoided by adding a see-through layer that works like a regular layer, except that the orbit camera is set to ignore it.
See-through layer.
This layer should only be used for objects that are small enough to ignore, or are interacted with a lot.
Pushing floating stuff around.
Can we make see-through objects invisible when they obstruct the view?
Stable Floating
Our current approach works fine for small objects, but it doesn’t look as good for larger and nonuniform objects. For example, large floating blocks should remain more stable when the sphere interacts with them. To increase stability we have to spread the buoyancy effect over a larger area. This requires a more complex approach, so duplicate CustomGravityRigidbody and rename it to StableFloatingRigidbody. Replace its buoyancy offset with an array of offset vectors. Turn submergence into an array as well and create it in Awake with the same length as the offset array.
public class StableFloatingRigidbody : MonoBehaviour {
…
[SerializeField]
//Vector3 buoyancyOffset = Vector3.zero;
Vector3[] buoyancyOffsets = default;
…
float[] submergence;
Vector3 gravity;
void Awake () {
body = GetComponent<Rigidbody>();
body.useGravity = false;
submergence = new float[buoyancyOffsets.Length];
}
…
}
Adjust EvaluateSubmergence so it evaluates submergence for all buoyancy offsets separately.
void EvaluateSubmergence () {
Vector3 down = gravity.normalized;
Vector3 offset = down * -submergenceOffset;
for (int i = 0; i < buoyancyOffsets.Length; i++) {
Vector3 p = offset + transform.TransformPoint(buoyancyOffsets[i]);
if (Physics.Raycast(
p, down, out RaycastHit hit, submergenceRange + 1f,
waterMask, QueryTriggerInteraction.Collide
)) {
submergence[i] = 1f - hit.distance / submergenceRange;
}
else {
submergence[i] = 1f;
}
}
}
Then have FixedUpdate apply drag and buoyancy per offset as well. Both drag and buoyancy have to be divided by the amount of offsets so the maximum effect remains the same. The actual effect experienced by the object depends on the submergence total.
void FixedUpdate () {
…
gravity = CustomGravity.GetGravity(body.position);
float dragFactor = waterDrag * Time.deltaTime / buoyancyOffsets.Length;
float buoyancyFactor = -buoyancy / buoyancyOffsets.Length;
for (int i = 0; i < buoyancyOffsets.Length; i++) {
if (submergence[i] > 0f) {
float drag =
Mathf.Max(0f, 1f - dragFactor * submergence[i]);
body.velocity *= drag;
body.angularVelocity *= drag;
body.AddForceAtPosition(
gravity * (buoyancyFactor * submergence[i]),
transform.TransformPoint(buoyancyOffsets[i]),
ForceMode.Acceleration
);
submergence[i] = 0f;
}
}
body.AddForce(gravity, ForceMode.Acceleration);
}
Four points are usually enough for any box shape, unless they are very large or often end up partially out of the water. Note that offsets are scaled with the object. Also, increasing the object’s mass makes it more stable.
Stabilized with four buoyancy offsets.
Accidental Levitation
If a point ends up sufficiently high above the surface then its ray cast will fail, which makes it incorrectly count as fully submerged. This is a potential problem for large objects with multiple buoyancy points, because some could end up high above the water while another part of the object is still submerged. The result would be that the high point ends up levitating. You can achieve this by pushing a large light object partially out of the water.
Levitation after being pushed.
The problem persists because part of the object still touches the water. To solve this we have to perform an extra query when the ray cast fails to check whether the point itself is inside a water volume. This can be done by invoking Physics.CheckSphere with the position and a small radius like 0.01 as arguments, followed by the mask and interaction mode. Only if that query returns true should we set submergence to 1. However, this could result in a lot of extra queries, so let’s make it optional by adding configurable safe-floating toggle. It’s only needed for large objects that could be pushes out of the water sufficiently.
[SerializeField]
bool safeFloating = false;
…
void EvaluateSubmergence () {
Vector3 down = gravity.normalized;
Vector3 offset = down * -submergenceOffset;
for (int i = 0; i < buoyancyOffsets.Length; i++) {
Vector3 p = offset + transform.TransformPoint(buoyancyOffsets[i]);
if (Physics.Raycast(
p, down, out RaycastHit hit, submergenceRange + 1f,
waterMask, QueryTriggerInteraction.Collide
)) {
submergence[i] = 1f - hit.distance / submergenceRange;
}
else if (
!safeFloating || Physics.CheckSphere(
p, 0.01f, waterMask, QueryTriggerInteraction.Collide
)
) {
submergence[i] = 1f;
}
}
}
Safe floating.
The next tutorial is Reactive Environment.