某愿朝闻道译/原文地址

  • 创建一个环游轨道摄像机
  • 支持手动和自动旋转摄像机
  • 进行相对摄像机的移动
  • 防止摄像机穿透游戏物体

(警告 : 跟着教程做的时候, 强烈建议把每一行代码都加上注释说明其作用或原因, 本教程代码较多, 作者的代码也很骚, 每句都加注释会减少一些不必要的学习时间成本)

这是移动控制系列教程的第四篇. 这次我们要关注一下游戏摄像机, 从我们控制的球体创建轨道视角.

本教程使用Unity2019.2.18f1制作(译者注: 我使用2019.2.15f1跟着做的). 并且也需要使用ProBuilder包进行一些建模工作(本文将在上一篇教程中完成的代码基础上继续编写本教程代码)
环游摄像机 - 图1
跟随球体的摄像机

跟随球体

固定视角只适合观察被限制在完全处于视野内的固定区域的球体运动. 但是通常游戏角色可以在更大的区域内漫游. 有两种典型的方法来观察随意移动的游戏角色, 一种是使用第一人称视角, 一种是使用第三人称视角来跟随角色. 也存在其他方式, 比如说根据角色所处的位置来切换不同的摄像机.

存在”第二人称视角”吗?

第三人称视角处于游戏世界之外, 代表的是进行游戏的真实玩家的视角. 第二人称视角存在与游戏之内. 它可以是任何除了角色之外的其他游戏元素的视角. 很少有游戏使用这种视角, 不过也有一些游戏使用这种视角作为表现手法, 比如说在Psychonauts中, 有一种特异功能就是获得其他游戏角色的视角

环游摄像机

我们将创建一个简单的环游摄像机, 它以第三人称视角跟随球体运行. 首先创建一个新的脚本OrbitCamera, 为其添加RequireComponent特性, 来保障该脚本附加的物体上一定会同时存在一个Camera组件 :

  1. using UnityEngine;
  2. [RequireComponent(typeof(Camera))]
  3. public class OrbitCamera : MonoBehaviour {
  4. }

然后新建一个场景, 将场景内默认的Main Camera摄像机改名为Orbit Camera, 并将新建的脚本添加给Orbit Camera, 然后在场景内添加一个平面, 再放置一个我们之前制作的球体预制体, 让摄像机斜向下45度恰好可以在视角中心观察到球体, 如下图所示 :
环游摄像机 - 图2

环游摄像机 - 图3
Orbit Camera

为什么不使用”Cinemachine”?

Cinemachine提供了完备的自由视角摄像机功能, 可以环绕我们的球体进行观察, 所以它是满足我们的功能需求的.
但是, 我们自己手动的从零开始制作一个环绕摄像机, 可以帮助我们更好的理解环绕摄像机的工作原理及其缺陷. 而且, 对于我们教程的内容来说, Cinemachine的功能更为复杂, 包含众多需要调节的属性设置, 我们自己手动制作的环游摄像机将会更加易于理解和调节.

保持相对位置

要让摄像机跟随球体, 我们需要告诉摄像机球体在哪里. 所以首先在OrbitCamera脚本中添加一个可配置的Transform类型的字段focus, 用来指定要跟随的球体的Transform. 同时在增加一个字段distance用来调整摄像机与球体之间的距离设置, 将其默认值设置为5 :

  1. [SerializeField]
  2. //用来指定摄像机要跟随的Transform
  3. Transform focus = default;
  4. [SerializeField, Range(1f, 20f)]
  5. //设置摄像机与跟目标之间的距离
  6. float distance = 5f;

环游摄像机 - 图4
记得将球体的Transform设置给focus

我们将持续的调整摄像机的位置, 使得其可以保持与球体之间的设置距离. 我们可以在LateUpdate方法中设置摄像机的位置, 这样就可以保证在Update方法影响了focus之后才设置摄像机的位置. 摄像机的位置通过将它向着focus位置的反方向移动指定距离得来, 该距离就是我们distance字段的值. 我们将使用focus的position而不是localPosition属性, 来得到它在场景中的绝对坐标 :

  1. //LateUpdate会在每次Update方法执行完毕后执行一次
  2. void LateUpdate () {
  3. //摄像机要跟随的位置点, 就是focus的position
  4. Vector3 focusPoint = focus.position;
  5. //摄像机的观察方向, 就是自身的z轴正方向, 也就是transform.forward
  6. Vector3 lookDirection = transform.forward;
  7. //摄像机的位置设置为从focus位置开始, 向负lookDirection方向, 前进distance距离
  8. //注:由于此时摄像机没有父物体, 所以设置localPosition还是position的效果都是一样的
  9. //我猜作者这里使用localPosition可能是习惯问题
  10. transform.localPosition = focusPoint - lookDirection * distance;
  11. }

物理系统按照固定的频率调整球体的位置的同时, LateUpdate方法也会通过上述代码同步的不断调整摄像机的位置. 但是当帧率与物理更新频率不匹配时, 球体的运动会不流畅, 从而导致出现不流畅的摄像机跟随 : HonorableBouncyFunnelweaverspider-mobile.mp4 (1000.89KB)在Time设置中, 设置FixedUpdate的间隔为0.2秒, 出现不流畅的摄像机跟随
(如果你发现设置为0.2秒之后球体穿透了平面, 去把Sphere刚体组件的Collision Detection设置为Continuous)

修复该问题最简单的有效的办法就是设置球体的刚体组件的Interpolate属性为Interpolate(如下图所示), 这样球体就会按照自身的位置对运动进行插值计算而平滑移动, 从而摄像机也会平滑的进行位置计算. 这样就解决了这种情况下的不流畅运动问题 :
环游摄像机 - 图6

