This is the third installment of a tutorial series about controlling the movement of a character. It's about refining how a sphere interacts with surfaces.

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

Sticking to the Ground

When our sphere reaches the top of a ramp it goes flying, due to its upward momentum. This is realistic, but might not be desirable.

Spheres go flying at top of ramp.

A similar thing happens when spheres bump into sudden small elevation differences. I made a test scene that demonstrates this for steps up to one unit high, with 0.1 increments.

Steps test scene.

When approaching with enough velocity a sphere will bounce if the step isn't too high. In the test scene this even happens a little for the flat lane, because I made it by reducing the step height to zero without merging the vertices. That produces what's known as a ghost collision. Scene geometry should be designed to avoid that, but I kept it in to point it out.

Bouncing off steps.

In real life there are various techniques to keep something stuck to the ground. For example Formula One race cars are designed to convert airflow to downforce. So there is a realistic basis to do something similar for our spheres.

Collision Timing Let's consider the moment a sphere would get launched off a ramp. To keep it stuck to the surface we'll have to make adjustments to its velocity, realigning it with the surface. Let's examine exactly when we'll receive the information that we need. I'll make the sphere white when it's not on the ground, by adjusting its color in Update based on OnGround , similar to the coloration demonstrated at the end of the previous tutorial. void Update () { … GetComponent<Renderer>().material.SetColor( "_Color", OnGround ? Color.black : Color.white ); } To observe the exact timing, temporarily reduce the physics timestep and time scale.



Three physics steps; timestep 0.2; time scale 0.5. The physics step during which the sphere gets launched still has a collision. We act on that data during the next step, so we think that we're still grounded while we no longer are. It's the step after that when we no longer get collision data. So we're always going to be a bit too late, but this isn't a problem as long as we're aware of it.

Steps Since Last Grounded Let's keep track of how many physics steps there have been since we considered ourselves grounded. Add an integer field for it and increment it at the start of UpdateState . Then if it turns out that we're on the ground set it back to zero. We'll use this to determine when we should snap to the ground. It can also be useful for debugging. int stepsSinceLastGrounded; … void UpdateState () { stepsSinceLastGrounded += 1; velocity = body.velocity; if (OnGround) { stepsSinceLastGrounded = 0; jumpPhase = 0; if (groundContactCount > 1) { contactNormal.Normalize(); } } else { contactNormal = Vector3.up; } } Don't we have to guard against integer overflow? We don't need to worry about that. It would take months in real time of not being grounded for the integer to overflow.

Snapping Add a SnapToGround method that keeps us stuck to the ground if needed. If it succeeds then we'll be grounded. Have it indicate whether this happened by returning a boolean, initially just returning false . bool SnapToGround () { return false; } That way we can conveniently combine it with the OnGround check in UpdateState using a boolean OR. That works because SnapToGround will only get invoked when OnGround is false . void UpdateState () { stepsSinceLastGrounded += 1; velocity = body.velocity; if (OnGround || SnapToGround() ) { … } … } SnapToGround only gets invoked when we're not grounded, so the amount of steps since last grounded is greater than zero. But we should only try to snap once directly after we lost contact. Thus when the amount of steps is greater than one we should abort. bool SnapToGround () { if (stepsSinceLastGrounded > 1) { return false; } return false; }

Raycasting We only want to snap when there's ground below the sphere to stick to. We can check this by casting a ray from the sphere body's position straight down, by invoking Physics.Raycast with body.position and the down vector as arguments. The physics engine will perform this raycast and return whether it hit something. If not then there is no ground and we abort. if (stepsSinceLastGrounded > 1) { return false; } if (!Physics.Raycast(body.position, Vector3.down)) { return false; } return false; If the ray did hit something then we must check whether it counts as ground. Information about what was hit can be retrieved via a third RaycastHit struct output parameter. if (!Physics.Raycast(body.position, Vector3.down, out RaycastHit hit )) { return false; } How does that code work? RaycastHit is a struct, thus a value type. We can define a variable via RaycastHit hit , then pass it as a third argument to Physics.Raycast . But it's an output argument, which means that it's passed by reference as if it were an object reference. This must be explicitly indicated by adding the out modifier to it. The method is responsible for assigning a value to it. Besides that, it's also possible to declare the variable used for the output argument inside the argument list, instead of on a separate line. That's what we do here. The hit data includes a normal vector, which we can use to check whether the surface we hit counts as ground. If not, abort. Note that in this case we're dealing with the true surface normal, not a collision normal. if (!Physics.Raycast(body.position, Vector3.down, out RaycastHit hit)) { return false; } if (hit.normal.y < minGroundDotProduct) { return false; }

