ue4蓝图运行顺序_UE4蓝图解析(四)

这是蓝图解析系列文章的第四部分,将介绍Statement优化和字节码生成

相关索引:南京周润发:UE4蓝图解析(一)​zhuanlan.zhihu.com《ue4蓝图运行顺序_UE4蓝图解析(四)》南京周润发:UE4蓝图解析(二)​zhuanlan.zhihu.com《ue4蓝图运行顺序_UE4蓝图解析(四)》南京周润发:UE4蓝图解析(三)​zhuanlan.zhihu.com《ue4蓝图运行顺序_UE4蓝图解析(四)》

对Statements进行优化

蓝图编译器会对Statements进行简单的优化,由FKismetFunctionContext::ResolveStatements()函数实现。

包括三个步骤:执行流程最终排序,处理对goto的补全,优化无用的jump

执行流程最终排序

此时,LinearExecutionList还只是根据数据引脚依赖进行深度优先拓扑排序生成的节点序列,与真正的节点执行序列还有一些差异,因此需要重新排序。

首先,需要把LinearExecutionList中未生成Statements的节点都去掉,比如K2Node_VariableGet节点和Comment节点,这些去掉的节点会以Terminal的形式参与代码生成。

排序好的节点会存入SortedLinearExecutionList数组中,刚开始时,数组中只有Function Entry一个节点,这比较好理解,因为Function Entry是函数的入口,必定是第一个节点。之后,程序会不断检查数组中的最后一个元素,如果该节点的最后一个Statement为KCST_UnconditionalGoto,那毫无疑问要将goto目标节点作为执行序列的下一个节点加入数组中。但当最后一个元素有多个output执行引脚,或者执行引脚连接了多个节点时,就会造成执行序列的分叉,参考ifthenelse节点。此时,需要把这些后继节点先加入NodesToStartNextChain容器中,然后采用“近似”深度优先的方式处理这些分叉的流程。

为什么是“近似”呢?因为这个执行序列依然只能保证一部分的顺序性,比如UnconditionalGoto指向的节点必定会紧挨着前一个节点,但多层分支直接的节点会形成穿插,所以这里所说的“最终排序”并不会真正意义上生成与逻辑顺序一致的顺序。

goto补全

首先,为什么需要对gotostatement进行补全呢?因为Statement::TargetLabel属性类型也为Statement,当对当前节点生成GotoStatement时,目标节点通常还没进行Statement生成,因此无法立即设置TargetLabel,只能先记下gotostatement和目标引脚的对应关系。因此,GotoStatement需要在此补全TargetLabel为目标节点的第一个Statement,并且设置目标Statement为JumpTarget。另外,如果goto的目标节点为NULL,则说明走到了一个执行流程的末尾,需要把Statement的类型设置为EndOfThread或者EndOfReturn。

优化无用的jump

对于LinearExecutionList中的相邻两个节点,如果前一个节点的最后一个Statement为UnconditionalGoto,且目标为下一个节点的第一个Statement,则这个GotoStatement是可以移除的,可以直接按照顺序执行到下个节点。从这可以看出,为什么K2Node_IfThenElse节点要先生成KCST_GotoIfNot再生成KCST_UnconditionalGoto Statement了,这样有助于把后面的UnconditionalGoto给优化掉。

另外,如果LinearExecutionList的最后一个节点的最后一个Statement为GotoReturn,则也可以优化调,因为逻辑都执行完了。

vm后端生成字节码

首先,如何看到蓝图生成的字节码?

通过ScriptDisassembler工具可以查看蓝图编译后的字节码,最简单的使用方式为在BaseEngine.ini中,把[Kismet]下的CompileDisplaysBinaryBackend参数设为true,这样在编译蓝图时,就会输出蓝图的字节码了。

生成字节码的入口在FKismetCompilerVMBackend::GenerateCodeFromClass函数,函数中会遍历FunctionList,然后调用ConstructFunction为每个函数生成字节码。

ConstructionFunction工作流程为:《ue4蓝图运行顺序_UE4蓝图解析(四)》

FScriptBuilderBase

该类用于处理节点和Statements,并生成字节码

主要属性:

FScriptBytecodeWriter Writer:负责写入字节码,以及管理字节码数组ScriptBuffer

主要方法:

GenerateCodeForStatement:对一个Statement生成字节码,最重要的方法

