在经历了17年诸般变动后,现在到了2018年。怀旧是因为之前几年发生太多,好与不好都已过去,17年迎来新的开始,所以也就有了后来些许感慨,有机会再说说这些行业感受了。今天先让我们专注于川最新解决的实际项目问题。
战争迷雾是很多带地图的游戏多少会考虑的一个功能。恰巧川在17年下半年开始做的项目也涉及到了这次的内容,而且在开始审题时,这简直是送分题啊!我们先来看看题。
“今有2D地图一张,需制作战争迷雾,地图随角色坐标与视野范围开迷雾,且被开的迷雾常亮。求实现功能。”
前人的实现:
在正式解决题目之前,这项内容本是组内一名同事先期在做的。他的思路通过动态修改迷雾图的alpha值,达到迷雾被打开的效果。具体流程可以概括为:
创建mask图->存储地图alpha数据到数组->遍历更新更新被开视野的数组数据->逐像素遍历更新mask图像素点的alpha值->刷新图片并应用
其实这就是为了实现功能而写的一段代码,实现了我们的命题,但是在扩展性以及性能方面没有丝毫值得借鉴的地方。倒是表现效果起码能让人在看过之后知道是怎么回事。
重新设计:
好吧,所谓前人的实现其实就是吐个槽,现在让我们开始改进。我们先借鉴利用二维数组存储地图数据的做法,根据地图尺寸创建一个m*n的数组,每个数据表示1平方米范围的迷雾alpha值。根据我们的测试,居然需要消耗10-30ms!这在游戏上是绝对无法容忍的。因此就需要围绕节省运算力着手。
根据编程经验,首先出问题的肯定是那个“遍历更新更新被开视野的数组数据”和“逐像素遍历更新mask图像素点的alpha值”,因为单单一步就需要遍历m*n次。假如地图是一个1000*1000的,那么每一帧就要遍历两百万次,太恐怖了!所以,根据题目的要求,我们完全可以只更新每一帧需要更新的区域,也就是角色视野范围大小的数据就可以。为此我们需要拥有一个Dictionary,专门存储不同视野值的视野范围数组,用于更新存储地图数据的二维数组。
在进行了上一步的优化之后,如果视野值为v,则我们每一帧只需要进行两次v*v的遍历就可以更新地图数据,并把更新后的数据写入到mask图中。这样我们的每一帧消耗的时间就已经控制在2-4ms。然后为了节约数据的存储空间,我们可以设置最大边长,然后等比换算,使这部分的存储不大量占用内存,唯一的缺陷就是当地图变大的时候,迷雾的分辨率会降低,不过目前来看已经解决问题了,不是吗?
问题出现:
然而,问题是没有真正解决那一日的,随着代码被创建,熵是只增不减滴!在探索mask图尺寸临界值的过程中会发现,mask图本身在变大的过程中,设置像素点的值依然不是性能瓶颈,取而代之的是刷新这张图。在Unity中,对于Texture2D的Apply代价是很高昂的,而且设置点越多、图片越大,消耗也就越大,这必然限制我们的执行效率与展示效果。
问题解决方案:
为了解决这个问题,川祭出了大学老师教给的“分治法”!既然一张大图的更新耗时,那么拆分为多个小图不就可以压缩这部分的运算量,达到追求效率的目的吗?说不准还能提升精度呢!于是说干就干。
这一解决方案的核心就是通过多张小图组成一整张mask图。为了实现这个效果,首先我们需要着手自己写写shader了。我们需要对迷雾采用自定义的shader,目的就是通过我们输入屏幕坐标与范围,令图片展示这一部分的迷雾,而不必对整张图片进行位置变换。在此基础上,通过把那张mask小图作为参数传入shader中,就可以实现迷雾的效果,从而不必使用大图来表示mask。
之后就是如何定义这些mask小图了。为了实现mask图切换的连贯,我们必然需要将mask图相互存在重叠,而且这部分重叠的区域必须满足一个屏幕所能展示的区域大小,即:一张mask小图与它左上角的mask小图的重叠部分的尺寸等于屏幕展示区域对应的数据尺寸。不难想象这样做其实是牺牲空间换取时间,在没有想到后面更好的解决方案前,这个解决方案是符合时间与空间的关系的。
根据新的方案,我们就可以每次只更新需要更新的mask小图,这样比更新一张大图要来的实惠。只是麻烦的点在于需要找到一个比例,令小图之间的重叠所占小图的比例不能太高,否则将创建太多的mask小图,反而得不偿失。
新问题出现:
虽然第一个问题解决了,但是很快又有新的问题出现了。
当地图的范围再大一些的时候,我们将会不得不创建更多的细碎图片来组成这张大地图的迷雾图,而且当图的数量上去了,那么计算量、硬件使用占用率都会提高,甚至相去我们需要解决的问题更远。于是,这就代表上一个解决问题的思路已经行不通了,必须考虑一些新的办法来解决问题了。
“节省空间方面无计可施时,从代码中剥离并退回起点集中心力研究数据,常常能有奇效。数据的表示形式是程序设计的根本”引自某位国外大牛(很抱歉我忘了出处了,但是这句话我记得很深刻)。不过在启动改进时我并没有想起这句话,但是在遇到现在这样的问题的时候,关于迷雾的数据表述确实是我们寻求新的解决思路最大的障碍。
其实最开始我就曾想过是否有一种可行性,即在一张图表示屏幕迷雾遮罩图的基础上,每一帧只改变发生变动的像素点的色值。现在索性按照这个思路往下走对问题解决方案进行重构。
重构方案:
如果比较出每一帧变化的像素点,则必须有一份对应的像素数据的存储结构。在这里,我依旧使用二维数组表示整图的迷雾数据。但是为了每一帧只改变视界范围的迷雾遮罩图(准确说是因为现在仅有的一张图只表示了视界的迷雾信息),所以我们需要有一个小号的二维数组表示视界的迷雾数据。这样当每一帧需要刷新时:首先刷新总图迷雾数据,之后将视界迷雾数据与上一帧缓存的视界数据进行比对,将变更的位置进行记录,并刷新视界迷雾数据,再之后根据记录的变更信息投射到迷雾的遮罩图上,实现迷雾遮罩图的更新。
流程如上图。这样的流程走下来,相比较之前的方案,我们省去了创建多张图的额外开销,记录信息变更位置并更新迷雾图使得对纹理的操作得到大幅度的减少,仅这两项就为我们节省了不少资源。不过,性能这个东西怎么优化都不为过,因而我们还将继续对方法进行改进。
优化方案1:
其实重新审视新方案,有一项最为致命的性能消耗,就是集中在了对全局迷雾进行数据存储的二维数组。而且这里还有一个潜藏的风险点,就是当我们的地图数据变得更大的时候,这个二维数组也将变大,最终是否会吃爆内存也是个未知数。但是这部分其实我们是可以优化掉的,毕竟对我们的需求来说,迷雾只分为打开和未打开两种状态,所以一个bit就可以搞定的,那我们何必再采取其他形式存储呢,毕竟一个bool或者一个char也有8位出去了?所以第一步就是精简数据存储所占用的空间。
纵观所有基础的数据形式,最为合适的就是64位的ulong类型。这是因为ulong类型的64位正好可以用来代表8*8的方块数据且不会造成数据的浪费,而如果选取32bit或8bit则无法达到100%利用bit位的效果。当然如果只是为了不浪费地利用数据,那么选取16bit也是可以满足效果的,可是这样造成的问题就在于我们创造的存储16bit的二维数组的尺寸将变大。在这一点上,我们在吃过for循环处理像素数据导致时间成本变大的亏之后,选择64bit而不用16bit就不言而喻了。
在选取好数据的存储形式之后,我们就可以开始对原有地图迷雾数据和视界的迷雾数据进行压缩。在采用64bit的二维数组表示每个点的迷雾数据之后,相较我们早期使用bool二维数组,内存的占用率前者是后者的1/512(别问我怎么算的,用64*8试试)。其实出现这么大的优化还是很让人汗颜的,因为这说明不是我们优化的好,而是前期设计还是太草率,没有分析清楚问题的症结所在。还好现在也不晚,所以继续进行性能优化才是上策。
优化方案2:
在优化方案1之后,我们就可以继续进行优化。这时候虽然每一步的运算时间已经维持在了个位数的毫秒阶段,但是问题还是很突出。现在的问题就集中在了“记录每一帧的视界迷雾数据变动信息”这里。
先说说现在这里出现的问题吧:因为在压缩数据存储之后并未改动其他操作,因而这一部分依旧使用老方法,即:逐点判断对应像素区域是否有数据变化。也就是说,如果我们是视界是通过500*400的二位数组代表视界区域的所有像素点,那么我们每一帧需要循环进行500*400次逐个去判断这一点是否有迷雾变化。
问题找到,那么解决方法呢?既然我们都把数据压缩了,那么为什么不把数据比较也进行压缩,然后整块进行比较呢?毕竟两个64bit值判断是否相等就可以代替64次点位数据的判断,这还是很划算的。但是这就遇到一个问题,就是我们的视界毕竟是会变动的,无法保证视界每一次的变动,它的边界都处在64bit数据代表的地图边界,必然会出现顶点在64bit方块内的情况。其实这是我钻牛角尖而产生的一个问题。如果说我们还需要将数据投射到纹理上,而且渲染所使用的shader也需要自己实现,那么干嘛不把这张纹理也做成有一定冗余,通过调整shader内的值来改变显示范围呢?所以这么一想,我们依旧可以采取整块比较的方法,无非在每一帧多四次基本运算求出遮罩纹理在四个边的冗余百分比即可。
通过这一次的优化,我们将视界数据的比较运算整体性能提升了64倍(1次运算代替原来的64次循环,可不提升这么多)。不管汗颜还是骄傲,总之有一个坑暂时被填平了(我也不知道为什么用暂时,也许我还想着再压榨一下性能,找个更好的办法?)。不过旧坑平了,原来的一些小坑就成为了新的大坑(相对论)。
优化方案3:
现在的问题又回到了更新纹理上。在方案2的作用下,数据压缩带来的是性能的提升,那么对于每一帧在视界内迷雾数据发生变更的点,又有什么办法呢?先说问题吧:虽然记录了发生数据变更的点,但是对这些点进行遍历并更新至纹理同样会有性能消耗,随着点的数量的不稳定,纹理更新这一整块代码的效率就也存在不稳定。
其实通过对Unity操作纹理的API的分析,这一部分的性能消耗依旧是集中在了数据的数量。即便减少了每一帧需要处理的点的数量(除了永远的第一帧,这部分优化掉的计算其实还是很多的),像素点的数量依旧很大。可是这些点已经很难精简了,所以只能考虑通过其他API,减少对纹理的操作次数。所以我们看到了API:Texture2D.SetPixels。
所以我们的解决方案就是在每增加一个数据点的统计的时候,对这个点进行归类,归类的依据就是像素点的横纵坐标。通过先期对同一x坐标下的y坐标连续的点进行数据合并,然后将合并后的数据统一发给纹理进行设置,可以减少设置点的操作次数。并由于设置一个像素点和设置一串连续的像素点所消耗的时间相差无几,这样做的明显好处就是为这一步的运算减少了很多运算时间。不过由于数量不固定,所以这样优化掉的时间并没有一个固定比例。仅以川的项目为例,在对视界范围为666*750、具体遮罩图尺寸为909*1024的纹理下进行像素更新,每一帧的更新时间由之前的平均4-7ms优化到了现在的0.2-0.6ms上下。这个进步还是很喜人的。
所以,川的新方案其实是从三方面着手,即对整图数据、视界数据、纹理材质三个环节进行性能优化。与其说性能优化,不如说其实都是在设计川能想到的最优的解决方案。
让人欣慰的是,整体流程在进行优化重新设计之后,以一张1000*800的世界地图下为例:
1.空间性能消耗由之前16张512*512的纹理图和6.1M的内存,减少到现在的仅用一张909*1024的纹理图搭配97K内存。
2.时间性能消耗由之前平均一帧10ms以上,减少到现在平均一帧0.8-1.2ms。
3.效果表现上,因为地图数据大小一致,这一部分本差不多,但是当摆脱了存储空间压力之后,视界纹理图尺寸变大,使得表现效果较之前自然细腻了一些。不过总色调其实还是大的像素块。
好吧,新问题来了,那就是表现还是大像素块。
最终方案,建立在底层优化之上:
正如标题。在底层进行了上述的一系列优化与重构之后,我们其实又有了挥霍的资本。正如人类让自己变得更好,其实是为了更好去“消费”自己。作为游戏从业者,川的本职工作就是让展现在玩家面前的是无与伦比的体验和感受,如果我们不是像素风游戏,那么对于迷雾的大像素块表现形式,川就是无法容忍的。
那么最终方案是什么呢?也许是之前对解决方案的设计耗费太多脑细胞,川想到最直接的方式就是扩容:增加数据量。
其实所谓大像素块,就是由于数据表达的精度不够,导致一个数据点需要通过多个物理像素点来表达。所以只要对整体数据进行扩容,就可以接近一个物理像素点表示一个迷雾数据点,甚至再扩容,可以达到一个物理像素点表示多个迷雾数据点(不过这么做就目前而言似乎并没有什么必要)。所以川就是通过对项目进行分析,得出了地图数据长宽各扩容3倍、总面积扩张到基础的9倍,可以实现非常棒的视觉体验。
源代码:
有些东西还是源代码看更清晰一些,针对我们所使用的方案,我做了精简上传,可以移步:https://github.com/jccg891113/2DMapFog。
原文地址:https://www.cnblogs.com/sachuan/p/8461093.html