RedZero9 发表于 2022-6-23 09:05

来,我们用Unity做一个大炮

(本文作者 @沈琰 )

前言

如标题所述,这次的目标是在unity里实现一门较为贴近现实情况的大炮,操作方式参考坦克世界的火炮视角。主要涉及的知识点是坐标系,向量,四元数的应用,难度适中。
话不多说,开搞。
<hr/>
偏转轴与俯仰轴

现实中的火炮进行瞄准的时候,一般在机械结构上驱动的部分为两个轴:偏转轴(yaw)与俯仰轴(pitch),并且是偏转轴带动俯仰轴旋转。


换句话说就是在unity物体结构中,俯仰轴是偏转轴的子物体:


用unity里默认的物体拼一个简单的火炮模型,外形长什么样无所谓,有那意思就行。关键点是确定旋转轴,使其在两个轴上的旋转相互独立:



<hr/>
旋转

按照功能需求,应该是鼠标指向场景中的一个点,然后火炮依据目标点的位置按一个设定的速度旋转到瞄准指向的目标。
public class Artillery : MonoBehaviour
{
    private Transform yaw;
    private float yaw_rotate_speed;
   
    private Transform pitch;
    private float pitch_rotate_speed;
   
   void Update()
    {
      if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out RaycastHit hit))
      {
            RotateToTarget(hit.point);
      }
    }
   void RotateToTarget(Vector3 p)
    {
      YawRotateTo(p);
      PitchRotateTo(p);
    }
   
   void YawRotateTo(Vector3 p)
    {
      
    }
    void PitchRotateTo(Vector3 p)
    {
      
    }
}
偏转角的计算很好写,获取落点的xz平面坐标到位置的向量,与火炮的朝向计算夹角,然后让偏转轴按设定速度转到目标方向。
void YawRotateTo(Vector3 p)
    {
      //yaw简化版,不考虑大炮本身的倾角,Yaw与pitch互不干扰
      //如若不然两个轴的旋转计算需要考虑到相互影响,计算顺序取决于两个轴的父子关系(类似万向锁)
      Vector3 p_xz = new Vector3(p.x, 0, p.z);
      Vector3 cur_xz = new Vector3(yaw.forward.x, 0,yaw.forward.z);

      float angle = Vector3.Angle(p_xz, cur_xz);
      yaw.localRotation = Quaternion.RotateTowards(
            yaw.localRotation,
            Quaternion.LookRotation(p_xz, yaw.up),
            Math.Min(angle, yaw_rotate_speed * Time.deltaTime));
    }
俯仰角的计算要用到一些数学和物理知识,稍微棘手一点。
如果把大炮当时朝向的yz平面看作一个坐标系,那么炮弹飞出去形成的轨迹是这个坐标系上的一条抛物线。


这部分内容,包括后面要用到的火炮射程计算,都是关于弹道的数学问题,这部分内容不难但比较繁琐。为不影响文章节奏,这里会略过涉及到弹道计算的数学推导部分,在代码里这些计算部分会抽象到一个类中,感兴趣的同学可以在工程中自行查阅,如有必要以后会把数学推导部分单独写出来。
public class Artillery : MonoBehaviour
{
    //......................
    //......................
    //集中存储一些实时更新的数据
    //根据这些数据计算弹道相关的数值
    class FireControlModule
    {
      //弹道计算........
    }
   private FireControlModulemFcm;
   private void Awake()
   {
      mFcm = new FireControlModule();
   }
    //......................
    //......................
}
根据计算得到的目标角去旋转俯仰轴部分,与偏转轴部分基本一样:
void PitchRotateTo(Vector3 p)
    {
      float tarAngle;
      if (mFcm.TryGet_fireAngle(
                pitch.position,p,max_muzzle_velocity,
                out float angle1,out float angle2,out float min_h))
      {
         //两个角度都能击中目标点,区别是一个平射一个吊射,这里取的时间较短的平射
            tarAngle = Math.Min(angle1,angle2);
      }
      float diff = tarAngle - pitch.localRotation.eulerAngles.x;
      pitch.localRotation = Quaternion.RotateTowards(
            pitch.localRotation,
            Quaternion.Euler(tarAngle, 0f, 0f),
            Math.Min(Math.Abs(diff), pitch_rotate_speed * Time.deltaTime)
      );
    }
