摘要
本文介绍了用cocos creator3.x开发的《我的世界》小游戏版《像素空间3d》的主要内容和涉及到的技术概述。
欢迎大家扫码体验
(体验前,也可以先看看下方游戏实录视频)
前言
22年末,我参加了cocos论坛的第五期征文,有幸凭借此作品获得了最终大奖:华为平板,再次感谢官方大佬们的认可!让我能够投入更多的精力来完善此作品。期间经历了多次重构优化优化优化,真是“朝发夕拾”。
晒一下第五期征文的奖品,哈哈哈
游戏介绍
《像素空间3D》是一款仿照《我的世界》用cocos creator3.7.1开发的一款小游戏。目前包体2.5M。游戏中,实现了诸如地形随机、地图随机、体素物理、体素亮度、流水、合成、熔炉、战斗等等功能。虽然相较于《我的世界》还有缺少非常多的内容和功能,不过还是可以进行一番探索和生存了。虽然性能还有一些缺陷,不过还是可以跪求体验的,虽然…
下方视频是手机录屏,试玩的体验版(自动播放,不要打我~)
《像素空间3D》微信小游戏实录演示
核心技术简介
体素空间
我所理解的体素非常简单,就是给定任意一个坐标,对其进行Math.floor就可以获得其所在方块的坐标。以此建立起整个3D像素空间
地形随机
- 地形随机是用的npm里的一个柏林噪声函数(perlin-simplex),将其改成typescript形式进行使用。其可以进行2d和3d的随机。返回-1到1之间的渐变随机数,(r+1)*0.5就可以返回0~1。地形的起伏用的noise2d,矿洞、矿物的生成用的noise3d。
- 每构造一个噪声对象,需要传递一个随机对象,用来构造最初的随机数。使用的是cocos的pseudoRandom函数构造随机对象。只需要给一个世界种子即可。世界种子基于某局游戏是一定的,所以就能保证每次进入都是相同的地形。
地图随机
游戏里主要使用了岛域的方式来划分地图,256*256范围为1个岛域。每个岛域内是一组定义好的地貌。岛域的随机是用pseudoRandom生成噪声二维数组来实现的,基于不同的世界种子,生成不同的数组。也能保证每次进入都是相同的岛域。不过因为版本的变化,可能会增加不同的岛域地貌,所以缓存了已经修改过的岛域,没有修改过的岛域就会随着版本对地貌的影响而改变。
地形展示视频
用cocos实现《我的世界》之雪、山洞、湖泊
体素存储
- 整个游戏空间里,主要有chunk(区块)和block(方块)两个概念。区块包含方块。在刚开始的版本里,方块也会是一个具体的对象。这导致了大量对象的创建,在pc上经过优化还能接受,不过到了小游戏平台,就完全不好使了。所以最后进行重构,block在chunk里用多个arraybuffer进行存储,包含类型arraybuffer,亮度arraybuffer等。结合上微信小游戏的sharedarraybuffer,就可以利用worker进行大量数据的处理和共享。
- 每一个chunk是一个对象。为了只用一维数组来存储所有chunk。使用了螺旋曲线的算法,给定chunk坐标,算出唯一id。然后写了一下给定长度,反解坐标的函数。我感觉很多需要用二维数组去存储对象的地方,都可以使用这种方式来减少数组的创建。
小游戏worker
- 大量的block数据,如果只是在主线程里进行运算,想要在小游戏里刷新地图时保持60帧那是很困难的。所以使用了微信小游戏的worker。核心是构造sharedArrayBuffer,然后主线程和worker线程可以共享这部分数据。在worker里进行各种随机,水流,光照,面优化,网格构造等功能,主线程直接读取buffer里的数据即可。但是数据量还是很大,一个面优化经常需要耗费worker的好几帧,一次日夜更替更是耗费1秒以上。所以在体验的过程中,会有效果延迟的现象。还在努力研究中。(上线后,发现微信小游戏ios正式版sharedarraybuffer无法正常传递,研究未果,所以只得暂时关掉IOS小游戏的worker功能,导致IOS在体验的地图刷新的时候,会有强烈的卡顿感。等到研究明白了,再加回来~)
- 在开发过程中,需要保证可以本地pc测试,也可以打包到微信小游戏。所以需要worker做的功能是在项目的assets目录里编写的,在项目外增加了worker的单独目录,编写入口文件,引入assets里的相关文件,最后通过tsc打包到build-template/wechatgame/workers里。因为小游戏worker不认识cc,所以和cc相关的功能都需要抽出去(只是worker用到的功能,不是所有功能)。比如vec类就需要自己复制出来一些用到的函数,自己构造类。
动态网格
- 之前版本动态网格只使用了一个submesh,不能同时构造土地和流水,所以重构版本里变成了多个submesh。抽离出了基础组件,可以适用于各种体素的动态网格现显示。不同的submesh使用meshrenderer上对应的material。并且重写了包围盒,避免更新时多余的Vec3类的创建。
- 动态网格在渲染的时候,需要提供typearry,如果变化频繁,可以只用一个相对较大的arraybuffer,然后用slice来截取数据,避免每次申请空间。
- 玩家角色手中持有的物品,也大量使用了动态网格。比如下图的火把,就是由动态网格实现的。其流程是在aseprite里用像素画好道具,再用脚本导出像素,然后拷贝到项目里,优化面后使用。这样就可以减少使用建模工具、创建材质的时间和消耗。(这个脚本完全是gpt整的)
火把道具,用动态网格实现
体素物理
- 新版本增加了水的浮力和阻力,使用的是简化的浮力加速度(重力加速度*实体淹没高度比/密度)和阻力加速度(水的粘度*实体速度)公式。
具体效果可以看下面的视频:
- 关于射线检测,之前版本使用的是八叉树来查找射线碰撞的方块。后来一想,不需要啊。虽然方块是多,但是都是均匀整齐排布的。我使用了一种步进式的检测方法,步骤是由射线原点开始,查看是否处在空气块里,如果不是则直接返回。如果是则,用cocos的intersect.rayAABB方法获得aabb内的射线距离射线方向最近面的距离,然后将射线原点按照射线方向移动这么远的距离,然后步进一个非常小的数,然后再进行检测,直到返回找到,或者超出检测范围。这样的话会省去非常多八叉树的创建和更新成本。检测性能也非常高!
光照系统
- 这个使用的是预计算光照,假设太阳光始终从上到下,计算所有块的基础日照亮度。然后再迭代计算所有非太阳光亮度的块,迭代过程中逐步减小亮度阈值,直到所有块的亮度设置完成。即使掉光头发优化,性能仍然一般,将就能跑。
- 在破坏、放置实体方块或者光源时,会BFS查找所有被影响的方块,再次迭代亮度。这个进行优化后,性能还可以接受。
- 针对亮部和暗部的对比,使用了自定义无光照shader。随着24小时变化,改变主光源位置,在shader里用法线和光源方向进行点乘,再叠加到主颜色里。这会导致一个问题,就是明明太阳光是从上到下,但是亮暗部却一直在变化。没有找到性能又好,效果又好的招式。
- 阴影,除了预计算的阴影,动态阴影暂时没有。在预计算过程中,不同的块的透光度不一样,光线从一个块穿过的时候,因为透光度的不同,光线经过的三个块可能不一样,从上到下照射太阳光的时候,比如经过了树叶,树叶不是完全透光,就会导致下方变暗,最终到地面上,就会相对暗一些,就呈现除了阴影。
- 角色在不同的地方,身上的亮度是不一样的。比如夜晚,在没有光源的地方,很黑,靠近光源就会亮。但是角色的shader是无光照,我用的是instancedAttribute,每0.1秒检测一下当前角色所在方块的亮度,然后设置到shader里,最后同亮暗功能一起加成。
可以看下方视频了解光照系统:
海、湖泊及流水
- 海是区块的表面高度到海平面的所有块会被设置为海水。
- 湖泊是检测区块的最低点,然后用BFS查找空气区域。
- 湖泊的水量是一定的,水会往空的块进行流动,水量为1的水无法流动。流动是在worker里每1秒一次执行当前区块的流水检测。
- 海水的水量是无穷的,只要附近有空气块,就会被海水填满。
- 水面的渲染,是通过查询每个顶点周围的4个方块的水量求平均值,设置成块的渲染高度,最后还要乘以一个缩放,来让水面低于方块,看起来更真实一些。
- 水下的效果是屏幕后处理,检测摄像机的位置是否有水,如果有则shader里乘上一定的蓝色。
可以看下方视频了解流水功能:
背包、合成、熔炉
- 所有背包的UI操作都继承自一个操作组件。即使像合成的目标格子就1个格子,那也是1个背包。背包可以选择是否支持放入和拿出。在所有当前打开的背包里,设置了一个当前激活背包变量,用来处理两个背包的交互。
- 合成功能配置了一个对象,对象的每个key都是一个道具名,value都是一个函数,每次合成背包或者工作台进行了变化,就会遍历这个配置里是否有符合要求的key。具体的逻辑是遍历配置对象,对每个函数传入当前背包,函数自己判断是否符合合成要求,如果是,则返回合成数量。比如木棍的检测逻辑是背包里仅有两格有物品,并且物品上下排列,平且都是木板,如果都符合,就返回4,代表合成了4个木棍。
- 熔炉的功能类似,也是配置燃料、材料和产品。数据很多都是问GPT得到的。
可以看下方视频了解合成功能(熔炉功能视频在光照那里有):
NPC和状态机
- 目前生物NPC仅有羊和僵尸两种。不过也方便扩展。NPC现在分为三类:动物、怪物和特殊。动物现在就是羊,怪物现在就是僵尸,特殊现在就是船(对,我把船也变成了NPC,目前看没啥不好的,等发现不适合了再说)。
- 现在设想的是所有动物公用1个材质和贴图,使用实例属性来设置材质偏移。怪物也是,特殊NPC也是。
- 写了一个简单的注册状态机。每个动物自己注册想要的状态,比如闲呆,巡逻,逃跑,被击,死亡。注册状态时配置相关参数,然后每帧执行当前状态的逻辑,并判断是否结束跳到下一个状态。
可以看下方两个视频来了解当前NPC:
其它次要内容
- 比如一个可以绘制ICON的画板,用graphics组件实现的。
- 比如摄像机弹簧臂,每帧检测是否第三人称,检测最近并且小于最大摄像机距离的实体方块,然后设置摄像机位置。
- 比如破坏粒子,是用体素物理来实现的。
- 比如破坏裂缝,是用shader偏移贴图实现的。
- 比如…好像也没啥了。哈哈哈。
其它基建
- UI管理器,是基于prefab名称的UI管理器,管理加载,卸载,传参,关闭回调等基础逻辑,方便快捷。
- 事件管理器,发布订阅模式的事件管理器,可以定义事件名称,回调参数,返回参数,在其它地方调用的时候,有很好的代码提示。
写在最后
欢迎大家加入我创建的微信群聊,一起探讨开发技术和游戏内容,共同成长!没事发发图,摇摇花手都是挺好的~~~