版权所有,欢迎转载,转载请说明出处。
关于Godot引擎的preload关键字
1. 本文组织
- 背景
- 源码分析
- 实验验证
- 结论
2. 背景
Godot引擎指引Step By Step -> Resources的Loading resources from code节中有这样一段话:
The second way is more optimal, but only works with a string constant parameter because it loads the resource at compile-time.
这段话引起了群组成员的讨论,什么叫compile-time?我们认识的GDScript是一门脚本语言还需要编译么?那么编译时加载是什么鬼?
于是纷纷猜想:
是不是就是说运行时就在内存里了?
启动的时候加载在内存里?
GD可能是运行时编译,编译的同时加载?
c++的运行时加载的?
只要是preload的,启动程序的时候都已经读到内存了
到底是什么呢?咱们来对照源码,并启动C++调试,探究一下这究竟是什么鬼。
3. 源码分析
先交代一下源码版本:godot-3.0.6-stable
我们开始吧。
首先,我们不难找到,在godot源码的 modules/gdscript/gdscript.cpp 第1736行,可以发现,preload是gds语言的一个关键字,并不是函数(教程里经常出现的PI,居然也是关键字),也难怪C#版本的脚本不支持preload(当然,我们可以用别的方式来达到相同的效果)。
既然preload是关键字,那么肯定会在做词法分析的时候有专门的case处理,好的,我们来到词法分析器的类 modules/gdscript/gdscript_tokenizer.cpp,我们顺利地在104行发现preloadtoken类型。继续,195行发现preloadtoken对应的token常量是GDScriptTokenizer::TK_PR_PRELOAD。
当然,词法分析期这里具体的实现我们并不关心,我们只关心GDScriptTokenizer::TK_PR_PRELOAD这个token被怎么处理的。于是,我们来到module/gdscript/gdscript_parser.cpp,直接搜索GDScriptTokenizer::TK_PR_PRELOAD就能发现在 388 行是处理改token的case。代码略长,有兴趣的可以自己看看代码,这里只摘出我们感兴趣的部分(第457行开始的部分):
Ref<Resource> res; if (!validating) { //this can be too slow for just validating code if (for_completion && ScriptCodeCompletionCache::get_singleton()) { res = ScriptCodeCompletionCache::get_singleton()->get_cached_resource(path); } else { // essential; see issue 15902 res = ResourceLoader::load(path); } if (!res.is_valid()) { _set_error("Can't preload resource at path: " + path); return NULL; } } else { if (!FileAccess::exists(path)) { _set_error("Can't preload resource at path: " + path); return NULL; } } ConstantNode *constant = alloc_node<ConstantNode>(); constant->value = res; expr = constant;
因为语法分析不只是执行脚本要用,内置的脚本编辑器也会用来检查你的代码的合法性,因此,第一个if (!validating)正如注释所说,作用是加快运行速度,毕竟写代码的时候并不需要加载资源,因此else分支里面也仅仅检查文件是否存在。
我们真正感兴趣的部分在 if 的 true 分支里,首先检查资源是否已经缓存了,是则用已经缓冲好的,否则现场加载资源,并在后面校验资源合法性。最后三行代码,就是设置 preload语句的返回值,返回的是一个“常量”(对GDScript来说)啦。
说点题外话 相信有经验的C++程序员已经能看出,这里使用的是“某种“智能指针,事实上,Godot的智能指针实现和C++11标准库的实现方式不相同,这里的智能指针的引用计数是被引用的对象所持有的(非常类似于OpenScenGraph里面的,WebRTC源码里面也有大量的这种风格智能指针,核心接口叫RefCountInterface),两种智能指针各有优劣。
其实到这里,我们就已经能得到结论了:preload在GDScript编译的时候加载,更准确地说,在对GDScript做词法分析的时候加载。
因此不再多说。于是……
再说点题外话 其实我们往外找,可以找到modules/gdscript/gdscript.cpp第1831行,ResourceFormatLoaderGDScript::load函数,该函数的作用是加载GDScript(不会直接调用,而是注册到ResourceLoader以后被间接调用)。可以看到,其实Godot引擎除了支持GDScript本身以外,似乎还支持两种扩展名的字节码:.gde和.gdc(有没有想起pyc?)。不过目前我没看到相关文档说支持,可能是我没看到,也可能是还没暴露出来吧(自己脑补:难道因为preload的行为可能得单独处理?)。
第二部分到此结束,激动人心的时刻到了……做!实!验!!!
4. 实验验证
我们设计一个实验来验证上面得到的结论。我这里用到的工具Visual Studio 2017 Community(以及其SDK和附带的工具)。
4.1. 获取源码,编译
此过程略,不会的请恕我不多说,自己看文档。
4.2. 使用我们编译出来的Godot引擎编辑器启动
同上,略
4.3 创建一个叫做TestPreload的项目
同上,略
4.4 创建Scene1
如图,创建一个场景,依次创建Node,在其下创建一个Button,将这个场景保存为Scene1.tscn。为Node分配GDS脚本,命名为Scene1.gd,内容如下:
extends Node func _ready(): $Button.connect("pressed", self, "on_button_pressed") func on_button_pressed(): get_tree().change_scene("res://Scene2.tscn")
4.5 创建Scene2
如图,创建一个场景,依次创建Node,在其下创建一个Button以及一个TextureRect(不为其设置纹理),将这个场景保存为Scene2.tscn。为Node分配GDS脚本,命名为Scene2.tscn,内容如下:
extends Node func _ready(): $Button.connect("pressed", self, "on_button_pressed") func on_button_pressed(): var tex = preload("res://icon.png") $TextureRect.texture = tex
说明 要从Scene1跳到Scene2是考虑到调试的问题。 即使你不查看代码,你也能发现Godot的游戏进程和Editor并不是同一个进程,调试Editor是无法调试游戏进程的。(不过不幸的是,即使它们是同一个进程也没法调试。如果你查看代码,会发现Godot的Project Manager和Editor并不是同一个进程,在Project Manager中选定项目后,Project Manager会在一个新进程中打开Editor,随后自己退出。) 我们想要在C++的级别对Godot游戏进行调试,需要使用调试器启动游戏进程(配置麻烦),所以选择了一个偷懒一点的做法:让游戏先启动,然后attach这个游戏进程。 而游戏进程一起动就会加载Default Scene内相关的资源,为了方便对照load和preload的行为,我让Default Scene做跳转,当我Debugger attacher上游戏进程以后,再跳转到Scene2。
4.6 启动游戏,使用Debugger
启动游戏之前,我们设置一下游戏名称,方便等下找到游戏进程。项目->项目设置->Application->Config->Name设为TestPreload。Ok,启动!
使用VS的调试器附加到游戏进程。在VS的菜单中选择调试->附加到进程,在可用进程列表中找到窗口标题为TestPreload的进程,点击附加按钮。附加成功的话,就可以看到VS的界面出现变化,可以看到很多被附加的进程的信息。当然,我们关心的只有preload。
4.7 设置断点,切换场景
只有一处断点是我们关心的:preload的资源什么时候加载。为此,我们顺着gdscript_parser.cpp第464行的藤,摸ResourceLoader::load(在core/io/resource_load.cpp第190行)的瓜,我们在resource_load.cpp第192行设置断点。看何时触发该断点。
开始实验前我们不妨预期一下实验结果:加载Scene2的时候,需要加载Scene2的场景文件、脚本文件,因此,这里至少会触发2次(当然我们并不关心),加载脚本文件的时候会编译脚本,此时preload会触发加载icon.png。好吧,开始~~
我们从调试器自动窗口p_path可以看到,首先触发此断点是因为加载Scene2.tscn,继续运行,第二次触发此断点是因为加载Scene2.gd,继续运行,第三次触发此断点是因为加载icon.png,第四次触发此断点又是因为加载Scene2.gd。
可以看到,preload确实是在加载(编译)Scene2.gd的时候完成加载的(查看第三次触发断点时的调用栈可以看出)。此时因为还没有执行on_button_pressed函数,TextureRect也是空的。好的,继续运行,不再触发断点,点击按钮,Texture显示了,但是仍然没有触发断点,继续点击按钮也不触发(因为preload的返回值是编译时产生的,对于GDScript来说是个常量)。
4.8 修改Scene2.gd的代码
将preload改成load。
4.9 将4.6的步骤再做一次。
可以看到,第一次触发断点是因为加载Scene2.tscn,第二次和第三次触发都是因为加载Scene2.gd,不会触发第四次断点,也就是说,这次在加载Scene2的时候,并没有加载icon.png。继续运行后,点击按钮,触发断点,因为要加载icon.png。继续运行,Texture显示了,不再触发断点。(如果再次点击按钮,仍然会触发断点,加载icon.png,这个好理解吧?因为调用了load函数。)
未解之谜 可以看到,两次实验中,脚本都被加载了2次,原因我目前也没看懂。求解!
5. 结论
所谓的“编译时”加载,就是指的编译 gd源码的时候加载的,更具体一点,做语法分析的时候加载 。
欢迎推送小组