unity中c#实现常见3D ACT 游戏主角镜头控制

unity中c#实现常见3D ACT 游戏主角镜头控制

​ 首先需要明确这个镜头需要完成的工作(我实现的这个摄像机镜头逻辑是参考鸣潮在游戏中的表现实现的):

​ 1.可以根据鼠标的左右上下滚动滚动视野,随鼠标滚轮缩放视野

​ 2.防止出现摄像机穿模,摄像机不可以位于地面地下,同时摄像机不可以位于障碍中

​ 同时需要实现配合相机的主角的基本移动方式,通常wasd代表前左下右的移动,这个方向是相对于摄像机器,所以通过RigidBody控制主角速度时速度方向的计算要结合摄像机。

​ 我们还可以加入act常见的隐藏光标来优化游戏的表现,本脚本默认隐藏鼠标的光标,按ctrl会呼出鼠标的光标。

实现思路

基本功能

​ 首先是基础逻辑部分,相机的缩放和视野球状滚动,我们需要下面的基础数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Header("目标与基础设置")]
public Transform target; //主角的transform
public Vector3 offset = new Vector3(0, 2, -5); //初始相对偏移
public LayerMask obstacleLayer; // 障碍物层
public LayerMask groundLayer; // 障碍物层


[Header("旋转设置")]
public float rotateSpeed = 2f; //相机x轴,y轴旋转灵敏度,常见实现中应该分成两个变量设计,这里偷懒了
public float minXAngle = -80f; // 相机向下最大角度
public float maxXAngle = 80f; // 相机向上最大角度

[Header("距离设置")]
public float minDistance = 2f; //相机缩放的最小距离
public float maxDistance = 10f; //相机缩放最大距离
public float scrollSpeed = 2f; //缩放灵敏度

​ 使用target获取主角的Transform引用,用来锚定相机的球心,offset用来标记相机的初始相对偏移位置,对于主角y坐标上偏移2个世界单位,z坐标-5个世界单位。

1
2
3
4
5
6
7
private void Start()
{
currentDistance = offset.magnitude;
Vector3 angles = transform.eulerAngles;
currentX = angles.y;
currentY = angles.x;
}

​ 上面是初始化的代码,我们根据默认偏移offset计算出我们目前的x轴和y轴的旋转量,注意这里的旋转量的x,y是便于我们理解的x,y。在unity中y的旋转实际上是关于x轴的,所以只有将欧拉角转换为四元数时需要把currentX放到x轴的旋转量中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void LateUpdate()
{
if (target == null) return;

currentX += Input.GetAxis("Mouse X") * rotateSpeed;
currentY += Input.GetAxis("Mouse Y") * rotateSpeed;
currentY = Mathf.Clamp(currentY, minXAngle, maxXAngle); // 限制垂直角度
currentX %= 360f;

currentDistance -= Input.GetAxis("Mouse ScrollWheel") * scrollSpeed;
currentDistance = Mathf.Clamp(currentDistance, minDistance, maxDistance);


Quaternion rotation = Quaternion.Euler(currentY, currentX, 0);
Vector3 direction = rotation * Vector3.forward;


//Vector3 desiredPosition = target.position + direction.normalized * currentDistance;

float safeDistance = currentDistance;
Vector3 safePosition = target.position + direction.normalized * safeDistance;
transform.position = safePosition;
transform.LookAt(target.position + Vector3.up * 0.5f); // 看向角色头部
}

​ 改段落代码是基础的实现计算相机相对坐标的逻辑,我们通过获取鼠标的x轴移动和y轴移动来计算当前应该的相对滚动的角度偏移,注意y轴的移动量我们需要在最小偏移和最大偏移之间。x轴的偏转最好对360取模,通过获取鼠标滚轮的输入来取得缩放的变化,缩放我们是通过维护一个距离角色的球面的距离,来实现缩放效果。注意rotation的计算,我们将currentX,currentY转化成四元数时的x轴旋转量放的是currentY,y轴旋转量是currentX。然后通过rotation * Vector3.forward就可以获取到相机相对于角色的偏转方向向量(注意这里一定要是Vector3.forward, 因为我们计算的四元数偏转是相对于世界方向正前方的)。

​ safePositon就是我们计算出来的相机位置,在让他看向角色的头顶就完成了基本的相机位置随鼠标移动,注意0.5f这个偏移根据角色的身高需要我们自己调整。

碰撞和防穿地检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
float safeDistance = currentDistance;
Vector3 safePosition = target.position + direction.normalized * safeDistance;

