相关文章推荐
礼貌的米饭  ·  C/C++ 恨透了 double free ...·  2 年前    · 
一个迫击炮的制作

一个迫击炮的制作

0.前序

项目已在Github上开源

最终版本demo已发布: 提取码:fug6


本篇是完整项目中的一个分部总结

包括了项目中,对于迫击炮这一角色的建模,UV展开,法线烘焙与修复,导入Unity,以及Unity中编码实现对迫击炮的操控,其中的全部技术要点总结

迫击炮的形象源自于 腾讯旗下的,原芬兰移动游戏公司supercell出品的 ,《部落冲突:全面开战》《部落冲突:皇室战争》


最终效果

录制的鼠标位置有差值... https://www.zhihu.com/video/1409600858288427008

项目制作相关软件&工具:

Autodesk 3ds Max 2018 :建模,UV展开,法线烘焙

Unity 2020.3.12f1c1 (64-bit) : 3D游戏引擎

GIMP 2.10.12 : 贴图纹理绘制


目录

1.迫击炮模型的制作

---1.1 切角高模的制作

---1.2 分离元素&低模拆解

---1.3 法线UV的排版

---1.4 烘焙法线贴图

---1.5 GIMP修复合成法线贴图&漫反射填色

---1.6 组装完整模型&导入前处理

---1.7 炮弹的制作&预留空节点

---1.8 Unity中渲染最终效果&问题的修复


2.Unity中实现迫击炮的发射操作功能

---2.1 射线检测实现发射范围的控制

---2.2 位置插值+物理刚体实现炮弹的效果

---2.3 引入对象池

---2.4 迫击炮的旋转优化


1.迫击炮的模型制作

为节约篇幅,本节中涉及到的主要技术要点,详细内容可参见以下文章,文中不会过多赘述

---1.1 切角高模的制作

首先我们在3dsMax中,参照《部落冲突:全面开战》的迫击炮形象,进行了建模

这里建模没什么技术细节好讲的,多边形基本功


对于一些软边的处理,并没有使用平滑/细分,而是用了可控性更强的切角+平滑组设置

如图所示的 大片的内平面,它们并没有设置平滑组 (法线烘焙结果将是背景色,不具有有效的法线信息)


但是这些没有平滑组的平面,它们向切角软边的过渡依旧平滑

模型切角平滑相关的技术要点可参见上面《环线切角与法线烘焙》的文章,在此不做过多赘述


然而这样切角出来的模型面数较多

因此我们计划拆解出一个低模,展开一套法线UV,将高模的法线烘焙到低模上

并且我们打算让漫反射和法线,共用一套uv1

---1.2 分离元素&低模拆解

这个迫击炮模型当中存在大量重复的元素/网格, 为了展开法线UV后对纹理空间的利用率最大化

我们将重复的元素只保留一份,形成一套 基本模型 ,对基本模型拆解低模,展开法线UV,烘焙贴图

随后通过基本模型各个元素的位移/旋转复制,重新组装出完整的迫击炮模型, 复制出来的网格顶点UV信息和之前的保持一致,从而重复元素会引用法线贴图上一块相同位置的法线信息,大大提高了法线纹理的空间利用率

将原本的高模,拆解出一个基本模型


再将高模的基本模型复制出一份,拆解为低模


拆解低模时,主要是 拆掉软边内的走线,将硬边留出来

最终需要形成 低模平面,承载高模曲面的对应关系 ,并保证 大轮廓上的一致性 (因为法线贴图只能改变着色,无法形成自遮、自阴影、闭塞效果)

低模平滑组要与高模对应 ,承载高模软边/曲面的低模部分要用用平滑组消除硬边

详细内容可参见上面的《环线切角与法线烘焙》一文



低模按材质ID,区分一下网格上的面,方便加入UVW展开修改器后的选则

第一类是计划在烘焙法线贴图后, 保持背景色,不包含有效法线信息的面

包括没有弧度呈现出平面的部分,或拆解低模后不打算对应高模法线准备保持原状的部分(没拆低或者拆了不打算对应)


第二类是包含 曲面&软边,需要对应法线纹理上有效信息的部分


第三类是 没有法线信息,但需要绘制漫反射纹理的部分

这里炮筒内部打算绘制一个渐变的纹理效果


---1.3 法线UV的排版

关于3dsMax UV的展开,可参见上面的《纹理贴图原理与3dMax贴图技法》

这里重点讲一下排版,这个模型计划漫反射与法线共用一套UV

其中法线贴图包含有效信息的部分较多,漫反射大都是纯色的效果,出了炮管需要绘制一个渐变纹理

拆解UV时先按照1.2解中的面分类,将纹理空间中的面排在两侧


无法线信息,漫反射纯色的面,再按照漫反射的纯色颜色重叠排好,排版在角落中


炮管的部分,没有法线信息,但漫反射要绘制一个渐变纹理的效果

