技术博客

ResourceLoader 的加载链:ResourceFormatLoader 的注册与选择

第 97 / 149 篇 · 2025-12-27 · godot · 资源管线源码分析引擎架构

资源缓存那篇讲了加载之后的生命周期: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 之后的同步加载链,每一步都有独立的失败面:

  1. 打开文件:FileAccess::open(path)——res:// 映射到工程目录或 PCK 包内(导出包那篇详述),user:// 映射到系统用户目录。这一步的失败最常见:路径打错一个字母,错误信息是文件打不开,不是"资源不存在"。
  2. 解析头:文本格式(.tscn/.tres)读 [gd_scene]/[gd_resource] 头部和 type= 字段;二进制格式读 magic 和 header。头信息决定反序列化策略。
  3. 反序列化:文本走 VariantParser(是的就是 Variant 那篇的文本格式),逐节构建对象;场景格式在这里实例化节点树(PackedScene 那篇的五步实例化)。
  4. 子资源解析:[ext_resource path="..."] 引用的外部资源在这一步递归 load——所以加载一个场景可能触发几十次嵌套加载,每一层都走完整条链(含缓存查询)。深度引用的加载耗时是递归展开的,最深的那个资源的加载时间决定总耗时下限。
  5. 后置处理: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 设计真正的价值:不是"支持更多扩展名",而是把任意数据源纳入引擎统一的资源抽象。

load() 的完整加载链:从字符串到 Ref<Resource>(4.2) ResourceLoader::load 路径预处理 ResourceCache 查询 命中即返回 recognize_path 逐个询问 loader 列表 扩展名只是提示,顺序在前者优先 选定 loader 之后的同步五步 ① FileAccess 打开(res:// 或 user://) ② 解析头部定策略 ③ VariantParser / 二进制反序列化 ④ ext_resource 递归 load(耗时 ∝ 引用图深度) ⑤ 后置钩子 + 进缓存 异步加载是同一条链由 worker 执行:状态机翻位,主线程 load_threaded_get 取货 接入一个新格式 = 实现一个 ResourceFormatLoader 注册进有序列表(插在前面),recognize 命中即接管;缓存 / 异步加载 / 引用追踪全部随之生效 两插件抢同一扩展名时:后注册者赢,排障先查插件启用顺序

本文代码引自 Godot 4.2.2-stable 的 core/io/resource_loader.cpp,有结构性删减;add_resource_format_loader 的顺序语义见该文件头部注释。