for(int i = 0; i < maxCheckIterations; i++)
{
Collider[] colliders = Physics.OverlapSphere(safePosition, cameraCheckRadius, obstacleLayer);

//防穿地检测
Vector3 rayStart = target.position + Vector3.up * 0.5f;
bool checkGround = Physics.Raycast(rayStart, direction.normalized, safeDistance, groundLayer);

if (colliders.Length == 0 && (!checkGround)) break;

safeDistance -= (currentDistance - minDistance) / maxCheckIterations; // 均匀缩小
safePosition = target.position + direction.normalized * safeDistance;
}

//这里要使用线性插值,防止连续障碍物,碰撞检测得到的安全位置偏差过大引起画面抖动
float realDistance = (transform.position - target.position).magnitude;
realDistance = Mathf.Lerp(realDistance, safeDistance, smoothSpeed * Time.deltaTime);

//没有发生碰撞检测直接跳跃
if (safeDistance == currentDistance) realDistance = currentDistance;

Vector3 finalPosition = target.position + direction.normalized * realDistance;

transform.position = finalPosition;
transform.LookAt(target.position + Vector3.up * 0.5f); // 看向角色头部

​ 将上文中计算出safePosition之后的内容全部改成上面的内容就完成了相机的碰撞和防穿地检测,我们根据计算出的safePosition做球体检测,直到相机的虚拟球形碰撞体没有碰撞的物品,并且相机于角色之间形成的线段不存在地面。我们找safePosition的过程就是暴力的缩短safeDistance来进行尝试,我设置的尝试次数maxCheckIterations为10。因为是参考模仿的鸣潮的摄像机运转,碰撞检测是不会改变currentDistance的,这意味着脱离碰撞后我们的摄像机会直接移动到一个之前滚轮滚到的缩放状态。

​ 同时需要注意我们的当前真实距离realDistance不可以直接修改成safeDistance。如果直接修改会造成连续碰撞的或连续触发穿地检测时会出现Distance的快速变化造成画面抖动的现象,我们速妖使用线性插值来进行优化。同时没有发生碰撞检测时我们可以直接跳转到currentDistance。

完整相机控制脚本(挂载在摄像机下)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ACTCameraController : MonoBehaviour
{
[Header("目标与基础设置")]
public Transform target; //主角的transform
public Vector3 offset = new Vector3(0, 2, -5); //初始相对偏移
public LayerMask obstacleLayer; // 障碍物层
public LayerMask groundLayer; // 障碍物层


[Header("旋转设置")]
public float rotateSpeed = 2f;
public float minXAngle = -80f; // 相机向下最大角度
public float maxXAngle = 80f; // 相机向上最大角度

[Header("距离设置")]
public float minDistance = 2f;
public float maxDistance = 10f;
public float scrollSpeed = 2f;
public float cameraCheckRadius = 0.4f; // 相机检测体积半径(模拟相机大小,需适配场景)

[Header("优化设置")]
public int maxCheckIterations = 10; // 最大碰撞检测迭代次数
public float smoothSpeed = 10f; // 位置平滑速度
private Vector3 currentVelocity; // SmoothDamp用的速度缓存

private float currentX = 0; //x轴旋转
private float currentY = 0; //y轴旋转
private float currentDistance; //相机距离主角的偏移距离

private void Start()
{
currentDistance = offset.magnitude;
Vector3 angles = transform.eulerAngles;
currentX = angles.y;
currentY = angles.x;
}

void LateUpdate()
{
if (target == null) return;

currentX += Input.GetAxis("Mouse X") * rotateSpeed;
currentY += Input.GetAxis("Mouse Y") * rotateSpeed;
currentY = Mathf.Clamp(currentY, minXAngle, maxXAngle); // 限制垂直角度
currentX %= 360f;

currentDistance -= Input.GetAxis("Mouse ScrollWheel") * scrollSpeed;
currentDistance = Mathf.Clamp(currentDistance, minDistance, maxDistance);


Quaternion rotation = Quaternion.Euler(currentY, currentX, 0);
Vector3 direction = rotation * Vector3.forward;

float safeDistance = currentDistance;
Vector3 safePosition = target.position + direction.normalized * safeDistance;

for(int i = 0; i < maxCheckIterations; i++)
{
Collider[] colliders = Physics.OverlapSphere(safePosition, cameraCheckRadius, obstacleLayer);

//防穿地检测
Vector3 rayStart = target.position + Vector3.up * 0.5f;
bool checkGround = Physics.Raycast(rayStart, direction.normalized, safeDistance, groundLayer);

if (colliders.Length == 0 && (!checkGround)) break;

safeDistance -= (currentDistance - minDistance) / maxCheckIterations; // 均匀缩小
safePosition = target.position + direction.normalized * safeDistance;
}

//这里要使用线性插值,防止连续障碍物,碰撞检测得到的安全位置偏差过大引起画面抖动
float realDistance = (transform.position - target.position).magnitude;
realDistance = Mathf.Lerp(realDistance, safeDistance, smoothSpeed * Time.deltaTime);

//没有发生碰撞检测直接跳跃
if (safeDistance == currentDistance) realDistance = currentDistance;

Vector3 finalPosition = target.position + direction.normalized * realDistance;

transform.position = finalPosition;
transform.LookAt(target.position + Vector3.up * 0.5f); // 看向角色头部
}
}

