大家好,我是董密!
目前正在找一份 Cocos Creator 的游戏开发工作。感谢晓衡哥的帮助和邀请,再次给大家做一点技术分享。
不过这次分享的内容会非常干,也非常丰富和有趣,包含大量的实机演示,建议收藏阅读。
我开发《像素空间3D》是一款基于 Cocos Creator 3.7.0 引擎,对标《我的世界》高自由度体素游戏整体解决方案。这篇文章主要介绍了 《像素空间3D》项目,主要所涉及到的12个大技术内容:
体素空间
地形随机
地图随机——平原、高山、雪山
体素物理——水中浮力与弹簧臂
NPC和状态机——采集与战斗
背包系统——合成与熔炉
水流系统——湖泊与大海
光照系统——日照与天气
动态网格——火把道具
微信小游戏——Worker 多线程
体素存储
其它要点
前言
22年末,我参加了 Cocos 论坛的第五期征文活动,有幸凭借此作品获得了最终大奖:华为平板。晒一下第五期征文的奖品,哈哈哈😄
再次感谢 Cocos 官方的认可,这让我产生一种无法抑制的冲动,誓必将《像素空间3D》做成一个完整的游戏作品。
在此期间,我经历了多次重构优化、优化、再优化,真的是“朝发夕拾”。
游戏介绍
我制作的《像素空间3D》参考了《我的世界》但目前包体仅 2.5M。
游戏中,实现了诸如 地形随机、地图随机、体素物理、体素亮度、流水、合成、熔炉、战斗 等等功能。
虽然相较于《我的世界》还缺少非常多的内容和功能,不过还是可以进行一番探索和生存了。
核心技术简介
01
体素空间
我所理解的体素非常简单,就是给定任意一个坐标,对其进行 Math.floor 就可以获得其所在方块的坐标。以此建立起整个3D像素空间。
02
地形随机
地形随机用的是 NPM 里的一个柏林噪声函数(perlin-simplex),将其改成 TypeScript 形式进行使用。其可以进行2D和3D的随机,返回-1到1之间的渐变随机数,(r+1)*0.5 就可以返回 0 ~ 1。
地形的起伏用的是 Noise2D;矿洞、矿物的生成用的 Noise3D。
每构造一个噪声对象,需要传递一个随机对象,用来构造最初的随机数。
使用的是 Cocos 的 pseudoRandom 函数构造随机对象。只需要给一个世界种子即可。
世界种子基于某局游戏是一定的,所以就能保证每次进入都是相同的地形。
03
地图随机
游戏里主要使用了岛域的方式来划分地图,256 * 256 范围为 1 个岛域,每个岛域内是一组定义好的地貌。
岛域的随机是用 pseudoRandom 生成噪声二维数组来实现的,基于不同的世界种子,生成不同的数组。也能保证每次进入都是相同的岛域。
不过因为版本的变化,可能会增加不同的岛域地貌,所以缓存了已经修改过的岛域,没有修改过的岛域就会随着版本对地貌的影响而改变。
04
体素物理——水中浮力与弹簧臂
新版本增加了水的浮力和阻力,使用的是简化的浮力加速度(重力加速度*实体淹没高度比/密度)和阻力加速度(水的粘度*实体速度)公式。
关于射线检测,之前版本使用的是八叉树来查找射线碰撞的方块。后来一想,不需要啊。虽然方块是多,但是都是均匀整齐排布的。
我使用了一种步进式的检测方法,步骤是由射线原点开始,查看是否处在空气块里,如果不是则直接返回。如果是,则用 Cocos 的 intersect.rayAABB 方法获得 aabb 内的射线距离射线方向最近面的距离。
然后将射线原点按照射线方向移动这么远的距离,然后步进一个非常小的数,然后再进行检测,直到返回找到,或者超出检测范围。
这样的话会省去非常多八叉树的创建和更新成本,检测性能也非常高!💪
05
海、湖泊及流水
B站视频——湖泊流水功能介绍
https://www.bilibili.com/video/BV1F24y1b727/
海是区块的表面高度到海平面的所有块会被设置为海水。
湖泊是检测区块的最低点,然后用BFS查找空气区域。
湖泊的水量是一定的,水会往空的块进行流动,水量为1的水无法流动。流动是在worker里每 1 秒一次执行当前区块的流水检测。
海水的水量是无穷的,只要附近有空气块,就会被海水填满。
水面的渲染是通过查询每个顶点周围的4个方块的水量求平均值,设置成块的渲染高度,最后还要乘以一个缩放,来让水面低于方块,看起来更真实一些。
水下的效果是屏幕后处理,检测摄像机的位置是否有水,如果有则 shader 里乘上一定的蓝色。
06
NPC和状态机
下面我介绍下游戏中的玩法功能,可以到我的B站号上看视频——杀羊取肉演示。
视频链接:https://www.bilibili.com/video/BV1yT411q7mV/
目前生物 NPC 仅有羊和僵尸两种,不过也方便扩展。
NPC 现在分为三类:动物、怪物和特殊。动物现在就是羊,怪物现在就是僵尸,特殊现在就是船(对,我把船也变成了 NPC 目前看没啥不好的,等发现不适合了再说)。
现在设想的是所有动物公用 1 个材质和贴图,使用实例属性来设置材质偏移。怪物也是,特殊 NPC 也是。
写了一个简单的注册状态机。每个动物自己注册想要的状态,比如闲呆,巡逻,逃跑,被击,死亡。注册状态时配置相关参数,然后每帧执行当前状态的逻辑,并判断是否结束跳到下一个状态。
B站视频—— AK47 打僵尸的玩法演示。
视频链接:https://www.bilibili.com/video/BV1c24y1w7xC/
07
背包、合成、熔炉
游戏中地图探索,资源收集与合成,是游戏最为重要的玩法内容部。B站视频——背包合成功能。
视频链接:https://www.bilibili.com/video/BV1N24y1J7hQ/
所有背包的 UI 操作都继承自一个操作组件。即使像合成的目标格子就 1 个格子,那也是 1 个背包。
背包可以选择是否支持放入和拿出。在所有当前打开的背包里,设置了一个当前激活背包变量,用来处理两个背包的交互。
合成功能配置了一个对象,对象的每个 key 都是一个道具名,value 都是一个函数,每次合成背包或者工作台进行了变化,就会遍历这个配置里是否有符合要求的key。
具体的逻辑是遍历配置对象,对每个函数传入当前背包,函数自己判断是否符合合成要求,如果是,则返回合成数量。
比如:木棍的检测逻辑是背包里仅有两格有物品,并且物品上下排列,平且都是木板,如果都符合,就返回4,代表合成了4个木棍。
熔炉的功能类似,也是配置燃料、材料和产品。数据很多都是问GPT得到的。
08
光照与阴影
B站视频上——光照系统的讲解。

