一个迫击炮的制作
0.前序
项目已在Github上开源
最终版本demo已发布: 提取码:fug6
本篇是完整项目中的一个分部总结
包括了项目中,对于迫击炮这一角色的建模,UV展开,法线烘焙与修复,导入Unity,以及Unity中编码实现对迫击炮的操控,其中的全部技术要点总结
迫击炮的形象源自于 腾讯旗下的,原芬兰移动游戏公司supercell出品的 ,《部落冲突:全面开战》《部落冲突:皇室战争》
最终效果

项目制作相关软件&工具:
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矫正,色彩管理矫正的影响需要在导入法线纹理的前后进行如下设置
我们可以在Max中,随便拉一个平面,烘焙一张2048*2048法线,不要Alpha
从而得到一张 背景法线 ,这张背景法线对应的法线向量,替换后会让片段元保持切线空间Z+的方向,也就是在三角型遍历阶段由顶点法线差值得到的原始法线方向
因为我们分别烘焙的的png法线是带Alpha通道的
所以图层可以直接重叠在一起
在GIMP中合成法线,并修复乱码
最终法线效果
漫反射填色
可以先在Max中用多维子材质确定大致的配色
导出一张UV模板,对照着填色
注意 填色区域一定要比UV模板的边缘还要大出一圈 ,因为纹理采样时, 边缘部分片元采样滤波的掩膜范围会超出边缘,如果紧贴UV模板边缘,边缘片元采样滤波时,就会混到外面的背景色
---1.6 组装完整模型&导入前处理
对基本模型的元素进行位移/旋转复制,组装出完成的模型
注意接缝的位置,要将顶点焊接起来,但UV保持默认的断开状态
因为 烘焙法线的时候,是按照断开的状态完成烘焙的 ,实际拼接之后,接缝左右两边对应的UV就应当是断开的, 两侧会索引到不同的位置
所以这里, 将顶点焊接起来,保证面的连续性,但UV保持断开 ,最终渲染时会基于UV断开产生 顶点细分 ,实际接缝边会细分出两组UV不同的顶点,各自对应左右两侧的面进行渲染
如果UV接缝拆除,强行融合两侧不同的UV,那么就无法索引到正确的法线信息
注意,针对这个迫击炮模型,我们需要分成两个部分,
父物体炮架,和子物体炮管(父子链接)
方便Unity中进行操控(分别设置炮架的LookAt和炮管的发射仰角)
确认网格无缩放(XYZ局部缩放都是100%),如果有缩放需要重置变换调整
对父物体进行轴心的位置朝向调整
旋转Y轴冲上,Z轴冲前,适应Unity的环境
轴心位置置于底部,方便在场景中摆放模型
子物体轴心,需要进行位置调整,置于正确的旋转中心处
更多有关3dsMax导出FBX对接Unity的注意点,可参阅文章
---1.7 炮弹的制作&预留空节点
结合炮管的大小,拉一个球形作为炮弹
将炮弹放到炮管底部 ,链接给炮管做子物体,导出时一起选中导出,在Unity场景中引入模型时,我们再删除这个炮弹的网格(避免Unity解析FBX时剔除空节点)
从而这个炮弹会作为一个 空节点, 当我们在Unity中发射炮弹时,会 将动态创建出的炮弹和这个空节点位置对齐
---1.8 Unity中渲染最终效果&问题的修复
先来确认一下拆解低模的效果
高模面数为 6992 + 3207 = 10199
低模面数 3770+1704 = 5470
也就是说我们拆下了 4.7k的面,将近少了一半
而通过烘焙法线,低模最终渲染出来的效果,与高模几乎一致
修复几个问题
Max中通过视口渲染只能检查大致的效果
一些问题只有在Unity中渲染时才能清楚的观察到
首先是这里的一个 卷错问题 ,出现了这种黑边的现象
这是因为用于承载高模曲面的低模平面,没有通过平滑组来消除硬边,且没有拆开UV,导致边线左右,存在法线突变,采样滤波时强行融合了两侧的法线(结果强行融合),但没有考虑两侧切线空间的差异
详见本节开头提到的,《环线切角与法线烘焙》一文
来到Max中检查果然发现了低模平滑组设置有误,承载高模曲面的平面内部出现硬边
我们调整平滑组重新进行烘焙,导出的模型也需要同步调整
GIMP中将这一块平面法线,替换原有,并重新修复乱码
之后是这里的软边过渡呈现出了硬边
Max中检查,烘焙时平滑组设置没有问题,而拼装出完整模型时,平滑组设置有误
我们设置正确的平滑组,重新导出模型
最后是这里切角软边,法线存在噪点
GIMP中检查法线果然发现了问题,予以修复
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 的形式
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,我们可以 根据场景的尺度 将它调试为一个合适的数值
---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;