这里渐变纹理横向是一致的,纵向呈现渐变过渡,因此我们将纹理空间中的面拉长,排版在一侧


其它有法线信息的面,根据其:

占据模型表面上的面积(面积越大,噪点越敏感,需要用更大的纹理空间面积)

位置的重要程度和法线的复杂程度(一些细节部分的转折,法线复杂,需要用更大的面积)

调整大小进行排版

排版时尽量旋转纹理空间的面,达到横平竖直的效果,方便之后修复乱码


---1.4 烘焙法线贴图

烘焙中要注意的地方

关于3dsMax法线贴图烘焙基本步骤可以参见上面《环线切角与法线烘焙》一文


烘焙法线时,需要把高低模位置重叠到一起,可能导致无法选中低模(因为低模是在高模之后创建的),我们可以通过创建选择集来进行选中


注意!!!这里一定要将G通道方向置于上方,默认是下方

这个G通道方向,会区出分DX/GL两种法线,

模型最终是要应用到Unity3d中渲染,需要将G通道方向置为上方

而Max中法线凹凸的G通道方向是默认的下方


烘焙法线需要使用投影修改器,将高模的表面与低模对应

投影是不允许有网格重叠的,否则会烘焙到重叠于之上的高模法线

例如这里存在网格重叠


烘焙结果,这个凸起的小角,就会将它的法线覆盖在下方平面上


为此我们需要将高低模, 按网格元素再拆解一下,重叠部分的网格要单独拆解出来,分别进行烘焙

(当然 其实不拆一起烘焙也是可以的 ,只要保证复制组装后, 重叠部分的一致性 ,上面的网格/元素始终挡住下面的网格,那么下面网格法线受影响也没关系)

这几个元素我们不打算对应高模法线,元素拆解时删除即可,无需烘焙


但拆解的好处是,分别烘焙, 减少单次工作量,可以提升渲染的速度,方便我们检查调整投影框架,以及后期处理法线的工作速度

使用png格式保存法线, 为方便将分别烘焙的法线合成,可启用alpha通道

色彩模式选则最高的48位RGB,Gamma选则覆盖1.0,贴图尺寸2048*2048


当然出来的png法线其实是一张 RGBA 16位,共64位深度的纹理


3dsMax引用烘焙出的法线纹理检查效果

复制出一个低模,删除投影修改器,给它一个材质

下方凹凸纹理,创建一张法线凹凸纹理


法线凹凸纹理设置如下

翻转G通道(这里按Unity3d的G通道向上烘焙法线,与Max相反)

法线纹理,选则位图,引用我们烘焙出的png法线


Gamma选则覆盖模式 1.0(不进行Gamma矫正)


法线纹理选则 在视口中明暗处理 ,从而在Max视口渲染中,按漫反射RGB查看模型表面对应的法线效果

渲染帧窗口 ,按场景默认光照,大致检查法线贴图的效果


调整投影修改器

渲染法线,会同步渲染出一张漫反射纹理

这张纹理上可能会出现一些 呈现纯红色的部分

(或者法线贴图上也能看见,不过同步烘焙出的漫反射纹理上看的更清楚)


这些部分是低模的投影修改器, 投影框架没有正确的撑开包裹住高模,导致没有投影到高模的有效信息

我们需要对相应位置的框架做出调整,包裹住高模

当然位于贴图左上角,那些不对应高模法线,想保持背景色的部分是无所谓的

重在调整,软边曲面部分的投影框架包裹

结合投影修改器--框架--明暗处理和点到点,对框架进行调整


---1.5 GIMP修复合成法线贴图&漫反射填色

法线的合成与修复

3dMax烘焙出的法线上,可能会有像这样的乱码,算错的部分

就需要我们进行一些后期处理来修复


这里我们用GIMP(2.10.12)对烘焙出的png法线纹理进行修复,为避免Gamma矫正,色彩管理矫正的影响需要在导入法线纹理的前后进行如下设置

首选项---色彩管理---取消默认的色彩管理
创建一张和法线纹理相同大小的画布,色彩空间选则RGB16位整数
图像色彩管理,取消色彩管理
图像精度,选则16位线性空间模式
修复完成,导出时,不要勾选保存矫正颜色,输出格式选则 16位 RGBA ,与烘焙出的法线纹理一致



我们可以在Max中,随便拉一个平面,烘焙一张2048*2048法线,不要Alpha

从而得到一张 背景法线 ,这张背景法线对应的法线向量,替换后会让片段元保持切线空间Z+的方向,也就是在三角型遍历阶段由顶点法线差值得到的原始法线方向


因为我们分别烘焙的的png法线是带Alpha通道的

所以图层可以直接重叠在一起

最下方是背景法线,上方是重叠在一起带Alpha通道的png法线,最上方是一个用于修复乱码的绘制图层



在GIMP中合成法线,并修复乱码