这里会遇到一个问题,如果我们把抛物线的二维坐标系的横轴视为火炮出射角的0度角,仰角为正俯角为负,那么旋转轴就是垂直于坐标系且朝外,而加入这根转轴的三维坐标系是一个右手坐标系,unity使用的则是左手坐标系,这就使计算出来的值与实际的旋转表现是一个相反的关系。


解决方法有很多,这里为了后续设置与计算的方便,选择颠倒俯仰轴节点的本地坐标系。随之而来的改变是:当后续要使用pitch.forward的值的时候都要取负,这点需要特别注意。
void PitchRotateTo(Vector3 p)
    {
      //........................
      //........................
      pitch.localRotation = Quaternion.RotateTowards(
            pitch.localRotation,
            Quaternion.Euler(tarAngle, 180f, 0f), //坐标系颠倒
            Math.Min(Math.Abs(diff), pitch_rotate_speed * Time.deltaTime)
      );
    }



forward的方向与炮口方向颠倒,使得仰角为正,俯角为负

<hr/>
显示弹道

现在要验证一下反向计算出的弹道落点是否与鼠标射线在场景中的坐标相吻合,需要把弹道显示出来。先暂时用Gizmos画出弹道线。
//弹道线
    List<Vector3> GetParabolaLine(Vector3 lanchPoint,Vector3 vdir,float maxVelocity)
    {
      parabolaPath=new List<Vector3>();
      Vector3 s;
      //计算抛物线到达时间
      if (mFcm.TryGet_flyTime(lanchPoint,vdir,maxVelocity,
                out float t,out Vector3 dp))
      {
            estimateFlyTime = t;
            int max_count = 1000;
            float t0 = 0f;
            float dt = 0.02f;
            parabolaPath.Add(lanchPoint);
            while (t0< t && max_count > 0)
            {
                t0 = Mathf.Min(t0 + dt, t);
                s = lanchPoint + 0.5f * g * t0 * t0 * Vector3.down + vdir * maxVelocity * t0;
                parabolaPath.Add(s);
                max_count--;
            }
      }
      return parabolaPath;
    }

private void OnDrawGizmos()
{
    var path= GetParabolaLine(pitch.position,-pitch.forward,max_muzzle_velocity);
    GizmosDrawPath(path, Color.green, Vector3.zero);
}



计算出的落点与鼠标位置应该完全吻合,否则可能哪里计算错误

<hr/>
限制俯仰角与最大射界

按我们现在的火炮外形设计,在偏转轴上没有角度限制,可以360度无死角旋转,但是在现实中绝大部分火炮肯定都有俯仰角的限制,然后加上火炮出膛最大速度与射击高度,进一步还能计算出火炮的最大射界。
public class Artillery : MonoBehaviour
{
    //..................
    //..................

   
    private float max_elevation_angle;//最大仰角

   
    private float max_depression_angle;//最大俯角
   
