238 lines
10 KiB
C#
238 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.Events;
|
|
|
|
namespace Unity.Cinemachine.Samples
|
|
{
|
|
/// <summary>
|
|
/// This behaviour keeps a player upright on surfaces. It can be used to make the player walk
|
|
/// on walls and ceilings or on the surfaces of arbitrary meshes. It rotates the player
|
|
/// up to match the surface normal. This script assumes that the pivot point of the player is at the bottom.
|
|
///
|
|
/// Raycasts are used to detect walkable surfaces.
|
|
///
|
|
/// When using this component, SimpleSplayerController's Up Mode should be set to _Player_, and it
|
|
/// should not have a Character Controller component, as that does not play nicely with
|
|
/// nonstandard Up directions.
|
|
///
|
|
/// Also, when CinemachineCameras are being used to track the character, the
|
|
/// [CinemachineBrain](CinemachineBrain.md)'s World Up Override setting should be set to the Player,
|
|
/// so that the Camera's Up matches the Player's Up.
|
|
/// </summary>
|
|
public class SimplePlayerOnSurface : 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<Collider> 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 (UnityEngine.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 && UnityEngine.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
|
|
&& UnityEngine.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(100, damping, Time.deltaTime) * 0.01f;
|
|
var fwd = tr.forward.ProjectOntoPlane(desiredUp);
|
|
if (fwd.sqrMagnitude > 0.000001f)
|
|
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 (UnityEngine.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<MeshCache> 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);
|
|
}
|
|
}
|
|
}
|