using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.Events; namespace Unity.Cinemachine.Samples { /// /// This script keeps a player upright on surfaces. /// It rotates the player up to match the surface normal. /// This script assumes that the pivot point of the player is at the bottom. /// public class PlayerOnSurface : MonoBehaviour { [Tooltip("How fast the player rotates to match the surface normal")] public float RotationDamping = 0.2f; [Tooltip("What layers to consider as ground")] public LayerMask GroundLayers = 1; [Tooltip("How far to raycast when checking for ground")] public float MaxRaycastDistance = 5; [Tooltip("The approximate height of the player. Used to compute where raycasts begin")] public float PlayerHeight = 1; [Tooltip("If enabled, then player will fall towards the nearest surface when in free fall")] public bool FreeFallRecovery; [Header("Events")] [Tooltip("This event is sent when the player moves from one surface to another.")] public UnityEvent SurfaceChanged = new (); Vector3 m_PreviousGroundPoint; Vector3 m_PreviousPosition; Collider m_CurrentSurface; float m_FreeFallRaycastAngle = 0; public bool PreviousSateIsValid { get; set; } void OnEnable() => PreviousSateIsValid = false; void OnValidate() { RotationDamping = Mathf.Max(0, RotationDamping); MaxRaycastDistance = Mathf.Max(PlayerHeight * 0.5f, MaxRaycastDistance); PlayerHeight = Mathf.Max(0, PlayerHeight); } // Rotate the player to match the normal of the surface it's standing on void LateUpdate() { var tr = transform; var desiredUp = tr.up; var down = -desiredUp; var damping = RotationDamping; var originOffset = 0.25f * PlayerHeight * desiredUp; var downRaycastOrigin = tr.position + originOffset; var fwdRaycastOrigin = tr.position + 2 * originOffset; var playerRadius = 0.25f * PlayerHeight; // Approximate player radius - can convert to a parameter if needed if (!PreviousSateIsValid) { m_PreviousPosition = fwdRaycastOrigin; m_PreviousGroundPoint = downRaycastOrigin; } // Find the direction of motion and speed var motionDir = fwdRaycastOrigin - m_PreviousPosition; var motionLen = motionDir.magnitude; if (motionLen < 0.0001f) motionDir = tr.forward; else motionDir /= motionLen; // Check whether we have walked into a surface bool haveHit = false; if (Physics.Raycast(m_PreviousPosition, motionDir, out var hit, motionLen + playerRadius, GroundLayers, QueryTriggerInteraction.Ignore)) { haveHit = true; desiredUp = CaptureUpDirection(hit); } var raycastLength = Mathf.Max(MaxRaycastDistance, PreviousSateIsValid ? (m_PreviousGroundPoint - downRaycastOrigin).magnitude + PlayerHeight : MaxRaycastDistance); if (!haveHit && Physics.Raycast(downRaycastOrigin, down, out hit, raycastLength, GroundLayers, QueryTriggerInteraction.Ignore)) { haveHit = true; desiredUp = CaptureUpDirection(hit); } // If nothing is directly under our feet, try to find a surface in the direction // where we came from. This handles the case of sudden convex direction changes in the floor // (e.g. going around the lip of a surface) if (!haveHit && PreviousSateIsValid && Physics.Raycast(downRaycastOrigin, m_PreviousGroundPoint - downRaycastOrigin, out hit, MaxRaycastDistance, GroundLayers, QueryTriggerInteraction.Ignore)) { haveHit = true; desiredUp = CaptureUpDirection(hit); } // If we don't have a hit by now, we're in free fall if (haveHit) m_FreeFallRaycastAngle = 0; else { SetCurrentSurface(null); if (FreeFallRecovery && Vector3.Dot(motionDir, desiredUp) <= 0 && FindNearestSurface(downRaycastOrigin, raycastLength, out var surfacePoint)) { desiredUp = (downRaycastOrigin - surfacePoint).normalized; damping = 0; if (!PreviousSateIsValid) m_PreviousGroundPoint = downRaycastOrigin - motionDir; } } // Rotate to match the desired up direction float t = Damper.Damp(1, damping, Time.deltaTime); var fwd = tr.forward.ProjectOntoPlane(desiredUp); if (fwd.sqrMagnitude > 0.0001f) tr.rotation = Quaternion.Slerp(tr.rotation, Quaternion.LookRotation(fwd, desiredUp), t); else { // Rotating 90 degrees - can't preserve the forward var axis = Vector3.Cross(tr.up, desiredUp); var angle = UnityVectorExtensions.SignedAngle(tr.up, desiredUp, axis); var rot = Quaternion.Slerp(Quaternion.identity, Quaternion.AngleAxis(angle, axis), t); tr.rotation = rot * tr.rotation; } m_PreviousPosition = fwdRaycastOrigin; PreviousSateIsValid = true; } Vector3 CaptureUpDirection(RaycastHit hit) { m_PreviousGroundPoint = hit.point; // Capture the last point where there was ground under our feet SetCurrentSurface(hit.collider); // Capture the current ground surface return SmoothedNormal(hit); } void SetCurrentSurface(Collider surface) { // If the surface has changed, send an event if (surface != m_CurrentSurface) { m_CurrentSurface = surface; SurfaceChanged.Invoke(m_CurrentSurface); } } bool FindNearestSurface(Vector3 playerPos, float raycastLength, out Vector3 surfacePoint) { surfacePoint = playerPos - transform.up; // default is to continue falling down // Starting at the bottom, we'll spread out a number of horizontal sweeps over several frames const float kVerticalStep = 10.0f; if (m_FreeFallRaycastAngle == 0 || m_FreeFallRaycastAngle > 180 - kVerticalStep) m_FreeFallRaycastAngle = kVerticalStep / 2 + Time.frameCount % kVerticalStep; else m_FreeFallRaycastAngle += kVerticalStep; // We'll do a horizontal sweep at this angle to find the nearest surface var up = transform.up; var dir = Quaternion.AngleAxis(m_FreeFallRaycastAngle, transform.right) * -up; const float kHorizontalalSteps = 12; const float kHorizontalStepSize = 360.0f / kHorizontalalSteps; dir = Quaternion.AngleAxis(Time.frameCount % (int)kHorizontalStepSize, -up) * dir; float nearestDistance = float.MaxValue; var rotStep = Quaternion.AngleAxis(kHorizontalStepSize, -up); for (int i = 0; i < kHorizontalalSteps; ++i, dir = rotStep * dir) { //Debug.DrawLine(playerPos, playerPos + dir * raycastLength, Color.yellow, 1); if (Physics.Raycast(playerPos, dir, out var hit, raycastLength, GroundLayers, QueryTriggerInteraction.Ignore)) { if (hit.distance < nearestDistance) { nearestDistance = hit.distance; surfacePoint = hit.point; } } } return nearestDistance != float.MaxValue; } // This code smooths the normals of a mesh so that they don't change abruptly. // We cache the mesh data for efficiency to reduce allocations. struct MeshCache { public MeshCollider Mesh; public Vector3[] Normals; public int[] Indices; } List m_MeshCacheList = new(); const int kMaxMeshCacheSize = 5; MeshCache GetMeshCache(MeshCollider collider) { for (int i = 0; i < m_MeshCacheList.Count; ++i) if (m_MeshCacheList[i].Mesh == collider) return m_MeshCacheList[i]; if (m_MeshCacheList.Count >= kMaxMeshCacheSize) m_MeshCacheList.RemoveAt(0); // discard oldest var m = collider.sharedMesh; var mc = new MeshCache { Mesh = collider, Normals = m.normals, Indices = m.triangles }; m_MeshCacheList.Add(mc); return mc; } Vector3 SmoothedNormal(RaycastHit hit) { var mc = hit.collider as MeshCollider; if (mc == null) return hit.normal; var m = GetMeshCache(mc); var n0 = m.Normals[m.Indices[hit.triangleIndex*3 + 0]]; var n1 = m.Normals[m.Indices[hit.triangleIndex*3 + 1]]; var n2 = m.Normals[m.Indices[hit.triangleIndex*3 + 2]]; var b = hit.barycentricCoordinate; var localNormal = (b[0] * n0 + b[1] * n1 + b[2] * n2).normalized; return mc.transform.TransformDirection(localNormal); } } }