    void PitchRotateTo(Vector3 p)
    {
       float tarAngle;
       if (mFcm.TryGet_fireAngle(
                pitch.position,p,max_muzzle_velocity,
                out float angle1,out float angle2,out float min_h))
      {
            //如果a1和a2都在clamp之内,两个角度都能击中目标点,区别是一个平射一个吊射,这里取的时间较短的平射
            tarAngle = Math.Min(
                Mathf.Clamp(angle1, max_depression_angle, max_elevation_angle),
                Mathf.Clamp(angle2, max_depression_angle, max_elevation_angle)
            );
      }
      else
      {
            //tarAngel等于此时最大射程的出射角
         }
      //......................
      //......................
    }
}
这里有个有趣的数学问题:中学的时候我们都学过,沿45度抛出的石头能丢的最远,但这个结论有一个限制条件:出射点和落点的高度必须相同。
但现在明显我们的火炮出射点要略高于落点,这会使得最大射程的倾角发生改变。具体的推导和计算也属于弹道数学的部分,暂且略过,直接说结论:若出射高度大于落点高度,会使最大射程的倾角小于45度,反之亦然。
所以只要火炮的出射高度不低于落点,且最大仰角不小于45度的情况下,火炮的最大射程其实就与角度限制无关了,只与出射高度和炮弹的出膛速度有关。
根据这个结论我们可以分别计算出火炮的最远、最近和零度角的射界范围,依然用Gizmos显示在场景中。
   void PitchRotateTo(Vector3 p)
    {
      CalculateFireRanges(out m_maxRange, out m_zeroRange, out m_minRange);
      //用m_maxRange反向计算出此时的炮塔倾角
      //这里不用再计算一元二次方程,因为达到最大射程的倾角就是x=m_maxRange且delta=0时的值
      //此时方程的根为-b/(2*a)
      float maxRangeAngle = Mathf.Atan((max_muzzle_velocity * max_muzzle_velocity * m_maxRange) /
                                       (g * m_maxRange * m_maxRange)) * Mathf.Rad2Deg;
      if (maxRangeAngle > max_elevation_angle)
      {
            //如果计算出的最远射程下的角度值大于最大仰角,还要反过来更新一下最大射程
            float h = pitch.position.y - p.y;
            mFcm.TryGet_fireRange(max_elevation_angle, h, max_muzzle_velocity, out m_maxRange);
            maxRangeAngle = max_elevation_angle;
      }
      float tarAngle;
      if (mFcm.TryGet_fireAngle(
                pitch.position, p, max_muzzle_velocity,
                out float angle1, out float angle2, out float min_h))
      {
            //如果a1和a2都在clamp之内,两个角度都能击中目标点,区别是一个平射一个吊射,这里取值为时间较短的平射
            tarAngle = Math.Min(
                Mathf.Clamp(angle1, max_depression_angle, max_elevation_angle),
                Mathf.Clamp(angle2, max_depression_angle, max_elevation_angle)
            );
      }
      else //方程没有实数根表示目标点超过了最远射程,最近射程一定有实数根,
            //此时火炮出射高度至少为min_h才可能击中目标点
      {
            tarAngle = maxRangeAngle;
      }
          //.............................
          //旋转
          //.............................
      }

void CalculateFireRanges(out float maxRange, out float zeroRange, out float minRange)
    {
      maxRange = zeroRange = minRange = 0f;
      float h = pitch.position.y;
      if(mFcm.TryGet_maxFireRange(pitch.position,max_muzzle_velocity,out float sqRange))
      {
            maxRange = Mathf.Sqrt(sqRange);
      }
      else if (mFcm.TryGet_fireRange(
                     max_elevation_angle > 45 ? 45 : max_elevation_angle,
                     h, max_muzzle_velocity, out maxRange)
                ) ;//如果出射点的高度大于落点,基本不可能到这一步,抛物线最大射程出射角一定小于45度

      //zero range
      zeroRange = max_muzzle_velocity * Mathf.Sqrt(2 * h / g);
      //min range
      if (mFcm.TryGet_fireRange(max_depression_angle, h, max_muzzle_velocity, out minRange)) ;
    }





最近(蓝)最远(红)0度(灰)

再把限制角的范围也用Gizmos显示在场景中,调整不同的参数看下逻辑是否正确:



最大射界的范围会随着参数调整实时变化

<hr/>
精度变化