一些Statement的字节码生成:

KCST_CallFunction Statement

对KCST_CallFunction Statement的处理由EmitFunctionCall()函数实现。如果调用的函数UFunction是否为Native且参数有UArrayProperty,则根据需要先在字节码中写入一个Array Terminal。

然后,如果调用的函数在大ubergraph中,会先取到Statement.TargetLabel在大ubergraph中的字节码偏移量,因为大ubergraph总是第一个进行字节码生成,所以此时偏移必定已知了。取到偏移后,会在Statement.RHS里面做个记录,这样以后生成字节码就知道要往哪跳转了。

对于函数中的Property,如果是返回值,就使用EmitDestinationExpression方法输出给目标赋值的字节码。一个函数最多只有一个返回值,但可以有多个Out输出(比如C++函数中把参数标记成&引用),这些Out输出不会产生字节码。

接下来要为函数自身产生字节码了。之前提到过,函数通常有一个Context,作为函数的调用者,或者作为一个static函数的执行环境,因此需要先把Statement.FunctionContext输出到字节码中。有一类函数例外,就是KismetMathLibrary中的数学函数,是不需要Context的,因此不用生成相应字节码。

函数又总体上可以分成两类:Final和Virtual。Final函数可以理解为普通函数,即编译期间地址就确定了,因此可以直接把UFunction指针写入字节码。而Virtual函数地址需要在运行时动态查找,因此会把函数的名称写入字节码。

接下来会把函数的UProperty中除返回值外的所有参数对应的Terminal都输出到字节码,通过EmitTerm函数。最后再输出EX_EndFunctionParms表示函数字节码输出全部完成。

例子:

Random Integer节点,主要包含KCST_CallFunction Statement:《ue4蓝图运行顺序_UE4蓝图解析(四)》

使用ScriptDisassembler解析后的字节码输出为:

Label_0x39:

$F: Let (Variable = Expression) #1+8(int64指针)

Variable:

$0: Local variable named CallFunc_RandomInteger_ReturnValue #1+8(int64指针)

Expression:

$68: Call Math (stack node KismetMathLibrary::RandomInteger) #1+8(int64指针)

$1D: literal int32 3 #1+4(int32)

$16: EX_EndFunctionParms #1

Label_0x5A:

Label_0x39表示这个Statement对应字节码数组的位置,用作goto目标位置,之后会进行介绍。0xF字节码为赋值,之后会先跟上一个ReturnValue UProperty指针,为什么这里会写入UProperty指针,下文会介绍。然后是0x0字节码,表示一个本地变量,是Let指令的左值,后面跟上ReturnValue UProperty的指针。之后的0x68代表调用math函数,后面跟上函数UFunction指针。0x1D代表int 常量,后面跟上int32的值。最后为0x16,代表结束函数参数。

上面例子的每一行后面都标记了产生字节码的长度,总和为33byte,刚好为Label_0x5A和Label_0x39之差。

处理EX_CallMath指令的函数如下:《ue4蓝图运行顺序_UE4蓝图解析(四)》

可见,首先从字节码数组中读取UFunction指针,然后使用UFunction的Native Func指针指向的函数处理函数的参数和返回值。

KCST_CallFunction例子中有几个比较重要的函数需要专门介绍一下

FContextEmitter::TryStartContext

用于输出调用函数的Context字节码,或者Terminal.Context的字节码。会先产生一个类似EX_Context的字节码,然后写入Context对应的Terminal。之后还会写入一个2字节或4字节的CodeSkipSizeType,以及Statement.LHS指针。这两个东西在前面Context获取失败时起作用,若Context获取成功,会直接跳过这两个东西的字节码,详细可见解析Context字节码的UObject::ProcessContextOpcode函数。

一个printstring节点的context例子如下:

$19: Context

ObjectExpression:

$20: EX_ObjectConst (000002A597FEF740:KismetSystemLibrary /Script/Engine.Default__KismetSystemLibrary)

Skip Bytes: 0x3D

R-Value Property: (null)

FScriptBuilderBase::EmitTerm(FBPTerminal* Term, UProperty* CoerceProperty = NULL, FBPTerminal* RValueTerm = NULL)

用于为一个Terminal输出字节码。

参数:

Term为要输出字节码的Terminal。

