地块解压不该待在游戏线程:UE 的三种异步封装
UE5 公布前两天,我把刚有雏形的 htrae for UE 迁了过去。
本来做好了打硬仗的准备,结果 UE5 在 API 层面的变化很小,改掉几个 4.26 时期的编译错误就跑起来了。
迁移不是这次的重点。真正花时间的,是迁完之后立刻撞上的老问题:地块细节的解压还待在游戏线程上。
三种封装,各有各的位置
UE 里做异步,常用的是三样东西:FAsyncTask、Async 这一族的一次性调度,和 FRunnable。
它们不是三选一的关系,更像三种粒度:
FAsyncTask/FAutoDeleteAsyncTask:一个有明确输入输出的工作单元,丢进引擎自己的线程池,用完就走。适合”一块数据,一次计算”。Async(EAsyncExecution::...)和AsyncTask(ENamedThreads::GameThread, ...):一次性的轻量调度。后者最常见的用途反而不是”去后台”,而是”回主线程”。FRunnable:自己拥有一条线程,跑一个长生命周期的循环。适合常驻的生产者 / 消费者,不适合零散的小任务。
它们也可以组合使用。这次的需求是典型的第一类:一块地形数据到手,解压它,把结果交出去。
所以走的是 FAsyncTask 解压、AsyncTask(ENamedThreads::GameThread) 回调这条链。
回调必须回到游戏线程
解压完之后,结果要交回 TypeScript。
这一步没什么选择余地:脚本虚拟机这一头,我还没有验证过它在非游戏线程上被访问会发生什么。在验证之前,保守做法是把所有和脚本相关的调用都收在游戏线程上,后台线程只负责纯计算。
于是形成了一个很朴素的分工:
GameThread 发起请求,记下一个 pending 状态
ThreadPool FAsyncTask 解压地块细节
GameThread AsyncTask 回调,把结果交给 TypeScript
好处是解压的耗时彻底离开了帧。
代价是这条链每穿越一次边界,都要自己保证对象还活着 —— 任务持有的是数据的拷贝,不是场景里那个 Actor。地块在解压期间被卸载是完全可能发生的。
TypeScript 这一头,现在还是丑的
脚本层把这件事包成了 async / await,调用方看起来很干净。
但底下的等待实现是 while 加 sleep 的轮询。
丑,但可用。它至少让上层逻辑先按最终形态写起来了 —— 以后换掉底下的等待方式,调用方一行都不用改。
后来在处理”等到下一帧”这个需求时,我看到了 puerts 官方的实现:把 Promise 的 resolve 挂到 tick 上,时机到了直接兑现,不占任何轮询。
这才是这类等待该有的样子。后续会把我那些丑实现挨个换成这种。
顺手做的 RHI 抽象
同一段时间还做了另一件事:把关键资源做了一层 RHI 抽象。
动机很直接 —— 我希望同一套高层逻辑,既能跑在 UE5 上,也能跑在 Cocos Creator 上。抽象之后确实做到了,两边共用同一份生成逻辑。
代价也很清楚:某些功能不得不为了兼容而作出妥协。抽象层只能表达两边都有的东西,一边独有的能力要么放弃,要么就得开一个后门破坏抽象。
现在还没到必须做这个取舍的时候。等到某一边真的需要一个对方没有的能力,这层抽象值不值,才会有答案。
还没解决的
- 轮询等待还在,换成挂 tick 的 resolve 是下一步。
- 后台线程访问脚本虚拟机到底安不安全,我只是绕开了,没有验证。
- 没有量化收益。我只知道解压不再卡帧,但没有留下前后的帧时间对照。