SarcasticJampackedIceblueredtopzebra-mobile.mp4 (1.53MB)FixedUpdate执行间隔依然是0.2, 消除了不流畅的摄像机跟随
(记得去时间设置里把执行间隔改回0.02)

为什么还是存在一点抖动感?

不规律的帧率会导致一些抖动现象, 尤其是帧率突然下降一些时. 编辑器容易出现这个情况, Build出的应用程序中该情况多数会有所好转, 运动会更加平滑

灵敏度

一直严格的跟随着球体会显得视角变化有些生硬, 因为即便是一点点微小的球体移动也会马上同步调整摄像机的位置, 从而影响到了视角变化. 我们可以为摄像机的跟随行为增加一定的灵敏度配置. 高灵敏度会更快的让摄像机跟随球体, 更低的灵敏度则会有一些延迟, 显得更加自然. 我们新增字段responsiveness, 值越大代表灵敏度越高, 摄像机跟随球体的反应越迅速, 最高灵敏度时应该让摄像机立即与球体位置同步, 我们使用0代表这种情况 :

  1. //设置摄像机跟随的灵敏度, 0代表最高灵敏度, 摄像机立即与球体位置进行同步; 大于0的情况下, 值越低灵敏度越低
  2. [SerializeField,Min(0f)]
  3. float responsiveness = 0f;

环游摄像机 - 图8
Responsiveness

灵敏度较低时, 需要我们从摄像机当前位置平滑的移动到球体运动到的新位置. 所以需要持续的平滑更改当前摄像机要同步的位置. 首先将focusPoint变量从LateUpdate方法中提出, 作为脚本的一个字段, 并且新建一个方法UpdateFocusPoint来更新它的值 :

  1. //将focusPoint变量从LateUpdate方法中拿出来, 作为一个字段
  2. Vector3 focusPoint;
  3. //程序运行时, 在Awake方法中初始化focusPoint的值为球体位置
  4. void Awake()
  5. {
  6. focusPoint = focus.position;
  7. }
  8. void LateUpdate()
  9. {
  10. //Vector3 focusPoint = focus.position;
  11. //删除focusPoint的声明语句, 并增加下面的UpdateFocusPoint方法调用
  12. UpdateFocusPoint();
  13. Vector3 lookDirection = transform.forward;
  14. transform.localPosition = focusPoint - lookDirection * distance;
  15. }
  16. //UpdateFocusPoint方法用来不断修改focusPoint的值
  17. void UpdateFocusPoint()
  18. {
  19. //用变量p获取到球体的当前位置
  20. Vector3 p = focus.position;
  21. //将focusPoint赋值为变量p
  22. focusPoint = p;
  23. }

如果responsiveness的值设置的不是0, 那么就要在摄像机的当前位置与球体位置之间进行插值计算来移动摄像机, 而平滑移动的时间基于responsiveness的值, 值越大, 需要的时间越长, 从而更加自然的跟随球体运动 :

  1. void UpdateFocusPoint()
  2. {
  3. Vector3 p = focus.position;
  4. //focusPoint = p;
  5. if(responsiveness > 0f){
  6. //如果灵敏度设置大于零, 则使用设置计算方法Lerp得到设置摄像机要跟随的位置点
  7. focusPoint = Vector3.Lerp(focusPoint, p, responsiveness * Time.deltaTime);
  8. }
  9. else{
  10. //如果灵敏度设置不大于0, 则表示摄像机要马上与球体位置同步, 则跟随点就等于球体位置
  11. focusPoint = p;
  12. }
  13. }

但是Tiem.deltaTime会受到游戏的时间缩放系数的影响, 比如说时间缩放系数设置为0, 则摄像机也不会跟随球体了, 为了避免这种情况, 我们应该使用不会被时间缩放系数影响的Time.unscaledDeltaTime代替它 :

  1. //focusPoint = Vector3.Lerp(focusPoint, p, responsiveness * Time.deltaTime);
  2. focusPoint = Vector3.Lerp(focusPoint, p, responsiveness * Time.unscaledDeltaTime);

最后, 还需要保障球体发生较大的位置变化时不会在视野内消失, 另一方面球体发生极小的位置变化时, 则可以忽略不计. 我们可以使用当前跟踪位置及球体位置之间距离的平方作为系数来进行插值, 从而实现这种效果 :

  1. //focusPoint = Vector3.Lerp(focusPoint, p, responsiveness * Time.unscaledDeltaTime);
  2. //使用当前球体位置p于与上次的跟随位置focusPoint之间的距离的平方作为系数, 这样可以让距离小于1时得到更小跟随点坐标, 距离大于1时得到更大的跟随点坐标
  3. float distanceSqr = (focusPoint - p).sqrMagnitude;//.sqrMagnitude会返回向量大小的平方
  4. focusPoint = Vector3.Lerp(focusPoint, p, distanceSqr * responsiveness * Time.unscaledDeltaTime);

OptimisticBriefFirebelliedtoad-mobile.mp4 (2.34MB)responsiveness = 5的摄像机跟随效果

这种方法受到帧率影响吗?

会受到帧率的影响.
更高的帧率将会比更低的帧率受到更大的抑制效果. 通过使用距离的平方作为插值的比例系数, 会使得距离小于1时得到更小的插值结果. 如果你的帧率太不稳定, 也可以使用下述代码来计算focusPoint :
focusPoint = Vector3.Lerp(focusPoint, p, distanceSqr * Mathf.Pow(responsiveness, Time.unscaledDeltaTime));

环绕球体

下一步我们需要调整摄像机朝向, 使得它可以在环绕球体的轨道上运动来观察球体. 我们可以手动控制这个轨道, 也可以让摄像机自动的随着它的跟随点而自动旋转.

朝向角度

