load() 是同步的:大资源在主线程加载,期间整个游戏冻住。Godot 3.x 的对策是 InteractiveLoader(分片轮询),4.x 换成了真正的后台线程方案 load_threaded_request。这篇把 4.x 这条链拆开:状态机、任务表、主线程取结果的契约,以及方案的真实边界。
公开接口与状态机
三个 API,分工清晰:
ResourceLoader.load_threaded_request(path):提交加载任务,立即返回,不阻塞。ResourceLoader.load_threaded_get_status(path):查询进度,返回四态之一。ResourceLoader.load_threaded_get(path):取结果,阻塞到加载完成(或返回已缓存的结果)。
四态的状态机是理解全部行为的钥匙:
request() 加载完成
INVALID ──────────→ IN_PROGRESS ──────────→ LOADED
│ │
│ 加载中抛错 │ get() 取走
▼ ▼
FAILED INVALID(任务从表删除)
两个细节容易踩。第一,LOADED 不是终态——load_threaded_get() 取走资源后任务从任务表删除,同路径再查状态回到 INVALID。如果你在 get 之后又拿原路径查 status 看到 INVALID 以为加载失败了,那只是令牌已被消费。第二,IN_PROGRESS 期间对同一路径再次 request 是幂等的,不会起两个线程加载同一份资源;但不同路径指向同一物理文件会起两个任务,引擎不做内容级去重。
内部结构:一张任务表加线程池
提交的任务进 ResourceLoader 里的线程加载任务表(按路径索引),每个条目是一个 ThreadLoadTask:路径、加载参数、状态字段、完成通知。执行体交给 WorkerThreadPool(多线程那篇的主角)或专门的加载线程跑,加载过程本身——打开文件、解析、构建资源对象——发生在后台线程,主线程完全无感。
取结果的瞬间是有代价的。 load_threaded_get 拿到后台线程产出的 Resource 后,有一步"主线程侧收尾":把资源登记进 ResourceCache(Resource 那篇的全局缓存表)和触发加载完成回调。这一步必须在主线程做,因为下游可能有脚本回调、信号连接、实例化触发,都假设自己跑在主线程。所以"线程化加载"消掉的是解析和 IO 的大头,不是全部——get 那一刻仍有小峰值,对几百 MB 的场景资源,实测收尾在百微秒级,可接受,但不是零。
与 3.x InteractiveLoader 的本质差异
3.x 的 load_interactive() 不分线程:它在主线程上把加载切成可轮询的小片,poll() 每次推进一点,靠"每帧 poll 几次"把卡顿摊平。它的天花板很明显——加载总量不变,只是切片;而且不是所有 loader 都实现了分片,遇到没实现的资源类型,poll 一步就走完全部,卡顿原样回来。
4.x 的线程化方案把总量挪出主线程,主线程只剩状态查询和收尾。代价是线程安全要求倒灌给 loader 实现:loader 在后台线程跑,解析时碰到的子资源加载、脚本编译都可能触达全局状态,引擎靠加载互斥和"子资源也在加载线程内完成"的约定兜底。自定义 ResourceFormatLoader 想支持线程化,得确认自己的解析逻辑没碰非线程安全的全局单例——否则偶发的后台崩溃会很难归因。
实战用法与边界
典型的进度条用法是请求一批资源后每帧轮询:
for path in level_paths:
ResourceLoader.load_threaded_request(path)
# 每帧 _process 里
for path in level_paths:
match ResourceLoader.load_threaded_get_status(path):
ResourceLoader.THREAD_LOAD_LOADED:
var res = ResourceLoader.load_threaded_get(path)
_install(path, res)
ResourceLoader.THREAD_LOAD_FAILED:
push_error("加载失败: " + path)
三个边界要记住。其一,轮询间隔决定进度条精度,不决定加载速度——后台线程全速跑,status 只是观察口。其二,load_threaded_get 的阻塞语义意味着你不能在没确认 LOADED 时调用它然后指望主线程还能响应——它不是异步的,get 就是"等到好"。想完全不阻塞,只有轮询 status 一条路。其三,依赖链自动内联加载:加载场景资源会连带加载它的子资源,子资源不再单独进任务表,进度只体现在父任务上——进度条按"父任务数"设计,别按文件数设计。
解决不了的部分也要说清:线程化加载管的是"资源进内存"这一段。实例化场景(instantiate)、把贴图传 GPU、shader 首次编译,都不在这条链上——它们是主线程工作,大场景首次进入时的卡顿大头常常在实例化和 GPU 上传,那部分要靠分帧实例化和渲染管线预热另外治。