Godot-StartUP

创建于:2018-07-28

创建人: Justus

44 信息 140 成员
讨论基于Godot以及Unity引擎的游戏开发经验,理论和最佳实践。共享一些通用思路以启发另一种生产工具中的实践。独立开发群QQ: 122017359

关于Godot引擎的preload关键字

carlcc 2018-11-13

版权所有,欢迎转载,转载请说明出处。


关于Godot引擎的preload关键字

1. 本文组织

  1.     背景
  2.     源码分析
  3.     实验验证
  4.     结论


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

Image title



如图,创建一个场景,依次创建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

Image title



如图,创建一个场景,依次创建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,启动!

Image title



    使用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源码的时候加载的,更具体一点,做语法分析的时候加载 。

(转发自:原日志地址
 

加入 indienova

  • 建立个人/工作室档案
  • 建立开发中的游戏档案
  • 关注个人/工作室动态
  • 寻找合作伙伴共同开发
  • 寻求线上发行
  • 更多服务……
登录/注册