角色移动脚本(相对于相机朝向的移动)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SimpleMoveController : MonoBehaviour
{
[SerializeField]
[Tooltip("玩家速度设置")]
private float maxMoveSpeed = 3.0f;
public float rotateSmoothSpeed = 35f; //角色转向速度(用于线性插值)
//public float rotateTime = 0.3f; //角色转向所需时间(固定转向时间的线性插值)

[Header("跟随的摄像头坐标")]
[SerializeField]
private Transform cameraTransform;

public int horizontal = 0;
public int vertical = 0;

private Rigidbody playRB;
void Awake()
{
playRB = GetComponent<Rigidbody>();
}


void Update()
{
Vector3 nowSpeed = new Vector3(0, 0, 0);
horizontal = 0;
vertical = 0;

Vector3 directionFront = cameraTransform.rotation * Vector3.forward;
Vector3 directionRight = cameraTransform.rotation * Vector3.right;
directionFront = new Vector3(directionFront.x, 0, directionFront.z);
directionRight = new Vector3(directionRight.x, 0, directionRight.z);

if (Input.GetKey(KeyCode.W))
{
nowSpeed += maxMoveSpeed * directionFront;
vertical += 1;
}
if(Input.GetKey(KeyCode.S))
{
nowSpeed += -maxMoveSpeed * directionFront;
vertical -= 1;
}
if(Input.GetKey(KeyCode.A))
{
nowSpeed += -maxMoveSpeed * directionRight;
horizontal -= 1;
}
if (Input.GetKey(KeyCode.D))
{
nowSpeed += maxMoveSpeed * directionRight;
horizontal += 1;
}
nowSpeed = nowSpeed.normalized;
nowSpeed *= maxMoveSpeed;
playRB.velocity = nowSpeed;
if(playRB.velocity.magnitude > 1e-6)
{
Quaternion targetRotation = Quaternion.LookRotation(playRB.velocity.normalized);
Quaternion smoothRotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotateSmoothSpeed);
playRB.MoveRotation(smoothRotation);
}
}
}

​ 上面是完整移动脚本。需要获取的基本数据,有部分数据可能没有使用到,是我测试代码时使用的数据。

​ 该脚本与传统移动脚本不同的地方需要注意的是下面的这个相对摄像机朝向的前方和右方的方向向量的计算。

1
2
3
4
Vector3 directionFront = cameraTransform.rotation * Vector3.forward;
Vector3 directionRight = cameraTransform.rotation * Vector3.right;
directionFront = new Vector3(directionFront.x, 0, directionFront.z);
directionRight = new Vector3(directionRight.x, 0, directionRight.z)

​ 还有下面这一段关于角色面向移动方向的线性插值实现代码,这里实际上我有考虑过两种实现,一种是直接用朴素的旋转速度来控制线性插值,一种是固定一个旋转时间使得转向任意角度都是一个固定的时间。最后基于实现难度和实际效果,我认为直接使用朴素的旋转速度来控制线性插值即可。

1
2
3
4
5
6
if(playRB.velocity.magnitude > 1e-6)
{
Quaternion targetRotation = Quaternion.LookRotation(playRB.velocity.normalized);
Quaternion smoothRotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotateSmoothSpeed);
playRB.MoveRotation(smoothRotation);
}

鼠标光标隐藏脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MouseController : MonoBehaviour
{
public bool lockCursor = true;

private void Start()
{
HideMouse();
}

private void Update()
{
if(Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl))
{
ShowMouse();
}
else
{
HideMouse();
}
}

private void HideMouse()
{
Cursor.visible = false;
if (lockCursor)
{
Cursor.lockState = CursorLockMode.Locked;
}
}

private void ShowMouse()
{
Cursor.visible = true;
if(lockCursor)
{
Cursor.lockState = CursorLockMode.None;
}
}
}

​ 就是基础的对于unity Cursor类的使用,具体的函数及变量作用可以查询unity开发者api脚本手册。