2018-7-26
纠结了很久也没有确定要做什么游戏,就挖一个大大的坑好了,我的世界,啃一下教程,尝试着挑战一下好了。
这种体素游戏利用unity来做的话,好像第一反应就是引擎自带的cube加纹理之后存为预制体就为所需要的体素了
但是存在的主要问题是很可能会卡死,你想,这么多数量的单个对象,每个都要渲染,100*100*100的还好说,那对象如果更多呢?
效率极低,否决。
那么,可能存在的优化点在哪里呢?
游戏,或者说计算机图形的目的是什么?仿真,让你看起来像是真的,那么我看不到的地方是否存在就跟本不需要计算渲染呢?比如地下,山的内部,你的所有地形能看到的只有表面的一层图像,那么其他部分的图像完全不需要生成出来,这就是优化的核心。
那么,这个优化要求不能使用cube的实体对象,而是要单独绘制每一个面来实现该效果。单独生成面,而且只生成我们需要的面。那我们开始吧。
由于给定的块对象无法满足需求,所以需要通过脚本来绘制我们所需要的面。
在绘制面之前,先来回顾一下基本的图形学。
法线,规定面垂直的线,为何要提起法线呢,因为面分正面反面,而法线则是规定面的正反的,我们绘制的面只有正面是看的到的,而反面则是透明的。
那么如何保证面的正反呢?
在学习了opengl之后,这个应该很好理解,三边面为三条边组成的面,即三角形;四边面为四条变组成的面,即矩形。
三边面在三维空间内是不可扭曲的,而四边面在三维空间中是可以扭曲的,所以unity只支持三边面(不只是unity)。
在unity中,坐标系为左手坐标系,即伸出左手大拇指为X轴,食指为Y轴,中指为Z轴(三指相互垂直),三指指向方向为其正方向。
为何要提到坐标系,因为其涉及到法线的方向。
法线方向与绘制方向有关。由于为左手坐标系,所以伸出左手,按照点的绘制顺序依次将四指向其握拳,大拇指指向的方向即为其法线的正方向。(左手螺旋定则)
其上三点可保证绘制出的面片与设想的具有法线的面一致。顺时针绘制
既然不用模块绘制块,则需脚本来绘制面。
创建一个脚本,这个脚本的目的为生成一个面对象,由两个三边面组成。
所以在创建对象时直接创建一个网格对象(Mesh)
通过一个点数组vector3
储存4个点信息
通过一个索引数组int
来储存绘制的顺序
然后将信息赋值给Mesh对象,重计算顶点信息和法线信息
最后将生成的面赋值给组件
GetComponent<MeshFilter>().mesh = mesh;
在场景中创建一个空节点,挂上此脚本和MeshFilter(网格)和MeshRenderer(纹理) 挂接一个纹理即完成面的绘制。
与opengl绘制正方体相同,可有多种方法,但同样是在刚刚绘制一个面的基础上直接绘制六个面。
前后左右上下。注意坐标点的位置及索引对应的序号(重要)。
可采取索引顺序都相同然后在新加的索引加上现在容器中的索引个数。
这样就能保证每次索引都为032,210。
将对应各面的点位置调好即可。
这应该是我第一次考虑效率问题,如果不算算法课要求的效率的话,应该这么说,这是我第一次在制作游戏的时候考虑效率问题。
由于我的世界的游戏的特质,由于其无限的美妙世界(或许是有大小的),所以为了满足其庞大的数据量,所以效率是极其重要的。
前两步只是将立方体对象变为面对象方便后面的部分显示,提升了效率,但还不够,所做的工作还有很多。
先从小处一点点做起,比如不起眼的坐标信息,我们用的Vector3
全部都为float
类型数据,但是我们的体素游戏的单位全部都为整数,那么这之间就存在效率的问题,所以的最好的解决方案为自己定义一个工具类Vector3Int
,及其他需要的工具类。unity从2017起包含了Vector3Int
类,但不尽完善,所以可以完成此类工作。
任务:完成工具类Vector3Int及相关拓展功能。将原本vector3类改写为需要的,然后添加辅助工具类。
完成之后先不急着改写chunk类,我们先来了解一下所谓的chunk是什么。
chunk是指Minecraft中一块16*16的区域,因为你知道Minecraft是个很自由度极高的游戏,传言如果把一格视为一米时,Minecraft的世界有50多个地球大,如果这么大的地图同时加载,游戏必会崩溃,所以游戏把地图分成了无数个16*16的chunk。当你离开某个chunk256格外时,这个chunk会被卸载,当你离某个chunk128格内时,这个chunk会被加载,这个系统保证了游戏的正常运行。
OK,上面那都没啥用,我们实际上是需要完成一个区域,一个并不大的区域,而后对区域进行操作以完成整个的动态的地图生成销毁。
而我们的块是存储在这个区域内的,地图存储的是这个区域,不直接用地图管理块。
chunk类的写即将块整合为一个区域,通过区域判断是否需要绘制,其内部是否需要绘制等,当然在地形生成时绘制考虑中的元素相对更多。
在完成chunk(暂时)类之后,map类只需要进行两个工作,生成chunk,储存chunk。
在完成了上述的chunk和map之后,有没有发现一些问题。
都是容器,都是结构!
辛辛苦苦把锅和碗都造好了,米呢?米呢!
别急,米来了。
所以虽然在之前不停的在调整结构,搭建容器。但真正工作的还是我们的块。但块也相同,不会只有一种块,而每种不同的块的贴图的方式也不是那么相同,所以我们的米也是五花八门的米。那就先来把米造出来。
方块需要有方向信息,创建枚举存储信息
方块本身该包含的信息,id号,名字,朝向,贴图坐标(每个面的uv信息,共六个面)
那么问题来了,对于类型不同的方块,每面的贴图不同如何解决?
构建多个构造函数,针对不同的方块分别进行不同类型的贴图操作。
那么不同的块如何管理存储?需要一个管理block的类。
用什么容器管理方便?非线性数据当然是散列表!
新建一个dictionary用于存储块对象,id为索引,在添加进字典时将贴图信息赋予好。
注意UV坐标的赋值方式应该如何填写?
ok,来复习一下uv坐标的赋值方法,对于一张纹理来说,其x,y分别为0~1,超过此范围为不同的重复方式,所以对于一张纹理来说,我们可以规定来贴纹理的哪一个区域,这就意味着我的纹理可为一张大图包含所有,只需要block赋予不同的纹理即可。
需要一个方法将对象从容器中取出。
2018-07-27
完成块的管理之后,现在要继续修改chunk类以添加纹理信息。方法与贴三角网格面基本类似。添加一个纹理信息的属性然后将纹理添加上去即可,不对,应该说方法与顶点基本相同,由于三角形绘制时保持了一致的顺序,所以对于纹理的顶点来说,所有面基本相同。
实际效果出现了问题,有明显的边界出现,这是刚刚的大图带来的影响,由于图是计算出来的,可能会有偏差(float不精准),所以可能会有白边或者杂色,那可以通过一个偏差值来解决此问题,也就是避开最边上的一个像素,取中间的纹理值,可有效避免白边。
测试一个chunk,对,很大,16*16*16的立方体,但效果没问题。
先来一个测试用玩家,直接使用第一人称控制器FPSController。
ok在添加了控制器,我们从Map类入手(因为单例类map是管理chunk的)。
上面说到map的主要作用两点,生成chunk,存储chunk。
所以先满足这两点需求
通过坐标生成chunk(协程生成)
判断chunk是否存在(坐标)
添加其他类型方块(不同纹理)(注意不同块的纹理方式不同,这时就需要运用不同的构造函数来构造块)
继续修改chunk类,使其不会只生成一个大大的土块。
更改start使用新的map函数检测是否存在。
在创建大土块的时候,让最上层随机出现草块。
添加PlayerController,用于检测玩家周围一定的范围内的chunk是否已经生成,如果没有就生成。
理论上可以生成无限大小的地图,因为在玩家位置的范围内不断生成的。而我们的对象虽然很多,但面数却并不是那么多,所以还可运行。
但报了一个错误,dictionary的key冲突。这个错是哪里来的错误呢,排查发现是chunk容器的key重名错误。
找到问题了,预制体chunk上之前测试挂了一个blocklist脚本,两个这个脚本肯定冲突啊,而且一个chunk对象冲突一次啊。MD!!!
2018-07-29
在能够生成出无限平面之后(我觉着多了会卡死)
现在需要生成出带高低起伏效果的地形了。
那么这个时候就需要噪声————Perlin噪声(或者其他的什么噪声)。
什么是噪声?这确实是我头一次接触这个概念。
【图形学】谈谈噪声
candycat的这篇文章可以让我了解这个概念。
ok我并无法理解,只是明白了噪声的仿真纹理的作用。
那就先来应用一下吧,其实本身unity是自带了Mathf.PerlinNoise函数的,但由于这个方法为2D的,所以引入LibNoise
导入之后,随机地形需要随机种子,那么添加一个类GameManager来管理随机种子。
通过世界坐标获取方块类型 而后在PlayerController中添加y方向的chunk生成
- 修改CreateMap方法来通过噪音生成地形
- 修改Chunk生成地形的方法
生成了地形之后,确实实现了高低起伏的地形和无限的地图,但并没有交互,那么接下来就需要人工改变地形了。
实现一个准星和射线,更改chunk中的blocks
准星实现:利用UGUI直接在中间添加准星即可,由于本身摄像机就是对准视口中心的,直接将准星加到UI中心即可。
ok将准星实现了之后,用射线确定操作的目标。新添加脚本给player对象,用以控制对地形的改变操作。完整的操作应该包含添加和删除,我们先来实现删除操作。
重点在于寻找到要操作的blocks。由于我们之前实现的数据结构,Map为单例,包含chunks,然后每个chunks包含16*16*16个blocks。所以通过这个数据结构来逐级寻找blocks。
先通过Map寻找chunk,由于在map中有一个容器存储了所有的chunk对象,所以通过世界坐标寻找chunk即可。注意坐标转换,hitInfo的点为世界坐标,但需要转换才能转化为正确的chunks的坐标(即为其名称)
在找到了正确的chunk的对象之后,通过在chunk函数中添加坐标转化函数将hitInfo的坐标转化为其中的blocks的坐标。由于hitInfo的坐标为世界坐标,所以其本身的坐标位置减去其chunk的坐标位置即为其block的局部坐标值(打印点发现有问题)
修正:
x坐标有问题,鬼畜问题,修改坐标转换方法。
改变坐标正确之后,修正方块的指向问题,因为不同的表面指向的得出的结果可能不是同一个方块,要保证在射线指向这个方块时,不管哪个面都是你想要的方块值。
在反复测试打印点之后,得出前平面(射线点法线方向为z=-1)、左平面(射线点法线方向为x=-1)block指向正确(x,y,z)
右平面(射线点法线方向为x=+1)block值为(x-1,y,z)
后平面(射线点法线方向为z=+1)block值为(x,y,z-1)
上平面(射线点法线方向为y=+1)block值为(x,y-1,z)
下平面待定。
(错)
在反复测试打印点之后,得出左平面(射线点法线方向为z=-1)、后平面(射线点法线方向为x=-1)block指向正确(x,y,z)
前平面(射线点法线方向为x=+1)block值为(x-1,y,z)
右平面(射线点法线方向为z=+1)block值为(x,y,z-1)
上平面(射线点法线方向为y=+1)block值为(x,y-1,z)
下平面待定。
此时在本chunk内的坐标是正确的了,但是一旦跨chunk,就会出问题。需要处理。chunk也存在边界问题,与单个的block类似,一旦跨边界,就可能找到错误的chunk导致找不到block导致越界。
通过修正block的函数中添加chunk来修正chunk,更新其坐标和正确的chunk。(有点妖)
一个明显的bug是在消除方块后纹理混乱。最开始怀疑是坐标转换导致的问题,最后反复修正坐标保证坐标无误之后,继续测试发现问题依旧存在。最后才发现在重绘mash时,没有将原本的纹理容器清空导致报错。
解决此bug后,另外一个bug可能是协程不同步导致的,一个方块在数据上已经删除了,但是面刷新的时候没有刷新成功,此时删除此块就会报错,在下一次成功删除刷新了其他面后,这块面也会消失。
看一下协程的相关内容。(暂且搁置)
提高效率,unity批量渲染?
完成了删除准星对准的块之后,新核心功能,添加块
基本与删除块类似,最麻烦的还是坐标问题。
2018-8-4
着重解决blocks刷新报错问题。
昨晚发现了报错的规律,当一个方块三面都为空时,再删除此方块就会报错。
添加时也是此类问题,当一个方块三面都有方块时,再添加就会报错。(凸字形三个的中间那个)。初步判断是添加删除面的原因。
报错误为,顶点数据已经删除但是triangles和uv数据都没有删除。
发现在报错的同时,发现在该chunk的x=15的边缘出现了贴图丢失错乱,可能为由于面数问题导致贴图和triangles的数量有误贴图丢失。
是哪一步的操作导致此问题呢。
发现坐标的魔改可能导致了一系列的问题,删除魔改发现chunk的坐标有问题,最小一行是从-1到0的,可能导致了后续的坐标混乱。
发现问题了,在绘制面的时候,规定的坐标原点,x坐标为1为原点,相当于所有的立方体都是在X方向上向负向画了-1,整体都错位了。
重新全部构建立方体。整体将坐标系原点向负方向移动1,ok,现在就不需要魔改坐标了。
接下来看一下一个块的各个面方向的问题。
重新更改了修正坐标(删除块的),解决了最边一行(x=0)无法删除的问题。
但凸型问题依旧存在。
查了很久很久的问题,最后发现是由于在重绘的时候,mesh没有clear,导致mesh容器的大小变化后不够了。
到此,方块的增删没有问题。
2018-8-5
添加了小人的模型和动画,虽然第一人称视角并看不见自己,但添加了模型和动画。
2018-8-6
添加了基岩,调整了地形生成样式。添加天空盒。
添加了无限射程模式,按键F1,再摁恢复。
更改块纹理。
添加新功能,按键控制放置块的类型。
添加玻璃块失败,需要更改shader,暂且搁置。
制作了滚轮控制更改块的UI,并在更改图像时,同步更改建造出的块。
2018-8-7
UI的制作。
2018-8-9
UI通过UI管理添加完成,框架管理虽然繁琐,但可拓展性强,也易于管理。
2018-8-10
添加新游戏模式,建造模式,地形不利用种子,超平坦
地形非无限大,有范围,要求可以保存。
2018-8-11
建造模式的数据保存。
我的MapBuild为单例对象,所有的数据都是基于它保存的,其下的所有的chunks数据都存在于map的dictionary中。
所以,在存储中,存储和读取的对象都为这个容器。
unity的保存本身自带的PlayerPrefs可以存储设置,但是不是设计用于保存游戏的。
所以,在保存游戏时,主要要用到的是,序列化。
序列化存储数据有三种方法:二进制,xml,json
由于我需要存储的内容为一个dictionary,所以需要斟酌使用哪种方法。
想了整整一天,陷入了误区,妄图直接将chunk全部存下来,结果始终都没有想到该如何保存。实际上我只需要保存有用的信息即可,告诉文件该怎么存,在加载的时候怎么读取赋值还原即可。
筛选除需要保存的内容先。
所以要存的东西是什么,想要保存地形、保存map、保存map中的dictionary、保存dictionary中的chunk、保存chunk中的每个block的信息(块的byte值)。这就涉及到怎么存的问题。
没加暂停没法测试,先加暂停和保存载入。
添加了暂停。
2018-8-12
开始的存储方法是错误的,有太多的无用数据,在删减修改调整过后,完成了存档载入功能。
2018-8-13
从游戏返回主菜单的功能的添加,有点魔改。
添加多个存档功能,将UI拼好然后修改了存档的文件,也没有修改,添加了其他的存档命名方式,使得多档位可以实现。
实现了在存档读档按钮上显示档的时间戳。
遇到的难点是在于开始菜单的载入,往往是场景加载完毕开始载入但是实际上场景的内容还没有生成完毕,就会出现空对象。
试了很多种方法,差点放弃之时看到之前为了让载入画面延迟消失的函数,这个函数后面有一个执行完毕之后的事件委托位置,峰会路转,可以用这个函数来实现延时加载的目的,尝试过后确实可行。又在存档处添加了判断文件是否存在的函数。总之,判断读档的条件比较多,详见代码即可。
将所有的UI添加完毕,游戏基本完成。(音乐没加)
2018-8-14 音乐添加完毕,值得一提的是为了使音乐设置持续生效,将音乐音量值序列化保存起来以使得设置持续生效。这种简单值的存储利用PlayerPrefs可以简单快捷的实现。 游戏基本全部完成,接下来的更改只会是调整细节。