调用堆栈和消息循环
YRE对Yuri-IL脚本进行演绎的过程就是接受用户的IO输入,在调用堆栈上解析脚本逻辑、处理流程控制并调用画音渲染器执行相应动作,该过程称之为YRE的消息循环。
在本节将详细讨论YRE中消息循环的技术细节,请注意此处的消息循环仅仅是对解析场景Yuri-IL的主调用堆栈而言。关于并行处理函数和信号系统(反)激活函数的调度,将在后续章节中继续讨论。
栈机
YRE调用堆栈是一个高级语言栈机,不同于高效动态语言虚拟机的堆栈机或状态机将所有的操作都以字节码形式表达,YRE栈机维护了一个装载着场景/函数栈帧实例的调用堆栈,如下图所示。
进行场景切换其实就是弹出当前场景栈帧并压入目标场景栈帧,而进行函数调用则是直接压入函数栈帧。由于栈的LIFO性质,实现了保护当前现场的功能,因此YRE的函数调用是可递归的。
栈机为运行时环境提供了如下的服务:
函数或属性名 | 作用 |
---|---|
Reset | 初始化栈机 |
Submit | 将一个栈帧提交到栈机 |
Consume | 弹出栈机顶部 |
Clear | 清空整个堆栈 |
Count | 计算堆栈中栈帧的数量 |
ESP | 栈顶指针 |
EBP | 中断或等待前指针 |
SAVEP | 场景指针 |
注意到栈机是一个可序列化对象,进行游戏保存的本质就是保存当前主调用堆栈和它引用的上下文。
栈帧
YRE栈机中的信息单元是运行时栈帧,它储存着当前的执行逻辑和临时性上下文的引用。它是游戏消息循环要怎么进行动作执行的描述子。消息循环
栈帧的数据结构如下:
属性名 | 作用 |
---|---|
IP | 下一指令指针,保存游戏时被悬空 |
IR | 下一指令的索引名字 |
PC | 指令计数器 |
State | 栈帧状态,指示当前是在处理什么(场景、函数、中断等) |
ScriptName | 正在解析的脚本名 |
Argv | 实参列表,用于向函数传递参数 |
Delay | 执行栈帧之前的延时 |
TimeStamp | 该栈帧诞生的时间戳 |
BindingSceneName | 该脚本所绑定的场景名 |
BindingFunctionName | 该脚本所绑定的函数名 |
BindingFunction | 栈帧的函数模板对象 |
BindingInterrupt | 栈帧的中断对象 |
Tag | 附加值 |
指令指针IP
用于寻指,它指示了当前栈帧所对应的执行可执行脚本的下一个要执行命令是什么,消息循环会检索它并执行它,当IP
已经指向空时,意味着该脚本已经执行完毕,调用堆栈将会弹出这个栈帧。但当游戏保存时,指针IP
将被悬空,以避免整个脚本的动作序列图都被序列化到存档文件中。
指令索引IR
用于记录IP
的索引名,它是一个字符串,用于保存游戏时记录当前正在解析的指令,使得恢复游戏存档时能够让栈帧的IP
指针从悬空恢复为指向下一指令。
计数器PC
指示了调用堆栈在当前栈帧停留的时间,避免出现无穷递归等情况。标签字典保存了这个场景中所有标签名和它对应指令的位置。所以,在YRE栈机中,场景跳转的本质就是弹出当前场景栈帧,压入新的场景栈帧;函数调用的本质就是压入函数实例的栈帧;跳转指令的本质就是查询标签字典并修改栈帧的IP
指针。
栈帧状态State
指示了当前栈帧正在进行何种处理。目前YRE中一共有6种栈帧状态:
类型 | 意义 | 并行安全 |
---|---|---|
Interpreting | 正在解析场景脚本 | √ |
FunctionCalling | 正在进行函数调用 | √ |
WaitUser | 等待用户IO | × |
WaitAnimation | 等待动画结束 | × |
AWait | 延时等待 | √ |
Interrupt | 系统中断 | √ |
栈帧中并未直接保存着场景或函数的上下文,这是因为场景上下文存在于堆区的Permanent Space中,由符号表管理器Yuri.PlatformCore.VM.SymbolTable
提供统一的访问接口;而函数的上下文对象则是被保存于函数对象的内部,即BindingFunction
之中。
消息循环
消息循环是YRE的核心,在.NET版本的YRE实现中,它由一个时钟事件触发。
消息循环的处理函数位于总控制器Director
中,函数名为UpdateContext
,该方法被周期性地执行。它从调用堆栈中查看当前栈顶的类型,如果是正在解析游戏脚本,则递归寻指
取得下一指令来交给画音渲染器UpdateRender
类去执行;如果是等待IO、等待延时、进行函数调用、系统中断等情况,就要进行相对应其他处理。在完成相应的状态处理函数后,UpdateContext
函数会刷新系统的IO信息,如鼠标点击、键盘的按键状态等,根据这些IO信息更新游戏内部相应标志位的值,以实现一些引擎内部的IO处理(例如分辨率改变、快速存档等快捷键的响应)。总而言之,消息循环驱动着游戏的进行。
消息循环的刷新频率是不固定的,但它的策略如下:
- 如果本次消息循环执行的指令是立即返回的(即不需要等待就能执行下一指令),那么消息循环会使用快启动机制,即下一趟消息循环不等待时钟事件来触发,而是立即继续进行
- 如果某次消息循环执行的指令是等待IO、等待动画、延时等待这些会持续一段时间的指令,那么按照标准时钟事件的模式继续消息循环。每次时钟事件触发的间隔不早于一个
GlobalConfigContext.DirectorTimerInterval
字段所对应的值(单位:万分之一毫秒),默认情况下1毫秒刷新一次
接下来考虑虚拟机的寻指机制。YRE在初始化时会将游戏的入口场景Main
压入调用堆栈,此时该栈帧的IP
指针指向它的第一条指令,游戏消息循环会在下一次迭代的时候取出并执行它。寻指按照以下先后顺序进行:
- 如果调用堆栈为空,就返回空,指示消息循环结束游戏
- 如果
IP
指向空,就弹出当前的栈帧,返回递归寻址函数的结果 - 如果
IP
非空,且IP
是一个控制流程指令,就按照该控制流程的约定条件寻找新的IP
并返回 - 返回
IP
,并让IP
移动到当前指令的下一指令位置
算法的伪代码如下:
IF CallStack IS empty
RETURN null
END IF
ret ← CallStack.ESP.IP
IF ret IS null
CallStack.Pop()
RETURN FetchNextInstruction() // recursive fetching
END IF
IF ret.type IS YuririGameCommand
CallStack.ESP.PC++
ELSE
ret ← ProcessControlFlow to find next GameCommand Instruction
ENDIF
CallStack.ESP.IP ← ret.next
RETURN ret
从伪代码中可以看到,递归寻址算法首先会查看调用堆栈是否为空,为空的话说明游戏应该结束了。非空时,使用调用堆栈的ESP
属性取栈顶的栈帧,并用IP
属性取下一指令。如果IP指针指向了空说明当前栈帧中所有的动作都已经执行完了,此时应该弹出调用堆栈的顶部,并递归调用自身,继续在新的栈顶进行寻指。
对于非空的指令,需要看它是否是一条可执行语句,如果是条件分支、循环等控制流程,就不应该直接返回它,而是通过计算它们的条件属性,确定程序指令的路径,并寻找下一个可执行的语句去返回给导演类Director
,否则下一递归寻指只能在导演类下一次消息循环时发生,在游戏逻辑中含有大量分支时很影响效率。
系统中断
YRE拥有系统中断机制,它使得一些状态变化所产生的高优先级调用可以打断当前消息循环正在处理的栈帧转而执行中断处理函数。中断的本质是将一个中断栈帧压入主调用堆栈的顶部,使得下一次消息循环先执行中断处理脚本。
按钮的单击处理函数调用就是使用中断机制实现的,当游戏里一个按钮被按下时,YRE将该单击处理函数包装成一个中断栈帧提交到主调用堆栈上,此时场景的解析执行会被保护现场,消息循环在中断栈帧上继续处理。当完成中断栈帧中绑定的中断处理函数后,栈帧出栈,恢复到原来中断前的上下文逻辑继续执行。
事实上,YRE提供的中断服务比上述的按钮中断要复杂得多。中断对象的数据结构如下:
函数或属性名 | 作用 |
---|---|
Type | 中断类型 |
Detail | 中断原因 |
InterruptSA | 中断处理动作(它在中断发生后最优先被执行,但它的后继结点将被忽略) |
InterruptFuncSign | 中断处理函数调用签名(这个动作将在处理完中断动作后被施加到调用堆栈) |
ReturnTarget | 中断结束后跳转的标签名(这个动作将在处理完中断函数调用后被施加到调用堆栈) |
PureInterrupt | 在执行完中断动作后是否处理后续动作 |
ExitWait | 是否在执行时弹空所有等待 |
符号表管理器
YRE使用符号表管理器SymbolTable
来为场景上下文和全局变量提供公共的访问接口。它是一个单例类,只有唯一实例,它可以被序列化。
它维护了一个全局变量的上下文对象GlobalCtxDao
,将全局变量名和变量在运行时环境的托管内存的引用关联起来。YRE通过RuntimeManager
中的赋值函数Assignment
和引用函数Fetch
两个方法操作符号表。由于栈顶可以时场景也可能是函数调用,因此这两个方法会根据调用堆栈栈顶的状态来判断去哪个符号表里寻找变量:如果是场景,那么就在场景静态符号表SceneCtxDao
中寻找;如果是函数调用,那么就要在函数栈帧中找它自己生成的局部变量符号表。还需要注意的一点是,在引用一个变量之前,首先要判断它是否在符号表里存在,如果不存在说明它在被引用之前从未被作为过左值,这是不正常的逻辑,YRE会抛出错误并强制结束游戏。
运行时信息管理器
运行时信息管理器RuntimeManager
负责整个YRE信息的管理,它是当前游戏状态的一种可序列化描述。它在运行时会维护以下的内容:
- CallStack:主调用堆栈
- Symbols:符号表管理器
- Screen:屏幕管理器,管理画面的描述子
- PlayingBGM:正在播放的BGM
- PerformingChapter:正在演绎的章节名
- DashingPureSa:当前正在执行的动作
- ParallelExecutorStack:并行处理栈,其栈帧对应主调用堆栈同一偏移量的场景所对应的并行处理函数的执行器向量。关于并行处理,请参阅后续小节“并行调度机制”
- ParallelHandler:并行消息循环处理函数委托。关于并行处理,请参阅后续小节“并行调度机制”
- SemaphoreBindings:信号量绑定字典,其键是信号量名称,键值是信号量激活函数和反激活函数名组成的二元组。关于信号量机制,请参阅后续小节“信号分发机制”
画面管理器的引用ScreenManager
和当前正在播放的音乐PlayingBGM
是当前游戏状态的文本化描述,用于保存和恢复游戏状态。ScreenManager
会为前端所有正在显示的画面元素保存它们的描述子Descriptor
,这些描述子记录了画面元素的位置、缩放、不透明度等参数,当存档被读取时,这些描述子会指导前端管理器ViewManager
如何重绘画面。同理,PlayingBGM
记录了当前的背景音乐,读取存档后将恢复播放。
保存和读取游戏存档
保存和读取游戏存档就是将游戏运行时信息管理器RuntimeManager
及其引用的对象进行序列化写稳定储存器上的过程及其逆过程。在序列化发生之前,需要对它做一些临时性处理,以移除不能序列化和不应该序列化的内容。
保存过程的执行逻辑如下:
- 调用
RuntimeManager
的PreviewSave
方法,它会创建一个临时包装对象PreviewSaveDataStoringPackage
- 弹主调用堆栈,直到
SAVEP
指针与ESP
指针相等,弹出的栈帧保存到临时包装对象中 - 缓存栈顶
ESP
栈帧的指令指针IP
到临时包装对象中,并悬空IP
指针 - 将并行句柄
ParallelHandler
和并行执行器向量ParallelExecutorStore
保存到临时包装对象中,并悬空它们的引用 - 序列化
RuntimeManager
,写到稳定储存器中 - 调用
FinishedSave
方法,逆转上述的临时包装过程,将缓存的内容恢复到原来的变量上
读取过程的执行逻辑如下:
- 从稳定储存器中读取存档内容并反序列化成
RuntimeManager
对象,并作为参数调用Director
的ResumeFromSaveData
方法 - 暂停当前的消息循环、并行处理
- 清空回滚快照
- 清空画面并停止音乐
- 将主控制器
Director
的运行时信息管理器实例引用更改为反序列化得到的对象 - 通过主调用堆栈栈顶的
IR
属性恢复IP
指针指向下一个要执行的指令 - 更新屏幕管理器,重绘整个画面,重绑定渲染器所作用的调用堆栈,重新演绎背景音乐
- 将保存前正在执行的动作封装成中断,提交到主调用堆栈,复现保存前最后一个动作
一个交互的例子
YRE涉及到的游戏交互具体用例是非常多的,这些结构在实现上有着细节上的差异,很难用一种统一的顺序图模型来描述它,这里选择介绍其中一种组合场景:“用户点击游戏界面里的一个按钮后播放指定的音乐文件。”,利用这个用例来从架构上分析YRE是如何实现它的。
这一过程的顺序图如上,整个Yuri引擎一共有6个主要对象参与了这个用例。用户点击前端的按钮后,Button
类的实例捕获到鼠标点击事件,随后向总控制器类Director
提交一个中断请求,并将中断处理函数func
和参数列表发送给Director
。使用中断机制是因为用户按下按钮时需要立即暂停系统当前的游戏逻辑来处理按钮事件。
总控制器Director
在接收到中断请求后将中断实例提交给YRE主调用堆栈,即提交到RuntimeManager
,它将中断实例压入主调用堆栈,完成本轮消息循环。随后,再一次消息循环时就会处理这个中断,调用按钮提供的中断处理函数func
,而上文说到按钮点击的目的是为了播放音频,因此func
的内容是一个播放音频的指令,该指令会被Director
发送给画音渲染器UpdateRender
,它在接受要播放的文件名并调用音频管理器Musician
来播放音乐,音频管理器请求资源管理器ResourceManager
返回这个文件在托管内存中的句柄,在成功获取资源后,Musician
将调用计算机的音频设备播放指定的音乐,至此整个事件结束。