拔高 · EXTRA
时间流的底层模型
Unreal Fest 2023 官方演讲精读:并发表达式就是时间维度上的 if / for。并发节点就是时间维度上的 Branch / ForEach。
进入拓展 →这一课拆开你复制了十几课的 <suspends>:它是函数的「时间通行证」,宣告这个函数会跨越模拟帧。再配上最小的时间积木 Sleep,你的设备从此学会等待与倒数——时间,开始为你所用。
回想你打过的每一场游戏:技能冷却 3 秒、毒圈 30 秒后收缩、复活倒数 5 秒……游戏的灵魂就是时间。可到目前为止,你写的所有代码——赋值、条件、for 循环——你连过的所有节点——Set 赋值、Branch 判断、ForEach 遍历——都是「瞬发技能」:在当前一帧之内从头跑到尾,中间绝不停顿。想让代码学会「等一等」,就轮到本课的主角登场:效果说明符 suspends。
上一课我们认识了说明符家族,suspends 属于其中的「效果说明符」:它写在参数表之后、返回类型之前,向编译器郑重声明——这个函数会跨越时间。带 suspends 的函数叫异步函数(async function):它可以在某一行暂停(挂起),把控制权协作式地交还给引擎,过了若干模拟帧再从原地醒来继续。你可以把它想成给蓝图函数贴的一张标签,向 Compile 郑重声明——这个函数会跨越时间。带 suspends 标签的函数就是异步函数(async function):它可以在中途某个节点上暂停(挂起),把控制权先交还给引擎,过了若干模拟帧再从原地醒来、沿着执行线继续往下——这跟蓝图里那种带小钟表图标的 latent 节点(比如 Delay)是同一种「会等一会儿」的味道。就像一张时间通行证,持证的函数才有资格穿越帧与帧之间的关卡。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
launch_device := class(creative_device):
# suspends 写在参数表之后、返回类型之前
Countdown()<suspends>:void =
Print("准备……")
Sleep(1.0)
Print("出发!")
OnBegin<override>()<suspends>:void =
# 异步上下文里直接调用异步函数:
# OnBegin 会停在这一行,等 Countdown 完整跑完
Countdown()
Print("倒计时结束后才轮到我")
读图翻译:这段有两个蓝图函数。Countdown 里连了三个节点——Print String「准备……」→ 一个时长 1 秒的 Delay(就是 Sleep(1.0))→ Print String「出发!」。OnBegin(相当于 Event BeginPlay)里,先把执行线接到 Countdown 上;因为 Countdown 里挂了会等的 Delay,主线会一直等它整根跑完,才轮到最后那句 Print String「倒计时结束后才轮到我」。
规则一条:异步函数只能在异步上下文中调用——要么在另一个带 suspends 的函数体内,要么在并发表达式(下一课的 spawn / race / sync)内部。上面的 OnBegin 自带 suspends,所以能直接调用 Countdown();而且这种「直接调用」会让 OnBegin 停在那一行,等 Countdown 从头到尾跑完才继续,相当于一次隐式的等待。注意:停下的只是 OnBegin 这一个协程,游戏世界照常运转。要么把它接在另一个带 suspends 标签的函数里,要么接在并发节点(下一课的 spawn / race / sync)内部。上面的 OnBegin 天生就带 suspends,所以能直接把执行线接到 Countdown() 上;而且这种「直接接上」会让 OnBegin 停在那个节点等着,等 Countdown 从头到尾跑完才往下走,相当于一次不用写的等待。注意:停下的只是 OnBegin 这一条执行线,游戏世界照常运转。
顺带揭开一个陈年谜底:从第 2 课起你复制粘贴到今天的 OnBegin<override>()<suspends>:void,现在终于能逐词读懂了——<override> 表示覆写父类的入口,<suspends> 表示这个入口本身就是异步的。Epic 把 OnBegin 设计成异步函数,就是默认你会在游戏开场写「等待、倒数、分阶段」这类跨时间的逻辑。<override> 就是蓝图里那个 Override 下拉菜单的意思——你在重写父类给好的入口;<suspends> 表示这个入口本身就会跨时间(带小钟表)。Epic 把 OnBegin(也就是 Event BeginPlay)设计成异步函数,就是默认你会在游戏开场做「等待、倒数、分阶段」这类跨时间的事。
有了通行证,还得有时间积木。Verse 里最小、最常用的一块叫 Sleep,签名是 Sleep(Seconds:float)<suspends>:void,住在 /Verse.org/Simulation 模块里——用之前记得写 using { /Verse.org/Simulation }。它自己就带 suspends,所以「谁调用 Sleep,谁就必须持有时间通行证」,这个要求会一层层往调用链上传。它就是蓝图里那个 Delay 节点——填一个秒数,执行线走到这儿就等这么久。它住在 /Verse.org/Simulation 这个抽屉里,用之前得先写 using { /Verse.org/Simulation } 把抽屉拉开(类比启用一个插件)。Sleep 自己带 suspends,所以「谁用了 Sleep,谁就得持有时间通行证」;而且这张证会顺着「A 调 B、B 调 C」的链子一层层往上要——只要链条上有人用了会等的节点,这一串函数就都得能等。
Sleep 有三种玩法,官方文档写得明明白白:
| 写法 | 实际效果 |
|---|---|
Sleep(1.0) |
当前协程挂起,约 1 秒后在最近的模拟帧醒来当前这条执行线挂起,约 1 秒后在最近的模拟帧醒来 |
Sleep(0.0) |
不是「不睡」——挂起到下一个模拟帧才恢复,「让出一帧」的标准写法 |
Sleep(无穷大) |
永远沉睡,只有所在任务被取消(例如输掉下一课的 race)才会结束 |
最容易被误解的是 Sleep(0.0):它的意思是「这一帧我做完了,下一帧再叫我」。官方文档专门建议:需要每帧做事的循环,就在循环体里放一个 Sleep(0.0) 让出控制权,避免独占处理器——这是第四节倒计时和以后所有逐帧逻辑的地基。
再纠正一个直觉错误:Sleep 不是游戏的暂停键。它只是让当前这一个协程当前这一条执行线钻进睡袋——营地外的世界照常运转:其他任务在跑、玩家在动、物理在算。你睡你的,游戏忙游戏的。
Sleep 睡多久才醒,取决于一个底层概念:模拟帧。官方术语表里 simulation update、tick、frame、update 四个词说的是同一件事——引擎推进世界的一次「心跳」。Fortnite 服务器大约每秒心跳 30 次,所以 Verse 能感知的时间最小刻度约为 1/30 秒。这意味着 Sleep(0.01) 并不会在 10 毫秒后醒来——它最早也要等到下一帧。想做高精度计时?Sleep 干不了这活,死心吧。
以「会不会跨帧」为界,Verse 把所有表达式分成两大阵营:Verse 把能连进图里的一切分成两大阵营:
| 阵营 | 特点 | 成员举例 |
|---|---|---|
| 即时表达式(immediate)即时的一类(immediate) | 保证在当前模拟帧内完成 | 赋值、算术、if、for、PrintSet 赋值、算术、Branch、ForEach、Print String |
| 异步表达式(async)异步的一类(async) | 可能耗费一个以上模拟帧 | Sleep、事件的 Await、spawn / race / sync 等并发表达式Sleep(Delay)、事件的 Await、spawn / race / sync 等并发节点 |
这个分类带来两条硬规则:其一,失败上下文(if 的条件、decides 函数体)里不允许调用异步函数——失败要求「像什么都没发生过」地回滚,而已经流逝的时间没法倒流;在「能不能走通」的判断里(比如 Branch 的条件、会掷骰失败的那种函数体)不允许放会等的节点——一条走不通的线要求「像什么都没发生过」地回滚,而已经流逝的时间没法倒流;其二,<suspends> 与 <decides> 不能同时标在一个函数上,理由同源。想看清 suspends 在整张效果矩阵里的坐标,文末拓展页安排上了。
把 suspends、Sleep、loop 三块积木拼起来,就是游戏里最经典的时间结构——倒计时。下面这台发射台每秒报一次数,数到 0 就发射。点「运行下一步」,重点盯住 Sleep(1.0) 那一行那个 Delay 节点:每次执行到它,真实时间就流逝一秒。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
countdown_device := class(creative_device):
OnBegin<override>()<suspends>:void =
Print("倒计时开始!")
var Remaining:int = 3
loop:
Print("剩余 {Remaining} 秒")
Sleep(1.0)
set Remaining -= 1
if (Remaining <= 0):
break
Print("发射!")
点「运行下一步」,看代码怎么一行行执行。
「loop + Sleep + break」是 Verse 时间循环的万能模板:loop 负责重复,Sleep 负责让时间流动,break 负责收尾。记住一条铁律:循环体里至少要有一个挂起点(Sleep 或 Await)循环体里至少要有一个「会等的节点」(Sleep 那种 Delay,或等事件的 Await)。少了它会发生什么?下一节的事故现场见。
坑一,也是全 Verse 出场率最高的编译错误之一:在没有 suspends 的函数里调用 Sleep。也是全 Verse 里最常让 Compile 亮红的错误之一:在没标 suspends 的函数里用了 Sleep(那个会等的 Delay)。
# ✗ 编译错误:Helper 没有 suspends,却调用了 Sleep
Helper():void =
Sleep(2.0)
Print("等两秒再说话")
编译器会甩给你这句原文:This invocation calls a function that has the 'suspends' effect, which is not allowed by its context.——「这次调用的函数带有 suspends 效果,而当前上下文不允许」。修法有二:给函数补上 <suspends>(然后顺着调用链检查上游是不是也得补);或者不想等它跑完,就用下一课的 spawn 把它抛出去独立运行。还有个高发变种:手写 OnBegin 时漏掉 <suspends> 或 <override>,后续所有异步调用集体爆红,新手往往从报错的那行开始排查——其实先回头检查签名!修法有二:给这个函数补上 <suspends> 标签(然后顺着「谁调用了谁」的链子往上,看上游是不是也得补);或者你根本不想等它跑完,就用下一课的 spawn 岔出一条新线让它自己跑。还有个高发变种:手写 OnBegin 时漏掉 <suspends> 或 <override>,结果后面所有会等的节点集体爆红,新手往往从爆红的那个节点开始查——其实要先回头检查入口那一行的标签!
# ✓ 修好了:补上时间通行证
Helper()<suspends>:void =
Sleep(2.0)
Print("等两秒再说话")
坑二:在 suspends 函数里写了 loop:,循环体里却没有任何 Sleep / Await。协程在同一帧里无限空转这条执行线在同一帧里无限空转,永远不把控制权还给引擎——轻则服务器冻结,重则触发运行时的无限循环错误。规矩:每帧循环至少放一个 Sleep(0.0)。
坑三:把 Sleep 当高精度秒表。Sleep(0.01) 实际要等到下一个模拟帧(约 1/30 秒)才醒。坑四:以为 Sleep 暂停整个游戏——它只挂起当前协程当前这条执行线,别的任务照常跑。坑五(预告):按钮事件 Subscribe 的回调是普通函数,里面不能直接 Sleep,得用 spawn 启动一个异步函数——第 20、21 课展开细讲。把按钮事件 Bind 过去的那个回调是普通(不能等的)函数,里面不能直接放 Sleep,得用 spawn 岔出一条能等的新线来跑——第 20、21 课展开细讲。
纸上谈兵到此为止,来修一台坏掉的巡逻机。它缺了两样东西:函数的时间通行证,和循环里的「让帧」积木。填好后点「检查答案」。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
patrol_device := class(creative_device):
# 修理任务 1:给函数补上「时间通行证」效果说明符
Patrol()<____>:void =
loop:
Print("巡逻中……")
# 修理任务 2:每帧让出控制权(填函数名)
____(0.0)
OnBegin<override>()<suspends>:void =
Patrol()
时间通行证已到手,闯三道关验收。答错零惩罚,可以一直重试。
在一个没有 <suspends> 的普通函数里调用 Sleep(2.0)放了一个 Sleep(2.0)(Delay),会发生什么?
Sleep(0.0) 的真实含义是?
Boss 战脚本里某个协程执行了 Sleep(5.0)某条执行线走到了 Sleep(5.0)(Delay),这 5 秒内游戏的其他部分会怎样?