摄像机的朝向可以使用两个角度进行描述. x角度定义了摄像机的垂直朝向, 0度表示水平, 90度表示垂直向下; y角度定义了摄像机的水平朝向, 0度表示看向世界空间的Z轴正方向. 使用一个Vector2类型的字段orbitAngles保存这两个角度, 设置其默认值为(45,0) :

  1. //保存摄像机的朝向角度
  2. Vector2 orbitAngles = new Vector2(45f, 0f);

在LateUpdate方法中, 我们要通过Quaternion.Euler方法来构造一个代表摄像机朝向变化的四元数, 向该方法传入orbitAngles作为参数. 不过该方法需要的参数类型是Vector3, 它会将Vector2类型的参数转换为Vector3, 转换后默认z轴值为0 :

之后可以使用得到的四元数与Vector3.forward向量相乘, 代替transform.forward, 计算出摄像机的新朝向, 然后通过transform.SetPositionAndRotation方法一次性的设置好摄像机的位置和朝向 :

  1. void LateUpdate(){
  2. UpdateFocusPoint();
  3. //根据orbitAngles得到代表摄像机朝向变化的四元数
  4. Quaternion lookRotation = Quaternion.Euler(orbitAngles);
  5. //Vector3 lookDirection = transform.forward;
  6. //根据朝向变化计算摄像机的新朝向
  7. Vector3 lookDirection = lookRotation * Vector3.forward;
  8. //transform.localPosition = focusPoint - lookDirection * distance;
  9. //新增lookPosition变量存储计算出的摄像机新位置
  10. Vector3 lookPosition = focusPoint - lookDirection * distance;
  11. //一次性设置摄像机的位置和朝向角度
  12. transform.SetPositionAndRotation(lookPosition, lookRotation);
  13. }

控制轨道

为了可以手动的控制摄像机环绕球体, 首先需要添加一个新的字段rotationSpeed, 它表示角速度, 单位: 角度/秒. 默认值设置为90 :

  1. //摄像机轨道角速度字段
  2. [SerializeField, Range(1f, 360f)]
  3. float rotationSpeed = 90f;

环游摄像机 - 图10
Rotation speed

新增方法ManualRotation, 用来获取输入指令. 我定义了一个”Vertical Camera”和”Horizontal Camera”输入轴, 用来获取控制摄像机角度的输入指令, 分别对应按键ikjl四个按键

===========翻译者补充内容开始↓↓↓========
添加上述两个输入轴的步骤如下 :
1) 前往菜单位置 : Edit > Project Settings…, 打开如下窗口后, 选择 Input标签 :
image.png
2) 右侧窗口内容中, 点击Axes前往的三角箭头, 展开被折叠的内容, 然后在Size右侧的输入栏中, 在原来的数字基础上, 增加2, 比如我这里显示18, 我更改为20后, 按回车
image.png
3) 额外Size输入看额外增加2后, 下方的列表末尾就会增加两个新的项目, 分别点开它们左侧的小箭头, 然后按下图进行配置 :
image.pngimage.png
===========翻译者补充内容结束↑↑↑========

在ManualRotation中, 我们增加一个变量e, 如果发现输入的值变化大于e, 则将这种变化通过rotationSpeed转换为摄像机轨道的角度变化, 此处还要乘以Time.unscaledDeltaTime, 使得角度变化速率不受帧率变化影响 :

  1. //根据用户输入设置orbitAngles
  2. void ManualRotation(){
  3. //使用input向量存的得到两个自定义输入轴的输入数据
  4. Vector2 input = new Vector2(Input.GetAxis("Vertical Camera"), Input.GetAxis("Horizontal Camera"));
  5. //常量e, const表示这是一个常量, 只能在初始化时赋值, 不能被其他代码改变值
  6. const float e = 0.001f;
  7. if(input.x < -e || input.x > e || input.y < -e || input.y > e){
  8. //如果在任意方向上的输入值大于e, 则计算新的orbitAngles值
  9. orbitAngles += rotationSpeed * Time.unscaledDeltaTime * input;
  10. }
  11. }

在LateUpdate方法中调用上述方法, 写在UpdateFocusPoint方法调用的下面一行 :

  1. void LateUpdate () {
  2. UpdateFocusPoint();
  3. //ManualRotation方法根据用户输入设置orbitAngles
  4. ManualRotation();
  5. }

UnacceptableHeavenlyAsianlion-mobile.mp4 (2.46MB)手动控制摄像机环绕球体

注意, 球体控制运动控制方向是相对世界空间的, 与摄像机此时的朝向无关, 所以如果你将摄像机水平旋转180度, 球体的运动控制表现会变得与你的输入完全相反. 虽然这种效果可以让球体始终完全正确的响应你的输入, 但是当摄像机进行不同程度的环绕运动后, 会让你难以准确的知道应该如何下达控制指令才能正确的让球体向你希望的屏幕方向运动. 所以接下来我们要让球体的运动控制输入与摄像机此时的视角相关联

约束角度

摄像机在水平方向上环绕球体并不会出现明显的问题, 但是如果在垂直方向上环绕超过90度运动, 画面看上去就出现了上下颠倒的情况. 特别是视角垂直向上或向下之后, 已经很难观察到球体将要移动到哪里. 所以我们需要增加两个字段来限制相机在垂直方向上环绕运动的角度范围, 设置最小可以旋转到-30度, 最大可以旋转到60度 :

  1. //相机环绕运动时在x轴上的角度变化限制字段, 最小30度, 最大60度
  2. //这两个字段可在Inspector中进行配置的取值范围是-89到89
  3. [SerializeField, Range(-89f, 89f)]
  4. float minVerticalAngle = -30f, maxVerticalAngle = 60f;

环游摄像机 - 图16
相机的最小和最大垂直旋转角度

