引言: 本文作者「董密」,他将介绍基于 Cocos Creator 3.7.0 引擎开发的《像素空间 3D》项目,干货满满,一起来看看。
大家好,我是董密!
目前正在找一份 Cocos Creator 的游戏开发工作。感谢晓衡哥的帮助和邀请,再次给大家做一点技术分享。
不过这次分享的内容会非常干,也非常丰富和有趣,包含大量的实机演示,建议收藏阅读。源码已上架,大家可以前往 Cocos Store 或 Cocos 官方微店进行下载。文末有特惠哟~
1、体素空间
2、地形随机
3、地图随机——平原、高山、雪山
4、体素物理——水中浮力与弹簧臂
5、NPC 和状态机——采集与战斗
6、背包系统——合成与熔炉
7、水流系统——湖泊与大海
8、光照系统——日照与天气
9、动态网格——火把道具
10、微信小游戏—— Worker 多线程
11、体素存储
12、其它要点
前言
22 年末,我参加了 Cocos 论坛的第五期征文活动,有幸凭借此作品获得了最终大奖:华为平板。晒一下第五期征文的奖品,哈哈哈😄
再次感谢 Cocos 官方的认可,这让我产生一种无法抑制的冲动,誓必将《像素空间 3D》做成一个完整的游戏作品。
在此期间,我经历了多次重构优化、优化、再优化,真的是“朝发夕拾”。
游戏介绍
我制作的《像素空间 3D》参考了《我的世界》但目前包体仅 2.5M。
游戏中,实现了诸如地形随机、地图随机、体素物理、体素亮度、流水、合成、熔炉、战斗等等功能。
虽然相较于《我的世界》还缺少非常多的内容和功能,不过还是可以进行一番探索和生存了。
核心技术介绍
体素空间
我所理解的体素非常简单,就是给定任意一个坐标,对其进行 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 的 intersect.rayAABB 方法获得 aabb 内的射线距离射线方向最近面的距离。
然后将射线原点按照射线方向移动这么远的距离,然后步进一个非常小的数,然后再进行检测,直到返回找到,或者超出检测范围。
这样的话会省去非常多八叉树的创建和更新成本,检测性能也非常高!💪
海、湖泊及流水

