说到蓝图,第一个想到的自然是UE中的蓝图系统。UE的蓝图系统对于开发而言真的及其的高效,因为它具有极高的可读性。对于一个复杂的产品工程而言,其代码量很可能大的夸张。而游戏可以说是最复杂的软件产品形式了。后期面对着几十万行的代码真的无从下手。
在多人协同开发或者后期代码量大到你自己都忘了项目的结构的时候,你打开一张整理归纳好的蓝图,瞬间就可以及其清晰的理解到功能的整体框架,然后你再去关注你期望关注的细节,深入到某一块逻辑或者某一个具体的节点背后。
并且,蓝图对于具有复杂状态的游戏角色或敌人来讲,也是其逻辑管理和设计模式优的一种极大的提升。在过去的游戏开发过程中,我一开始只给敌人使用状态机,后来才逐渐意识到了状态机不仅仅可以用于AI,它是一种设计模式,可以用于所有需要状态管理的东西,包括整个游戏的状态,UI等等,都可以用到这种设计思维。而AI,在有需求的情况下,它值得更好的更复杂的(行为树、GOAP、ML等)。
后来我写的是代码上的FSM(就是逻辑上是FSM,但没有可视化的节点图),这样已经可以做到非常好的逻辑控制了,但是为了统一FSM的调用接口等问题,每个新状态我都有单独做代码创建,然后接入状态机等等的,搞得我很繁琐。其次如果一个状态内的逻辑太复杂了,这个状态内的代码依然不太具备可读性。这也就是为什么我需要一个蓝图系统了。
为什么不用Unity的Visual Scripting
首先最简单的第一个原因,我喜欢写引擎工具(bushi)。
其实用过UE的蓝图再对比Unity的Visual Scripting就会知道,UE的蓝图相对而言是高度概括化的,粗颗粒度的,功能封装的。当然,UE也可以做到及其细致的颗粒度,但实际上你会发现,像是人物移动这种事情,只需要调用一个节点就完成了。而在Unity中想要效果足够好,光是人物移动的代码可能就要两三百行不止(兼容可移动平台,上下坡,跨越楼梯,地面判断等),同样的复杂逻辑在Visual Scripting中实现起来几乎就是灾难。另外就是UE的惯用开发流程是将复杂的、高度复用的、性能敏感的逻辑,利用C++编写再封装成单一节点。这样策划在配置实现逻辑的时候可以非常的高效,且游戏性能表现可以得到保证。
再者是Visual Scripting高度依赖于C#的反射运行,且运行时相当于解释性语言,这样C#这种本就是半编译性语言的优势都完全丧失了,性能不占优,无法满足我们项目可能同频四五十个复杂逻辑调用的对象同时存在的性能要求。虽然Visual Scripting也支持封装C#代码为一个节点,但其从底层结构上又不满足我对于不同的蓝图形式的要求(比如项目中我实现了FSM图结构,类UE中Actor的蓝图结构,以及技能蓝图结构SkillGraph),也无法足够的简化让我们团队没有编程背景的策划也可以上手改逻辑。所以我正好借此机会开发自定义的蓝图工具。
开发编辑器工具的思维 与 策划的思维是有共同之处的,首先就是明确核心目的。开发蓝图系统本身需要解决的问题是:
优秀健壮的代码逻辑管理
方便战斗策划配置玩家&敌人单位逻辑
方便关卡策划配置机关逻辑
优化策划用户体验,满足游戏功能需要
同时因为我自己是我们项目的战斗策划,主要负责了角色3C+所有技能的设计+小怪和Boss的设计,以及战斗与烹饪系统的数值平衡工作。所以整个蓝图工具开发的过程,我自己作为开发者和用户一起在同步优化这个工具。
蓝图系统本身可以分为三个大部分:数据存储 / 编辑工具 / 运行时逻辑。针对编辑工具这一块,其实我个人感觉Unity的ShaderGraph的用户体验是非常不错的,完全满足我们的需要。并且经过Research,Unity的Shader Graph是基于Unity自己的GraphView开发的,这个GraphView就是我们需要的节点图底层。最终我找到了这个非常棒的开源库:
这是一个基于GraphView开发的可以自定义节点的蓝图编辑工具 + 数据存储工具。其基于Unity Scriptable Object来进行数据存储,这就需要我们非常小心的管理序列化。但Unity Scriptable Object的优势就是可以方便我们对Project中的各种资源进行引用,并且搭配Odin的话还可以实现将System.Type也序列化的神奇功能。
BaseGraph承载BaseNode和Parameter,Parameter作为外部引用或者全局变量,BaseNode则包含具体的逻辑,Edge则表达了各个Node之间的连接关系。这是一个非常标准的蓝图结构。这样我们可以直接拓展BaseGraph,实现我自己期望的FSM Graph,Action Graph(类UE的Actor Graph),Skill Graph。针对各类复杂逻辑,也可以直接拓展BaseNode即可。
但这个插件存在着一个问题,它的BaseGraph除了数据存储逻辑以外,还包含了运行时逻辑,并且插件本身不会实例化这个BaseGraph,而是直接调用它,完全基于BaseGraph自身的来进行运行时数据的存储,逻辑的处理。这是非常不符合我的数据与逻辑分离的设计思维的,像是目前我项目中的所有配置文件,全部遵从的是这个逻辑,要产生任何的运行时数据时,全部需要一个专门的脚本来承载,对待无论是Scriptable Object还是自己的Json等配置文件,应当全部当作这些数据是只读的。
它这样的运行逻辑会带来的最首要的问题,就是蓝图无法复用。同一张蓝图在Unity的内存中只存在一份,假设我有30个敌人,他们同时调用这同一张蓝图。每个敌人抢着修改同一份蓝图里的参数,到最后就完全乱套了。另外它在Editor上也有一些问题,譬如ExposedParameter按理说应该是写入全局参数的地方,但却不知为何,安装后就被Unity的序列化为只读的,而且我尝试了许多方法都修不好(有点难绷说实话🤦)索性我们直接来一个小重构,修改它的运行时逻辑吧。
我这里采用了一点ECS(Entity Component System)的思想,一个FSM Entity引用FSM Graph来记录自己具体用的是哪个蓝图逻辑。然后蓝图上的Exposed Parameter它本来就不可用,还是那个原因,比如我有三十个敌人,每个敌人要处理移动自己的逻辑的话,Parameter上30个敌人引用的应该是30个不同的自己,但原本的蓝图逻辑根本无法做到这一点,除非你把同一份蓝图复制30遍。所以这里我干脆不要让蓝图本身的Parameter可以被配置引用,而只是像申明一个接口一样。然后在FSM Entity上做具体的引用,这样一份Graph就可以有无限个不同版本的Parameter引用了。
这里补充一下,这个插件原本的逻辑上,Graph里的Node要获取数据,是直接向Graph获取的。Graph本身初始化的时候,还会实例化其上面的Node和Edge,建立Node间的连接关系。因此我们除非大改逻辑,否则还是比较依赖Graph本身帮我进行初始化的。因此我这里的做法是,FSM Entity在启动的时候会复制一份FSM Graph,变为Cloned FSM Graph(但这是个天坑,后面会讲优化),接着根据自己引用Parameter,设置Cloned FSM Graph里的Exposed Parameter,这样因为每一份Graph在运行的时候都被复制了一遍,所以直接设置Parameter不会导致Graph间的数据冲突。接着FSM Entity为了加速运行时速度,会缓存Graph上的节点数据,方便快速索引。
GC(Garbage Collection)是Unity C#的一种内存管理机制。C#不需要程序员手动管理内存,而是在C#自己去检测哪些内存是可以释放掉的,然后触发垃圾回收机制来回收内存(也就是GC)。虽然C#帮忙管理内存非常的省心,但GC一旦多起来,对性能的影响将是毁灭性的。
很不巧,在后续项目的开发过程中,我尝试进行无感的地图切换功能的开发,但是我们从其他场景进入大箱庭世界的时候,加载时间长达7秒,一开始我还以为是因为我们场景文件过大(超过100mb),层级结构过于复杂导致的Unity数据解析困难。但利用Unity Profiler检查之后发现,FSM Entity居然是造成加载卡顿的罪魁祸首:
可以看到,在加载的一帧内,FSM Entity触发了486次Instantiate(用于复制FSM Graph),触发了1680万次GC🤦,这一阵的GC高达1GB。这也是Unity的Scriptable Object的一个问题了,如果想要复制它,就必须使用Instantiate来复制,但是Unity的Instantiate的开销极其的高。因此我们必须要找到一个解决办法,彻底抛弃掉复制和Instantiate,同时尽可能小的重构当前架构,以解决问题为优先目标。
首先,想一下我们原本为什么要复制Graph来着?因为我们的Node还在向Graph请求数据,而Graph如果不复制多份的话,就无法将不同单位直接的数据隔离开。因此我们不如修改一下,新增一个叫做GraphContext(上下文)的概念。
在Context中,我们存储所有的Parameter引用,和各种委托调用的注册。这样的话,我们可以完全抛弃掉Instantiate,所有的Entity直接引用Scriptable Object本身,然后将其当作只读的存在。经过了优化之后的FSM Entity在加载时:
相同场景下,GC直接优化到了只有35M了,Instantiate是0就已经不显示了。并且观察PlayerLoop这一项的Time ms,已经从之前的7.5秒优化到现在的1.3秒了。蓝图系统已经不再是加载的主要开销了。
后续我搭配Additive场景加载,异步加载场景中的敌人,缓存池策略进一步节省Instantiate,增加加载动画的方式,基本已经实现了体感非常迅速的加载。
评论区
共 条评论热门最新