另外, 最大旋转角度不应该低于最小旋转角度, 所以要通过在OnValidate方法添加相应代码保障这一点. 由于我们只通过Inspector来修改旋转角度的范围, 所以不需要在代码中调用该方法 :

  1. //OnValidate方法只会在编辑器环境下自动调用, 调用的条件是脚本初次被加载或是脚本中的字段通过Inspector中被修改时
  2. void OnValidate(){
  3. if(maxVerticalAngle < minVerticalAngle){
  4. //如果最大角度小于最小角度, 设置最大角度等于最小角度
  5. maxVerticalAngle = minVerticalAngle;
  6. }
  7. }

接着新增一个ConstraninAngles方法, 将orbitAngles的值锁定在最小与最大旋转角度之间. 虽然摄像机的y轴旋转角度不需要限制, 不过我们还是要将其进行处理, 使得其始终是在0-360之间的角度值 :

  1. //处理orbitAngles的值, 使得x轴旋转角度处于合法范围内, y轴旋转角度始终转换为360度内的数值
  2. void ConstrainAngles(){
  3. //处理x轴旋转角度, 使其不会小于最小角度, 也不会最大角度
  4. orbitAngles.x = Mathf.Clamp(orbitAngles.x, minVerticalAngle, maxVerticalAngle);
  5. if(orbitAngles.y < 0f){
  6. //如果y轴旋转角度是负数, 则加上360度
  7. orbitAngles.y += 360f;
  8. }
  9. else if(orbitAngles.y > 360f){
  10. //如果y轴旋转角度大于360度, 则减去360度
  11. orbitAngles.y -= 360f;
  12. }
  13. }

为什么不使用循环语句来不断的调整y轴旋转角度, 使得其最终处于360度内?

如果轨道角度是任意变化的, 那么确实需要多次循环处理, 不断增加或减少360度, 使得其最终处于0-360度范围内.
然而, 我们只会通过输入控制, 连续的, 进行较小的角度变化, 所以在这里不需要循环处理, 因为摄像机不会出现一次更新就改变了360度以上的情况

我们只需要在角度被改变时才对其进行约束修改, 所以修改一下ManualRotation方法, 使其在对角度做出修改时候返回true, 反之返回false. 然后在LateUpdate中使用if语句判断其返回值, 返回true时候调用ConstrainAngles方法, 此外, 我们也只需要在角度出现更改时才重新计算摄像机的朝向, 否则只需要与摄像机当前朝向一致即可 :

  1. //void ManualRotation(){
  2. //方法声明处增加bool类型返回值关键字
  3. bool ManualRotation(){
  4. Vector2 input = new Vector2(Input.GetAxis("Vertical Camera"), Input.GetAxis("Horizontal Camera"));
  5. const float e = 0.001f;
  6. if (input.x < -e || input.x > e || input.y < -e || input.y > e) {
  7. orbitAngles += rotationSpeed * Time.unscaledDeltaTime * input;
  8. //orbitAngles有更改时返回true
  9. return true;
  10. }
  11. //orbitAngles没有更改时返回false
  12. return false;
  13. }
  14. void LateUpdate () {
  15. UpdateFocusPoint();
  16. //ManualRotation();
  17. //Quaternion lookRotation = Quaternion.Euler(orbitAngles);
  18. //因为需要根据if判断设置lookRotation的值, 所以单独声明, 先不赋值
  19. Quaternion lookRotation;
  20. if (ManualRotation()) {
  21. //如果ManualRotation返回true, 说明相机旋转角度发生了改变, 所以要调用ConstrainAngles()方法对新的旋转角度进行约束处理
  22. ConstrainAngles();
  23. //使用约束处理后的orbitAngles来计算摄像机新的朝向四元数
  24. lookRotation = Quaternion.Euler(orbitAngles);
  25. }
  26. else {
  27. //如果ManualRotation返回false, 说明相机旋转角度没有变化, 所以旋转角度与当前一致即可
  28. lookRotation = transform.localRotation;
  29. }
  30. }

我们还需要在Awake方法中保障摄像机在最开始的旋转情况与orbitAngles的初始值相匹配 :

  1. void Awake () {
  2. focusPoint = focus.position;
  3. //保障摄像机在最开始的旋转情况与orbitAngles的初始值相匹配
  4. transform.localRotation = Quaternion.Euler(orbitAngles);
  5. }

自动对准

环游摄像机的一种常见特性就是, 相机会保持被跟随的角色后方对准玩家. 我们可以通过自动调整相机在水平方向上的轨道角度来实现这种效果. 不过由于玩家的输入可以在任意时刻旋转相机的角度, 所以自动旋转对准功能不会随时立即生效. 针对这种情况我们增加一个字段alignDelay, 用来指定用户修改轨道x轴的角度多少时间后, 才进行自动对准, 该延迟值没有上限, 所以你可以根据自己的需要设置任意延时时间, 本例中我们其默认延迟为5秒 :

  1. //设置用户在水平旋转相机多少秒后才可以进行相机自动对准
  2. [SerializeField, Min(0f)]
  3. float alignDelay = 5f;

环游摄像机 - 图17
alignDelay

新增字段lastManualRotationTime, 用来存储最后一次改变相机X轴角度的时间点, 此处我们依然使用不受时间缩放系数影响的时间属性来获取时间点 :

  1. //用来存储最后一次改变相机X轴角度的时间点
  2. float lastManualRotationTime;
  3. bool ManualRotation(){
  4. if (input.x < -e || input.x > e || input.y < -e || input.y > e) {
  5. orbitAngles += rotationSpeed * Time.unscaledDeltaTime * input;
  6. //存储最后一次改变相机X轴角度的时间点
  7. lastManualRotationTime = Time.unscaledTime;
  8. return true;
  9. }
  10. return false;
  11. }

