从Unity迁移到Godot再到入坟
-
作为一个较为新生的引擎,Godot包含了不少现代化的设计理念,提供了许多开箱即用的功能,但也仍有许多不足之处。本文主要记录从Unity迁移到Godot时,常见功能的实现、需要注意的坑、引擎之间的对比、开发思维上的转变,以及引擎设计上值得深入探讨的部分等,长期随缘乱序更新,如有错误欢迎指出。
-
🤔“万能”的节点与场景
引用Godot作者对于Unity与Godot概念上对应关系的解释:
实体 → 节点
组件 → 节点
场景设置 → 节点
导航 → 节点
光照贴图 → 节点
视口 → 节点
行为 → 节点+脚本
预制体 → 场景
场景组合 → 场景
ScriptableObject → 资源几乎所有内容都是节点、场景或资源...Unity中很多非常复杂的子系统,在Godot中它们表达得更加自然和直观。
从Unity转到Godot时,一个常见的错觉便是把节点当作GameObject,但稍微使用便会发现,节点上只能“挂载”一个脚本,似乎有点不太对劲。如果硬要将两者的概念对应起来,那么在Untiy中带有多个Component的GameObject,在Godot中则是带有多个子节点的节点:
这便是节点身兼多职的一个体现,Unity中Component直接挂载在GameObject上,Inspector中列出当前GameObject上的所有Component;Godot中节点作为子节点来充当Component的角色,Inspector中从上到下列出当前节点所继承的类型。
这也是为什么节点上无法“挂载”多个脚本,因为脚本并不对应Unity中的Component,Godot中的脚本更多是节点功能的扩展,不像Unity中脚本都继承自MonoBehavior,Godot脚本必须继承某个特定的节点类型,因为它的目的就是为这个类型的节点增加功能和行为。这种继承设计的一个好处是,节点只会拥有它所继承类型的功能而不会多出额外的不需要的功能,比如某个节点压根用不着Transform,也不需要在场景中显示,那不继承Node3D或Node2D就行。
这种设计有哪些弊端?
首先场景树的复杂程度会不可避免地增加,一个根节点下会有作为子节点的节点和作为组件的节点,同时子节点还会有它的组件子节点,这棵树会变得很大,所以Godot也鼓励尽可能将它们细分,都制作成场景(预制体),再作为子场景实例化到父场景中,并且利用多页签同时编辑多个场景、单独调试运行某个场景来提升开发效率。
另一点则是脚本继承限制了根节点的类型,例如我希望在游戏中有一个基类Character来作为游戏中的各种角色,其他类型的角色都继承于它:
而在制作预制体时,这些角色的根节点可能是CharacterBody3D(会动的),可能是StaticBody3D(静止的),也可能是Node3D(物理无关的),如果Character要作为根节点,那它只能继承于Node3D,而继承自Character的PlayerCharacter、EnemyCharacter等就失去了直接使用CharacterBody3D、StaticBody3D等节点的能力。
当然有很多种办法可以间接解决这个问题,例如获取自身节点后转换成指定类型(较为丑陋):
class_name PlayerCharacter extends Character var character_body: CharacterBody3D func _ready(): var self_node = get_parent().get_node("%s" % name) if self_node is CharacterBody3D: character_body = self_node as CharacterBody3D
或者改变思路,重新划分职责,既然根节点没法做到一致,就将Character用作子节点,根节点再用其他类去处理。毕竟用一个引擎的最好方式就是用符合它的设计思路来做开发,而不是头铁强行做它不擅长的事。
由于Inspector中也是按照继承关系依次显示当前节点继承的类型,并且默认将分组折叠,导致像调整Transform这种在Unity中很方便的操作,到了Godot中需要往下翻几层才行:
-
👍按确定顺序执行的事件函数
Unity中,如果不在
Project Settings
->Script Execution Order
指定执行顺序,则脚本的执行顺序是不确定的,例如在一个场景中存在A、B两个脚本,A的Awake
、Start
、Update
等事件函数可能比B先执行,也可能后执行。这种不确定性削弱了开发者对程序的控制,除了会导致踩坑之外,一些需求实现起来也变得更加繁琐。在Godot中,绝大部分操作都是按场景树顺序执行,个人认为这个设计是非常简洁与巧妙的,简单概括就是“从上往下执行,
_ready
函数特例”:大部分函数如
_init
、_enter_tree
、_process
等都是从上往下执行,_ready
函数是一个特例,当一个节点的所有子节点的_ready
都执行完后,该节点的_ready
才会执行。这是符合直觉的:只有一棵树的所有子节点都准备好了,才能说这棵树准备好了。Godot的另一个符合直觉的设计是,Autoload的节点会优先进入场景树,例如有一个App节点,负责管理一些全局的内容,游戏中的各个模块都要依赖到它:
而现在有一个这样的场景:
那么运行之后的场景树是这样的,App会成为场景树根节点下的第一个节点:
此时各函数的执行顺序:
_init
、_enter_tree
、_process
等:App - SceneRoot - A - B - C - D - E_ready
:App - B - C - A - E - D - SceneRoot
在这种情况下,可以在App节点的
_ready
做一些全局的初始化操作,随后场景中各节点的_ready
才会执行。并且由于App是Autoload,所有场景执行的时候它都会被添加到节点树,所以各个场景单独运行时都可以依赖到App节点,这与Godot的运行当前场景功能也非常契合。 -
🤔GDScript vs C#
reddit上这张梗图虽然搞笑但也有点佛家三重境界的味道。
Godot中GDScript和C#各有优缺点:
GDScript优点
- 简单易上手,文档详细,相关教程多,使用占比最高,社区庞大
- 随引擎第一时间更新,与引擎的集成度最高
- 支持导出到所有平台
- 无需编译,支持热重载
GDScript缺点
- 综合性能比C#差
- 动态类型语言消耗脑力多一些,在重构、编写规模庞大的项目时相对弱势
C#优点
- 拥有.NET生态,海量的第三方库
- 综合性能比GDScript好
- 静态类型语言,结合Rider、VSCode等工具可以大幅降低脑力消耗,让精力集中到具体的业务开发上
- 异步编程支持完善
- CoreCLR GC完爆Unity的Bohem GC
C#缺点
- 目前(2024/04)无法导出到WebGL平台
- 导出包体大很多,主要多了.NET相关dll(目前没有看到对裁剪的支持也没去尝试过)
- 无法从C#直接调用GDExtension
- Godot相关的C#开源库比GDScript少
所以选择哪种语言需要综合个人和项目情况考虑,避开无法接受的短板。
对我个人来说,GDScript编写起来更快也更费精神,代码提示不全、重构经常有遗漏都会带来额外的工作,但无需编译、热重载、引擎集成度高这些优势都是我需要的;C#则是在Unity、.NET中使用了多年的老朋友,但Godot C#包体大、缺少WebGL支持也是无法忽视的问题。
-
🤔内存管理
好消息:GDScript没有GC,所以不会有GC工作引起掉帧的问题。
坏消息:用的是引用计数和手动释放,不小心可能会内存泄漏。
不过比起Unity中的谈GC色变,Godot的内存管理可以说轻松很多,引擎已经完成了大部分工作,需要注意的是避免引用循环:
my_class.gd
class_name MyClass extends RefCounted var other
extends Node func _ready(): var a = MyClass.new() var b = MyClass.new() a.other = b b.other = a # !内存泄漏了
Godot中大部分类型都继承自这两个类:
Object
、RefCounted
。Object需要手动管理内存,即一个Object被实例化之后,它会一直保持在内存中,直到它的free
方法被调用;RefCounted继承自Object,加入了引用计数,即一个RefCounted被实例化之后,如果它不再被其他地方引用,就会自动回收。在上面的例子中,a、b两个RefCounted互相引用,永远无法回收,导致内存泄漏。那么我们经常用的节点
Node
是否会有这个问题呢?Node继承自Object,除了free
还有queue_free
方法可以将其从内存中释放,当free
或queue_free
被调用后,节点及其所有子节点都会被删除,所以节点与子节点互相引用是没有问题的。但仍然有一种情况会导致节点的内存泄漏:
extends Node const SomeScene = preload("res://scenes/some_scene.tscn") func _ready(): var node = SomeScene.instantiate() # 缺少add_child(node)
由于node没有被添加到场景树,它变成了一个“孤儿节点”(Orphan node),即使游戏退出也无法得到释放,导致内存泄漏。运行中是否产生Orphan node可以在编辑器底部
Debugger
->Monitors
->Object
->Orphan Nodes
中查看。资源的泄露暂时没有遇到,之后如果有再来补充。
没有GC的设计好吗?
GC的主要目的是自动回收不再使用的内存,将开发者从繁琐且容易出错的手动内存管理中解放出来。而因为GC工作会导致掉帧而彻底不用GC,是否有点因噎废食了呢?事实上并没有,Godot中使用最频繁的是节点与资源,资源继承自RefCounted,引用计数会将它们处理好;节点需要手动释放,但场景树接手了这一工作,开发者只需要注意释放的时机即可,所以大部分情况下开发者花费在内存管理上的工作并不多。
-
🤔资源管理
挖个坑!之后来填(咕咕)
Make Unique
加载
打包
与Unity Addressables对比
-
👍选择性暂停游戏内容
这个需求很常见,例如暂停游戏时菜单UI不暂停,但在Unity中只能自行实现或使用插件。Godot可以为每个节点指定不同的处理模式,或者说运行模式,这里的“处理”指的是
_process
、_physics_process
等函数的执行,对应Unity中的Update
、FixedUpdate
。当游戏暂停时,不同处理模式下的节点会有不同的行为:- Inherit 继承父节点的设置
- Pausable 在游戏暂停时被暂停
- WhenPaused 在游戏暂停时运行
- Always 不论游戏是否暂停都运行
- Disabled 不运行
另外,
_process
、_physics_process
也可以通过set_process
与set_physics_process
手动开启关闭。 -
-
-
-
-
-
-
😔没有内置的3D线条渲染实现
即Unity中的LineRenderer和TrailRenderer,目前Godot只有Line2D没有Line3D。很多时候人们会感叹“Godot连这功能都有”,那么这个便是”Godot连这功能都没有“。
-
-
👍节点元数据
非常方便但容易被忽视的功能。之后来填