一些边缘的地方乱码,直接画笔填背景色上去即可
或者沿走向复制前面的纹理,覆盖乱码的部分,要注意排版时进行旋转,让纹理空间的面横平竖直
沿走向,吸取临近色填充


最终法线效果

乱码修复前
乱码修复后


漫反射填色

可以先在Max中用多维子材质确定大致的配色


导出一张UV模板,对照着填色

注意 填色区域一定要比UV模板的边缘还要大出一圈 ,因为纹理采样时, 边缘部分片元采样滤波的掩膜范围会超出边缘,如果紧贴UV模板边缘,边缘片元采样滤波时,就会混到外面的背景色


---1.6 组装完整模型&导入前处理

对基本模型的元素进行位移/旋转复制,组装出完成的模型

注意接缝的位置,要将顶点焊接起来,但UV保持默认的断开状态

因为 烘焙法线的时候,是按照断开的状态完成烘焙的 ,实际拼接之后,接缝左右两边对应的UV就应当是断开的, 两侧会索引到不同的位置

所以这里, 将顶点焊接起来,保证面的连续性,但UV保持断开 ,最终渲染时会基于UV断开产生 顶点细分 ,实际接缝边会细分出两组UV不同的顶点,各自对应左右两侧的面进行渲染

如果UV接缝拆除,强行融合两侧不同的UV,那么就无法索引到正确的法线信息


注意,针对这个迫击炮模型,我们需要分成两个部分,

父物体炮架,和子物体炮管(父子链接)

方便Unity中进行操控(分别设置炮架的LookAt和炮管的发射仰角)


确认网格无缩放(XYZ局部缩放都是100%),如果有缩放需要重置变换调整

对父物体进行轴心的位置朝向调整

旋转Y轴冲上,Z轴冲前,适应Unity的环境

轴心位置置于底部,方便在场景中摆放模型

Unity中需要通过LookAt,调整Z+朝向,将迫击炮对准发射方向,因此我们需要将轴心Z+冲向正确的方位


子物体轴心,需要进行位置调整,置于正确的旋转中心处

Unity中,发射仰角的表现,需要调整炮管的旋转欧拉角,子物体轴心位置需要被置于正确的旋转中心处


更多有关3dsMax导出FBX对接Unity的注意点,可参阅文章


---1.7 炮弹的制作&预留空节点

结合炮管的大小,拉一个球形作为炮弹


将炮弹放到炮管底部 ,链接给炮管做子物体,导出时一起选中导出,在Unity场景中引入模型时,我们再删除这个炮弹的网格(避免Unity解析FBX时剔除空节点)

从而这个炮弹会作为一个 空节点, 当我们在Unity中发射炮弹时,会 将动态创建出的炮弹和这个空节点位置对齐

---1.8 Unity中渲染最终效果&问题的修复

先来确认一下拆解低模的效果

高模面数为 6992 + 3207 = 10199

低模面数 3770+1704 = 5470


也就是说我们拆下了 4.7k的面,将近少了一半

而通过烘焙法线,低模最终渲染出来的效果,与高模几乎一致

Max中低模漫反射+法线,最终渲染出的效果,可以看到切角软边的地方呈现与高模一样的效果


修复几个问题

Max中通过视口渲染只能检查大致的效果

一些问题只有在Unity中渲染时才能清楚的观察到


首先是这里的一个 卷错问题 ,出现了这种黑边的现象

这是因为用于承载高模曲面的低模平面,没有通过平滑组来消除硬边,且没有拆开UV,导致边线左右,存在法线突变,采样滤波时强行融合了两侧的法线(结果强行融合),但没有考虑两侧切线空间的差异

详见本节开头提到的,《环线切角与法线烘焙》一文


来到Max中检查果然发现了低模平滑组设置有误,承载高模曲面的平面内部出现硬边

我们调整平滑组重新进行烘焙,导出的模型也需要同步调整


GIMP中将这一块平面法线,替换原有,并重新修复乱码

ok问题解决


之后是这里的软边过渡呈现出了硬边

Max中检查,烘焙时平滑组设置没有问题,而拼装出完整模型时,平滑组设置有误

我们设置正确的平滑组,重新导出模型

问题得到解决


最后是这里切角软边,法线存在噪点


GIMP中检查法线果然发现了问题,予以修复

仔细观察,中间部分有一定的噪点差异
ok修复完成


Unity中的最终渲染效果



2.Unity中实现迫击炮的发射操作功能

---2.1 发射范围的控制

我们希望实现这样的一个效果

当点击鼠标右键时,可以切换显示迫击炮的发射范围(一个透明的环形Sprite),并通过鼠标设置发射的目标位置(一个透明的圆形Sprite)

同时迫击炮会LookAt看向发射的目标位置,炮管置于正确的发射仰角


Sprite动态尺寸缩放控制

