===================更新一下源工程链接:
链接:https://pan.baidu.com/s/15bxH-MPregp2ZIN92fK7XA 提取码:e7bp
===================(与本文相比有修改)
效果视频演示:
https://www.bilibili.com/video/av88249417
完整代码在最下面
最近练习Unity,想做一个第三人称射击游戏的Demo。首先来做一个武器瞄准效果,即让枪能指向目标。
查了很久不知道怎么实现,后来查到了IK这个概念,IK就是反向动力学,按我的理解是让骨骼中的下层带动上层运动,与FK(前向动力学)相反,比如伸手去触摸物体时带动整个身体前倾。所以武器的瞄准就可以用通过IK来做,让手和武器瞄准目标,带动身体旋转,这样就很自然。
如图:
Unity自带的IK实在是不好用,连这个功能都很难实现,于是我用了一个插件:Final IK,用里面一个叫AimIK的组件就可以解决了。
附上Final IK1.6的链接:
链接:https://pan.baidu.com/s/1zY4bluDzi8xSSWPtJBLGWQ
提取码:eovc
教程参考这篇博客:
https://blog.csdn.net/weixin_38239050/article/details/101831392
Final IK的使用不是本文的重点。
用代码来设置瞄准的思路是:从摄像机的位置向摄像机的forward方向发出一定距离的射线,如果射线打到物体就让把它设为瞄准目标,否则瞄准射线的终点。注意射线可能需要屏蔽玩家本身碰撞体的干扰。
设置AimIK的target位置的代码为:
aimIK.solver.target.position = targetPos;
然后是视角控制,虽然Unity有相应的插件,但是直接用就没意思了。本来以为比较简单,没想到还是搞了几天(太菜了)
实现的功能如下:
1.摄像机平滑跟随玩家
2.摄像机绕玩家旋转,并限制角度
3.往上看时随角度增大而拉近摄像机,往下看时随角度增大而拉远摄像机
4.瞄准时拉近摄像机,停止瞄准时回到默认位置
5.碰到墙壁遮挡时拉近摄像机
下面依次介绍,完整代码在最下面
记录摄像机与人物的位置偏移向量
playerOffset = player.position - transform.position;
当摄像机旋转后需要更新playerOffset,再次计算上面这句代码。
更新playerOffset后需要在下一帧使用插值让摄像机移动到player.position - playerOffset这个位置
transform.position = Vector3.Lerp(transform.position, player.position - playerOffset, moveSpeed * Time.deltaTime);
主要使用transform.RotateAround函数,让摄像机绕人物旋转。由于人物可以移动,而摄像机的位置是使用插值改变的,当人物移动时,playerOffset这个向量的长度和旋转角度会发生改变,人物走的越远,偏差会越大,不符合要求。
我的办法是当摄像机绕玩家旋转后,让playerOffset向量同步旋转相同的角度,更新playerOffset。旋转后playerOffset的长度不变,这样可保证player.position-playerOffset始终是摄像机的最终位置。
RotateAround(Vector3 point, Vector3 axis, float angle);
point:要围绕的点;
axis:要围绕的轴,如x,y,z
angel:旋转的角度
所以摄像机水平和垂直绕玩家旋转为:(注意旋转的轴不同)
transform.RotateAround(player.position, Vector3.up, axisX);
transform.RotateAround(player.position, transform.right, -axisY);
其中axisX和axisY为对应鼠标的偏移量xTime.deltatimex旋转速度:
float axisX = Input.GetAxis("Mouse X") * rotateSpeed * Time.deltaTime;
float axisY = Input.GetAxis("Mouse Y") * rotateSpeed * Time.deltaTime;
而让playerOffset向量绕一个点旋转则要先获取水平和垂直旋转的四元数:(同样注意绕的轴)
Quaternion rotX = Quaternion.AngleAxis(axisX, Vector3.up);
Quaternion rotY = Quaternion.AngleAxis(-axisY, transform.right);
然后让这两个四元素与向量相乘就可以旋转向量而长度不变:(注意这里相乘不具有交换性,不要改变顺序或是简写)
playerOffset = rotX * rotY * playerOffset;
这里获取向量旋转看不懂可以参考文章:
https://gameinstitute.qq.com/community/detail/127450
要限制角度,我们要先让它旋转,然后获取旋转后垂直方向的欧拉角:
float x = (transform.rotation).eulerAngles.x;
但是欧拉角的范围是0-360度循环的,不利于判断,要转换范围成-180度-180度,向上为负,向下为正。然后判断它是否在我们给定的范围内如果超出范围则还原摄像机在垂直方向的旋转,并且让playerOffset只进行水平旋转;否则让playerOffset进行水平和垂直的旋转。
//欧拉角范围为0~360,这里要转为-180~180方便判断
if (x > 180) x -= 360;
if (x < minAngle || x > maxAngle)//超出角度
{
//还原位置和旋转
transform.position = posPre;
transform.rotation = rotPre;
//更新offset向量,offset与本物体同步旋转
//我们需要通过这offset去计算本物体(包括摄像机)应该平滑移向的位置
//如果仅仅使用RotateAround函数,当人物在移动时会出现误差
playerOffset = rotX*playerOffset;
}
else//垂直视角符合范围的情况
{
//更新offset向量,offset与本物体同步旋转
playerOffset = rotX * rotY * playerOffset;
}
最高视角:(根据minAngle,根据需要设定,我设为-40)
最低视角:(根据maxAngle,根据需要设定,我设为50)
摄像机与人物的距离需要时可变的,比如在瞄准时应该将摄像机拉近人物,但是如果直接改变摄像机的位置又会破坏跟随玩家和自由视角的功能。
为了使摄像机在能够根据需要偏移的同时又不影响平滑跟随玩家,我的办法是把上面的控制代码挂在一个空物体上,将摄像机作为这个空物体的子物体,这样空物体跟随玩家时,摄像机也会做相同的位移。而让摄像机的位置产生偏移只需要改变它的localPosition,也就是它与父物体的相对位置。
如图:TPSCameraParent为空物体
摄像机偏移是改变TPSCamera的localPosition.z,让TPSCamera相对于TPSCameraParent前后移动。定义一个总的偏移量:
float localOffset = 0;
这个偏移量的影响因素有三个:
1.垂直视角角度
2.是否瞄准
3.是否有遮挡
下面来看垂直视角角度的影响:
上面我们获取到了垂直方向旋转欧拉角float x,就可以根据x与我们给定的最大角度的比值来设定摄像机的前后偏移:
//更据角度设置摄像机位置偏移
if (x < 0)//往上角度为负
{
//往上看时距离拉近
localOffsetAngle = (x / minAngle) * localOffsetAngleUp;
}
else
{
//往下看时距离拉远
localOffsetAngle = -(x / maxAngle) * localOffsetAngleDown;
}
其中localOffsetAngle为根据角度计算的偏移量,localOffsetAngleUp和localOffsetAngleDown分别为向上看和向下看时这个偏移量的最大值。当x=0时即摄像机平视前方时,这个偏移量=0。
那怎么让摄像机能够前后偏移呢?让总偏移量加上它:
localOffset+=localOffsetAngleMax;
在最后使摄像机平滑移动到偏移位置:
Vector3 offsetPos = new Vector3(0, 0, localOffset);//这是相机应该移向的位置
//使相机平滑移动到这个位置
cam.transform.localPosition = Vector3.Lerp(cam.transform.localPosition, offsetPos, localOffsetSpeed * Time.deltaTime);
规定当鼠标右键按住时瞄准,松开时停止瞄准。
再定义一个偏移量localOffsetAim和一个bool值isAiming
public float localOffsetAim = 2;//根据是否瞄准而产生的偏移量,表示瞄准时摄像机应该前进多远距离,根据需要设值
private bool isAiming = false;//是否正在瞄准
然后每帧判断鼠标事件:
if (Input.GetMouseButtonDown(1))//鼠标右键按下为瞄准
{
isAiming = true;
}
if (Input.GetMouseButtonUp(1))//鼠标右键松开停止瞄准
{
isAiming = false;
}
接着根据isAiming来决定是否让localOffset加上这个偏移量:
//根据是否瞄准而调整
if (isAiming)
{
localOffset += localOffsetAim;
}
当人物身后有墙壁或是其他物体靠近时,会遮挡视线:
这时候需要将摄像机拉近到合适距离,所以再定义一个偏移量:
float localOffsetCollider = 0;
然后在一帧中逐渐增加这个偏移量去试探有没有遮挡,试探的方法还是利用射线检测,看是否能打到除玩家外的碰撞体,我这里玩家身上挂的是CapsuleCollider:
private bool CheckView(Vector3 checkPos)
{
//发出射线来检测碰撞
RaycastHit hit;
//射线终点为玩家物体的中间位置
Vector3 endPos = player.position + player.up * player.GetComponent<CapsuleCollider>().height * 0.5f;
Debug.DrawLine(checkPos,endPos, Color.blue);
//从checkPos发射一条长度为起点到终点距离的射线
if (Physics.Raycast(checkPos,endPos-checkPos,out hit,(endPos-checkPos).magnitude)){
if (hit.transform == player)//如果射线打到玩家说明没有遮挡
return true;
else//如果射线打击到其他物体说明有遮挡
return false;
}
return true;//如果射线没有打到任何物体也说明没有遮挡
}
根据是否有遮挡而调整localOffsetCollider
Vector3 checkPos = transform.position + cam.transform.forward * localOffset;//这是没有调整前相机应该移向的位置
for(localOffsetCollider=0; !CheckView(checkPos);localOffsetCollider+=0.2f)//让localOffset递增直至没有遮挡
{
//更新checkPos为我们想要移动到的位置,再去试探
checkPos = transform.position + cam.transform.forward * (localOffset+localOffsetCollider);
}
再让localOffset加上这个试探出的localOffsetCollider。
分为两个脚本:
1.TPSCamera.cs
using UnityEngine;
public class TPSCamera : MonoBehaviour
{
public static TPSCamera _instance;//用作单例模式
public Camera cam;//摄像机,是本物体下的子物体
public Transform player;//玩家物体的Transform
public Vector3 playerOffset;//本物体与玩家位置的偏移向量
public float rotateSpeed;//控制旋转速度
public float moveSpeed;//控制跟随的平滑度
public float minAngle;//垂直视角的最小角度值
public float maxAngle;//垂直视角的最大角度值
public float localOffsetSpeed = 8;//控制相机与父物体偏移时的平滑度
public float localOffsetAim = 2;//根据是否瞄准而产生的偏移量,表示瞄准时摄像机应该前进多远距离,根据需要设值
private float localOffsetAngle = 0;//根据垂直视角角度而产生的偏移量
public float localOffsetAngleUp = 1.5f;//根据向上的角度而产生的偏移量的最大值
public float localOffsetAngleDown = 1.5f;//根据向下的角度而产生的偏移量的最大值
private float localOffsetCollider = 0;//根据玩家与摄像机间是否有遮挡而产生的偏移量
private bool isAiming = false;//是否正在瞄准
private void Awake()
{
_instance = this;
player = GameObject.Find("Player").transform;//根据名字找到玩家物体
playerOffset = player.position - transform.position;//初始化playerOffset
cam = transform.GetComponentInChildren<Camera>();//获取子物体的Camera组件
}
private void Update()
{
if (Input.GetMouseButtonDown(1))//鼠标右键按下为瞄准
{
isAiming = true;
}
if (Input.GetMouseButtonUp(1))//鼠标右键松开停止瞄准
{
isAiming = false;
}
SetPosAndRot();//设置视角旋转后的位置和朝向
Cursor.visible = false;//隐藏鼠标
}
///
/// 上下移动鼠标时,相机围绕玩家旋转,并且限制旋转角度
///
public void SetPosAndRot()
{
//更新本物体的position,相机会和本物体做相同的位移,使相机平滑跟随玩家
transform.position = Vector3.Lerp(transform.position, player.position - playerOffset, moveSpeed * Time.deltaTime);
//获取鼠标移动量
float axisX = Input.GetAxis("Mouse X") * rotateSpeed * Time.deltaTime;
float axisY = Input.GetAxis("Mouse Y") * rotateSpeed * Time.deltaTime;
//计算水平和垂直的旋转角
Quaternion rotX = Quaternion.AngleAxis(axisX, Vector3.up);
Quaternion rotY = Quaternion.AngleAxis(-axisY, transform.right);
//摄像机在水平方向绕玩家旋转
transform.RotateAround(player.position, Vector3.up, axisX);
//保存未旋转垂直视角前的position和rotation
Vector3 posPre = transform.position;
Quaternion rotPre = transform.rotation;
//先垂直绕玩家旋转,注意这里旋转的轴为transform.right
transform.RotateAround(player.position, transform.right, -axisY);
//判断垂直角度是否符合范围
float x = (transform.rotation).eulerAngles.x;
//欧拉角范围为0~360,这里要转为-180~180方便判断
if (x > 180) x -= 360;
if (x < minAngle || x > maxAngle)//超出角度
{
//还原位置和旋转
transform.position = posPre;
transform.rotation = rotPre;
//更新offset向量,offset与本物体同步旋转
//我们需要通过这offset去计算本物体(包括摄像机)应该平滑移向的位置
//如果仅仅使用RotateAround函数,当人物在移动时会出现误差
playerOffset = rotX*playerOffset;
}
else//垂直视角符合范围的情况
{
//更新offset向量,offset与本物体同步旋转
playerOffset = rotX * rotY * playerOffset;
//更据角度设置摄像机位置偏移
if (x < 0)//往上角度为负
{
//往上看时距离拉近
localOffsetAngle = (x / minAngle) * localOffsetAngleUp;
}
else
{
//往下看时距离拉远
localOffsetAngle = -(x / maxAngle) * localOffsetAngleDown;
}
}
//设置摄像机与父物体的偏移,三个影响因素
SetLocalOffset();
}
///
/// 根据是否瞄准、垂直视角和是否有遮挡来调整摄像机与父物体的偏移
///
public void SetLocalOffset()
{
float localOffset = 0;//摄像机与父物体(即本脚本所在的空物体)的偏移
//根据垂直视角调整
localOffset += localOffsetAngle;
//根据是否瞄准而调整
if (isAiming)
{
localOffset += localOffsetAim;
}
//根据是否有遮挡而调整
Vector3 checkPos = transform.position + cam.transform.forward * localOffset;//这是没有调整前相机应该移向的位置
for(localOffsetCollider=0; !CheckView(checkPos);localOffsetCollider+=0.2f)//让localOffset递增直至没有遮挡
{
//更新checkPos为我们想要移动到的位置,再去试探
checkPos = transform.position + cam.transform.forward * (localOffset+localOffsetCollider);
}
localOffset += localOffsetCollider;//加上这个试探出的偏移量
Vector3 offsetPos = new Vector3(0, 0, localOffset);//这是调整后相机应该移向的位置
//使相机平滑移动到这个位置
cam.transform.localPosition = Vector3.Lerp(cam.transform.localPosition, offsetPos, localOffsetSpeed * Time.deltaTime);
}
///
/// 检查玩家与摄像机之间是否有碰撞体遮挡
///
/// 假设相机的位置
///
private bool CheckView(Vector3 checkPos)
{
//发出射线来检测碰撞
RaycastHit hit;
//射线终点为玩家物体的中间位置
Vector3 endPos = player.position + player.up * player.GetComponent<CapsuleCollider>().height * 0.5f;
Debug.DrawLine(checkPos,endPos, Color.blue);
//从checkPos发射一条长度为起点到终点距离的射线
if (Physics.Raycast(checkPos,endPos-checkPos,out hit,(endPos-checkPos).magnitude)){
if (hit.transform == player)//如果射线打到玩家说明没有遮挡
return true;
else//如果射线打击到其他物体说明有遮挡
return false;
}
return true;//如果射线没有打到任何物体也说明没有遮挡
}
}
这个脚本挂在TPSCameraParent上,即摄像机的父物体上
相关参数根据需要设定,我的如下,供参考:
2.ShootControl.cs
using RootMotion.FinalIK;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ShootControl : MonoBehaviour
{
public TPSCamera tpsCamera;//控制相机视角的脚本
public Camera cam;//摄像机
public float range;//射线距离
private float offsetDis;//摄像机与玩家的距离
public Vector3 targetPos;//目标位置
private AimIK aimIK;//对应的final ik组件
public float speed;//移动速度
public float rotateSpeed;//旋转速度
private void Awake()
{
tpsCamera = GameObject.Find("TPSCameraParent").GetComponent<TPSCamera>();//获取相机的父物体
cam = tpsCamera.GetComponentInChildren<Camera>();//相机为tpsCamera的子物体
aimIK = GetComponent<AimIK>();//获取AimIk组件
offsetDis = Vector3.Distance(transform.position, cam.transform.position);//初始化offsetDis
}
private void Update()
{
SetTarget();//设置瞄准的目标位置
OnKeyEvent();//处理按键响应
}
///
/// 设置瞄准的目标
/// 从摄像机位置向摄像机正方向发射射线(即从屏幕视口中心发出)
/// 射线的长度=range,可以近似设为子弹的射程
/// 若射线打到非玩家的物体则将该物体设为目标
/// 若射线没有打到物体则将目标设为射线的终点
///
public void SetTarget()
{
//从摄像机位置向摄像机正方向发射射线(即从屏幕视口中心发出)
RaycastHit hit;
if (Physics.Raycast(cam.transform.position, cam.transform.forward, out hit, range))
{
//若射线打到非玩家的物体则将该物体设为目标
//我这里并没有进行判断该物体是否是玩家,因为我设置的玩家位于屏幕的偏左下位置,射线不会穿过玩家
//需要的话,可以给玩家设定layer,然后让射线屏蔽这个layer
targetPos = hit.point;
}
else
{
//若射线没有打到物体则将目标设为射线的终点
targetPos = cam.transform.position + (cam.transform.forward * range);
}
//画出射线便于观察(不会显示在game中)
Debug.DrawRay(cam.transform.position, cam.transform.forward * range, Color.green);
//按下鼠标右键时开启AimIK,进入瞄准状态
if (Input.GetMouseButtonDown(1))
{
aimIK.enabled = true;
}
//按住鼠标右键时为瞄准状态,人物身体始终朝向摄像机的前方
if (Input.GetMouseButton(1))
{
RotateBodyToTarget();
}
else//松开右键时为自由视角状态,关闭AimIK,不进行瞄准
{
//注意这里使用Disale(),不要直接enabled=false,原因不清楚
aimIK.Disable();
}
}
///
/// 旋转玩家身体,使玩家朝向摄像机的水平前方
///
private void RotateBodyToTarget()
{
Vector3 rotEulerAngles = cam.transform.eulerAngles;//获取摄像机的旋转的欧拉角
rotEulerAngles.x = 0;//垂直方向不进行旋转
//使用插值让玩家平滑转向
transform.rotation = Quaternion.Lerp(transform.rotation, Quaternion.Euler(rotEulerAngles), rotateSpeed * Time.deltaTime);
SetAimIKTarget();//更新AimIK的target的位置
}
///
/// 更新AimIK的target的位置
///
private void SetAimIKTarget()
{
//将AimIK的target位置设为之前射线检测到的位置
aimIK.solver.target.position = targetPos;
}
///
/// 管理键盘的响应,这里只用来控制玩家移动,不重要,可以忽略
///
private void OnKeyEvent()
{
//Horizontal和Vertical的默认按键为ad←→和ws↑↓
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
if (h != 0 || v != 0)
{
Vector3 moveDir = new Vector3(h, 0, v);
transform.Translate(moveDir * speed * Time.deltaTime);
RotateBodyToTarget();
}
}
}
挂在玩家身上,玩家名为“Player”,参数参考:
玩家身上还需挂载的组件:
AimIK参数:(建议先看一下AimIK的使用,如果不需要瞄准功能的话可以不用ShootControl和AimIK组件)
其中Target为一个任意的空物体,拖进去,FirePos为武器的空子物体,位于枪口位置。
如图:
初始时将需要跟随的人物放在摄像机视线的左下角,否则需要在ShootControl.cs中的射线检测那里屏蔽掉玩家所在的层。
另外准星绘制参考这个博客:
https://blog.csdn.net/xboxbin/article/details/88069638