拔高 · EXTRA
时间流的底层模型
Unreal Fest 2023 官方演讲精读:并发节点就是时间维度上的 Branch / ForEach。
进入拓展 →蓝图里让时间流动,靠的是 Delay 和那些带小钟表的 latent 节点;Verse 把这件事写进了函数签名——<suspends> 就是函数的时间通行证。这一课用 Sleep 换掉 Delay,并讲清「Delay 在函数里用不了」这条蓝图限制,在 Verse 里变成了什么规则。
在蓝图里,想让一件事「等三秒再发生」,你会伸手去拿 Delay 节点。到目前为止本站教你写的每一行 Verse,对应的都是「瞬发」节点——Set 赋值、Branch 判断、ForEach 遍历——在当前一帧之内从头跑到尾,中间绝不停顿。那 Delay 呢?它在 Verse 里叫 Sleep;而蓝图用一个小钟表图标暗示「这个节点会等」,Verse 则把这件事直接写进函数签名:效果说明符 suspends。
上一课我们认识了说明符家族,suspends 属于其中的「效果说明符」:你可以把它想成给蓝图函数贴的一张标签,向 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 这一条执行线,游戏世界照常运转。
顺带揭开一个陈年谜底:从第 6 课起你复制粘贴到今天的 OnBegin<override>()<suspends>:void,现在终于能逐词读懂了——<override> 就是蓝图里那个 Override 下拉菜单的意思——你在重写父类给好的入口;<suspends> 表示这个入口本身就会跨时间(带小钟表)。Epic 把 OnBegin(也就是 Event BeginPlay)设计成异步函数,就是默认你会在游戏开场做「等待、倒数、分阶段」这类跨时间的事。
有了通行证,还得有时间积木。Verse 里最小、最常用的一块叫 Sleep,它就是蓝图里那个 Delay 节点——填一个秒数,执行线走到这儿就等这么久。它住在 /Verse.org/Simulation 这个抽屉里,用之前得先写 using { /Verse.org/Simulation } 把抽屉拉开(类比启用一个插件)。Sleep 自己带 suspends,所以「谁用了 Sleep,谁就得持有时间通行证」;而且这张证会顺着「A 调 B、B 调 C」的链子一层层往上要——只要链条上有人用了会等的节点,这一串函数就都得能等。
Sleep 有三种玩法,官方文档写得明明白白:
| 写法 | 实际效果 |
|---|---|
Sleep(1.0) |
当前这条执行线挂起,约 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 把能连进图里的一切分成两大阵营:
| 阵营 | 特点 | 成员举例 |
|---|---|---|
| 即时的一类(immediate) | 保证在当前模拟帧内完成 | Set 赋值、算术、Branch、ForEach、Print String |
| 异步的一类(async) | 可能耗费一个以上模拟帧 | Sleep(Delay)、事件的 Await、spawn / race / sync 等并发节点 |
这个分类带来两条硬规则:其一,在「能不能走通」的判断里(比如 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 那种 Delay,或等事件的 Await)。少了它会发生什么?下一节的事故现场见。
坑一,也是全 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>,结果后面所有会等的节点集体爆红,新手往往从爆红的那个节点开始查——其实要先回头检查入口那一行的标签!
# ✓ 修好了:补上时间通行证
Helper()<suspends>:void =
Sleep(2.0)
Print("等两秒再说话")
坑二:在 suspends 函数里写了 loop:,循环体里却没有任何 Sleep / Await。这条执行线在同一帧里无限空转,永远不把控制权还给引擎——轻则服务器冻结,重则触发运行时的无限循环错误。规矩:每帧循环至少放一个 Sleep(0.0)。
坑三:把 Sleep 当高精度秒表。Sleep(0.01) 实际要等到下一个模拟帧(约 1/30 秒)才醒。坑四:以为 Sleep 暂停整个游戏——它只挂起当前这条执行线,别的任务照常跑。坑五(预告):把按钮事件 Bind 过去的那个回调是普通(不能等的)函数,里面不能直接放 Sleep,得用 spawn 岔出一条能等的新线来跑——第 24、25 课展开细讲。
纸上谈兵到此为止,来修一台坏掉的巡逻机。它缺了两样东西:函数的时间通行证,和循环里的「让帧」积木。填好后点「检查答案」。
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()
「让时间流动」这件事,你在蓝图里早就天天在做,只是零件散落在不同节点上。下面把它们和 Verse 的写法一一对上——差异那一列才是重点。
| 蓝图里的做法 | Verse 里的写法 | 差异 |
|---|---|---|
| Delay 节点,Duration 填秒数 | Sleep(1.0) |
语义相同。但 Sleep 是普通函数调用,能塞进循环体、能写在分支里,不必为它单独腾一个节点位 |
| 带小钟表图标的 latent 节点(Delay、Move Component To、Play Anim…) | 带 <suspends> 的函数 |
蓝图靠图标提示「这个会等」;Verse 写进函数签名,由编译器强制核对——谁能等、谁不能等,连调用链一起管 |
| Delay 只能放在事件图里,自定义 Function 里放不了 | 任何函数只要标上 <suspends> 就能等 |
Verse 没有「事件图能等、函数不能等」的二分;代价是这个标签会沿着「谁调用了谁」的链子一路往上传染 |
| Event Tick,引擎每帧替你调一次 | loop: 循环体里放一个 Sleep(0.0) |
Verse 没有 Tick 事件,逐帧要自己写。而且能不写就别写——第 25 课会讲,事件驱动几乎总比每帧轮询划算 |
| Timeline 轨道,拖一条曲线做插值 | 没有直接对应物:自己写「loop + Sleep + 按已流逝时间算插值」 | Verse 这边缺一个可视化曲线编辑器;换来的是插值逻辑就是普通代码,能复用、能 diff、能进版本控制 |
| Set Timer by Event,记住句柄,回头 Clear Timer | 把等待写成一条任务线,用第 24 课的 race / branch 管它的生死 |
蓝图的定时器句柄要你自己记着清理;Verse 的结构化并发让任务随所在作用域一起收场 |
差异的根子只有一条:蓝图把「会等」藏在节点的外观里,Verse 把它提到了类型层面。所以在蓝图里「忘了这个节点是 latent」通常只是行为不如预期,在 Verse 里则是 Compile 当场亮红——第五节那句 'suspends' effect ... not allowed by its context,就是这套检查在说话。
还有一处反方向的差异值得记住:蓝图的 Delay 节点在计时期间被再次触发时会忽略新的调用(编辑器还会提示 Delay 已在进行中);Verse 的 Sleep 没有这种隐藏行为——每条执行线各睡各的,谁也不吞谁。想要「只许有一份在跑」,得靠下一课的并发表达式自己表达出来。
时间通行证已到手,闯三道关验收。答错零惩罚,可以一直重试。
在一个没有 <suspends> 的普通函数里放了一个 Sleep(2.0)(Delay),会发生什么?
Sleep(0.0) 的真实含义是?
Boss 战脚本里某条执行线走到了 Sleep(5.0)(Delay),这 5 秒内游戏的其他部分会怎样?