首先我们在GIMP中,通过镜像过渡填充+蒙版,绘制这样一个半透明的圆形,尺寸1000*1000(直径1000像素,半径500像素),导出为png格式


Unity中引入,将Texture Type,置为Sprite

注意这里的Pixels Per Unit,默认值为100

Unity中一个Cube立方体边长为1m,Transform位移每1单位对应物理尺寸1m

Pixels Per Unit为100,也就是说,这个直径1000像素的圆形,对应的Sprite放入Unity世界后的物理尺寸是: 分辨率像素/PPU = 1000/100 = 10m(在100%缩放下)


定义发射范围的最远距离(半径),和伤害范围(半径),通过调整XY轴向的缩放,来改变Sprite大小

过Sprite,在100%缩放半径为5m的基础物理尺寸,我们可以反算出所需的缩放值

5m * 缩放值 = 所需半径(m)

缩放值 = 所需半径(m) / 5m = 所需半径 * 0.2f

乘法比除法更快,所以化为 *0.2f 的形式

场景中改变圆形的大小,需要调整XY轴的缩放
public float maxRange=30.0f; //最大发射距离(m)
public float dmRange = 3.0f; //伤害范围(m)
[SerializeField]
GameObject cirRGSP; //发射范围Sprite
[SerializeField]
GameObject cirDMSP; //伤害范围Sprite
cirRGSP.transform.localScale = new Vector3(maxRange * 0.2f,maxRange * 0.2f,1);
cirDMSP.transform.localScale = new Vector3(dmRange * 0.2f, dmRange * 0.2f, 1);


Sprite中心空洞效果

我们还需要对发射范围中间挖一个洞(最小发射范围)

真实的迫击炮攻击范围,就是这样的一个环形,不能太近否则伤及自身,不能太远因为炮弹初速度有限

这个挖洞的效果可以通过Shader编码实现


场景中Sprite的渲染,其实就是生成了一个带UV的正方形平面,并通过纹理贴图+Alpha混合表现

我们可以在片段元着色中,通过对UV坐标的判定, discard丢弃片元 ,从而实现挖洞的效果

类似的逻辑也被应用在 透明测试 中,通过判定片元Alpha是否为0直接丢弃片元,实现全透明的效果

正方形平面,左下角UV坐标(0,0),右上角UV坐标(1,1),中心UV坐标(0.5,0.5)

我们将输入片元的UV坐标,减去中心坐标(0.5,0.5),得到中心指向片元的UV坐标的向量

计算未开方模长,并与未开方模长uvRange比较,判定是否丢弃(这里开方运算十分昂贵,尽量可能少算开方)

若发射范围最大maxRange,最小minRange,对应转换到uv坐标下,中心需要挖洞的圆形未开方半径uvRange就是 pow((minRange/maxRange)/2.0f,2),通过脚本setFloat一次性设置uvRange即可

//C#脚本 Awake阶段
cirRGRend = cirRGSP.GetComponent<SpriteRenderer>();
cirRGRend.material.SetFloat("uvRange", Mathf.Pow((minRange/maxRange)/2.0f,2)); 
//Shader 
//定义Uniform属性
uvRange("uvRange",Float) = 0.25
//片段元着色
float4 frag(Fragment IN) : COLOR
        //discard计算
        float uvx = abs(IN.uv_MainTex.x - 0.5f);
        float uvy = abs(IN.uv_MainTex.y - 0.5f);
        if (uvx*uvx + uvy*uvy < uvRange) discard; //将小于minRange的片元丢弃
        //纹理采样,叠加_Color颜色    
        half4 c = tex2D(_MainTex, IN.uv_MainTex);
        c *= _Color;
        return c;


鼠标的射线检测操控

我们还需实现一个射线检测,发射的目标Sprite随鼠标移动,并且如果鼠标超出了maxRange或minRange,我们应计算方向向量,将位置夹取到边界处

先将场景地板置于一个Layer层上


大致代码如下

//Awake中
//碰撞相关
 maxR2 = maxRange * maxRange; //一次性计算 方便Update中使用 比较未开方距离
 minR2 = minRange * minRange;