Realigning with the Ground If we haven't aborted at this point then we've just lost contact with the ground but are still above ground, so we snap to it. Set the ground contact count to one, use the found normal as the contact normal, and return true . if (hit.normal.y < minGroundDotProduct) { return false; } groundContactCount = 1; contactNormal = hit.normal; return true ; Now we consider ourselves to be grounded, although we're still in the air. The next step is to adjust our velocity to align with the ground. This works just like aligning the desired velocity, except that we have to keep the current speed and we'll calculate it explicitly instead of relying on ProjectOnContactPlane . groundContactCount = 1; contactNormal = hit.normal; float speed = velocity.magnitude; float dot = Vector3.Dot(velocity, hit.normal); velocity = (velocity - hit.normal * dot).normalized * speed; return true; At this point we are still floating above the ground, but gravity will take care of pulling us down to the surface. In fact, the velocity might already point somewhat down, in which case realigning it would slow convergence to the ground. So we should only adjust the velocity when the dot product of it and the surface normal is positive. if (dot > 0f) { velocity = (velocity - hit.normal * dot).normalized * speed; } This is enough to keep our spheres sticking to the ramp when going over the top. They will float for a little bit but this is hardly noticeable in practice. Even though the spheres will turn white for one frame, in FixedUpdate we'll treat the spheres as grounded the whole time. It's just that Update gets invoked while we're in an intermediate state. Sticking to slope. It also prevents spheres from getting launched when bouncing off a step. Sticking to a step. Note that we're only considering a single point below us to decide whether we're above ground. This works fine as long as the level geometry isn't too noisy nor too detailed. For example a tiny deep crack could cause this to fail if the ray happened to be cast into it.

Max Snap Speed It makes sense that at high speeds our sphere gets launched anyway, so let's add a configurable max snap speed. Set it to the maximum speed by default so snapping always happens when possible. [SerializeField, Range(0f, 100f)] float maxSnapSpeed = 100f; Max snap speed. Then also abort SnapToGround when the current speed exceeds the max snap speed. We can do this before the raycast by calculating the speed earlier. bool SnapToGround () { if (stepsSinceLastGrounded > 1) { return false; } float speed = velocity.magnitude; if (speed > maxSnapSpeed) { return false; } if (!Physics.Raycast(body.position, Vector3.down, out RaycastHit hit)) { return false; } if (hit.normal.y < minGroundDotProduct) { return false; } groundContactCount = 1; contactNormal = hit.normal; //float speed = velocity.magnitude; float dot = Vector3.Dot(velocity, hit.normal); if (dot > 0f) { velocity = (velocity - hit.normal * dot).normalized * speed; } return true; } Note that setting both max speeds to the same value can produce inconsistent results due to precision limitations. It's better to make the max snap speed a bit higher or lower than the max speed. Same max speeds produce inconsistent results.

Probe Distance We're snapping when there's ground below the sphere, no matter how far away it is. It's better to only check for nearby ground. We do this by limiting the range of the probe. There is no best maximum distance, but if too low snapping can fail at steep angles or high velocities, while too high can lead to nonsensical snapping to ground far below. Make it configurable with a minimum of zero and a default of one. As our sphere has a radius of 0.5 that means we check up to half a unit below the sphere's bottom. [SerializeField, Min(0f)] float probeDistance = 1f; Probe distance. Add the distance as a fourth parameter to Physics.Raycast . if (!Physics.Raycast( body.position, Vector3.down, out RaycastHit hit , probeDistance )) { return false; }

Ignoring Agents When checking for ground to snap to it makes sense that we only consider geometry that could represent ground. By default the raycast checks anything except for objects put on the Ignore Raycast layer. What shouldn't count can vary, but the spheres that we're moving most likely don't. We won't accidentally hit the sphere we're casting for, because we're casting from its position outward, but we might hit another moving sphere. To avoid that we can set their Layer to Ignore Raycast, but let's create a new layer for everything that's active and should be ignored for this purpose. Go to the layer settings, either via the Add Layer... option of a game object's Layer dropdown or the Tags and Layers section of the project settings. Then define a new custom user layer. Let's name it Agent, for generic active entities that aren't part of the level geometry. Tags and layers, with custom Agent layer 8. Move all spheres to that layer. Changing the prefab's layer will do. Layer set to Agent. Next, add a configurable LayerMask probe mask to MovingSphere , initially set to −1, which matches all layers. [SerializeField] LayerMask probeMask = -1; Then we can configure the sphere so it probes all layers except Ignore Raycast and Agent. Probe mask. To apply the mask add it as a fifth parameter to Physics.Raycast . if (!Physics.Raycast( body.position, Vector3.down, out RaycastHit hit, probeDistance , probeMask )) { return false; }