CoerceProperty为EmitTermExpr中处理字面Terminal时用于校验的UProperty,判断字面Terminal是否与UProperty对应

RValueTerm为Context的右值Terminal,与产生Context嵌套链的字节码有关,可以处理null context异常

如果Terminal内联了数学运算Statement,则为这个Statement生成字节码,常用于优化,这样就不用专门为这个Terminal生成“Terminal字节码“了。

如果terminal的Context为Struct类型,则会先输出Terminal关联Property的字节码,之后再为Context执行EmitTerm。

其他情况下,最终都会调用到EmitTermExpr方法,执行最具体的字节码生成。

FScriptBuilderBase::EmitTermExpr(FBPTerminal* Term, UProperty* CoerceProperty = NULL, bool bAllowStaticArray = false)

参数:

Term与CoerceProperty和EmitTerm函数中含义相同

bAllowStaticArray表示Term为Struct时内部的Tarray是否允许为StaticArray

虽然整个函数的代码有500多行,但流程却很清晰。首先判断Terminal是否为字面的,如果是字面的则根据数据类型,比如String,int64,float等等,生成对应的字节码。如果不是字面的,则会根据Termminal关联变量的类型生成对应指令,比如EX_LocalVariable,然后把关联的UProperty指针写入到字节码数组中。

KCST_Assignment

对KCST_Assignment的处理由EmitAssignmentStatment函数实现

Statement的LHS和RHS分别保存了目的Terminal和源Terminal。

首先,需要使用EmitDestinationExpression函数为LHS Terminal生成字节码,Writer先根据LHS类型选择对应的指令,比如EX_LetBool、EX_LetObj等,之后再把LHS引用的UProperty写入字节码数组。写入UProperty时,会先把UProperty对象的指针转型为uint64整数,无论在32位平台还是64位平台上,应该是为了保证字节码的统一性,之后把uint64数字写入字节码数组。之后再执行EmitTerm(),函数写入左值Terminal(即写入了两次Lint UProperty)。为什么要向字节码数组写入两次左值Terminal呢?查看处理EX_Let指令的execLet函数可以发现,赋值操作的左值是由EmitTerm函数写入的Terminal,之前写入的UProperty好像是作为备用,具体原因还不清楚。

之后,执行EmitTerm(SourceExpression, DestinationExpression->AssociatedVarProperty)为右值生成字节码。EmitTerm()在之前已介绍过。

处理EX_Let指令的函数如下:《ue4蓝图运行顺序_UE4蓝图解析(四)》

可见,函数会先读取最先写入的UProperty,然后继续处理字节码。因为我们已经了解EX_Let字节码的生成方式,因此我们知道之后会读取左值UProperty,如果成功,Stack.MostRecentPropertyAddress会被设置为左值的地址。当成功获取左值后,会继续处理字节码,读取右值,并赋给左值的地址。

KCST_GotoIfNot等Goto Statement的处理

对这些Goto的处理由EmitGoto函数实现

对于各种Goto的处理比较简单,基本上都是先写入对应的Goto指令,然后写入跳转目标的字节码偏移,再写入用于跳转判断的Terminal。

以KCST_GotoIfNot为例,首先写入EX_JumpIfNot指令,然后写入跳转目标Statement的字节码偏移占位符,最后写入Statement.LHS作为判断依据。

字节码偏移占位符

Goto的目标是TargetLabel在字节码数组中的偏移,但是生成Goto相关字节码时往往TargetLabel的字节码还未生成。因此,只能先记下Goto参数的地址,并在这个地址写入一个占位符“-1”,以及TargetLabel,当字节码数组都生成后,再与之前类似的做一次fixup操作,把TargetLabel的字节码偏移填入Goto参数地址中。

ExecutionSequence节点的字节码生成

ExecutionSequence节点主要会生成KCST_PushState和KCST_UnconditionGoto两个Statement。

KCST_PushState使用EmitPushExecState函数处理,会先输出指令EX_PushExecutionFlow,然后写入执行序列节点的下一个分支的字节码偏移,这样当这个执行流程结束时,会执行EX_PopExecutionFlow指令,就能取出这个偏移,执行接下来的字节码了。《ue4蓝图运行顺序_UE4蓝图解析(四)》

    原文作者:网件NETGEAR
    原文地址: https://blog.csdn.net/weixin_30126739/article/details/111945168
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