下一步是可以调整相机的方向,以便可以描述围绕焦点的轨道。我们将可以手动控制轨道,并使相机自动旋转以跟随其焦点。
摄像机的方向可以用两个轨道角来描述。X角定义其垂直方向,其中0°直视地平线,90°直视向下。Y角定义水平方向,沿着世界的Z轴看为0°。在 Vector2 字段中跟踪这些角度,默认情况下设置为45°和0°。
Vector2 orbitAngles = new Vector2(45f , 0f );
在 LateUpdate 中,我们现在必须通过 Quaternion.Euler 方法构造一个四元数来定义相机的外观旋转,并将其传递给轨道角度。它需要一个 Vector3 ,我们的向量隐式转换为该向量,Z旋转设置为零。
然后可以通过用四元数乘以正向向量替换 transform.forward 来找到视线方向。现在,不仅要设置摄像机的位置,我们还要调用 transform.SetPositionAndRotation 并具有一次的外观位置和旋转。
void LateUpdate () { UpdateFocusPoint(); Quaternion lookRotation = Quaternion.Euler(orbitAngles); Vector3 lookDirection =lookRotation * Vector3.forward; Vector3 lookPosition= focusPoint - lookDirection * distance; transform.SetPositionAndRotation(lookPosition, lookRotation); }
要手动控制轨道,请添加转速配置选项,以每秒度数表示。每秒90°是合理的默认设置。
[SerializeField, Range(1f , 360f )] float rotationSpeed = 90f ;
旋转速度。
添加 ManualRotation 方法来检索输入向量。为此,我定义了垂直摄像机 和“水平摄像机” 输入轴,并绑定到第三和第四轴,ijkl和qe键,鼠标的灵敏度提高到0.5。最好在游戏中配置灵敏度并允许翻转轴方向,但这是一个好主意,但是在本教程中我们不会为之烦恼。
如果输入值超过某个小ε值(如0.001),则将输入值添加到轨道角度,并按旋转速度和时间增量进行缩放。同样,我们将其与游戏时间无关。
void ManualRotation ( ) { Vector2 input = new Vector2( Input.GetAxis("Vertical Camera" ), Input.GetAxis("Horizontal Camera" ) ); const float e = 0.001f ; if (input.x < -e || input.x > e || input.y < -e || input.y > e) { orbitAngles += rotationSpeed * Time.unscaledDeltaTime * input; } }
在 LateUpdate 中的 UpdateFocusPoint 之后调用此方法。
void LateUpdate ( ) { UpdateFocusPoint(); ManualRotation(); … }
手动旋转;聚焦半径为零。
请注意,无论相机的方向如何,球体仍在世界空间中受到控制。因此,如果将摄像机水平旋转180°,则球体的控件将显示为翻转状态。这样无论摄像机的视线如何,都可以轻松保持相同的航向,但可能会迷失方向。如果您对此有疑问,可以同时打开游戏窗口和场景窗口,并依靠后者的固定角度。稍后,我们将使球形控件相对于摄影机视图。
虽然相机可以描述完整的水平轨道,但垂直旋转将使世界在任何方向超过90°时都可以将其颠倒。甚至在此之前,在上下左右看时都很难看清要去的地方。因此,让我们添加配置选项来约束最小和最大垂直角度,极端情况在任一方向上的最大限制为89°。让我们使用−30°和60°作为默认值。
[SerializeField, Range(-89f , 89f )] float minVerticalAngle = -30f , maxVerticalAngle = 60f ;
最小和最大垂直角度。
最大值永远不会低于最小值,因此请在 OnValidate 方法中强制实施。由于这仅通过检查器清理配置,因此我们不需要在构建中调用它。
void OnValidate ( ) { if (maxVerticalAngle < minVerticalAngle) { maxVerticalAngle = minVerticalAngle; } }
添加 ConstrainAngles 方法,将垂直轨道角度钳位到配置的范围。水平轨道没有限制,但请确保角度保持在0–360范围内。
void ConstrainAngles () { orbitAngles.x = Mathf.Clamp(orbitAngles.x, minVerticalAngle, maxVerticalAngle); if (orbitAngles.y < 0f ) { orbitAngles.y += 360f ; } else if (orbitAngles.y >= 360f ) { orbitAngles.y -= 360f ; } }
如果轨道角度是任意的,那么确实要继续加减360°直到落入该范围内才是正确的。但是,我们仅少量地逐步调整角度,所以这不是必需的。
当角度改变时,我们只需要约束角度。因此,使 ManualRotation 返回是否进行了更改,并基于 LateUpdate 中的内容调用 ConstrainAngles 。如果发生更改,我们也只需要重新计算轮换,否则我们可以检索现有的轮换。
bool ManualRotation () { … if (input.x < e || input.x > e || input.y < e || input.y > e) { orbitAngles += rotationSpeed * Time.unscaledDeltaTime * input; return true; } return false; } … void LateUpdate () { UpdateFocusPoint(); Quaternion lookRotation; if (ManualRotation()) { ConstrainAngles(); lookRotation = Quaternion.Euler(orbitAngles); } else { lookRotation = transform.localRotation; } //Quaternion lookRotation = Quaternion.Euler(orbitAngles); … }
我们还必须确保初始旋转与 Awake 中的轨道角度相匹配。
void Awake ( ) { focusPoint = focus.position; transform.localRotation = Quaternion.Euler(orbitAngles); }
轨道摄像头的一个共同特征是,它们会对齐以保持在玩家头像后面。我们将通过自动调整水平轨道角度来做到这一点。但是重要的是,播放器可以始终覆盖此自动行为,并且自动旋转不会立即开始。因此,我们将添加可配置的对齐延迟,默认情况下设置为5秒。此延迟没有上限。如果您根本不希望自动对齐,则只需设置很高的延迟即可。
[SerializeField, Min(0f) ] float alignDelay = 5f ;
对齐延迟。
跟踪上次手动旋转发生的时间。再一次,我们依靠的是这里的非标度时间,而不是游戏中的时间。
float lastManualRotationTime; … bool ManualRotation ( ) { … if (input.x < -e || input.x > e || input.y < -e || input.y > e) { orbitAngles += rotationSpeed * Time.unscaledDeltaTime * input; lastManualRotationTime = Time.unscaledTime; return true ; } return false ; }
然后添加 AutomaticRotation 方法,该方法还返回是否更改了轨道。如果当前时间减去上次手动旋转时间小于对齐延迟,它将中止。
bool AutomaticRotation ( ) { if (Time.unscaledTime - lastManualRotationTime < alignDelay) { return false ; } return true ; }
现在,在 LateUpdate 中,按照顺序尝试手动或自动旋转时,限制角度并计算旋转。
if (ManualRotation()|| AutomaticRotation()) { ConstrainAngles(); lookRotation = Quaternion.Euler(orbitAngles); }
用于对齐相机的条件各不相同。在我们的案例中,我们将仅基于自上一帧以来焦点的移动。这个想法是,朝着焦点最后前进的方向看是最有意义的。为了使之成为可能,我们需要知道当前和先前的焦点,因此,请 UpdateFocusPoint 设置这两个焦点。
Vector3 focusPoint, previousFocusPoint; … void UpdateFocusPoint () { previousFocusPoint = focusPoint; … }
然后让 AutomaticRotation 计算当前帧的运动矢量。由于我们仅水平旋转,因此只需要在XZ平面中进行2D移动。如果此运动矢量的平方幅度小于一个较小的阈值(例如0.0001),那么运动就不多了,我们就不会打扰旋转。
bool AutomaticRotation ( ) { if (Time.unscaledTime - lastManualRotationTime < alignDelay) { return false ; } Vector2 movement = new Vector2( focusPoint.x - previousFocusPoint.x, focusPoint.z - previousFocusPoint.z ); float movementDeltaSqr = movement.sqrMagnitude; if (movementDeltaSqr < 0.000001f ) { return false ; } return true ; }
否则,我们必须找出与当前方向匹配的水平角度。创建一个静态 GetAngle 方法以将2D方向转换为该角度。方向的Y分量是所需角度的余弦,因此将其放入 Mathf.Acos ,然后从弧度转换为度。
static float GetAngle (Vector2 direction ) { float angle = Mathf.Acos(direction.y) * Mathf.Rad2Deg; return angle; }
但是该角度可以表示顺时针或逆时针旋转。我们可以看一下方向的X分量来知道它是什么。如果X为负,则它为逆时针方向,我们必须从360°中减去该角度。
returndirection .x < 0f ? 360f - angle :angle ;
回到 AutomaticRotation 中,我们可以使用 GetAngle 来获取航向角,并向其传递标准化的运动矢量。由于我们已经有了平方的强度,所以自己进行归一化会更有效率。结果成为新的水平轨道角。
if (movementDeltaSqr < 0.0001f ) { return false ; } float headingAngle = GetAngle(movement / Mathf.Sqrt(movementDeltaSqr)); orbitAngles.y = headingAngle; return true ;
立即对齐。
自动对齐有效,但立即对齐以匹配前进方向太突然了。我们也通过将配置的旋转速度也用于自动旋转来降低它的速度,因此它模仿手动旋转。我们可以为此使用 Mathf.MoveTowardsAngle ,它与 Mathf.MoveTowards 一样,除了它可以处理0-360度的角度范围。
float headingAngle = GetAngle(movement / Mathf.Sqrt(movementDeltaSqr)); float rotationChange = rotationSpeed * Time.unscaledDeltaTime; orbitAngles.y = Mathf.MoveTowardsAngle(orbitAngles.y,headingAngle, rotationChange);
受转速限制。
这样比较好,但是即使对于较小的重新排列,也始终使用最大转速。一种更自然的行为是使旋转速度与当前角度和所需角度之差成比例。我们将使其线性缩放至全速旋转的某个角度。通过添加对齐平滑范围配置选项(0-90范围,默认值为45°)来使该角度可配置。
[SerializeField, Range(0f , 90f )] float alignSmoothRange = 45f ;
对齐平滑范围。
为了完成这项工作,我们需要知道 AutomaticRotation 中的角度增量,我们可以通过将当前角度和所需角度传递给 Mathf.DeltaAngle 并取其绝对值来找到它。如果此增量落在平滑范围内,则进行相应的旋转调整。
float deltaAbs = Mathf.Abs(Mathf.DeltaAngle(orbitAngles.y, headingAngle)); float rotationChange = rotationSpeed * Time.unscaledDeltaTime; if (deltaAbs < alignSmoothRange) { rotationChange *= deltaAbs / alignSmoothRange; } orbitAngles.y = Mathf.MoveTowardsAngle(orbitAngles.y, headingAngle, rotationChange);
这涵盖了焦点移离相机的情况,但是当焦点移向相机时,我们也可以这样做。这样可以防止摄像机全速旋转,每次航向越过180°边界时都会改变方向。除了我们使用180°减去绝对增量之外,它的工作原理相同。
if (deltaAbs < alignSmoothRange) { rotationChange *= deltaAbs / alignSmoothRange; } else if (180f - deltaAbs < alignSmoothRange) { rotationChange *= (180f - deltaAbs) / alignSmoothRange; }
最后,通过将旋转速度缩放为时间增量和平方运动增量中的最小值,可以进一步减小微小角度的旋转。
float rotationChange = rotationSpeed *Mathf.Min(Time.unscaledDeltaTime, movementDeltaSqr);
平滑对齐。
请注意,通过这种方法,可以将球体朝相机方向直线移动而不会旋转。方向上的微小偏差也将得到抑制。一旦方向发生重大变化,自动旋转将顺利生效。
180°对齐。
目前,我们的相机仅关心其相对于焦点的位置和方向。它对场景的其余部分一无所知。因此,它直接穿过其他几何形状,这会引起一些问题。首先,这很丑。其次,它可能导致几何形状阻塞我们对球体的视线,从而使其难以导航。第三,裁剪几何可以揭示不可见的区域。我们将仅考虑将相机的焦距设置为零的情况。
有多种策略可用于保持相机的视角有效。我们将应用最简单的方法,如果摄像机和对焦点之间出现物体,则将摄像机沿其外观方向向前拉。
检测问题的最明显方法是从焦点向我们要放置相机的位置投射光线。确定外观方向后,即可在 OrbitCamera.LateUpdate 中执行此操作。如果我们命中了某物,那么我们将使用命中距离而不是配置的距离。
Vector3 lookDirection = lookRotation * Vector3.forward; Vector3 lookPosition = focusPoint - lookDirection * distance; if (Physics.Raycast( focusPoint, -lookDirection, out RaycastHit hit, distance )) { lookPosition = focusPoint - lookDirection * hit.distance; } transform.SetPositionAndRotation(lookPosition, lookRotation);
将相机拉近对焦点可以使其靠近以使其进入球体。当球体与相机的近平面相交时,它可能会部分被截断,甚至被完全截断。您可以强制执行最小距离来避免这种情况,但这将意味着相机仍保留在其他几何图形内。对此没有完美的解决方案,但是可以通过限制垂直轨道角度,不使水准仪几何形状过紧以及减小相机的近裁剪平面距离来缓解这种情况。
投射单一光线不足以完全解决问题。这是因为,即使在相机的位置和对焦点之间有一条清晰的线,相机的近平面矩形仍可以部分切穿几何图形。解决方案是改为执行盒子投射,以匹配摄影机在世界空间中的近平面矩形,该矩形代表摄影机可以看到的最接近的物体。它类似于相机的传感器。
相机盒铸件;靠近平面的矩形是三角形的长边。
首先, OrbitCamera 需要对其 Camera 组件的引用。
Camera regularCamera; … void Awake () { regularCamera = GetComponent<Camera>(); focusPoint = focus.position; transform.localRotation = Quaternion.Euler(orbitAngles); }
其次,盒子投射需要一个3D向量,其中包含盒子的一半延伸,这意味着它的宽度,高度和深度是一半。
高度的一半可以通过将相机视场角的一半的正切值(以弧度为单位)找到,并由其近乎剪辑平面的距离缩放。宽度的一半是由相机的纵横比缩放的。盒子的深度为零。让我们在一个方便的属性中进行计算。
Vector3 CameraHalfExtends { get { Vector3 halfExtends; halfExtends.y = regularCamera.nearClipPlane * Mathf.Tan(0.5f * Mathf.Deg2Rad * regularCamera.fieldOfView); halfExtends.x = halfExtends.y * regularCamera.aspect; halfExtends.z = 0f; return halfExtends; } }
是的,假设相关的相机属性未更改。计算每个框架可确保它始终有效,但是您也可以仅在必要时显式重新计算它。
现在,用 LateUpdate 中的 Physics.BoxCast 替换 Physics.Raycast 。必须将扩展的一半作为第二个自变量添加,并将框的旋转作为新的第五个自变量添加。
if (Physics .BoxCast ( focusPoint,CameraHalfExtends ,-lookDirection, out RaycastHit hit, lookRotation,distance )) { lookPosition = focusPoint - lookDirection * hit.distance ; }
近平面位于相机位置的前面,因此我们只能向上投射到该距离,该距离是配置的距离减去相机的近平面距离。如果我们最终撞到东西,那么最终距离就是命中距离加上近平面距离。
if (Physics.BoxCast( focusPoint, CameraHalfExtends, -lookDirection, out RaycastHit hit, lookRotation, distance- regularCamera.nearClipPlane )) { lookPosition = focusPoint - lookDirection *(hit.distance+ regularCamera.nearClipPlane); }
请注意,这意味着相机的位置仍可以在几何图形内部结束,但是其近平面矩形将始终保留在外部。当然,如果盒子投射已经在几何体内部开始,则这可能会失败。如果焦点对象已经与几何相交,则相机也可能会相交。
我们当前的方法有效,但前提是聚焦半径为零。放宽焦点后,即使理想的焦点有效,我们也可以在几何体内部得到焦点。因此,我们不能指望焦点是盒子投射的有效起点,因此我们必须使用理想的焦点。我们将从那里投射到近平面框位置,方法是从相机位置移至焦点位置,直到到达近平面。
Vector3 lookDirection = lookRotation * Vector3.forward; Vector3 lookPosition = focusPoint - lookDirection * distance; Vector3 rectOffset = lookDirection * regularCamera.nearClipPlane; Vector3 rectPosition = lookPosition + rectOffset; Vector3 castFrom = focus.position; Vector3 castLine = rectPosition - castFrom; float castDistance = castLine.magnitude; Vector3 castDirection = castLine / castDistance; if (Physics.BoxCast( castFrom, CameraHalfExtends,castDirection, out RaycastHit hit, lookRotation,castDistance )) { … }
如果有东西被击中,则我们将盒子放置在尽可能远的地方,然后我们偏移以找到相应的相机位置。
if (Physics.BoxCast( castFrom, CameraHalfExtends, castDirection, out RaycastHit hit, lookRotation, castDistance )) { rectPosition = castFrom + castDirection * hit.distance; lookPosition =rectPosition - rectOffset; }
通过在执行箱式投射时忽略摄像机,可以使摄像机与某些几何形状相交。出于性能原因或相机稳定性,这可以忽略小的细微几何形状。可选地,这些物体仍然可以被检测到,但是会淡出而不是影响相机的位置,但是在本教程中我们不会介绍该方法。透明几何也可以忽略。最重要的是,我们应该忽略领域本身。从球体内部进行投射时,它将始终被忽略,但是响应速度较慢的摄影机最终可能会从球体外部进行投射。如果它随后撞击球体,相机将跳到球体的另一侧。
我们可以通过图层蒙版配置字段来控制此行为,就像球体使用的字段一样。
[SerializeField ] LayerMask obstructionMask = -1 ; … void LateUpdate ( ) { … if (Physics.BoxCast( focusPoint, CameraHalfExtends, castDirection, out RaycastHit hit, lookRotation, castDistance, obstructionMask )) { rectPosition = castFrom + castDirection * hit.distance; lookPosition = rectPosition - rectOffset; } … }
阻塞遮罩。
下一个教程是自定义重力 (Custom Gravity) 。
https://bitbucket.org/catlikecodingunitytutorials/movement-05-custom-gravity/
声明:发布此文是出于传递更多知识以供交流学习之目的。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与我们联系,我们将及时更正、删除,谢谢。
原作者:Jasper Flick
原文:
https://catlikecoding.com/unity/tutorials/movement/orbit-camera/
翻译、编辑、整理:MarsZhou
More:【微信公众号】 u3dnotes