视频链接:https://www.bilibili.com/video/BV1VX4y1k7Xu/
这个使用的是预计算光照,假设太阳光始终从上到下,计算所有块的基础日照亮度。
然后再迭代计算所有非太阳光亮度的块,迭代过程中逐步减小亮度阈值,直到所有块的亮度设置完成。即使掉光头发优化,性能仍然一般,将就能跑。
在破坏、放置实体方块或者光源时,会BFS查找所有被影响的方块,再次迭代亮度。这个进行优化后,性能还可以接受。
针对亮部和暗部的对比,使用了自定义无光照 shader。随着24小时变化,改变主光源位置,在shader里用法线和光源方向进行点乘,再叠加到主颜色里。
这会导致一个问题,就是明明太阳光是从上到下,但是亮暗部却一直在变化。没有找到性能又好,效果又好的招式。😭
阴影,除了预计算的阴影,动态阴影暂时没有。
在预计算过程中,不同的块的透光度不一样,光线从一个块穿过的时候,因为透光度的不同,光线经过的三个块可能不一样,从上到下照射太阳光的时候。
比如经过了树叶,树叶不是完全透光,就会导致下方变暗,最终到地面上,就会相对暗一些,就呈现除了阴影。
角色在不同的地方,身上的亮度是不一样的。
比如夜晚,在没有光源的地方,很黑,靠近光源就会亮。
但是角色的 shader 是无光照,我用的是 instancedAttribute 每 0.1 秒检测一下当前角色所在方块的亮度,然后设置到shader里,最后同亮暗功能一起加成。
09
动态网格火把道具
一个小小的火把道具,是使用动态网格实现的,这里面需要整活的也不少。
之前版本动态网格只使用了一个 submesh,不能同时构造土地和流水,所以重构版本里变成了多个 submesh。
为此,我抽离出了基础组件,可以适用于各种体素的动态网格现显示。
不同的 submesh 使用 meshrenderer 上对应的 material。并且重写了包围盒,避免更新时多余的 Vec3 类的创建。
动态网格在渲染的时候,需要提供 typearry,如果变化频繁,可以只用一个相对较大的arraybuffer,然后用slice来截取数据,避免每次申请空间。
玩家角色手中持有的物品,也大量使用了动态网格。比如下图的火把,就是由动态网格实现的。其流程是在aseprite里用像素画好道具,再用脚本导出像素,然后拷贝到项目里,优化面后使用。
这样就可以减少使用建模工具、创建材质的时间和消耗。(这个脚本完全是gpt整的)
10
小游戏 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类就需要自己复制出来一些用到的函数,自己构造类。
11
体素存储
整个游戏空间里,主要有 chunk(区块)和 block(方块)两个概念,区块包含方块。在刚开始的版本里,方块也会是一个具体的对象。
这导致了大量对象的创建,在 PC 上经过优化还能接受,不过到了小游戏平台,就完全不好使了。
所以最后进行重构,block 在 chunk 里用多个 arraybuffer 进行存储,包含类型 arraybuffer,亮度 arraybuffer 等。
结合上微信小游戏的 sharedarraybuffer,就可以利用 worker 进行大量数据的处理和共享。
每一个 chunk 是一个对象,为了只用一维数组来存储所有chunk,使用了螺旋曲线的算法,给定chunk坐标,算出唯一id。然后写了一下给定长度,反解坐标的函数。
我感觉很多需要用二维数组去存储对象的地方,都可以使用这种方式来减少数组的创建。
12.其它次要内容
比如一个可以绘制ICON的画板,用 graphics 组件实现的。
比如摄像机弹簧臂,每帧检测是否第三人称,检测最近并且小于最大摄像机距离的实体方块,然后设置摄像机位置。
比如破坏粒子,是用体素物理来实现的。
比如破坏裂缝,是用shader偏移贴图实现的。
比如...好像也没啥了,哈哈哈。
13.其它基建
UI管理器,是基于 prefab 名称的 UI 管理器、管理加载、卸载、传参、关闭回调等基础逻辑,方便快捷。
事件管理器,发布订阅模式的事件管理器,可以定义事件名称,回调参数,返回参数,在其它地方调用的时候,有很好的代码提示。
写在最后
源码上架 Cocos 微店商城预售特惠
前3天1.5折,仅需¥249
扫码加入开发者微信群
可以领取¥50优惠券,仅需¥199

欢迎大家加入我创建的微信群聊,一起探讨开发技术和游戏内容,共同成长!没事发发图,摇摇花手都是挺好的。
往期精彩