Verse Wiki — 写给蓝图作者的 Verse 手册
拔高 · EXTRA

事件驱动 vs 轮询:两种架构的代价对比

「别把逻辑放进 Event Tick」是 Unreal 讲了几十年的老话。这一页把同一个需求用轮询和事件各写一遍,逐项算账:每秒开销、响应延迟、会不会漏掉事件、代码怎么读——顺带说清轮询仍然是正确选择的那几种场景。

一、同一个需求,两种写法

需求来自 Epic 开发者论坛的一个真实提问:一条巡逻循环在后台跑着,玩家按下停止按钮时要让它停下来。新手的直觉是在订阅上去的那张图里写 break——此路不通,break 只能待在循环体内部,而处理函数是另一段代码,够不着那个 loop

于是有了两条真正可行的路。方案 A(轮询式):处理函数只负责升起一面旗子,循环每圈自查一次。

patrol_flag_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

patrol_flag_device := class(creative_device):

    @editable
    StopButton:button_device = button_device{}

    var ShouldStop:logic = false

    OnBegin<override>()<suspends>:void =
        StopButton.InteractedWithEvent.Subscribe(OnStop)
        Patrol()

    OnStop(Agent:agent):void =
        # 处理函数只做一件事:升旗
        set ShouldStop = true

    Patrol()<suspends>:void =
        loop:
            if (ShouldStop?):
                break
            Print("巡逻中……")
            # 每圈必须让出控制权,否则处理函数永远没机会跑
            Sleep(0.0)
        Print("巡逻结束")

这就是你在蓝图里做同一件事的做法:一个 bool 变量 + Event Tick 里的 Branch。两个细节决定成败。if (ShouldStop?) 里的问号是在查这个 logic 的值(第 12 课的失败上下文);而循环里那句 Sleep(0.0) 不能省——第 23 课讲过,一个循环里如果没有让出控制权的点,它会死死占住不撒手,处理函数根本没机会跑,旗子永远升不起来。

方案 B(事件驱动式):把「巡逻」和「等停止信号」摆进同一个 race,让取消自动发生。

patrol_race_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# event(t) 的 Signal / Await 来自 /Verse.org/Concurrency
using { /Verse.org/Concurrency }

patrol_race_device := class(creative_device):

    @editable
    StopButton:button_device = button_device{}

    StopEvent:event() = event(){}

    OnBegin<override>()<suspends>:void =
        StopButton.InteractedWithEvent.Subscribe(OnStop)
        race:
            block:
                loop:
                    Print("巡逻中……")
                    Sleep(1.0)
            block:
                # 这一臂安静地等信号;信号一到,它获胜
                StopEvent.Await()
        Print("巡逻结束")

    OnStop(Agent:agent):void =
        StopEvent.Signal()

链路走一遍:玩家按下按钮 → OnStop 跑起来 → StopEvent.Signal() 拉响 → 第二臂的 Await() 收到信号、这条臂算跑完 → race 判它先到 → 巡逻臂被剪断 → 执行流走到 Print("巡逻结束")。注意第 24 课的取消规则在这里生效:输掉的循环臂不是在 Signal 的那一瞬间被掐断,而是跑到它下一个「能让出去的点」(那句 Sleep)才退出。

方案 B 里没有标志位、没有 break,「巡逻,直到收到停止信号」几乎就是大白话。这是蓝图作者在 Verse 里能拿到的最大结构性红利之一——蓝图那边没有 race,只能走方案 A。

二、逐项算账

维度 轮询(Tick / loop + Sleep) 事件驱动(Subscribe / Await)
每秒执行次数 约 30 次(服务器模拟帧节奏),不管有没有事发生 事情发生几次就跑几次,一整局可能只有两次
随对象数量的增长 线性:20 台门就是 20 条每帧空转的执行线 常数级:订阅是一条挂着的记录,不消耗每帧预算
响应延迟 最多晚一帧(约 1/30 秒) 事件发生时立刻执行
会不会漏 会。只看得到「现在是什么状态」,同一帧内按下又松开就丢了 不会。事件记录的是「发生了什么」,每次都不落
可读性 意图散落在标志位、循环、处理函数三处 「等到 X 发生,做 Y」几乎是自解释的
清理成本 标志位要自己复位;循环要自己退出 race 里输家自动取消;Await 一次性,不留记录

最容易被低估的是「会不会漏」那一行。性能差距还能靠机器扛过去,漏事件是正确性问题——它表现为「偶尔不生效」,是最难复现、最耗时间的一类 bug。而它的根因很简单:轮询采样的是状态,事件记录的是变化,状态采样天生看不见两次采样之间发生过什么。

三、什么时候轮询仍然是对的

把话说全:轮询不是罪,只是被滥用。下面这几类场景,逐帧循环仍然是正确答案。

一条能替你做大部分决定的判据:问自己「这件事是离散的还是连续的」。离散(按下、进入、死亡、拾取)用事件;连续(位置、进度、剩余时间的显示)才用循环。蓝图里这条判据同样成立,只是 Verse 把「等一件事发生」写得更顺手,于是滥用 Tick 的诱惑更小。

race 方案里,StopEvent.Signal() 之后,巡逻 loop 是什么时候真正停下的?

来源

本文整理自 Epic 开发者论坛与官方文档: