知方号

知方号

游戏程序员的自我修养<如何看懂蓝图>

游戏程序员的自我修养

内容概要 这篇博客主要是深入理解蓝图整个流程的的底层机制,包括节点编辑、编译、字节码解释执行。理解了这些,对前面几篇所讲的蓝图扩展,可以有一个更清晰的认识

【欢迎转载,请注明作者:房燕良,原文出处:游戏程序员的自我修养】

前面几篇博客谈了几种常用的蓝图扩展方式,其中也对蓝图的底层机制进行了部分的解析,但是还不够整体。这篇文章谈一下目前我对蓝图技术架构的系统性的理解,包括蓝图从编辑到运行的整个过程。

蓝图的发展历程

蓝图是一个突破性的创新,它能够让游戏设计师亲手创造自己想要的“游戏体验”。使用可视化编程的方式,可以大大的加速那种“以体验为核心”的游戏开发的迭代速度,这是一次大胆的尝试,也是一次成功的尝试!(蓝图对于国内流行的那种“以数值成长为核心,以挖坑为目的”的游戏开发,可能没有那么大的意义)

就像很多其他的创新一样,它也是有一个渐进的过程的。它的萌芽就是Unreal Engine 3时代的Kismet。在Unreal Engine 3中,Unreal Script还是主要开发语言,但是可以使用Kismet为关卡添加可视化的事件处理脚本,类似于今天的Level Blueprint。

Unreal Engine 3 官方文档:Kismet Visual Scripting

Blueprint 这个名字很可能是UE4开发了一大半之后才定的。这就是为啥UE4源码里面那么多蓝图相关的模块都以Kismet命名,连蓝图节点的基类也是class UK2Node啦,又有少量模块用的是Blueprint这个名字,其实指代的都是同一系统。

以实例理解蓝图的整个机制

这篇博客的目的是把蓝图的整个体系结构完整的梳理一遍,但是如果只是讲抽象的框架的,会很枯燥,所以我打算以“案例分析”的方式,从一个最简单的蓝图入手,讲解每一步的实际机制是怎样的。

这个案例很简单

新建一个从Actor派生的蓝图 在它的Event Graph中,编辑BeginPlay事件,调用PrintString,显示一个Hello World!

我尽量细的讲一下我这个案例涉及到的每一步的理解!

新建蓝图:BP_HelloWorld

这个过程的核心是创建了一个 class UBlueprint 对象的实例,这个对象在编辑器中可以被作为一种Asset Object来处理。class UBlueprint是一个class UObject的派生类。理论上任何UObject都可以成为一个Asset Object,它的创建、存储、对象引用关系等都遵循Unreal的资源管理机制。

具体到代码的话:当我们在编辑器中新建一个蓝图的时候,Unreal Editor会调用UBlueprintFactory::FactoryCreateNew()来创建一个新的class UBlueprint对象;