下一步工作是模拟火炮在瞄准时,由于旋转炮管产生的抖动造成的角度偏差,使得实际出射角变化产生的落点散布问题,也就是原版游戏中的“缩圈”功能。
按照原版表现,需要以落点为圆心显示一个“圈”,表示火炮落点可能的散布范围。当火炮旋转瞄准时散布圈会变大,当静止时,散布圈会变小。
那么第一步是记录火炮的旋转状态,这里简单点写,把偏转轴和俯仰轴合在一起计算状态,也就是不管哪个轴在旋转,都会实时影响精度。
private bool m_rotateState
    {
      get
      {
            if (m_isRotating !=
                (yaw_isRotating || pitch_isRotating))
            {
                m_isRotating = yaw_isRotating || pitch_isRotating;
                m_precision_change_timestamp = Time.time;
            
            return m_isRotating;
      }
    }
    private float m_precision_change_timestamp;//旋转状态改变的时间戳

    private bool m_isRotating;
    private bool yaw_isRotating;
    private bool pitch_isRotating;


void YawRotateTo(Vector3 p)
    {
       //................................
       //................................

      float angle = Vector3.Angle(p_xz, cur_xz);
      yaw_isRotating = Mathf.Abs(angle) > 0.1f;

       //................................
       //................................
    }

void PitchRotateTo(Vector3 p)
    {
       //................................
       //................................
      
      float diff = tarAngle - pitch.localRotation.eulerAngles.x;
      pitch_isRotating = Mathf.Abs(diff % 360) > 0.1f; //由于是方位角,减法计算有可能得到结果 diff=-360
      
      //................................
       //................................
    }
接着根据旋转状态来更新精度因子。先以落点为圆心,精度乘以一个常数作为半径画一个圈来观察下逻辑是否正确。
    private float min_precision;
    private float max_precision;

    private float yaw_perturbation_angle;
    private float pitch_perturbation_angle;

    private float precision_attenuation_time; //精度自然衰减到最小所用时间(瞄准时间)
    private float precision_perturbation_time; //旋转状态时的精度的扰动所用时间

void PrecisionUpdate()
    {
      //实时计算,方便调试看到变化
      float delta = m_isRotating
            ? Mathf.Abs(min_precision-max_precision)/precision_perturbation_time
            : (min_precision-max_precision)/precision_attenuation_time;
      
      m_current_precision_factor = Mathf.Clamp(m_current_precision_factor + delta*Time.deltaTime,
            min_precision, max_precision);
    }



<hr/>
落点散布

但是我们应该能猜到,实际的落点散布形状肯定不会是个圆,因为偏转角和俯仰角分别对落点位置的影响系数不同,那么投影到地面上的形状肯定不会是一个规则图形。但是直接这么想有点太过抽象,所以干脆试着让不同的炮口扰动形状投影到地面的形状显示在场景中。
以四个最大偏转角度为端点连接起来会形成一个矩形,我们假设偏转和俯仰的扰动角相等并且实时精度系数也相等,那么就相当于用炮管画出来一个正方形。


然后遍历这个“框”计算弹道落点,把落点形成的形状显示在场景中。


这个形状有点像汽车的前车窗,上下两个边是弧形,并且随着落点远近,长宽都会发生变化。
但是在实际情况中,炮口的随机抖动在两个轴的角度上都取到极值的几率很低,两个扰动角都取更接近实际的正态分布的随机值的话,炮口出射角的分布形状应该更接近一个圆。所以用同样的方法再来计算一下圆形炮口扰动的落点散布形状。


此时散布形状就变成了一个上面较宽下面较窄的近似椭圆,与原型功能的散布形状相似,这也从侧面也解释了游戏里散布形状的由来。



坦克世界里火炮投影到地面的散布圈接近一个椭圆

<hr/>
收尾与完善

到这一步基本上要实现的功能都完成了,剩下的就是补齐之前用Gizmos替代的功能,如弹道线、散布圈等,还有炮弹的发射、爆炸特效,摄像机移动等一些琐碎功能。这些东西比较简单,就不展开细说了,对实现方式感兴趣的同学可以直接查看工程。
最后的效果如下图:


工程链接: https://pan.baidu.com/s/1u3Z94yD8UzSmvBaz9wkWQg?pwd=6g2a
<hr/>皮皮关与网易联合开发了完备的游戏开发线上课程。想要进军游戏开发领域的童鞋,可戳下面链接了解课程详情:
如果有任何问题,还可以通过以下入口与网易小伙伴取得联系,有什么问题直接正面怼他(战术后退
同时,欢迎加入游戏开发群欢乐搅基:1082025059

xiangtingsl 发表于 2022-6-23 09:14

动图好酷,有点油管教程的感觉了[赞]

maltadirk 发表于 2022-6-23 09:21

sensiNB

FeastSC 发表于 2022-6-23 09:30

必须顶
页: [1]
查看完整版本: 来,我们用Unity做一个大炮