海是区块的表面高度到海平面的所有块会被设置为海水
湖泊是检测区块的最低点,然后用 BFS 查找空气区域
湖泊的水量是一定的,水会往空的块进行流动,水量为 1 的水无法流动。流动是在 worker 里每 1 秒一次执行当前区块的流水检测
海水的水量是无穷的,只要附近有空气块,就会被海水填满
水面的渲染是通过查询每个顶点周围的 4 个方块的水量求平均值,设置成块的渲染高度,最后还要乘以一个缩放,来让水面低于方块,看起来更真实一些
水下的效果是屏幕后处理,检测摄像机的位置是否有水,如果有则 shader 里乘上一定的蓝色
NPC和状态机
下面我介绍下游戏中的玩法功能,可以到我的 B 站号上看视频——杀羊取肉演示
目前生物 NPC 仅有羊和僵尸两种,不过也方便扩展。
NPC 现在分为三类:动物、怪物和特殊。动物现在就是羊,怪物现在就是僵尸,特殊现在就是船(对,我把船也变成了 NPC 目前看没啥不好的,等发现不适合了再说)。
现在设想的是所有动物公用 1 个材质和贴图,使用实例属性来设置材质偏移。怪物也是,特殊 NPC 也是。
写了一个简单的注册状态机。每个动物自己注册想要的状态,比如闲呆,巡逻,逃跑,被击,死亡。注册状态时配置相关参数,然后每帧执行当前状态的逻辑,并判断是否结束跳到下一个状态。
B 站视频—— AK47 打僵尸的
背包、合成、熔炉
游戏中地图探索,资源收集与合成,是游戏最为重要的玩法内容部。
B站视频——背包合成功能
所有背包的 UI 操作都继承自一个操作组件。即使像合成的目标格子就 1 个格子,那也是 1 个背包。
背包可以选择是否支持放入和拿出。在所有当前打开的背包里,设置了一个当前激活背包变量,用来处理两个背包的交互。
合成功能配置了一个对象,对象的每个 key 都是一个道具名,value 都是一个函数,每次合成背包或者工作台进行了变化,就会遍历这个配置里是否有符合要求的 key。
具体的逻辑是遍历配置对象,对每个函数传入当前背包,函数自己判断是否符合合成要求,如果是,则返回合成数量。
比如:木棍的检测逻辑是背包里仅有两格有物品,并且物品上下排列,平且都是木板,如果都符合,就返回 4,代表合成了 4 个木棍。
熔炉的功能类似,也是配置燃料、材料和产品。数据很多都是问 GPT 得到的。
光照与阴影
B 站视频上——光照系统的讲解
这个使用的是预计算光照,假设太阳光始终从上到下,计算所有块的基础日照亮度。
然后再迭代计算所有非太阳光亮度的块,迭代过程中逐步减小亮度阈值,直到所有块的亮度设置完成。即使掉光头发优化,性能仍然一般,将就能跑。
在破坏、放置实体方块或者光源时,会 BFS 查找所有被影响的方块,再次迭代亮度。这个进行优化后,性能还可以接受。
针对亮部和暗部的对比,使用了自定义无光照 shader。随着 24 小时变化,改变主光源位置,在 shader 里用法线和光源方向进行点乘,再叠加到主颜色里。
这会导致一个问题,就是明明太阳光是从上到下,但是亮暗部却一直在变化。没有找到性能又好,效果又好的招式。😭
阴影,除了预计算的阴影,动态阴影暂时没有。
在预计算过程中,不同的块的透光度不一样,光线从一个块穿过的时候,因为透光度的不同,光线经过的三个块可能不一样,从上到下照射太阳光的时候。
比如经过了树叶,树叶不是完全透光,就会导致下方变暗,最终到地面上,就会相对暗一些,就呈现除了阴影。
角色在不同的地方,身上的亮度是不一样的。
比如夜晚,在没有光源的地方,很黑,靠近光源就会亮。
但是角色的 shader 是无光照,我用的是 instancedAttribute 每 0.1 秒检测一下当前角色所在方块的亮度,然后设置到 shader 里,最后同亮暗功能一起加成。
动态网格火把道具
一个小小的火把道具,是使用动态网格实现的,这里面需要整活的也不少
之前版本动态网格只使用了一个 submesh,不能同时构造土地和流水,所以重构版本里变成了多个 submesh。
为此,我抽离出了基础组件,可以适用于各种体素的动态网格现显示。
不同的 submesh 使用 meshrenderer 上对应的 material。并且重写了包围盒,避免更新时多余的 Vec3 类的创建。
动态网格在渲染的时候,需要提供 typearry,如果变化频繁,可以只用一个相对较大的 arraybuffer,然后用 slice 来截取数据,避免每次申请空间。
玩家角色手中持有的物品,也大量使用了动态网格。比如下图的火把,就是由动态网格实现的。其流程是在 aseprite 里用像素画好道具,再用脚本导出像素,然后拷贝到项目里,优化面后使用。
这样就可以减少使用建模工具、创建材质的时间和消耗。(这个脚本完全是 GPT 整的)
小游戏 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 类就需要自己复制出来一些用到的函数,自己构造类。
体素存储
整个游戏空间里,主要有 chunk(区块)和 block(方块)两个概念,区块包含方块。在刚开始的版本里,方块也会是一个具体的对象。
这导致了大量对象的创建,在 PC 上经过优化还能接受,不过到了小游戏平台,就完全不好使了。
所以最后进行重构,block 在 chunk 里用多个 arraybuffer 进行存储,包含类型 arraybuffer,亮度 arraybuffer 等。
结合上微信小游戏的 sharedarraybuffer,就可以利用 worker 进行大量数据的处理和共享。
每一个 chunk 是一个对象,为了只用一维数组来存储所有 chunk,使用了螺旋曲线的算法,给定 chunk 坐标,算出唯一 id。然后写了一下给定长度,反解坐标的函数。
我感觉很多需要用二维数组去存储对象的地方,都可以使用这种方式来减少数组的创建。
其他次要内容
-
比如一个可以绘制 ICON 的画板,用 graphics 组件实现的 -
比如摄像机弹簧臂,每帧检测是否第三人称,检测最近并且小于最大摄像机距离的实体方块,然后设置摄像机位置 -
比如破坏粒子,是用体素物理来实现的 -
比如破坏裂缝,是用 shader 偏移贴图实现的 -
比如...好像也没啥了,哈哈哈
感谢董密的分享!源码特惠活动中👇
https://store.cocos.com/app/detail/4871
更多讲解视频👇
https://www.bilibili.com/video/BV1F24y1b727/
https://www.bilibili.com/video/BV1yT411q7mV/
https://www.bilibili.com/video/BV1c24y1w7xC/
https://www.bilibili.com/video/BV1N24y1J7hQ/
https://www.bilibili.com/video/BV1VX4y1k7Xu/
往期精彩
本文分享自微信公众号 - COCOS(CocosEngine)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。