UObject* UBlueprintFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn, FName CallingContext){ // ...... // 略去非主干流程代码若干 // ...... UClass* BlueprintClass = nullptr;UClass* BlueprintGeneratedClass = nullptr;IKismetCompilerInterface& KismetCompilerModule = FModuleManager::LoadModuleChecked("KismetCompiler");KismetCompilerModule.GetBlueprintTypesForClass(ParentClass, BlueprintClass, BlueprintGeneratedClass);return FKismetEditorUtilities::CreateBlueprint(ParentClass, InParent, Name, BPTYPE_Normal, BlueprintClass, BlueprintGeneratedClass, CallingContext);}/** Create a new Blueprint and initialize it to a valid state. */UBlueprint* FKismetEditorUtilities::CreateBlueprint(UClass* ParentClass, UObject* Outer, const FName NewBPName, EBlueprintType BlueprintType, TSubclassOf BlueprintClassType, TSubclassOf BlueprintGeneratedClassType, FName CallingContext){// ...... // 略去细节处理流程代码若干 // ......// Create new UBlueprint objectUBlueprint* NewBP = NewObject(Outer, *BlueprintClassType, NewBPName, RF_Public | RF_Standalone | RF_Transactional | RF_LoadCompleted);NewBP->Status = BS_BeingCreated;NewBP->BlueprintType = BlueprintType;NewBP->ParentClass = ParentClass;NewBP->BlueprintSystemVersion = UBlueprint::GetCurrentBlueprintSystemVersion();NewBP->bIsNewlyCreated = true;NewBP->bLegacyNeedToPurgeSkelRefs = false;NewBP->GenerateNewGuid(); // ...... // 后面还有一些其他处理 // . Create SimpleConstructionScript and UserConstructionScript// . Create default event graph(s)// . Create initial UClass // ......}

详见引擎相关源代码:

class UBlueprint: Source/Runtime/Engine/Classes/Engine/Blueprint.h class UBlueprintFactory:Source/Editor/UnrealEd/Classes/Factories/BlueprintFactory.h class FKismetEditorUtilities: Source/Editor/UnrealEd/Public/Kismet2/KismetEditorUtilities.h

另外,这个操作还创建了一个class UPackage对象,作为class UBlueprint对象的Outer对象,这个我在后面“保存蓝图”那一小节再展开。

双击打开BP_HelloWorld

当我们在Content Browser中双击一个“BP_HelloWorld”这个蓝图时,Unreal Editor会启动蓝图编辑器,它是一个独立编辑器(Standalone Editor),这个操作是Asset Object的标准行为,就像Material、Texture等对象一样。

Unreal Editor通过管理AssetTypeAction来实现上述功能。具体到蓝图的话,有一个class FAssetTypeActions_Blueprint,它实现了class UBlueprint所对应的AssetTypeActions。启动蓝图编辑器这个操作,就是通过:FAssetTypeActions_Blueprint::OpenAssetEditor()来实现的

class ASSETTOOLS_API FAssetTypeActions_Blueprint : public FAssetTypeActions_ClassTypeBase{public:virtual void OpenAssetEditor(const TArray& InObjects, TSharedPtr EditWithinLevelEditor = TSharedPtr()) override;};

这个函数它则调用“Kismet”模块,生成、初始化一个IBlueprintEditor实例,也就是我们天天在用的蓝图编辑器。

void FAssetTypeActions_Blueprint::OpenAssetEditor( const TArray& InObjects, TSharedPtr EditWithinLevelEditor ){EToolkitMode::Type Mode = EditWithinLevelEditor.IsValid() ? EToolkitMode::WorldCentric : EToolkitMode::Standalone;for (UObject* Object : InObjects){if (UBlueprint* Blueprint = Cast(Object)){FBlueprintEditorModule& BlueprintEditorModule = FModuleManager::LoadModuleChecked("Kismet");TSharedRef NewKismetEditor = BlueprintEditorModule.CreateBlueprintEditor(Mode, EditWithinLevelEditor, Blueprint, ShouldUseDataOnlyEditor(Blueprint));}}}

详见引擎相关源代码:

class FAssetTypeActions_Blueprint:Source/Developer/AssetTools/Public/AssetTypeActions/AssetTypeActions_Blueprint.h class FBlueprintEditorModule: Source/Editor/Kismet/BlueprintEditorModule.h class IBlueprintEditor: Source/Editor/Kismet/BlueprintEditorModule.h添加节点:PrintString

我们在蓝图编辑器里面的每放入一个蓝图节点,就会对应的生成一个class UEdGraphNode的派生类对象,例如前面一篇博客介绍的里面自己所实现的:class UBPNode_SaySomething : public UK2Node(你猜对了:UK2Node是从UEdGraphNode派生的)。UEdGraphNode会管理多个“针脚”,也就是class UEdGraphPin对象。编辑蓝图的过程,主要就是就是创建这些对象,并连接/断开这些针脚对象等。引擎中有一批核心的class UK2Node的派生类,也就是引擎默认提供的那些蓝图节点,具体见下图:

详见引擎相关源代码:

UEdGraph相关代码目录:Source/Runtime/Engine/Classes/EdGraph 引擎提供的蓝图节点相关代码目录:Source/Editor/BlueprintGraph/Class

对于我们这个例子来说,新添加的“PrintString”这个节点,是创建的一个class UK2Node_CallFunction的实例,它是class UK2Node的派生类。它内部保存了一个UFunction对象指针,指向下面这个函数:

void UKismetSystemLibrary::PrintString(UObject* WorldContextObject, const FString& InString, bool bPrintToScreen, bool bPrintToLog, FLinearColor TextColor, float Duration)

详见:Source/Runtime/Engine/Classes/Kismet/KismetSystemLibrary.h

另外还有一个比较有意思的点是:蓝图编辑器中的Event Graph编辑是如何实现的?我想在这里套用一下“Model-View-Controller”模式:

蓝图编辑器管理一个class UEdGraph对象,这个相当于Model 其他的基于Graph的编辑器可能使用class UEdGraph的派生类,例如Material Editor:class UMaterialGraph : public UEdGraph 它使用class UEdGraphSchema_K2来定义蓝图Graph的行为,相当于Controller 这些行为包括:测试Pin之间是否可以连接、创建或删除连接等等 它是class UEdGraphSchema的派生类 详见:Source/Editor/BlueprintGraph/Classes/EdGraphSchema_K2.h 整体的UI、Node布局等,都是一个复用的SGraphEditor,相当于View Graph中的每个Node对应一个可扩展的Widget,可以从class SGraphNode派生之后添加的SGraphEditor中。对于蓝图来说,它们都是:class SGraphNodeK2Base的派生类 详见:Source/Editor/GraphEditor/Public/KismetNodes/SGraphNodeK2Base.h 点击[Compile]按钮:编译蓝图

当点击[Compile]按钮时,蓝图会进行编译。编译的结果就是一个UBlueprintGeneratedClass对象,这个编译出来的对象保存在UBlueprint的父类中:UBlueprintCore::GeneratedClass。

蓝图编译流程的入口函数为:

void FBlueprintEditor::Compile() 这个函数的核心操作是调用:void FKismetEditorUtilities::CompileBlueprint(UBlueprint* BlueprintObj, EBlueprintCompileOptions CompileFlags, FCompilerResultsLog* pResults) 详见:Source/Editor/Kismet/Private/BlueprintEditor.cpp 详见:Source/Editor/UnrealEd/Private/Kismet2/Kismet2.cpp

4.21版本之后的,蓝图编译通过FBlueprintCompilationManager异步进行,对于分析蓝图原理来说增加了难度,可以修改项目中的“DefaultEditor.ini”,添加下面两行关闭这一特性。

[/Script/UnrealEd.BlueprintEditorProjectSettings]bDisableCompilationManager=true

就我们这个例子来说,编译的核心过程如下:

void FKismetCompilerContext::Compile(){CompileClassLayout(EInternalCompilerFlags::None);CompileFunctions(EInternalCompilerFlags::None);}

可见,蓝图编译主要由两部分:Class Layout,以及根据Graph生成相应的字节码。

Class Layout也就是这个蓝图类包含哪些属性(即class UProperty对象),包含哪些函数(即class UFunction对象),主要是通过这两个函数完成:

UProperty* FKismetCompilerContext::CreateVariable(const FName VarName, const FEdGraphPinType& VarType) void FKismetCompilerContext::CreateFunctionList()

下面就看一下蓝图Graph编译生成字节码的过程。首先来分享一个查看蓝图编译结果的方法,我们可以修改工程里面的:DefaultEngine.ini,增加一下两行:

[Kismet]CompileDisplaysBinaryBackend=true

就可以在OutputLog窗口里看到编译出的字节码,我们这个Hello World编译的Log如下:

BlueprintLog: New page: Compile BP_HelloWorldLogK2Compiler: [function ExecuteUbergraph_BP_HelloWorld]:Label_0x0: $4E: Computed Jump, offset specified by expression: $0: Local variable named EntryPointLabel_0xA: $5E: .. debug site ..Label_0xB: $68: Call Math (stack node KismetSystemLibrary::PrintString) $17: EX_Self $1F: literal ansi string "Hello" $27: EX_True $27: EX_True $2F: literal struct LinearColor (serialized size: 16) $1E: literal float 0.000000 $1E: literal float 0.660000 $1E: literal float 1.000000 $1E: literal float 1.000000 $30: EX_EndStructConst $1E: literal float 2.000000 $16: EX_EndFunctionParmsLabel_0x46: $5A: .. wire debug site ..Label_0x47: $6: Jump to offset 0x53Label_0x4C: $5E: .. debug site ..Label_0x4D: $5A: .. wire debug site ..Label_0x4E: $6: Jump to offset 0xALabel_0x53: $4: Return expression $B: EX_NothingLabel_0x55: $53: EX_EndOfScriptLogK2Compiler: [function ReceiveBeginPlay]:Label_0x0: $5E: .. debug site ..Label_0x1: $5A: .. wire debug site ..Label_0x2: $5E: .. debug site ..Label_0x3: $46: Local Final Script Function (stack node BP_HelloWorld_C::ExecuteUbergraph_BP_HelloWorld) $1D: literal int32 76 $16: EX_EndFunctionParmsLabel_0x12: $5A: .. wire debug site ..Label_0x13: $4: Return expression $B: EX_NothingLabel_0x15: $53: EX_EndOfScript

在蓝图编译时,会把所有的Event Graph组合形成一个Uber Graph,然后遍历Graph的所有节点,生成一个线性的列表,保存到“TArray FKismetFunctionContext::LinearExecutionList”;接着遍历每个蓝图节点,生成相应的“语句”,正确的名词是:Statement,保存到“TMap< UEdGraphNode*, TArray > FKismetFunctionContext::StatementsPerNode”,一个Node在编译过程中可以产生多个Statement;最后调用FScriptBuilderBase::GenerateCodeForStatement()将Statement转换成字节码,保存到TArrayUFunction::Script 这个成员变量中。

对于我们这个案例来说,PrintString是使用class UK2Node_CallFunction实现的:

它通过void FKCHandler_CallFunction::CreateFunctionCallStatement(FKismetFunctionContext& Context, UEdGraphNode* Node, UEdGraphPin* SelfPin)来创建一系列的Statement,最重要的是一个“KCST_CallFunction”。 最后通过void FScriptBuilderBase::EmitFunctionCall(FKismetCompilerContext& CompilerContext, FKismetFunctionContext& FunctionContext, FBlueprintCompiledStatement& Statement, UEdGraphNode* SourceNode)来生成蓝图字节码;根据被调用函数的不同,可能转换成以下几种字节码: EX_CallMath、EX_LocalFinalFunction、EX_FinalFunction、EX_LocalVirtualFunction、EX_VirtualFunction 我们这个PrintString调用的是UKismetSystemLibrary::PrintString(),是EX_FinalFunction 点击[Save]按钮:保存蓝图

这个蓝图保存之后,磁盘上会多出一个“BP_HelloWorld.uasset”文件,这个文件本质上就是UObject序列化的结果,但是有一个细节需要注意一下。

UObject的序列化常用的分为两个部分:

UPROPERTY的话,会通过反射信息自动由底层进行序列化 可以在派生类中重载void Serialize(FArchive& Ar)函数可以添加定制化的代码 对于自定义的Struct,可以实现一套“»”、“«”操作符,以及Serialize()函数

序列化属于虚幻引擎的基础设施,网上这方面相关的帖子很多,这里就不重复了。

值得一提的是,其实这个BP_HelloWorld.uasset并不直接对于class UBlueprint对象,而是对应一个class UPackage对象。Unreal Editor的Asset处理有一个基础流程,在新建Asset对象时,默认会创建一个class UPackage实例,作为这个Asset的Outer对象。

UObject* UAssetToolsImpl::CreateAsset(const FString& AssetName, const FString& PackagePath, UClass* AssetClass, UFactory* Factory, FName CallingContext){const FString PackageName = UPackageTools::SanitizePackageName(PackagePath + TEXT("/") + AssetName);UClass* ClassToUse = AssetClass ? AssetClass : (Factory ? Factory->GetSupportedClass() : nullptr); //! 请注意这里:创建Package对象UPackage* Pkg = CreatePackage(nullptr,*PackageName);UObject* NewObj = nullptr;EObjectFlags Flags = RF_Public|RF_Standalone|RF_Transactional;if ( Factory ){ //! 请注意这里:Pkg作为OuterNewObj = Factory->FactoryCreateNew(ClassToUse, Pkg, FName( *AssetName ), Flags, nullptr, GWarn, CallingContext);}else if ( AssetClass ){ //! 请注意这里:Pkg作为OuterNewObj = NewObject(Pkg, ClassToUse, FName(*AssetName), Flags);}return NewObj;}

这个Package对象在序列化时,也是作为标准的UObject进入序列化流程,但是它起着一个重要的作用:

在整个UObject及其子对象组成的树状结构中,只有最外层(Outermost)的对象是同一个对象时,才会被序列化到一个.uasset文件中 详见:UPackage* UObjectBaseUtility::GetOutermost() const

这样就巧妙的解决了序列化时,如何判断对象之间的关系是聚合、还是

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至lizi9903@foxmail.com举报,一经查实,本站将立刻删除。