//Upadte中
if (rangeVis) //范围可见
            if(




    
Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition),out raycast,float.MaxValue,layerMask)) {//射线碰撞检测
                //位置计算
                rcx = raycast.point.x; //鼠标射线击中地面的世界位置
                rcz = raycast.point.z;
                lcx = rcx - transform.position.x; //从迫击炮指向鼠标位置的向量
                lcz = rcz - transform.position.z;
                rcdis = lcx*lcx+lcz*lcz;//计算未开方距离
                if (rcdis > maxR2)//夹取到最大范围
                    rcnormal = new Vector2(lcx, lcz).normalized; //计算方向向量
                    rcx = maxRange * rcnormal.x + transform.position.x;
                    rcz = maxRange * rcnormal.y + transform.position.z;
                    rcdis = maxRange;
                else if (rcdis < minR2)//夹取到最小范围
                    rcnormal = new Vector2(lcx, lcz).normalized;
                    rcx = minRange * rcnormal.x + transform.position.x;
                    rcz = minRange * rcnormal.y + transform.position.z;
                    rcdis = minRange;
                else rcdis = Mathf.Sqrt(rcdis);
                //根据 rcx 和 rcz 设置迫击炮的LookAt&伤害范围Sprite位置



完成后测试一下,

能够通过鼠标的位置控制发射的目标Sprite,超出最大最小位置时能被夹取到边界

并且,即便迫击炮不在XZ平面原点,仍能正常运作


Sprite计时器效果

我们还希望在炮弹冷却状态时,发射的目标Sprite,能有一个扇形填充的计时器效果

和UGUI中Image组件的Filled填充一样

冷却时颜色的改变很容易实现,就不赘述了



对于扇形填充效果的实现,可以直接换上一个Canvas画布,承载一个UGUI的Image,来替代伤害范围的Sprite

不过和前面实现中心挖洞的效果一样,我们也可以通过Shader编码,在片段元着色中discard片元来实现

核心算法同样是基于UV坐标,计算 从中心指向片元的向量 基准向量 夹角 是否满足要求

不过用反三角函数运算有些麻烦,另更快更简单的方法是 直接使用两个方向向量的点积 ,点积结果范围是 [1,-1] 这代表了两个向量的接近程度,类似的算法也常用在着色计算中

不过点积的问题在于,左右两个半圆域内结果都是[1,-1],这意味我们还需要进行一定的映射处理

首先通过对结果 *0.25+0.25 将范围映射为 [0,0.5] ,之后通过判定方向向量.y 的正负(也可以是.x的正负,取决于基准向量要怎么选),我们再将一侧的半圆域中结果映射为 [0.5,1],从而整个圆360方向向量与基准向量点积最终映射结结果为 [0,1]

我们只需在脚本中,计算冷却时间时,将 当前冷却用时/总用时 ,范围同样是 [0,1] 通过setFloat传递给Shader即可

if (!isLoad) { //装填冷却
    nlodtime += Time.deltaTime;
    if (nlodtime >= loadTime) {
        isLoad = true;
        cirDMRend.material.SetFloat("load", 1);
    else cirDMRend.material.SetFloat("load", nlodtime / loadTime);
float4 frag(Fragment IN) : COLOR
        //discard计算  基准向量为 (0,1) 因此不用计算方向向量的x
        float nx = IN.uv_MainTex.x - 0.5;
        float ny = IN.uv_MainTex.y - 0.5;
        float nmod = sqrt(nx*nx+ny*ny);
        ny /= nmod; //单位向量y轴
        ny = -ny * 0.25 + 0.25; //左右两半圆 Range [0,0.5]
        if (nx < 0) ny = 1 - ny;//右半圆 Range [0,0.5] ===> [0.5,1]
        if (ny > load) discard;
        //正常的纹理采样,叠加_Color颜色    
        half4 c = tex2D(_MainTex, IN.uv_MainTex);
         c *= _Color;
         return c;
}


这里sqrt开方不可避免,就算用反三角函数,我们也需要得到向量的开方模长

点积其实就是就是把反三角函数的前置运算直接拿来应用

但是这样处理有一个坏处是, 计时器旋转的速度将是非线性的 (因为点积本质是用了Cos函数的输出)

如果想得到线性旋转的计时器,那么就只能再进行一个反三角函数运算(反映射)


---2.2 位置插值+物理刚体实现炮弹的效果

这里并没有完全使用Unity的刚体,物理模拟来实现炮弹的效果

为了能够保证炮弹能够精确的击中设置的落点, 炮弹的飞行采用代码差值实现,而落地后的滚动,则采用物理模拟

先定义以下几个状态标签,来控制炮弹

public enum ShellState
    onFly, //正在飞行
    onDisappear, //落地滚动 & 消失
    over //结束表现


对于炮弹的飞行位置差值,和之后设置落地滚动的刚体速度,都需要知道炮弹的速度属性

我们的思路是这样的,假定迫击炮,针对每一套最远&最近发射范围的设定, 其炮弹的初速度V是一致的 ,并且迫击炮会在与地面呈45度夹角时,将炮弹发射到最远处

对于炮弹的飞行,我们用一个简单的,从水平面斜上抛,再回到水平面的模型

由此,我们可以引入一个重力加速度g,从而根据我们设置的最远范围 maxRange ,通过简单的物理运算,得到炮弹初速度 V = \sqrt{g*maxRange}

之后根据所需的落点位置(也就是发射目标Sprite的位置),到迫击炮的距离(XZ平面距离dis),以及初速度V,和重力加速度g,我们就可以反解出发射所需的角度 θ

注意,这里炮管在初始0度角的时候是竖直状态的, 也就是说我们要解的角度 θ 并不是与地面的夹角,而是与地面夹角的余角

V_{纵} = Vcos\theta \\ V_{横} = Vsin\theta \\ 滞空时间 t 满足: gt = 2V_{纵}\\ 所以 t = \frac{2V纵}{g}\\ 水平方向达到所需距离 dis应有: tV横 = dis \\ 2V^{2}sin\theta cos\theta = g*dis\\ V^{2}sin2\theta = g*dis \\ sin2\theta = \frac{g*dis}{V^{2}}\\ \theta = 0.5*arcsin(\frac{g*dis}{V^{2}})


代码大致如下:

这里Mathf.asin计算结果是弧度,我们还需要乘Mathf.Rad2Deg因子将其转化为角度

float baseVelocity;//初速度
float bv2;//初速度的平方
baseVelocity = Mathf.Sqrt(gravity * maxRange);
bv2 = baseVelocity * baseVelocity;
deg = Mathf.Asin(rcdis * gravity / bv2) * Mathf.Rad2Deg *0.5f;
cannon.eulerAngles = new Vector3(cannon.eulerAngles.x, cannon.eulerAngles.y, deg);


解出发射角度后,我们就可以得到每次发射的,纵向速度和水平速度

这里其实 只需要一个纵向速度,结合重力加速度,计算出滞空时间,再由滞空时间控制炮弹差值即可

炮弹在XZ平面上的运动是匀速的,通过滞空时间,直接Lerp差值即可

而在Y轴纵向的运动,则是一个匀减速直线运动,可以直接通过sin函数来控制位移差值

但要注意的是, 由于炮弹初始发射位置其实并不在水平面上 ,而是有一定的高度差

因此我们将Y轴运动差值分成两段,首先从初始位置===>YMax最大高度,之后从YMax最大高度===>目标Y,

两段的分界点,根据当前飞行时间,与总滞空时间二分之一比较来控制。借此简单的,忽略掉初始发射高度的影响


大致代码如下

if(state == ShellState.onFly)
            if (nbfT < bfTime) //发射预卷时间,这里是需要和音效匹配的关系 
                nbfT += Time.deltaTime;
            }else //飞行
                flyTime += Time.deltaTime;
                if (flyTime >= totalTime) //已落地,切换物理模拟
                    //刚体设置
                    gameObject.transform.position = tarPos;
                    state = ShellState.onDisappear;
                    rgbody.isKinematic = false;
                    rgbody.useGravity = true;
                    rgbody.velocity = (tarPos - basePos).normalized * vx;
                    AudioCon.Instance.playClip("hit");
                else//差值飞行
                    flyPercent = flyTime / totalTime;
                    //XZ平面匀速运动
                    moveX = basePos.x + (tarPos.x - basePos.x) * flyPercent;
                    moveZ = basePos.z + (tarPos.z - basePos.z) * flyPercent;
                    //Y上抛运动  0~1 ==> sin(0~pi)
                    if (flyPercent < 0.5f) //basePos.y ===> MaxY
                        moveY = basePos.y + (tarY - basePos.y) * Mathf.Sin(flyPercent * Mathf.PI);
                    else //tarY ===> tpos.y
                        moveY = tarPos.y + (tarY - tarPos.y) * Mathf.Sin(flyPercent * Mathf.PI);
                    transform.position = new Vector3(moveX, moveY, moveZ);


当g重力加速度增大时,炮弹的飞行,会更高更快,并总是接近于真实的斜上抛物理

事实上这里重力加速度g不一定非要取9.8,我们可以 根据场景的尺度 将它调试为一个合适的数值

本例中g最终调试到了30


---2.3 引入对象池

主要是针对炮弹这个对象

如果我们简单的通过克隆,动态创建炮弹,那么我们 有多少次发射,就需要创建多少枚炮弹

但事实上本例中,炮弹并不会永久的在地面上滚动,保留下去

我们对炮弹应用了一个透明材质,并在开启物理模拟滚动时,逐渐将炮弹变为透明(逐渐消失)

从而那些消失掉的炮弹,其实可以被回收起来重复使用

这里就引入对象池这个设计模式,它是单例模式的一种变体,提供了一种特殊的动态对象创建方法

当发出动态对象创建的请求时,对象池会先检查当前池内,有无被回收的对象,如果有那么将回收的对象重启(reSet)并返回,如果没有,再考虑传统意义上的,分配新的内存来创建新对象


我们定义一个PoolObj的接口来设置那些被对象池管理的对象所具有的功能,它主要有以下三个方法:

void reSetObj();//重置脏对象

void startMove();//开始运作

void hangUp();//挂起,返回对象池中


当我们从对象池中,重启一个被回收的对象时,会调用reSetObj,这是由于这个对象之前是被使用过的(脏对象),需要通过这个方法,将对象重置/刷新为,等同于一开始被创建出来时的状态

本例中,我们就需要重置炮弹刚体的状态,以及将透明化的炮弹,改为不透明

public void reSetObj()
        //脏对象最后是通过物理模拟滚动,这里要取消重力,并置取消物理模拟
        rgbody.isKinematic = true; 
        rgbody.useGravity = false;
        rgbody.velocity = Vector3.zero;
        trailRenderer.Clear();
        //脏对象最后透明化消失了
        meshRenderer.material.color = new Color(meshRenderer.material.color.r, meshRenderer.material.color.g, meshRenderer.material.color.b, 1.0f);


startMove是在外部,从对象池中获取到对象后,开启对象运作的方法

本例中,我们将炮弹的激活,状态标签设置放在这个方法中

public void startMove()
    state = ShellState.onFly;
    gameObject.SetActive(true);


hangUp是当对象认为自己的工作已经完成时,停止运作,返回到对象池中的方法

public void hangUp()
   state = ShellState.over;
   gameObject.SetActive(false);
   belongPool.backtoPool(this);


对象池基于单例模式,主要提供getObj,和backtoPool两个方法

基于C# Dictionary容器实现,大致代码如下

public class ObjPool:MonoBehaviour{
	public static ObjPool Instance = null;
	Dictionary<string, List<GameObject>> pool;
    private void Awake() { 
                //这里不考虑Awake竞速,Awake阶段不应进行对象间通信,直接公开静态引用
		if (!Instance) Instance = this;
		else if (Instance != this) Destroy(gameObject);//针对场景重启,对象DontDestroy需要保唯一自毁
    private void Start()
		pool = new Dictionary<string, List<GameObject>>();
	public GameObject getObj(string objname)
		GameObject outobj;
		if (pool.ContainsKey(objname) && pool[objname].Count > 0)
			outobj = pool[objname][0];
			pool[objname].Remove(outobj);
			outobj.GetComponent<PoolObj>().reSetObj();
			outobj = Instantiate(Resources.Load(objname) as GameObject);
			outobj.GetComponent<PoolObj>().BelongPool = this;
			outobj.GetComponent




    
<PoolObj>().ObjName = objname;
		return outobj;
	public void backtoPool(PoolObj backobj)
		if (pool.ContainsKey(backobj.ObjName)) //字典中已有对应Name的List容器
			if (!pool[backobj.ObjName].Contains(backobj.Obj)) //这里要防止重复入池  一些包含子弹的射击类游戏常有此状况
				pool[backobj.ObjName].Add(backobj.Obj);
		else //字典中还没有创建对应Name的List容器
			pool.Add(backobj.ObjName, new List<GameObject>());
			pool[backobj.ObjName].Add(backobj.Obj);
		backobj.Obj.SetActive(false);


引入对象池后,无论我们发射了多少炮弹, 实际场景中最后创建出的炮弹在达到某一个峰值后就不再增加

之后的发射,只需要对已有的炮弹对象不断的回收重利用即可,无需再创建新的炮弹

按我们的这一套参数,最多只需要两个炮弹重复回收利用即可


---2.4 迫击炮的旋转优化

如果我们单纯的,就使用LookAt去设置迫击炮的旋转, 这就会有一些...恶心

尤其是刚刚发射完炮弹的时候, 炮弹还没来得及从炮管里飞出来,迫击炮就转向别的方向了

因此我们定义以下状态标签,优化控制迫击炮的旋转

public enum MortarRoState //迫击炮旋转行为
    onRotate, //正在跟踪,通过RotateTowards旋转
    Catch,    //捕获到鼠标,通过LookAt旋转
    Lock      //发射时被锁定的/僵直的无法旋转
}


相关的变量有:

public float roSpeed = 480.0f;//旋转跟踪速度
public float catchDeg = 3.0f;//捕获允许最大偏移,当小于偏移时,从 onRotate ==> Catch
public float launchLock = 1.5f;//发射锁定时间


从而设置迫击炮旋转的代码大致如下

//前面是1.3节
if(roState == MortarRoState.onRotate) { //正在旋转 尝试进入 Catch
    roStep = roSpeed * Time.deltaTime;
    lookVec = new Vector3(rcx, gameObject.transform.position.y, rcz) - gameObject.transform.position;
    //旋转迫击炮
    lookRo = Quaternion.LookRotation(lookVec, Vector3.up);
    gameObject.transform.rotation = Quaternion.RotateTowards(gameObject.transform.rotation, lookRo, roStep);
    //旋转炮管
    deg = Mathf.Asin(rcdis * gravity / bv2) * Mathf.Rad2Deg / 2.0f;
    cannon.eulerAngles = new Vector3(cannon.eulerAngles.x, cannon.eulerAngles.y,
                         Mathf.MoveTowards(cannon.eulerAngles.z,deg, roStep));
    if (Vector3.Angle(transform.forward, lookVec) < catchDeg && Mathf.Abs(cannon.eulerAngles.z - deg) < catchDeg) roState = MortarRoState.Catch;//进入Catch状态
} else if(roState == MortarRoState.Catch) {//已捕获的
    gameObject.transform.LookAt(new Vector3(rcx, gameObject.transform.position.y, rcz), Vector3.up);
    //设置炮管角度
    deg = Mathf.Asin(rcdis * gravity / bv2) * Mathf.Rad2Deg / 2.0f;
    cannon.eulerAngles = new Vector3(cannon.eulerAngles.x, cannon.eulerAngles.y, deg);
}//else if(roState == MortarRoState.Lock) 锁定下不改变迫击炮和炮管的旋转


Update中,当攻击范围Range置为不可见时,如果处于Catch捕捉状态,那么要切换到onRotate旋转跟踪,从而用户下一次开启范围显示时,迫击炮会有一个旋转跟踪的效果(而不是直接LookAt一步到位)

if(Input.GetMouseButtonDown(1))
    rangeVis = !rangeVis;
    cirRGSP.SetActive(rangeVis);
    cirDMSP.SetActive(rangeVis);
    if(!rangeVis&&roState==MortarRoState.Catch) roState = MortarRoState.onRotate; //当范围不可见时,若已捕捉,转为旋转


Update中,当发现处于发射锁定(发射僵直),累加计时,切换到onRotate旋转跟踪

if(roState == MortarRoState.Lock)
    nlTime += Time.deltaTime;
    if (nlTime >= launchLock) roState = MortarRoState.onRotate;



这里,迫击炮发射的装填冷却置为3s,发射锁定僵直置为1.5s,旋转跟踪速度置为480度每秒

而最大的旋转跟踪角度,不过是180度(转圆的最小弧)

从而迫击炮一定会在发射冷却完成之前,重新旋转跟踪到鼠标位置

唯一需要担心的的一个问题,是用户在开启范围显示的瞬间就摁下了发射,此时迫击炮还未完成旋转跟踪,但已经要发射了

这里我们有两个选择

1.如果摁下发射,但检测迫击炮处于onRotate状态,将发射的信息记录下来,当旋转跟踪完成,转入Catch捕捉到鼠标时,若检测到有发射信息记录,完成发射并转为Lock发射僵直状态

2.如果摁下发射,但检测迫击炮处于onRotate状态,未完成旋转跟踪,那么使用LookAt方法,立刻完成跟踪,并发射炮弹

总之我们必须要先完成旋转跟踪,再发射炮弹,因为只有完成了旋转跟踪,炮弹的初始位置才能正确的对齐到那个空节点下


这里其实方案1的扩展,就是类似LOL英雄联盟那样,基于施法前摇时间和用户快速操作的不匹配性,需要引入一个 施法队列

而本例中我们选择方案2,一方面迫击炮只是一个很简单的发射控制而已(没那么技能,没必要搞那么复杂,我也想偷懒...)

另一方面,我们的旋转跟踪速度是480度每秒,最大旋转角度是180度,因此最多只需要 0.375s 就可以完成旋转跟踪,实际一般情况应该更少

而用户在这么短的时间内就立刻设置了发射,大概率也是不想多等,所以直接LookAt完成跟随,立刻发射就好

发射炮弹的代码大致如下

if(Input.GetMouseButtonDown(0)&&rangeVis&&isLoad)
            if(roState == MortarRoState.onRotate)//在开启Range后立刻发射,还未完成跟踪
                gameObject.transform.LookAt(new Vector3(rcx, gameObject.transform.position.y, rcz), Vector3.up);
                //设置炮管角度
                deg = Mathf.Asin(rcdis * gravity / bv2) * Mathf.Rad2Deg / 2.0f;
                cannon.eulerAngles = new Vector3(cannon.eulerAngles.x, cannon.eulerAngles.y, deg);
                //设置 dmRange worldPositon Rotate
                cirDMSP.transform.position = new Vector3(rcx, cirDMSP.transform.position.y, rcz);
                cirDMSP.transform.eulerAngles = new Vector3(90, 0, 0);
            //发射炮弹
            var shellCon = ObjPool.Instance.getObj("Shell").GetComponent<ShellCon>();
            shellCon.shellInit(shellPoint.position,
                new Vector3(cirDMSP.transform.position.x, transform.position.y, cirDMSP.transform.position.z),
                baseVelocity * Mathf.Cos(deg * Mathf.Deg2Rad),
                gravity);
            shellCon.startMove();
            //旋转锁定
            roState = MortarRoState.Lock;//设置锁定状态
            nlTime = 0f;
            AudioCon.Instance.playClip("launch");
            //进入冷却
            rangeVis = false;
            cirRGSP.SetActive(false);
            cirDMSP.SetActive(false);
            isLoad = false;
            nlodtime = 0;