接着添加一个新的方法AutomaticRotation, 该方法将在当前时间点比lastManualRotationTime多alignDelay秒以上时返回false, 否则返回true :

  1. <br />然后我们就可以在LateUpdate方法中, 判断发生了手动旋转或自动旋转时, 约束旋转角度并重新计算相机的朝向四元数 :
  2. ```csharp
  3. //if (ManualRotation()) {
  4. //在if判断中新增AutomaticRotation()方法, 注意两个方法的顺序, 先判断是否有手动旋转, 再判断是否有自动旋转
  5. if (ManualRotation() || AutomaticRotation()) {
  6. ConstrainAngles();
  7. lookRotation = Quaternion.Euler(orbitAngles);
  8. }

强制对准球体前进方向

有多种实现相机方向对准的方法, 文中将基于上一帧中跟随位置点的运动情况来进行摄像机的对准. 具体的思路就是, 让摄像机看向跟随位置点最后一次的前进方向. 要实现这种效果, 我们需要知道当前的跟随位置点和上一个跟随位置点, 所以需要新一个字段来存储上一个跟随位置点, 并在UpdateFocusPoint方法中对它们进行设置 :

  1. //新增字段previousFocusPoint, 存储上一个相机跟随位置点
  2. Vector3 previousFocusPoint;
  3. //…
  4. void UpdateFocusPoint () {
  5. //设置上一个跟随位置点
  6. previousFocusPoint = focusPoint;
  7. //Vector3 p = focus.position;
  8. //不再需要变量p, 直接设置新的当前跟随位置点
  9. focusPoint = focus.position;
  10. if (responsiveness > 0f) {
  11. //float distanceSqr = (focusPoint - p).sqrMagnitude;
  12. //不再使用变量p来计算distanceSqr
  13. float distanceSqr = (focusPoint - previousFocusPoint).sqrMagnitude;
  14. //focusPoint = Vector3.Lerp(focusPoint, p, distanceSqr * Mathf.Pow(responsiveness, Time.unscaledDeltaTime));//responsiveness * Time.unscaledDeltaTime);
  15. //不再使用变量P来计算focusPoint
  16. focusPoint = Vector3.Lerp(previousFocusPoint, focusPoint, distanceSqr * responsiveness * Time.unscaledDeltaTime);
  17. }
  18. //每次都在if里对focusPoint赋值了, 所以不在需要此处的else处理
  19. //else {
  20. // focusPoint = p;
  21. //}
  22. }

然后需要在AutomaticRotation方法中计算当前帧, 球体的移动方向. 因为只需要让摄像机在水平方向旋转来实现对准效果, 所以我们只需要考虑球体在XZ平面内的2D运动. 如果运动向量的平方小于阈值, 比如0.000001, 那么就认为球体没有发生运动, 所以也就不需要对摄像机进行对准旋转 :

  1. bool AutomaticRotation () {
  2. if (Time.unscaledTime - lastManualRotationTime < alignDelay) {
  3. return false;
  4. }
  5. //计算到从上一个跟随位置点到当前跟随位置点的运动向量movement
  6. Vector2 movement = new Vector2(focusPoint.x - previousFocusPoint.x, focusPoint.z - previousFocusPoint.z);
  7. //计算球体运动向量大小的平方
  8. float movementDeltaSqr = movement.sqrMagnitude;
  9. if (movementDeltaSqr < 0.000001f) {
  10. //如果运动向量大小的平方小于指定的阈值, 则认为球体没有发生移动, 则不进行自动对准, 返回false
  11. return false;
  12. }
  13. return true;
  14. }

如果球体确实在进行移动, 那么我们需要算出与当前运动方向一致的水平旋转角度. 首先创建一个静态方法GetAngle来将2D方向转换为一个角度值. 运动方向的y值就是我们要计算的角度的余弦, 所以可以使用Mathf.Acos方法得到余弦对应的弧度, 并将弧度转换为角度 :

  1. //GetAngle方法将二维向量转换为其所在平面内代表的角度
  2. static float GetAngle(Vector2 direction){
  3. //Mathf.Acos 将传入的参数值视为余弦值, 并返回该余弦值对应的弧度值
  4. float angle = Mathf.Acos(direction.y) * Mathf.Rad2Deg;
  5. return angle;
  6. }

但是该角度可以代表相对Z轴的顺时针角度也可以代表逆时针角度. 我们可以查看运动方向的x轴分量来知道角度的旋转方向. 如果x是负数, 则表示这是逆时针角度, 我们就需要使用360度减去它得到我们需要的顺时针角度值 :

  1. //return angle
  2. //如果向量的x分量小于0, 则说明结果是逆时针角度, 则需要使用360度减去它来得到顺时针角度值
  3. return direction.x < 0f ? 360f - angle : angle;

接着我们在AutomaticRotation中将球体的运动向量归一化后, 传递给GetAngle方法来获得球体的前进角度. 因为我们已经得到了该运动向量大小的平方, 那么我们自己来计算该归一化结果会更有效率. 将计算出的结果赋给orbitAngles的y分量, 也就是代表水平方向上的摄像机轨道旋转角度 :

  1. if (movementDeltaSqr < 0.0001f) {
  2. return false;
  3. }
  4. //计算球体的前进方向角度, movement / Mathf.Sqrt(movementDeltaSqr) 是用来计算movement的归一化结果的, 原作者意思是这样利用上之前的向量平方, 比使用Normalize方法更有效率
  5. float headingAngle = GetAngle(movement / Mathf.Sqrt(movementDeltaSqr));
  6. //使用计算出的球体前进方向角度为水平方向上的摄像机轨道旋转角度赋值
  7. orbitAngles.y = headingAngle;
  8. return true;

RemorsefulAlarmedAsianporcupine-mobile.mp4 (2.23MB)立即完成的摄像机方向对准

平滑过渡对准过程

我们的摄像机自动对准功能虽然实现了, 但是现在会立即对准球体的运动变化方向, 有点儿僵硬. 我们可以设置一个摄像机对准旋转速度来平滑的进行这个过程, 类似于我们手动旋转摄像机的效果. 为了实现这个效果需要用到Mathf.MoveTowardsAngle方法, 该方法的功能类似MoveTowards, 只不过它是用来处理0到360度的角度值的 :

  1. float headingAngle = GetAngle(movement / Mathf.Sqrt(movementDeltaSqr));
  2. //使用与手动操作旋转一样的角速度rotationSpeed得到每次自动旋转需要更新的水平角度变化
  3. float rotationChange = rotationSpeed * Time.unscaledDeltaTime;
  4. //orbitAngles.y = headingAngle;
  5. //使用MoveTowardsAngle方法与计算出的rotationChange来平滑的进行摄像机自动旋转
  6. orbitAngles.y = Mathf.MoveTowardsAngle(orbitAngles.y, headingAngle, rotationChange);

FrankSeriousAgouti.webm (599.83KB)不再立即对准前进方向, 平滑的变化旋转角度

虽然现在的对准过程效果更好了, 但是却始终使用最大的旋转速度进行对准. 更加自然的方式应该让旋转速度因需要旋转的角度大小而进行缩放. 我们将让缩放系数在需要旋转的角度大于某个角度时达到最大值. 所以首先我们增加一个新的字段alignSmoothRange, 将它在Inspector中的调整范围设置为0-90, 并且默认值为45 :

  1. [SerializeField, Range(0f, 90f)]
  2. //当摄像机当前朝向与球体前进方向夹角大于等于
  3. float alignSmoothRange = 45f;

环游摄像机 - 图20
alignSmoothRange

然后我们需要在AutomaticRotation中得到当前角度和目标角度之间相差的角度, 可以将这两个角度作为参数传递给Mathf.DeltaAngle方法来得到这个角度, 然后我们取得这个角度的绝对值, 如果绝对值小于alignSmoothRange, 那么就需要按照按照该角度相对alignSmoothRange的比例来缩放旋转速度 :

  1. //计算当前摄像机水平角度与球体运动前进方向角度间的最小角度差距的绝对值, Mathf.DeltaAngle方法将得到两个角度值参数直接的最小相差的角度, 比如10度与20度最小相差10度, 370度与20度同样相差10度
  2. float deltaAbs = (Mathf.DeltaAngle(orbitAngles.y, headingAngle));
  3. float rotationChange = rotationSpeed * Time.unscaledDeltaTime;
  4. if (deltaAbs < alignSmoothRange) {
  5. //如果deltaAbs小于alignSmoothRange, 则最终旋转速度要乘以deltaAbs / alignSmoothRange, 也就是降低了实际的旋转速度
  6. rotationChange *= deltaAbs / alignSmoothRange;
  7. }
  8. orbitAngles.y = Mathf.MoveTowardsAngle(orbitAngles.y, headingAngle, rotationChange);

上述方法可以很好的处理球体向远离摄像机的方向前进时摄像机旋转速度的变化, 我们还可以用类似方法处理球体朝着摄像机所在位置接近时摄像机旋转速度的变化, 防止相机进行大角度跟随时突然发生快速旋转, 这时我们对旋转你速度的缩放参数的分子要使用180度减去最小角度差距的绝对值 :

  1. if (deltaAbs < alignSmoothRange) {
  2. rotationChange *= deltaAbs / alignSmoothRange;
  3. }
  4. //当相机水平朝向角度与球体运动方向角度相差大于180-alignSmoothRange时, 也对旋转速度进行限制, 使其由慢渐渐变快
  5. else if (180f - deltaAbs < alignSmoothRange) {
  6. rotationChange *= (180f - deltaAbs) / alignSmoothRange;
  7. }

最后, 我们可以通过时间增量和移动增量中的最小值与rotationSpeed相乘来进一步抑制微小移动时的镜头旋转 :

  1. //float rotationChange = rotationSpeed * Time.unscaledDeltaTime;
  2. //使用Time.unscaledDeltaTime与movementDeltaSqr中的较小值来与rotationSpeed相乘去计算角度变化, 进一步印制微小移动下的镜头旋转
  3. float rotationChange = rotationSpeed * Mathf.Min(Time.unscaledDeltaTime, movementDeltaSqr);

GlossyDigitalBaldeagle-mobile.mp4 (2.33MB)平滑的对准

我们现在的实现摄像机自动对准的方法会在球体笔直的滚向摄像机朝向的反方向时不发生对准, 此时如果我们对于球体运动方向进行控制使其发生一些偏转, 才会出现摄像机的字段对准旋转 :

FamousElectricDavidstiger-mobile.mp4 (1.78MB)球体运动方向与摄像机朝向相差180度时, 需要对运动方向进行一些偏移才会发生摄像机对准

相对摄像机移动

至此我们已经有了一个不错的环绕摄像机. 接下来我们要让玩家的相对于摄像机视角进行控制输入

输入坐标空间

输入信息可以使用任何坐标空间进行定义, 而不只是对应世界坐标空间(也就是输入数据代表的上下左右, 不止可以作为世界空间的上下左右, 可以自己定义这个方向的含义). 我们可以使用Transform组件定义任何坐标空间. 首先想Moving Sphere脚本添加一个代表玩家输入坐标空间的字段playerInoutSpace :

  1. [SerializeField]
  2. //该字段代表玩家输入的坐标空间
  3. Transform playerInputSpace = default;

加入该字段后, 前往球体的Inspector, 将摄像机物体拖拽到这个字段输入栏上, 从而将摄像机的Transform组件分配给它. 顺便说一下, 由于该字段被分配的引用是本场景下的摄像机, 对于该场景外就没有意义了, 所以该字段的这个值不能被保存到球体的预制体数据中.

我们要通过这个字段使得球体的输入控制基于摄像机的朝向
环游摄像机 - 图23
playerInputSpace字段

如果我们希望使用摄像机自身的Transform作为输入的坐标空间, 就需要将这个自定义的输入坐标空间转换为世界坐标空间. 我们可以在MovingSphere脚本中的Update方法中, 使用Transform.TransformDirection方法来做到这一点 :

  1. //如果playerInputSpace没有分配一个transform组件, 则其值为null, 将会按照false处理, 否则按照true处理
  2. if (playerInputSpace) {
  3. //如果playerInputSpace被分配了一个代表自定义坐标空间的transform, 那么就将运动方向设置该transform的本地空间下, 并通过TransformDirection方法将本地方向转换为世界空间下的方向, 使用这个方向向量来计算最终的球体运动速度
  4. desiredVelocity = playerInputSpace.TransformDirection(playerInput.x, 0f, playerInput.y) * maxSpeed;
  5. }
  6. //将desiredVelocity赋值的语句放到新增的else语句中
  7. else {
  8. desiredVelocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
  9. }

MinorJaggedBream-mobile.mp4 (1.99MB)现在所有而输入控制都会相对于摄像机的朝向来决定上下左右方向

方向归一化

尽管目前的代码可以让球体向着正确的方向移动, 但是它向前的速度会被垂直方向上的轨道角度所影响. 摄像机的朝向越偏向下方向, 球体的水平移动速度就越慢. 这是因为球体的移动方向位于XZ平面内, 而摄像机朝向不一定平行于该平面, 从而导致通过摄像机本地空间计算出来的运动方向只有一部分分量在XZ平面内.

我们可以先获取摄像机的前方向和右方向向量, 然后舍弃它们的y分量, 再将它们归一化. 然后球体的目标速度就应该是这些向量与玩家输入的乘积之和 :

  1. if (playerInputSpace) {
  2. //得到摄像机本地空间 前方向 在世界坐标空间下的向量
  3. Vector3 forward = playerInputSpace.forward;
  4. //舍弃前方向的y分量
  5. forward.y = 0f;
  6. //将前方向归一化
  7. forward.Normalize();
  8. //得到摄像机本地空间 右方向 在世界坐标空间下的向量
  9. Vector3 right = playerInputSpace.right;
  10. //舍弃右方向的y分量
  11. right.y = 0f;
  12. //将右方向归一化
  13. right.Normalize();
  14. //球体的目标运动速度方向就等于摄像机的前方向和右方向与输入数据的乘积之和, 再乘以maxSpeed得到最终速度
  15. desiredVelocity = (forward * playerInput.y + right * playerInput.x) * maxSpeed;
  16. }

摄像机碰撞

现在我们的摄像机只关心它相对于跟踪位置点的位置及朝向. 它不会考虑场景中存在的其他东西. 因此, 它会在直接穿透阻挡它的物体, 这会导致一点问题. 首先, 这很挫. 其次, 这会遮挡我们观察球体的视线, 进而会让我们不易直观的操控球体. 第三, 这可能会让玩家看到场景中不应该被看见的内容

ExcellentCanineKentrosaurus-mobile.mp4 (1.58MB)摄像机穿透场景中的物体

减小观察距离

有多种策略可以用来保持摄像机显示恰当的视角. 我们选择一种较为简单的方法, 那就是在摄像机和它的跟随位置点之间出现其他东西时, 将摄像机沿着它的观察方向向前推

检测摄像机视角是否存在问题的最直接方式就是从跟踪位置点向我们希望放置摄像机的位置发射一条射线. 在OrbitCamera脚本的LateUpdate方法中, 当我们计算出lookDirection后, 就可以来做这件事. 如果我们的射线击中了什么东西, 那么就使用击中物体的距离代替我们配置的摄像机距离 :

  1. Vector3 lookDirection = lookRotation * Vector3.forward;
  2. //定义变量来存储计算出的摄像机最终距离
  3. float lookDistance;
  4. //下面if的写法, 是一种语法糖, 直接声明了hit这个变量, 所以可以在后面使用
  5. if (Physics.Raycast(focusPoint, -lookDirection, out RaycastHit hit, distance)) {
  6. //如果从跟随目标点向摄像机方向发射的距离为distance的射线击中了什么东西, 那么就让被击中物体的距离作为新的摄像机距离
  7. lookDistance = hit.distance;
  8. }
  9. else {
  10. //如果射线没有击中什么, 则摄像机保持配置的距离不变
  11. lookDistance = distance;
  12. }
  13. //Vector3 lookPosition = focusPoint - lookDirection * distance;
  14. //摄像机的位置使用lookDistance代替之前的distance来计算
  15. Vector3 lookPosition = focusPoint - lookDirection * lookDistance;

CornyRemarkableImperialeagle-mobile.mp4 (1.6MB)摄像机字段保持在场景物体前
(准确的说, 必须是带有Collider类组件的物体, 这样射线才能检测到)

摄像机可能会被向前推进球体模型内部. 当球体与摄像机的近裁面相交时, 可能会导致球体模型部分不可见甚至完全不可见. 你可以强制设置一个最小距离来避免这种情况, 但是那意味着摄像机会穿透场景内的物体. 所以这种方案并不完美. 我们可以通过限制垂直轨道角度来减轻这种问题的影响, 不要让关卡内的物体挨得太紧, 同时减少摄像机的近裁面距离.
(近裁面和远裁面指的摄像机的Clipping Plane属性的Near值和Far值, 比Near近和Far远的物体都不会被这个摄像机渲染显示)

防止近裁面穿透物体

发射一条线状射线不足以完全解决问题. 这是因为即便在摄像机的位置和跟踪位置点之间存在通畅的连线, 摄像机的近裁面矩形仍然可以部分的穿透场景内的物体. 解决这个问题的办法是在世界空间内投射与摄像机近裁面矩形相匹配的盒状投射(box cast), 这代表了摄像机可以看见的最近的物体, 相当于摄像机的传感器

首先, 早OrbitCamera脚本中需要增加一个字段代表摄像机组件 :

  1. //使用该字段存储摄像机引用
  2. Camera regularCamera;
  3. void Awake () {
  4. //在Awake方法中将脚本所在物体的Camera组件引用赋值给regularCamera
  5. regularCamera = GetComponent<Camera>();
  6. focusPoint = focus.position;
  7. transform.localRotation = Quaternion.Euler(orbitAngles);
  8. }

第二步, 盒状投射需要一个包含一半盒子尺寸的向量, 也就是盒子一半的宽度, 高度和深度.

一半的高度可以通过以弧度为单位的摄像机视场角(field-of-view)的切线获得. 通过它的近裁面距离来对其进行缩放. 一半的宽度可以按照摄像机的横纵比和高度计算得来. 盒子的深度为0. 让我们通过一个属性的get方法得到这个代表盒子的向量 :

  1. //该属性的get方法将返回盒状投射所需的盒子向量
  2. Vector3 CameraHalfExtends {
  3. get {
  4. //该向量代表盒子尺寸的一半
  5. Vector3 halfExtends;
  6. //计算高度
  7. halfExtends.y = regularCamera.nearClipPlane * Mathf.Tan(0.5f * Mathf.Deg2Rad * regularCamera.fieldOfView);
  8. //计算宽度
  9. halfExtends.x = halfExtends.y * regularCamera.aspect;
  10. //深度为0
  11. halfExtends.z = 0f;
  12. return halfExtends;
  13. }
  14. }

不能缓存这个盒子的尺寸吗?

可以, 只要摄像机的属性不发生变化, 就可以计算一次后进行缓存供后续使用. 不过我们在每一帧都进行计算, 可以保障不出现失误, 你可以自己修改代码使得盒子的尺寸只在有需要时才进行计算(我不推荐你改)

现在, 在LateUpdate方法中使用Physics.BoxCast代替Physics.Raycast. 将CameraHalfExtends属性作为方法的第二个参数, lookRotation作为新的第五个参数 :

  1. //if (Physics.Raycast(focusPoint, -lookDirection, out RaycastHit hit, distance)) {
  2. //Physics.BoxCast方法功能与RayCast方法类似, 只不过它将沿着指定方向发射出一个盒状区域, 而不是一条射线
  3. //之所以使用盒状射线代替线状射线, 是为了检测到让摄像机推进到近裁面完全不会与障碍物表面发生穿透的位置
  4. if (Physics.BoxCast(focusPoint, CameraHalfExtends, -lookDirection, out RaycastHit hit, lookRotation, distance)) {
  5. lookDistance = hit.distance;
  6. }

我们应该使用摄像机面到近裁面的距离作为盒状射线的长度, 只需要使用distance减去摄像机的近裁面距离设置即就可以得到盒装射线应该使用的长度, 如果盒状射线撞到了任何东西, 则最终的distance值应该是射线击中的距离再加上近裁面的距离 :

  1. //if (Physics.BoxCast(focusPoint, CameraHalfExtends, -lookDirection, out RaycastHit hit, lookRotation, distance)) {
  2. //使用distance - regularCamera.nearClipPlane作为射线长度, 从而只检测跟着位置点到摄像机近裁面之间是否存在物体
  3. if (Physics.BoxCast(focusPoint, CameraHalfExtends, -lookDirection, out RaycastHit hit, lookRotation, distance - regularCamera.nearClipPlane)) {
  4. //lookDistance = hit.distance;
  5. //如果盒状射线在跟随位置点与摄像机近裁面之间检测到了物体, 则设置摄像机近裁面处于射线击中的距离处
  6. lookDistance = hit.distance + regularCamera.nearClipPlane;
  7. }

ReflectingRemoteDartfrog-mobile.mp4 (2.78MB)摄像机不再会半透场景内的物体表面

注意, 摄像机的位置依然可能处于场景物体内部, 但是摄像机的近裁面矩形将始终不会穿透物体表面.

障碍物遮罩

教程的最后, 我们为射线检测增加一个Layer列表, 从而可以选择性的排除物体使得它们不会引发摄像机的观察距离变化, 这样可以在场景中存在一些微小的细节物体时起到作用, 从而可以忽略这些细节物体对于摄像机视野的阻挡.

我们将通过一个LayerMask类型的字段来实现这个效果 :

  1. [SerializeField]
  2. //在Inspector中配置该LayerMask列表, 被选中的Layer中的物体才可能引起摄像机推近观察距离
  3. LayerMask obstructionMask = -1;
  4. void LateUpdate () {
  5. //if (Physics.BoxCast(focusPoint, CameraHalfExtends, -lookDirection, out RaycastHit hit, lookRotation, distance - regularCamera.nearClipPlane)) {
  6. //新增obstructionMask作为BoxCast方法新增的最后一个参数, 用来指定射线可以检测那些Layer内的物体
  7. if (Physics.BoxCast(focusPoint, CameraHalfExtends, -lookDirection, out RaycastHit hit,lookRotation, distance - regularCamera.nearClipPlane, obstructionMask)) {
  8. lookDistance = hit.distance + regularCamera.nearClipPlane;
  9. }
  10. }

环游摄像机 - 图28
obstructionMask

下个教程, 我们将会进一步强化球体的移动能力(新教程原作者还未制作出来, 移动控制教程章节暂时结束)

license
教程源码
PDF