资源缓存那篇讲了加载之后的生命周期:ResourceCache 怎么保证同一个路径只活一个实例。这篇往回退一步,看加载本身——load("res://player.tscn") 从字符串到 Ref<Resource> 中间经过了几道手,以及"扩展名决定解析器"这个直觉为什么只对了一半。
入口与第一个分叉
ResourceLoader::load() 是静态门面,核心逻辑在 core/io/resource_loader.cpp。删减后主干:
// core/io/resource_loader.cpp, 4.2(结构性删减)
Ref<Resource> ResourceLoader::load(const String &p_path, ...) {
// ... 相对路径、缺失扩展名补全等预处理 ...
// ① 已在缓存中?直接返回(ResourceCache 那篇的主角)
Ref<Resource> cached = ResourceCache::get_ref(path);
if (cached.is_valid()) return cached;
// ② 没有缓存:找 loader
ResourceFormatLoader *loader = nullptr;
for (ResourceFormatLoader *l : loader_unions) { // 有序列表
if (l->recognize_path(path, type)) { // 逐个问:"你认吗?"
loader = l;
break;
}
}
ERR_FAIL_NULL_V(loader, Ref<Resource>());
// ③ 交给 loader 解析
Ref<Resource> res;
Error err = loader->load(path, ...);
// ...
return res;
}
②就是标题说的分叉点。引擎维护一个有序的 ResourceFormatLoader 列表,加载时逐个询问 recognize_path(path, type),第一个说"认"的 loader 拿到这个文件。扩展名只是绝大多数 loader 实现 recognize 时的快捷判断——.tscn 认、.tres 认——但接口契约上没有任何东西强制 recognize 必须按扩展名判断。一个 loader 完全可以根据路径前缀(user://save_slot_2/...)、文件名模式、甚至打开文件读几个字节(magic number)来决定认不认。
"recognize 才是裁判"这个设计解决的是多对一与一对多两个方向的问题。多对一:同一个 .json 文件,普通 JSON loader 认,你的存档 loader 也认——顺序在前者赢,引擎通过列表顺序给了明确的裁决机制。一对多:.res 二进制格式里装的可能是材质也可能是网格,扩展名无从区分,recognize 只能读内容判别。
loader 从哪来:注册与顺序
loader 列表的成员在引擎各处注册:核心格式(.tres/.res 二进制、.tscn/.scn 场景)在引擎初始化时进表,模块(text_server、图像格式)在 register_module_types() 里注册(模块注册那篇的主角),GDExtension 提供的 loader 通过 ResourceLoader::add_resource_format_loader() 动态加入。
顺序即优先级,而且新注册者插在前面——后注册的 loader 优先被询问。这个"后来者居上"的顺序不是随便定的:它让扩展可以覆盖内置行为。你的插件注册一个认 .png 的 loader,它会在内置 PNG loader 之前被问到,想完全接管某种格式的解析(比如加密贴图)就是注册一个 loader 的事。代价是顺序依赖变成隐式契约:两个插件抢同一种扩展名时,谁后启用谁赢,而启用顺序取决于插件列表顺序——排障时记住这条隐式规则。
load() 内部:同步路径的五步
选定 loader 之后的同步加载链,每一步都有独立的失败面:
- 打开文件:
FileAccess::open(path)——res://映射到工程目录或 PCK 包内(导出包那篇详述),user://映射到系统用户目录。这一步的失败最常见:路径打错一个字母,错误信息是文件打不开,不是"资源不存在"。 - 解析头:文本格式(
.tscn/.tres)读[gd_scene]/[gd_resource]头部和type=字段;二进制格式读 magic 和 header。头信息决定反序列化策略。 - 反序列化:文本走
VariantParser(是的就是 Variant 那篇的文本格式),逐节构建对象;场景格式在这里实例化节点树(PackedScene 那篇的五步实例化)。 - 子资源解析:
[ext_resource path="..."]引用的外部资源在这一步递归 load——所以加载一个场景可能触发几十次嵌套加载,每一层都走完整条链(含缓存查询)。深度引用的加载耗时是递归展开的,最深的那个资源的加载时间决定总耗时下限。 - 后置处理:
Resource::setup_local_to_scene()等钩子,最后进 ResourceCache 并返回。
第 4 步的递归是最值得记住的结构事实:.tscn 里的外部引用存的是路径字符串,实例化时才解析。这意味着加载成本和引用图深度成正比——一个引用链 20 层的场景,即使每层缓存命中率高,也要付出 20 层递归调用的栈成本。扁平化引用结构(子场景合并、用预加载常量代替松散路径引用)是资源加载优化的基本动作。
异步加载:同一条链,不同的驱动
load_threaded_request() 不是另一条实现,而是同一条同步链换到工作线程上跑:主线程发出请求后,一个 worker 线程执行上面五步,主线程用 load_threaded_get_status() 轮询状态机(THREAD_LOAD_INVALID → IN_PROGRESS → LOADED/FAILED)。状态机存在的意义是加载回调需要线程安全:worker 加载完成时不能直接把资源塞进调用方的数据结构,只能翻状态位,由主线程在下一次查询时取走。
这就是为什么异步加载的最终"取货"调用 load_threaded_get() 必须在主线程做:它做缓存写入和信号广播,两者都不是线程安全的。把"执行"放到后台而把"交付"留做主线程操作,是引擎里反复出现的线程分工模式(物理步、资源加载、着色器编译同构)。
自定义 loader 的最小骨架
把某种自定义格式(比如 .csv 配表)变成引擎资源,全部工作就是实现一个 ResourceFormatLoader 并注册:
class_name CsvTable
extends Resource
var rows: Array = []
class Loader:
extends ResourceFormatLoader
func _get_recognized_extensions() -> PackedStringArray:
return ["csv"]
func _handles_type(type: StringName) -> bool:
return type == "Resource" # 或自定义类型名
func _get_resource_type(path: String) -> String:
return "CsvTable"
func _load(path: String, _orig_path: String, _use_sub_threads: bool, _cache_mode: int):
var t := CsvTable.new()
var f := FileAccess.open(path, FileAccess.READ)
while not f.eof_reached():
t.rows.append(f.get_csv_line())
return t
# 插件启用时
ResourceLoader.add_resource_format_loader(Loader.new())
_get_recognized_extensions 是编辑器"导入"对话框显示过滤的依据,而运行时裁决走 _recognize_path/_handles_type。编辑器里选中 .csv 直接预览、Inspector 里把它当资源拖拽,都是 loader 注册后白拿的能力——接入成本只有这一个类,收益是整条资源管线(缓存、异步加载、引用追踪)对你的格式全部生效。这就是 recognize 设计真正的价值:不是"支持更多扩展名",而是把任意数据源纳入引擎统一的资源抽象。
本文代码引自 Godot 4.2.2-stable 的 core/io/resource_loader.cpp,有结构性删减;add_resource_format_loader 的顺序语义见该文件头部注释。