2019-12-07 21:49:40 weixin_44332254 阅读数 17
  • 从这里开始虚幻4-第2辑-蓝图 v4.18

    本课程系列取名英译系列,是录制人员参考国外英文原版经典教程,结合中国人的习惯录制而成。希望能够给大家以帮助。从这里开始虚幻4系列教程,是Unreal的官方发布的入门教学,非常经典,是学习Unreal的佳入口。

    2243 人正在学习 去看看 杨石兴

最近开始摸索Unreal的蓝图基础使用,一些基本的操作大家可以在官方网站中的教程中找到,所以我们直接跳到事件图表上来讲。
首先,Unreal有丰富的注释功能,按住键盘C可以创建出注释区域,在右侧的“细节”面板我们可以轻松调整颜色以及气泡的出现。

节点

见过一些别人编辑好的蓝图的同学们都知道,蓝图中有错综复杂的连线,这些连线从一个地方出来,到另一个地方去,控制了流程和数据的输入输出。就像是交通线,每个城市就相当于“节点”

节点类型
事件(Events)、自定义事件、函数调用、事件调度器、流程控制、数组节点、时间轴、数学表达式节点、随机流、注释。这些都属于节点类型,丰富的节点类型可以帮助我们在小型的游戏开发中基本不需要编写自己的C++代码,实现一些功能,但是同时者考验来蓝图编写者的逻辑思维能力。

事件
在蓝图中可以定义各种事件,创建蓝图时默认就会为我们提供三种事件:BeginPlay Tick以及Overlap。对应着我们游戏开始,帧调用以及碰撞触发事件。 在一个蓝图中,事件的种类可以有多个,但是同种类型的事件只能有一个。
另外,我们也可以自定义事件,Add Event一个事件,然后我们在细节面板可以修改自定义事件的名字,定义各种事件的成员变量。

函数调用
函数的调用是蓝图中同样重要的一环,函数调用可以简单分为一般函数调用,其他函数调用和纯函数调用。
所谓一般函数调用,是可以在蓝图中形成的操作,同属于目标Actor或对象的函数相对应 。
其他函数调用,是属于除了该蓝图之外的其他对象或者Actor的函数。
纯函数调用,是可以执行的特殊动作,它不会影响世界或者世界中的对象。比如输出一个属性值,进行数学运算等。

流程控制
switch:提供了各种switch判断的类型,也可以自定义枚举类型在这里判断。
branch:相当于if else语句
Do N:接受条件执行,但是有次数限制,达到次数上限后,再次接受相同条件无法执行后续节点操作。但是可以通过重置使次数归0 .
DoOnce:同上,但是只能执行一次
DoOnceMultiInput:可以根据不同情况执行不同的操作,但是如果想要再次执行,需要Reset。
FlipFlop:交替执行A和B后面的内容。
ForLoop:for循环
ForLoopWithBreak:可以在一定条件下终止循环,执行相应的语句。
Gate:门,顾名思义。可以设置Enter的条件和Exit的执行语句,可以设置open的条件,close的条件,或者用toggle使其自动open和close·······
MultiGate:可以又多种输出的开关门,可以设置开始和重置,可以选择是否随机或者循环,可以从只当index的输出开始
Sequence:按顺序一次执行,但是值得注意的是,即使写了Delay,Sequence也会直接执行后面的语句

变量

存放一个值或者引用世界中一个Object或Actor的属性。
变量类型:
在这里插入图片描述
在这里插入图片描述

详细介绍:

布尔值(bool):小案例:利用不布尔值设置一个point light的intensity,按下键盘一个键后,如果灯是打开则关闭,如果关闭则打开。
易犯错误:将一个布尔值取反后,忘记重新把这个取反后的值设置回这个变量中,导致只能发生一次变化。
字节(byte):学过一定计算机理论的知道,1字节是8位,在UE中存储范围是0-255。在右侧细节栏中可以设置UI中滑动的范围以及值的取值范围,后者控制前者。
整型和64位整型:整型是32位,两者仅仅范围不同
浮点型(float):范围很大,案例:做一个四舍五入的功能(利用浮点型转整型操作)。
Name,String以及Text:Name是轻量级的字符串,经常被用于对象的命名 string是我们通常用的字符串,支持字符串的拼接,查找等其他语言中都存在的操作 Text是大段文本,如果游戏中出现大段对话,基本我们就使用Text,这样会对我们的本地化非常有帮助。
Vector:引擎中很多属性例如位置,旋转,缩放都是使用Vector。案例:使用Vector修改物体的位置和缩放。
Rotator:和Vector类似,可以用来定义一个旋转角度。
Transform:Transform即物体的位置,旋转和缩放,第一个和第三个是Vector类型,第二个是Rotator类型。案例:获取物体的Transform中的旋转进行输出。

Enum枚举类型:我们可以在内容浏览器中新建一个自己的枚举类型,为其设置各种情况以及解释
Structure结构体:我们可以在内容浏览器中新建一个结构体,类似于各种编程语言中的struct
综合案例:创建一个学生结构体,属性为姓名,年龄,以及学校(利用枚举类型),在蓝图中实现这样的功能:根据学生年龄的大小,输出“姓名”+是否成年+“(学校类型)”。

数组节点:
由于本篇文章是基于有一定其他编程语言经验的人编写,所以数组方面可能不会那么详尽的去介绍。
在这里插入图片描述
在这个地方可以修改变量的类型,有普通变量,数组,集以及字典。

数组中的方法:
在这里插入图片描述
很多方法都是其他语言中存在的一些常用的方法,大多都属于增删改查,具体如何使用各位客官可以自己摸索。

集合:
接下来就是我们的第三种数据存储容器,集合
在这里插入图片描述
在C#中,我们有数组,列表,字典,哈希表,栈和队列等数据结构,集合也是一种类数据结构容器,在初等数学中我们应该学过集合相关的知识。
如果硬要对比的话,集合就好比一个键值对相同的字典。
在蓝图中,可以对集合进行如上图的操作。其中也包含了最常用的差并交三个基本操作。

地图:
第四种数据存储容器:map
这个就很熟悉了,有的编程语言中它叫map,有的中是Dictionary,本质上就是一个键值对,而且是一种引用类型的键值对;与之相对的是,哈希表是一种值类型的键值对。
在这里插入图片描述
上面是map中一些常用的方法,有一定数据结构知识的客官们应该能轻松应用这些方法。

函数(Function)

在这里插入图片描述
在上图中,我们已经大概了解了图标和变量的含义,那么接下来是函数。
在这里插入图片描述
可以覆盖,但由于我们还没有继承的子类或者父类,我们就先新建一个函数
在这里插入图片描述
点击新建的函数,可以进入我们的函数界面,在右侧的细节面板有我们的一些函数相关的设置,例如一些注释,访问方式(public protected private) 是否为纯函数 在输入和输出中,我们可以定义相应的函数的输入和输出数据流。

实操训练:对一个数组进行排序
unreal蓝图中实现数组的排序是非常消耗性能且复杂的。

宏:
宏跟函数非常类似,可以定义输出和输出,而它比函数多了一个功能,它可以在右侧的细节面板设置多个输入和输出引脚,有点类似于我们的分支,具体如何使用到之后继续总结。

数学节点–蓝图中的math类
在这里插入图片描述
如图所示,在蓝图中搜索math,会出现很多一些蓝图中的数学节点,这些节点提供了我们对一些数据类型的基本的操作。
数学节点也包括对transformation的操作,另外,我们的Random节点也归在数学节点里,不再做进一步的介绍,请各位客官尝试使用一下各种数学函数,灵活运用到自己的项目中。

时间轴:
Timeline为我们提供了一个基于时间的控制器,不仅仅是做动画,Timeline代表了一种在时间轴上进行各种操作的编程习惯。
对Timeline的介绍分为两部分,首先是Timeline的曲线编辑器:
在这里插入图片描述最上面一排,可以添加基于各种类型变量的曲线,设置曲线的时间,自动播放,循环等属性。在一个Timeline中可以定义多条曲线,都会在事件图表中显示出来。中间是我们的曲线编辑窗口,我们可以按shift去添加关键帧,用delete去删除关键帧。右键点击关键帧,可以设置关键帧在此处的状态是平滑,线性还是剧变等等状态。

然后是事件图表中的Timeline节点:
在这里插入图片描述
Play:播放
Play from Start:从头开始播放
Stop:停止
Reverse:从后往前播放
Reverse from End:从最后往前播放
Set New Time:为动画设置一个新的时间
Update:每帧执行
Finished:结束执行
Direction:播放是从后往前还是从前往后

Timeline在创建后,在左侧会以组件的形式出现,我们可以像对其他变量和组件一样对它进行get 和set
下面是关于Timeline节点的一些方法:
在这里插入图片描述

2019-11-24 03:22:22 Neil3D 阅读数 621
  • 从这里开始虚幻4-第2辑-蓝图 v4.18

    本课程系列取名英译系列,是录制人员参考国外英文原版经典教程,结合中国人的习惯录制而成。希望能够给大家以帮助。从这里开始虚幻4系列教程,是Unreal的官方发布的入门教学,非常经典,是学习Unreal的佳入口。

    2243 人正在学习 去看看 杨石兴

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

蓝图的发展历程

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

就像很多其他的创新一样,它也是有一个渐进的过程的。它的萌芽就是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<IKismetCompilerInterface>("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<UBlueprint> BlueprintClassType, TSubclassOf<UBlueprintGeneratedClass> BlueprintGeneratedClassType, FName CallingContext)
{
	// ......
  	// 略去细节处理流程代码若干
  	// ......

	// Create new UBlueprint object
	UBlueprint* NewBP = NewObject<UBlueprint>(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
  	// ......
}

详见引擎相关源代码:

  1. class UBlueprint: Source/Runtime/Engine/Classes/Engine/Blueprint.h
  2. class UBlueprintFactory:Source/Editor/UnrealEd/Classes/Factories/BlueprintFactory.h
  3. 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<UObject*>& InObjects, TSharedPtr<class IToolkitHost> EditWithinLevelEditor = TSharedPtr<IToolkitHost>()) override;
};

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

void FAssetTypeActions_Blueprint::OpenAssetEditor( const TArray<UObject*>& InObjects, TSharedPtr<IToolkitHost> EditWithinLevelEditor )
{
	EToolkitMode::Type Mode = EditWithinLevelEditor.IsValid() ? EToolkitMode::WorldCentric : EToolkitMode::Standalone;

	for (UObject* Object : InObjects)
	{
		if (UBlueprint* Blueprint = Cast<UBlueprint>(Object))
		{
				FBlueprintEditorModule& BlueprintEditorModule = FModuleManager::LoadModuleChecked<FBlueprintEditorModule>("Kismet");
				TSharedRef< IBlueprintEditor > NewKismetEditor = BlueprintEditorModule.CreateBlueprintEditor(Mode, EditWithinLevelEditor, Blueprint, ShouldUseDataOnlyEditor(Blueprint));
		}
	}
}

详见引擎相关源代码:

  1. class FAssetTypeActions_Blueprint:Source/Developer/AssetTools/Public/AssetTypeActions/AssetTypeActions_Blueprint.h
  2. class FBlueprintEditorModule: Source/Editor/Kismet/BlueprintEditorModule.h
  3. class IBlueprintEditor: Source/Editor/Kismet/BlueprintEditorModule.h

添加节点:PrintString

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

  1. UEdGraph相关代码目录:Source/Runtime/Engine/Classes/EdGraph
  2. 引擎提供的蓝图节点相关代码目录: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_HelloWorld
LogK2Compiler: [function ExecuteUbergraph_BP_HelloWorld]:
Label_0x0:
     $4E: Computed Jump, offset specified by expression:
         $0: Local variable named EntryPoint
Label_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_EndFunctionParms
Label_0x46:
     $5A: .. wire debug site ..
Label_0x47:
     $6: Jump to offset 0x53
Label_0x4C:
     $5E: .. debug site ..
Label_0x4D:
     $5A: .. wire debug site ..
Label_0x4E:
     $6: Jump to offset 0xA
Label_0x53:
     $4: Return expression
       $B: EX_Nothing
Label_0x55:
     $53: EX_EndOfScript
LogK2Compiler: [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_EndFunctionParms
Label_0x12:
     $5A: .. wire debug site ..
Label_0x13:
     $4: Return expression
       $B: EX_Nothing
Label_0x15:
     $53: EX_EndOfScript

在蓝图编译时,会把所有的Event Graph组合形成一个Uber Graph,然后遍历Graph的所有节点,生成一个线性的列表,保存到“TArray<UEdGraphNode*> FKismetFunctionContext::LinearExecutionList”;接着遍历每个蓝图节点,生成相应的“语句”,正确的名词是:Statement,保存到“TMap< UEdGraphNode*, TArray<FBlueprintCompiledStatement*> > FKismetFunctionContext::StatementsPerNode”,一个Node在编译过程中可以产生多个Statement;最后调用FScriptBuilderBase::GenerateCodeForStatement()将Statement转换成字节码,保存到TArray<uint8>``UFunction::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的序列化常用的分为两个部分:

  1. UPROPERTY的话,会通过反射信息自动由底层进行序列化
  2. 可以在派生类中重载void Serialize(FArchive& Ar)函数可以添加定制化的代码
  3. 对于自定义的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作为Outer
		NewObj = Factory->FactoryCreateNew(ClassToUse, Pkg, FName( *AssetName ), Flags, nullptr, GWarn, CallingContext);
	}
	else if ( AssetClass )
	{
    	//! 请注意这里:Pkg作为Outer
		NewObj = NewObject<UObject>(Pkg, ClassToUse, FName(*AssetName), Flags);
	}


	return NewObj;
}

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

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

这样就巧妙的解决了序列化时,如何判断对象之间的关系是聚合、还是链接的问题!我们来考虑另外一个例子:class UStaticMeshComponent:你可以想象一下,当Level中具有一个AStaticMeshActor,它包含UStaticMeshComponent,其静态模型是引用的另外一个UStaticMesh对象,那么序列化的过程是怎么样的呢?

  • 如果UStaticMesh对象序列进入Component、Actor,以至于进入Level,那就不对啦!因为一个静态模型可能在关卡中放置多个实例,如果每个都保存一遍,那就不只是浪费资源了,而是个错误的设计啦!
  • 在引擎中,因为UStaticMesh对象是保存在另外一个.uasset文件中,也就是说它的Outermost对象是另外一个Package,所以在UStaticMeshComponent序列化的时候,它是通过“路径链接”的方式记录的,而不是完整对象!

把BP_HelloWorld拖放到关卡中

在这里插入图片描述
因为BP_HelloWorld是一个从Actor派生的,所以它可以添加到关卡中。当我们吧BP_HelloWorld拖放到窗口中的时候,和C++创建的Actor派生类一样,其核心操作都调用了AActor* UWorld::SpawnActor( UClass* Class, FTransform const* UserTransformPtr, const FActorSpawnParameters& SpawnParameters )来创建一个新的class AActor派生类对象。对于我们这个例子来说,第一个参数UClass *Class是一个UBlueprintGeneratedClass对象,也就是前面我们是的蓝图编译产生的那个UBlueprintGeneratedClass

点击[Play]按钮:运行蓝图

在这里插入图片描述
下面我们就看看这个蓝图在关卡运行时的调用过程。首先,BP_HelloWorld是一个标准的Actor,但是它的BeginPlay事件和C++的Actor派生类重载BeginPlay()实现又有差别。下面我们就先看一下这个事件节点,然后再从字节码解释执行的层面看看PrintString节点是如何被调用的。

BeginPlay事件:AActor::ReceiveBeginPlay()

蓝图编辑器中的BeginPlay事件节点对应的并不是AActor::BeginPlay(),而是AActor::ReceiveBeginPlay()这个事件,我们看一下它的声明:

/** Event when play begins for this actor. */
UFUNCTION(BlueprintImplementableEvent, meta=(DisplayName = "BeginPlay"))
void ReceiveBeginPlay();

从这个声明可以看出:

  1. DisplayName = "BeginPlay",它只是看上去叫做“BeginPlay”,但是和AActor::BeginPlay()函数是两个东西。AActor::BeginPlay()是C++的实现,并在里面调用了ReceiveBeginPlay();
  2. ReceiveBeginPlay()是一个“用蓝图实现的事件”,这种函数我们不需要使用C++写它的函数体。
  3. ReceiveBeginPlay()的函数体由UBT生成。生成的代码如下:
static FName NAME_AActor_ReceiveBeginPlay = FName(TEXT("ReceiveBeginPlay"));
void AActor::ReceiveBeginPlay()
{
	ProcessEvent(FindFunctionChecked(NAME_AActor_ReceiveBeginPlay),NULL);
}

这段自动生成的代码实际上是做了两件事:

  1. 找到名为“ReceiveBeginPlay”的UFunction对象;
  2. 执行“ProcessEvent”函数。

我们先来看一下这个“FindFunctionChecked()”操作,它的调用过程如下:

  • UObject::FindFunctionChecked(),this==BP_MyActor对象实例
    • UObject::FindFunction(),其实现为:GetClass()->FindFunctionByName(InName)
      • UClass::FindFunctionByName(),this==BP_MyActor的UClass对象实例;在这个例子中,this的类型为UClass的子类:UBlueprintGeneratedClass;
      • 上述函数就返回了“ReceiveBeginPlay”对应的一个UFunction对象指针;

在这个例子中,返回的UFunction对象,对应的就是一个“Kismet callable function”(代码注释里的说法),或者是说“蓝图函数”,其字节码就定义在在它的父类UStruct上:TArray<uint8> UStruct::Script。在蓝图编辑器中拉的那个Graph。

接下来,这个UFunction对象作为参数,调用了“AActor::ProcessEvent()”函数,这个函数是父类:UObject::ProcessEvent()的一个简单封装。后者就是蓝图字节码解释执行的部分了!

蓝图字节码的解释执行

首先我们看一下蓝图的字节码长什么样子吧。 在CoreUObject/Public/UObject/Script.h这个文件中有一个enum EExprToken,这个枚举就是蓝图的字节码定义。如果学过汇编语言、JAVA VM或者.Net CLR IL的话,对这些东西并不会陌生:

//
// Evaluatable expression item types.
//
enum EExprToken
{
  ...
  EX_Return = 0x04,	// Return from function.
  EX_Jump = 0x06,	// Goto a local address in code.
  EX_JumpIfNot  = 0x07,	// Goto if not expression.
  EX_Let  = 0x0F,	// Assign an arbitrary size value to a variable.

  EX_LocalVirtualFunction = 0x45, // Special instructions to quickly call a virtual function that we know is going to run only locally
  EX_LocalFinalFunction = 0x46, // Special instructions to quickly call a final function that we know is going to run only locally
  ...
};

这些字节码又是怎样被解释执行的呢?这部分功能完全是由UObject这个巨大的基类来完成的,引擎并没有一个单独的Blueprint VM之类的模块。这个不必吐槽,这是Unreal的传统,从Unreal第一代的Unreal Script就是这样的。引擎中使用一个全局查找表,把上述字节码映射到函数指针。在运行时,从一个字节码数组中逐个取出字节码,并查找函数指针,进行调用,也就完成了所谓的“字节码解释执行”的过程。

具体的说,引擎定义了一个全局变量:FNativeFuncPtr GNatives[EX_Max],它保存了一个“字节码到FNativeFuncPtr的查找表。在引擎中通过DEFINE_FUNCTIONIMPLEMENT_VM_FUNCTION来定义蓝图字节码对应的C++函数,并注册到这个全局映射表中,例如字节码“EX_Jump”对应的函数:

DEFINE_FUNCTION(UObject::execJump)
{
	CHECK_RUNAWAY;

	// Jump immediate.
	CodeSkipSizeType Offset = Stack.ReadCodeSkipCount();
	Stack.Code = &Stack.Node->Script[Offset];
}
IMPLEMENT_VM_FUNCTION( EX_Jump, execJump );

字节码解释执行的过程在ProcessLocalScriptFunction()函数中。它使用一个循环while (*Stack.Code != EX_Return)从当前的栈上取出每个字节码,也就是UFunction对象中的那个TArray<uint8> Script成员中的每个元素,解释字节码的代码十分直观:

void FFrame::Step(UObject* Context, RESULT_DECL)
{
	int32 B = *Code++;
	(GNatives[B])(Context,*this,RESULT_PARAM);
}

详见相关引擎源码:

  1. CoreUObject/Public/UObject/Script.h
  2. CoreUObject/Private/UObject/ScriptCore.h
Hello World的执行

在我们这个例子中,这个函数做了以下几件核心的事情:

  1. 创建了一个 FFrame 对象,这个对象就是执行这个UFunction所需要的的“栈”对象,他内部保存了一个uint8* Code指针,相当于汇编语言的PC,指向当前需要的字节码;
  2. 调用这个UFunction::Invoke(),this就是刚才找到的那个代表ReceiveBeginPlay的UFunction对象;
  3. 调用ProcessLocalScriptFunction()函数,解释执行字节码。

我们的PrintString对应的字节码是EX_FinalFunction,最终通过下面这个函数来实现。

DEFINE_FUNCTION(UObject::execFinalFunction)
{
	// Call the final function.
	P_THIS->CallFunction( Stack, RESULT_PARAM, (UFunction*)Stack.ReadObject() );
}
IMPLEMENT_VM_FUNCTION( EX_FinalFunction, execFinalFunction );

它内部通过void UFunction::Invoke(UObject* Obj, FFrame& Stack, RESULT_DECL)调用到UKismetSystemLibrary::PrintString()

小结一下

OK,罗里吧嗦说了这么多,下面让我们用简练的语言概述一下上面所有内容

  1. 蓝图首先作为一种引擎的Asset对象,可以被Unreal Editor的Asset机制所管理,并且可以被Blueprint Editor来编辑;
  2. 在Blueprint Editor中,蓝图的Event Graph以class UEdGraph对象的方式被Graph Editor来编辑;
  3. 蓝图通过编译过程,生成一个UClass的派生类对象,即UBlueprintGeneratedClass对象实例;这个实例对象就像C++的UObject派生类对应的UClass那样,拥有UProperty和UFunction;
  4. 与C++生成的UClass不同的是,这些UFunction可能会使用蓝图字节码;
  5. 在运行时,并不存在一个单独的“蓝图虚拟机”模块,蓝图字节码的解释执行完全是有UObject这个巨大的基类来完成的;
  6. 每个字节码对应一个Native函数指针,通过GNatives[ByteCode]查找、调用;
  7. UObject通过解释执行蓝图脚本字节码,调用相应的C++实现的Thunk函数来完成具体的操作;

参考资料

2019-09-06 07:32:38 Neil3D 阅读数 759
  • 从这里开始虚幻4-第2辑-蓝图 v4.18

    本课程系列取名英译系列,是录制人员参考国外英文原版经典教程,结合中国人的习惯录制而成。希望能够给大家以帮助。从这里开始虚幻4系列教程,是Unreal的官方发布的入门教学,非常经典,是学习Unreal的佳入口。

    2243 人正在学习 去看看 杨石兴

通过本系列文章上篇的介绍,我们已经可以创建一个“没什么用”的蓝图节点了。要想让它有用,关键还是上篇中说的典型应用场景:动态添加Pin,这篇博客就来解决这个问题。

目标

和上篇一样,我还将通过一个尽量简单的节点,来说明"可动态添加Pin的蓝图节点"的实现过程,让大家尽量聚焦在“蓝图自定义节点”这个主题上。

设想这样一个节点:Say Something,把输入的N个字符串连接起来,然后打印输出。也就是说,这个节点的输入Pin是可以动态添加的。我们将在上篇的那个工程基础上实现这个自定义节点。最终实现的效果如下图所示:
在这里插入图片描述
下面我们还是来仔细的过一遍实现步骤吧!

创建Blueprint Graph节点类型

首先,我们还是需要创建一个class UK2Node的派生类,这个过程在上篇中已经详细说过了,照单炒菜,很容易就创建了下图这样一个空的自定义节点,这里就不赘述了。不清楚的话,可以返回去在照着上篇做就好了。
在这里插入图片描述

创建自定义的节点Widget

我们要动态增加Pin的话,需要在节点上显示一个"加号按钮",点击之后增加一个“input pin”。这就不能使用默认的Blueprint Graph Node Widget了,需要对其进行扩展。这个扩展的思路和前面一样,也是找到特定的基类,重载其虚函数即可,这个基类就是class SGraphNodeK2Base。我们要重载的两个核心的函数是:

  1. CreateInputSideAddButton(),创建我们需要的添加输入Pin的按钮;
  2. OnAddPin(),响应这个按钮的操作;

来看一下最简化的代码吧:
SGraphNodeSaySomething.h

class SGraphNodeSaySomething : public SGraphNodeK2Base
{
public:
	SLATE_BEGIN_ARGS(SGraphNodeSaySomething){}
	SLATE_END_ARGS()

	void Construct(const FArguments& InArgs, UBPNode_SaySomething* InNode);
protected:
	virtual void CreateInputSideAddButton(TSharedPtr<SVerticalBox> InputBox) override;
	virtual FReply OnAddPin() override;
};

SGraphNodeSaySomething.cpp

void SGraphNodeSaySomething::Construct(const FArguments& InArgs, UBPNode_SaySomething* InNode)
{
	this->GraphNode = InNode;
	this->SetCursor( EMouseCursor::CardinalCross );
	this->UpdateGraphNode();
}

void SGraphNodeSaySomething::CreateInputSideAddButton(TSharedPtr<SVerticalBox> InputBox)
{
	FText Tmp = FText::FromString(TEXT("Add word"));
	TSharedRef<SWidget> AddPinButton = AddPinButtonContent(Tmp, Tmp);

	FMargin AddPinPadding = Settings->GetInputPinPadding();
	AddPinPadding.Top += 6.0f;

	InputBox->AddSlot()
	.AutoHeight()
	.VAlign(VAlign_Center)
	.Padding(AddPinPadding)
	[
		AddPinButton
	];
}

FReply SGraphNodeSaySomething::OnAddPin()
{ }

如果你接触过Unreal Slate的话,上面这个Slate Widget的代码很容易看懂啦,如果你没有玩过Slate。。。。Slate是虚幻自己的一套 Immediate Mode UI framework,建议先过一下官方文档

最后,因为这个基类:SGraphNodeK2Base,属于GraphEditor模块,所以要修改MyBlueprintNodeEditor.Build.cs,把它添加到PrivateDependencyModuleNames:

PrivateDependencyModuleNames.AddRange(new string[] {
            "UnrealEd",
            "GraphEditor",
            "BlueprintGraph",
            "KismetCompiler",
            "MyBlueprintNode"
        });

扩展蓝图编辑器的节点Widget

OK,上面我们已经创建了两个类,分别是:

  1. class UBPNode_SaySomething : public UK2Node
  2. class SGraphNodeSaySomething : public SGraphNodeK2Base

下面我们就需要让蓝图编辑器知道:创建UBPNode_SaySomething对象的时候,需要使用SGraphNodeSaySomething这个Widget。

添加自定义Node Widget的两种方式(参见引擎源码class FNodeFactory):

  1. 重载UEdGraphNode::CreateVisualWidget()函数,例如:
TSharedPtr<SGraphNode> UNiagaraNode::CreateVisualWidget() 
{
	return SNew(SNiagaraGraphNode, this);
}
  1. 使用 class FEdGraphUtilities 注册 class FGraphPanelNodeFactory对象,例如:
void FBehaviorTreeEditorModule::StartupModule()
{
	GraphPanelNodeFactory_BehaviorTree = MakeShareable( new FGraphPanelNodeFactory_BehaviorTree() );
	FEdGraphUtilities::RegisterVisualNodeFactory(GraphPanelNodeFactory_BehaviorTree);
}

在这里,我们使用第一种方式,也就是在class UBPNode_SaySomething中重载父类的虚函数CreateVisualWidget()。

TSharedPtr<SGraphNode> UBPNode_SaySomething::CreateVisualWidget() {
	return SNew(SGraphNodeSaySomething, this);
}

完成上述代码之后,运行蓝图编辑器,添加Say Something节点,就可以看到这个Widget了:
在这里插入图片描述

动态增加输入参数变量

当用户点击“Add Word +”按钮时,SGraphNodeSaySomething::OnAddPin()会被调用,下面是它的实现代码:

FReply SGraphNodeSaySomething::OnAddPin()
{
	UBPNode_SaySomething* BPNode = CastChecked<UBPNode_SaySomething>(GraphNode);

	const FScopedTransaction Transaction(NSLOCTEXT("Kismet", "AddArgumentPin", "Add Argument Pin"));
	BPNode->Modify();

	BPNode->AddPinToNode();
	FBlueprintEditorUtils::MarkBlueprintAsModified(BPNode->GetBlueprint());

	UpdateGraphNode();
	GraphNode->GetGraph()->NotifyGraphChanged();

	return FReply::Handled();
}

上面这段代码主要是响应用户的UI操作,添加Pin的核心操作,还是放在UBPNode_SaySomething::AddPinToNode()这个函数里面去实现的:

void UBPNode_SaySomething::AddPinToNode()
{
	TMap<FString, FStringFormatArg> FormatArgs= {
			{TEXT("Count"), ArgPinNames.Num()}
	};
	FName NewPinName(*FString::Format(TEXT("Word {Count}"), FormatArgs));
	ArgPinNames.Add(NewPinName);

	CreatePin(EGPD_Input, UEdGraphSchema_K2::PC_String, NewPinName);
}

现在我们就可以在蓝图编辑器里面操作添加输入Pin了 :
在这里插入图片描述

动态删除Pin

如果用户想要删除某个输入变量Pin,他需要在那个Pin上点击鼠标右键,呼出Context Menu,选择“删除”菜单项将其移除。下面我们就看看这个操作是如何实现的。

在这里插入图片描述

我们可以通过重载void UEdGraphNode::GetContextMenuActions(const FGraphNodeContextMenuBuilder& Context) const来定制Context Menu。

void UBPNode_SaySomething::GetContextMenuActions(const FGraphNodeContextMenuBuilder & Context) const
{
	Super::GetContextMenuActions(Context);

	if (Context.bIsDebugging)
		return;

	Context.MenuBuilder->BeginSection("UBPNode_SaySomething", FText::FromString(TEXT("Say Something")));

	if (Context.Pin != nullptr)
	{
		if (Context.Pin->Direction == EGPD_Input && Context.Pin->ParentPin == nullptr)
		{
			Context.MenuBuilder->AddMenuEntry(
				FText::FromString(TEXT("Remove Word")),
				FText::FromString(TEXT("Remove Word from input")),
				FSlateIcon(),
				FUIAction(
					FExecuteAction::CreateUObject(this, &UBPNode_SaySomething::RemoveInputPin, const_cast<UEdGraphPin*>(Context.Pin))
				)
			);
		}
	}// end of if

	Context.MenuBuilder->EndSection();
}

这个函数的实现很直白啦,就是操作MenuBuilder,添加菜单项,并绑定UIAction到成员函数UBPNode_SaySomething::RemoveInputPin,接下来就是实现这个函数了。

void UBPNode_SaySomething::RemoveInputPin(UEdGraphPin * Pin)
{
	FScopedTransaction Transaction(FText::FromString("SaySomething_RemoveInputPin"));
	Modify();

	ArgPinNames.Remove(Pin->GetFName());

	RemovePin(Pin);
	FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(GetBlueprint());
}

也很简单,就是直接调用父类的RemovePin(),并同步处理一下自己内部的状态变量就好了。

实现这个蓝图节点的编译

通过前面的步骤,蓝图编辑器的扩展就全部完成了,接下来就是最后一步了,通过扩展蓝图编译过程来实现这个节点的实际功能。

我们延续上篇的思路来实现这个节点的功能,也就是重载UK2Node::ExpandNode()函数。

核心的问题是如何把当前的所有的输入的Pin组合起来? 答案很简单,把所有输入的Pin做成一个TArray<>,这样就可以传入到一个UFunction来调用。

首先我们在 class UMyBlueprintFunctionLibrary 中添加一个函数:

UCLASS()
class MYBLUEPRINTNODE_API UMyBlueprintFunctionLibrary : public UBlueprintFunctionLibrary
{
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true"))
		static void SaySomething_Internal(const TArray<FString>& InWords);
};

然后,仍然与上篇相同,使用一个 class UK2Node_CallFunction 节点实例对象来调用这个UFunction,不同的是,我们需要使用一个 class UK2Node_MakeArray 节点的实例来把收集所有的动态生成的输入Pin。下面是实现的代码:


void UBPNode_SaySomething::ExpandNode(FKismetCompilerContext & CompilerContext, UEdGraph * SourceGraph) {
	Super::ExpandNode(CompilerContext, SourceGraph);

	UEdGraphPin* ExecPin = GetExecPin();
	UEdGraphPin* ThenPin = GetThenPin();
	if (ExecPin && ThenPin) {

		// create a CallFunction node
		FName MyFunctionName = GET_FUNCTION_NAME_CHECKED(UMyBlueprintFunctionLibrary, SaySomething_Internal);

		UK2Node_CallFunction* CallFuncNode = CompilerContext.SpawnIntermediateNode<UK2Node_CallFunction>(this, SourceGraph);
		CallFuncNode->FunctionReference.SetExternalMember(MyFunctionName, UBPNode_SaySomething::StaticClass());
		CallFuncNode->AllocateDefaultPins();

		// move exec pins
		CompilerContext.MovePinLinksToIntermediate(*ExecPin, *(CallFuncNode->GetExecPin()));
		CompilerContext.MovePinLinksToIntermediate(*ThenPin, *(CallFuncNode->GetThenPin()));

		// create a "Make Array" node to compile all args
		UK2Node_MakeArray* MakeArrayNode = CompilerContext.SpawnIntermediateNode<UK2Node_MakeArray>(this, SourceGraph);
		MakeArrayNode->AllocateDefaultPins();

		// Connect Make Array output to function arg
		UEdGraphPin* ArrayOut = MakeArrayNode->GetOutputPin();
		UEdGraphPin* FuncArgPin = CallFuncNode->FindPinChecked(TEXT("InWords"));
		ArrayOut->MakeLinkTo(FuncArgPin);
		
		// This will set the "Make Array" node's type, only works if one pin is connected.
		MakeArrayNode->PinConnectionListChanged(ArrayOut);

		// connect all arg pin to Make Array input
		for (int32 i = 0; i < ArgPinNames.Num(); i++) {

			// Make Array node has one input by default
			if (i > 0)
				MakeArrayNode->AddInputPin();

			// find the input pin on the "Make Array" node by index.
			const FString PinName = FString::Printf(TEXT("[%d]"), i);
			UEdGraphPin* ArrayInputPin = MakeArrayNode->FindPinChecked(PinName);

			// move input word to array 
			UEdGraphPin* MyInputPin = FindPinChecked(ArgPinNames[i], EGPD_Input);
			CompilerContext.MovePinLinksToIntermediate(*MyInputPin, *ArrayInputPin);
		}// end of for
	}

	// break any links to the expanded node
	BreakAllNodeLinks();
}

核心步骤来讲解一下:

  1. 创建了一个class UK2Node_CallFunction的实例,然后把自身节点的两端的Exec Pin重定向到这个Node的两端;
  2. 使用“函数参数名称”找到UK2Node_CallFunction节点的输入Pin,把它连接到一个新建的UK2Node_MakeArray的节点实例上;
  3. 把自己所有的输入变量Pin重定向到UK2Node_MakeArray的输入上(需要为它动态添加新的Pin);

结束语

今天涉及到的class稍微有点多,我整理了一个UML静态结构图,看看这几个classes直接的关系以及它们所在的模块。完整源代码仍然是在我的GitHub:https://github.com/neil3d/UnrealCookBook/tree/master/MyBlueprintNode
在这里插入图片描述
至此,通过派生class UK2Node和class SGraphNodeK2Base来扩展Blueprint Graph Editor,我们可以自己定义蓝图节点,以及编辑器中的Node Widget,可以添加按钮,以及其他任何你想要做的东西。通过这个定制化的Node Widget,可以实现编辑时对Blueprint Graph Node的交互控制。至此,我们已经掌握了最强大的蓝图节点的扩展方法。动态添加Pin这个问题说明白之后,下篇将写什么呢?先卖个关子,且待下回分解吧~

2019-04-27 10:59:52 Neil3D 阅读数 1223
  • 从这里开始虚幻4-第2辑-蓝图 v4.18

    本课程系列取名英译系列,是录制人员参考国外英文原版经典教程,结合中国人的习惯录制而成。希望能够给大家以帮助。从这里开始虚幻4系列教程,是Unreal的官方发布的入门教学,非常经典,是学习Unreal的佳入口。

    2243 人正在学习 去看看 杨石兴

Unreal的蓝图和C++一样,也是一种静态类型的编程语言,它又不像其他静态类型语言那样支持模板,有些时候就觉得很不方便。思考了一下这个问题。想要蓝图节点支持任意类型的参数,主要分为两种情况:

  • UObject派生类对象:那很简单了,使用基类指针作为参数就好,在C++里面可以Cast,或者取得对象的UClass,就可以根据反射信息做很多事了;
  • Struct类型,或者TArray<MyStruct>类型:这个是本文的重点。

其实说蓝图完全不支持“模板”也是不对的,引擎中其实已经有很多能够处理任意Struct或者TArray<MyStruct>类型的节点了!官方文档中把这种情况叫做参数“Wildcard”(通配符)。感谢Unreal开源,通过阅读源代码,加上一点实验,就能够搞清楚具体实现方法和背后的细节。

下面主要探讨使用UFUNCTION的CustomThunk描述符,实现自定义的Thunk函数;然后通过指定meta的CustomStructureParamArrayParm参数,来实现参数类型“通配符”!这中间的难点是:需要明确蓝图Stack的处理方式。Demo如下图所示:
在这里插入图片描述

在上图的Demo中:

  1. 自定义了一个蓝图Struct:MyStruct
  2. 使用C++实现了一个蓝图节点“Show Struct Fields”:可以接受任意UStruct的引用,具体类型可以由C++或者蓝图定义;
  3. 蓝图节点“Array Numeric Field Average”:可以接受任意类型的TArray<MyStruct>,并对数组中指定的数值型字段求平均;

完整的Demo工程可以从我的GitHub下载:https://github.com/neil3d/UnrealCookBook

实现蓝图功能节点的几种方式

在Unreal开发中可以使用C++对蓝图进行扩展,生成Unreal蓝图节点最方便的方法就是写一个UFUNCTION,无论是定义在UBlueprintFunctionLibrary派生类里面的static函数,还是定义在UObject、AActor派生类里面的类成员函数,只要加上UFUNCTION宏修饰,并在宏里面添加BlueprintCallable标识符,就可以自动完成蓝图编辑节点、蓝图节点执行调用的整个过程。不过,由于C++和蓝图都属于“静态类型”编程语言,这种形式编写的蓝图节点,所有的输入、输出参数的类型都必须是固定的,这样引擎才能自动处理蓝图虚拟机的栈。

先来总结一下C++实现蓝图节点的几种方式:

  1. UFUNCTION,上面已经说过了;
  2. 实现class UK2Node的派生类,这是最强大的方式,是对蓝图节点最深入的定制开发,如果你需要动态的添加、删除蓝图节点的针脚,就只能用这种方式了。例如我们常用的“Format Text”节点,可以根据输入字符串中的“{index}”来动态增加输入节点,输入节点的类型也是动态的,这个就是通过class UK2Node_FormatText这个类来实现的;
  3. 还有介于上面两者之间的一种方式,就是在UFUNCTION中使用“CustomThunk”标识,告诉UHT(Unreal Header Tool)不要生成默认的蓝图包装函数,而是由我们手工实现。这种方式,需要手工控制蓝图虚拟机的“栈”,但是不用处理蓝图编辑器UI部分,相对第2种来说代码量要少很多,相对第1种来说,又多了很多控制力;
  4. 另外,蓝图的“宏”–Macros,也可以实现自己的节点。

使用第3种方式,结合UFUNCTION的其它meta标识符,可以实现参数类型的“通配符”,就可以实现模板函数,也就是输入、输出参数可以处理多种数据类型,类似C++的泛型。这些meta标识符主要有:

  1. ArrayParm="Parameter1, Parameter2, ..":说明 BlueprintCallable 函数应使用一个Call Array Function节点,且列出的参数应被视为通配符数组属性;
  2. ArrayTypeDependentParams="Parameter":使用 ArrayParm 时,此说明符将指定一个参数,其将确定 ArrayParm 列表中所有参数的类型;
  3. CustomStructureParam="Parameter1, Parameter2, ..":列出的参数都会被视为通配符。

引擎源代码中,这种编程方式的典型的例子有:

  • 蓝图编辑器中的“Utilities”->“Array”菜单中的所有节点,他们可以处理任意的UStruct类型的数组。这些节点对应的源代码是:class UKismetArrayLibrary
  • class UDataTableFunctionLibrary::GetDataTableRowFromName(UDataTable* Table, FName RowName, FTableRowBase& OutRow)

详见官方文档:UFunctions

CustomThunk函数

如果在UFUNCTION宏里面指定了CustomThunk,那么UHT就不会自动生成这个函数的“thunk”,而需要开发者自己实现。这里的“thunk”是什么呢?我们看个例子。

我们来做个最简单的小试验,在工程中建立一个Blueprint Function Library,添加一个简单的UFUNCTION:

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "MyBlueprintFunctionLibrary.generated.h"

UCLASS()
class MYBLUEPRINTNODES_API UMyBlueprintFunctionLibrary : public UBlueprintFunctionLibrary
{
	GENERATED_BODY()
public:
	UFUNCTION(BlueprintCallable)
	static int Sum(int a, int b);
};

然后在对应的cpp文件中,使用C++实现这个函数:

#include "MyBlueprintFunctionLibrary.h"

int UMyBlueprintFunctionLibrary::Sum(int a, int b) {
	return a + b;
}

项目build一下,然后你就可以在“Intermediate”目录找到这个"MyBlueprintFunctionLibrary.generated.h"文件。在这个文件里面,你可以找到这样一段代码:

    DECLARE_FUNCTION(execSum) \
	{ \
		P_GET_PROPERTY(UIntProperty,Z_Param_a); \
		P_GET_PROPERTY(UIntProperty,Z_Param_b); \
		P_FINISH; \
		P_NATIVE_BEGIN; \
		*(int32*)Z_Param__Result=UMyBlueprintFunctionLibrary::Sum(Z_Param_a,Z_Param_b); \
		P_NATIVE_END; \
	}

这段代码就是蓝图函数节点的thunk了!这段代码做了这样几件事:

  1. 声明了一个名为“execSum”的函数,函数的签名为:void func( UObject* Context, FFrame& Stack, RESULT_DECL )
  2. 使用P_GET_PROPERTY宏,从“FFrame& Stack”(也就是蓝图虚拟机的栈)中取出函数参数;
  3. 调用P_FINISH宏;
  4. 使用取出的这些参数调用我们实现的UMyBlueprintFunctionLibrary::Sum()函数;

“thunk”函数是一个包装,它完成的核心任务就是处理蓝图虚拟机的Stack,然后调用我们使用C++实现的函数。

我们还可以看一下UHT帮我们生成的另外一个文件:MyBlueprintFunctionLibrary.gen.cpp,在其中有这样一段代码:

void UMyBlueprintFunctionLibrary::StaticRegisterNativesUMyBlueprintFunctionLibrary()
	{
		UClass* Class = UMyBlueprintFunctionLibrary::StaticClass();
		static const FNameNativePtrPair Funcs[] = {
			{ "Sum", &UMyBlueprintFunctionLibrary::execSum },
		};
		FNativeFunctionRegistrar::RegisterFunctions(Class, Funcs, ARRAY_COUNT(Funcs));
	}

这段代码把刚才"MyBlueprintFunctionLibrary.generated.h"中声明的excSum函数注册到了UMyBlueprintFunctionLibrary::StaticClass()这个UClass对象之中,并指定它的名字为“Sum”,也就是我们原始C++代码中声明的函数名,也是在蓝图编辑器中显示的名字。

看清楚了什么是“thunk函数”,“CustomThunk函数”也就不言自明了。在UFUNCTION中指定“CustomThunk”标识符,就是告诉UHT,不要在.generated.h中生成DECLARE_FUNCTION那部分代码,这部分代码改由手写。为啥要抛弃自动生成,而手写呢?回到本文主题:要实现“参数类型通配符”(或者叫做“蓝图模板节点”),就必须手写thunk!

蓝图Stack探索

要实现自己的thunk函数,核心任务就是“准确的处理蓝图虚拟机的栈”,可惜的是官方并没有这方面的文档!下面我就把自己的一些探索记录下来,请大家指正。

以上面的int Sum(int a, int b)函数为例,thunk函数使用P_GET_PROPERTY宏从Stack取值,这个宏P_GET_PROPERTY(UIntProperty,Z_Param_a)展开之后的代码如下所示:

	UIntProperty::TCppType Z_Param_a = UIntProperty::GetDefaultPropertyValue();
	Stack.StepCompiledIn<UIntProperty>(&Z_Param_a);

其中UIntProperty派生自TProperty_Numeric<int32>UIntProperty::TCppType就是“int32”无疑!

我们还需要处理TArray<MyStruct>这样的数据,所以我们重点要看一下这种参数类型的栈处理。
假设我们有一个C++的UStruct:

USTRUCT(Blueprintable)
struct FMyStruct {
	GENERATED_USTRUCT_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FString Name;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	int Value;
};

类似这样一个UFUNCTION:

UFUNCTION(BlueprintCallable)
static void PrintMyStructArray(const TArray<FMyStruct>& MyStructArray);

则在.h中的thunk函数为:

DECLARE_FUNCTION(execPrintMyStructArray) \
	{ \
		P_GET_TARRAY_REF(FMyStruct,Z_Param_Out_MyStructArray); \
		P_FINISH; \
		P_NATIVE_BEGIN; \
		UMyBlueprintFunctionLibrary::PrintMyStructArray(Z_Param_Out_MyStructArray); \
		P_NATIVE_END; \
	} \

其中P_GET_TARRAY_REF(FMyStruct,Z_Param_Out_MyStructArray);这个宏展开之后的代码为:

PARAM_PASSED_BY_REF(Z_Param_Out_MyStructArray, UArrayProperty, TArray<FMyStruct>)

最终展开为:

TArray<FMyStruct> Z_Param_Out_MyStructArrayTemp;
TArray<FMyStruct>& Z_Param_Out_MyStructArray = Stack.StepCompiledInRef<UArrayProperty, TArray<FMyStruct> >(&Z_Param_Out_MyStructArrayTemp);

综合上面两个例子,我们发现核心操作都是调用template<class TProperty> void FFrame::StepCompiledIn(void*const Result)这个模板函数。通过跟踪这个函数的执行,发现它实际调用了UObject::execInstanceVariable()函数。

  1. 更新"FFrame::PropertyChainForCompiledIn"这个成员变量;
  2. 使用更新后的“FFrame::PropertyChainForCompiledIn”值,更新了"FFrame::MostRecentPropertyAddress"成员变量。

再结合引擎中CustomThunk函数的实现源码,可以得出这样的结论:

  1. 通过调用Stack.StepCompiledIn()函数,就可以更新蓝图虚拟机的栈顶指针;

  2. Stack.MostRecentPropertyAddressStack.MostRecentProperty这两个变量,就是当前参数值的内存地址和反射信息。

有了具体变量的内存地址和类型的反射信息,就足够做很多事了。下面我们就开始实践。

实践1:接受任意UStruct类型参数

下面我们就看一下文章开头的这张图里面的蓝图节点“Show Struct Fields”是如何接受任意类型UStruct参数的。

先上代码, BlueprintWildcardLibrary.h

USTRUCT(BlueprintInternalUseOnly)
struct FDummyStruct {
	GENERATED_USTRUCT_BODY()

};

UCLASS()
class UNREALCOOKBOOK_API UBlueprintWildcardLibrary : public UBlueprintFunctionLibrary {
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintCallable, CustomThunk, Category = "MyDemo", meta = (CustomStructureParam = "CustomStruct"))
		static void ShowStructFields(const FDummyStruct& CustomStruct);
	static void Generic_ShowStructFields(const void* StructAddr, const UStructProperty* StructProperty);

	DECLARE_FUNCTION(execShowStructFields) {

		Stack.MostRecentPropertyAddress = nullptr;
		Stack.MostRecentProperty = nullptr;

		Stack.StepCompiledIn<UStructProperty>(NULL);
		void* StructAddr = Stack.MostRecentPropertyAddress;
		UStructProperty* StructProperty = Cast<UStructProperty>(Stack.MostRecentProperty);


		P_FINISH;

		P_NATIVE_BEGIN;
		Generic_ShowStructFields(StructAddr, StructProperty);
		P_NATIVE_END;
	}
};

BlueprintWildcardLibrary.cpp

#include "BlueprintWildcardLibrary.h"
#include "Engine/Engine.h"

void UBlueprintWildcardLibrary::Generic_ShowStructFields(const void* StructAddr, const UStructProperty* StructProperty) {
	UScriptStruct* Struct = StructProperty->Struct;
	for (TFieldIterator<UProperty> iter(Struct); iter; ++iter) {

		FScreenMessageString NewMessage;
		NewMessage.CurrentTimeDisplayed = 0.0f;
		NewMessage.Key = INDEX_NONE;
		NewMessage.DisplayColor = FColor::Blue;
		NewMessage.TimeToDisplay = 5;
		NewMessage.ScreenMessage = FString::Printf(TEXT("Property: [%s].[%s]"),
			*(Struct->GetName()),
			*(iter->GetName())
		);
		NewMessage.TextScale = FVector2D::UnitVector;
		GEngine->PriorityScreenMessages.Insert(NewMessage, 0);
	}
}

解释一下这段代码:

  1. 首先声明了一个UFunction:static void ShowStructFields(const FDummyStruct& CustomStruct);,其参数类型是“FDummyStruct”,这只是一个占位符;
  2. 在UFUNCTION宏里面指定“CustomThunk”和“CustomStructureParam”;
  3. 实现一个execShowStructFields函数。这个函数很简单,主要是处理蓝图的Stack,从中取出需要的参数,然后对用C++的实现;
  4. 具体功能实现在:static void Generic_ShowStructFields(const void* StructAddr, const UStructProperty* StructProperty)这个函数中。

实践2:对数组中的Struct的数值型求平均

下面我们再来一下文章开头的这张图里面的“Array Numeric Field Average”蓝图节点是如何通过“CustomThunk”函数来实现的。

参照引擎源代码,我定义了这样一个宏,用来从栈上取出泛型数组参数,并正确的移动栈指针:

#define P_GET_GENERIC_ARRAY(ArrayAddr, ArrayProperty) Stack.MostRecentProperty = nullptr;\
		Stack.StepCompiledIn<UArrayProperty>(NULL);\
		void* ArrayAddr = Stack.MostRecentPropertyAddress;\
		UArrayProperty* ArrayProperty = Cast<UArrayProperty>(Stack.MostRecentProperty);\
		if (!ArrayProperty) {	Stack.bArrayContextFailed = true;	return; }

通过这个宏,可以得到两个局部变量:

  • void* ArrayAddr: 数组的起始内存地址;
  • UArrayProperty* ArrayProperty: 数组的反射信息,ArrayProperty->Inner就是数组成员对应的类型了;

有了这个宏,我们就可以很方便的写出thunk函数了:

DECLARE_FUNCTION(execArray_NumericPropertyAverage) {

		// get TargetArray
		P_GET_GENERIC_ARRAY(ArrayAddr, ArrayProperty);

		// get PropertyName
		P_GET_PROPERTY(UNameProperty, PropertyName);

		P_FINISH;

		P_NATIVE_BEGIN;
		*(float*)RESULT_PARAM = GenericArray_NumericPropertyAverage(ArrayAddr, ArrayProperty, PropertyName);
		P_NATIVE_END;
	}

经过以上的准备,我们就已经可以正确的处理“泛型数组”了。下一步就是对这个数组中指定的数“值类型成员变量”求均值了,这主要依靠Unreal的反射信息,一步步抽丝剥茧,找到数组中的每个变量即可。反射系统的使用不是本文的重点,先看完整代码吧。

BlueprintWildcardLibrary.h

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "BlueprintWildcardLibrary.generated.h"

#define P_GET_GENERIC_ARRAY(ArrayAddr, ArrayProperty) Stack.MostRecentProperty = nullptr;\
		Stack.StepCompiledIn<UArrayProperty>(NULL);\
		void* ArrayAddr = Stack.MostRecentPropertyAddress;\
		UArrayProperty* ArrayProperty = Cast<UArrayProperty>(Stack.MostRecentProperty);\
		if (!ArrayProperty) {	Stack.bArrayContextFailed = true;	return; }

UCLASS()
class UNREALCOOKBOOK_API UBlueprintWildcardLibrary : public UBlueprintFunctionLibrary {
	GENERATED_BODY()

public:

	UFUNCTION(BlueprintPure, CustomThunk, meta = (DisplayName = "Array Numeric Property Average", ArrayParm = "TargetArray", ArrayTypeDependentParams = "TargetArray"), Category = "MyDemo")
		static float Array_NumericPropertyAverage(const TArray<int32>& TargetArray, FName PropertyName);
	static float GenericArray_NumericPropertyAverage(const void* TargetArray, const UArrayProperty* ArrayProperty, FName ArrayPropertyName);

public:
	DECLARE_FUNCTION(execArray_NumericPropertyAverage) {

		// get TargetArray
		P_GET_GENERIC_ARRAY(ArrayAddr, ArrayProperty);

		// get PropertyName
		P_GET_PROPERTY(UNameProperty, PropertyName);

		P_FINISH;

		P_NATIVE_BEGIN;
		*(float*)RESULT_PARAM = GenericArray_NumericPropertyAverage(ArrayAddr, ArrayProperty, PropertyName);
		P_NATIVE_END;
	}
};

BlueprintWildcardLibrary.cpp


#include "BlueprintWildcardLibrary.h"
#include "Engine/Engine.h"

float UBlueprintWildcardLibrary::Array_NumericPropertyAverage(const TArray<int32>& TargetArray, FName PropertyName) {
	// We should never hit these!  They're stubs to avoid NoExport on the class.  Call the Generic* equivalent instead
	check(0);
	return 0.f;
}

float UBlueprintWildcardLibrary::GenericArray_NumericPropertyAverage(const void* TargetArray, const UArrayProperty* ArrayProperty, FName PropertyName) {

	UStructProperty* InnerProperty = Cast<UStructProperty>(ArrayProperty->Inner);
	if (!InnerProperty) {
		UE_LOG(LogTemp, Error, TEXT("Array inner property is NOT a UStruct!"));
		return 0.f;
	}

	UScriptStruct* Struct = InnerProperty->Struct;
	FString PropertyNameStr = PropertyName.ToString();
	UNumericProperty* NumProperty = nullptr;
	for (TFieldIterator<UNumericProperty> iter(Struct); iter; ++iter) {
		if (Struct->PropertyNameToDisplayName(iter->GetFName()) == PropertyNameStr) {
			NumProperty = *iter;
			break;
		}
	}
	if (!NumProperty) {
		UE_LOG(LogTemp, Log, TEXT("Struct property NOT numeric = [%s]"),
			*(PropertyName.ToString())
		);
	}


	FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray);
	int Count = ArrayHelper.Num();
	float Sum = 0.f;

	if(Count <= 0)
		return 0.f;

	if (NumProperty->IsFloatingPoint())
		for (int i = 0; i < Count; i++) {
			void* ElemPtr = ArrayHelper.GetRawPtr(i);
			const uint8* ValuePtr = NumProperty->ContainerPtrToValuePtr<uint8>(ElemPtr);
			Sum += NumProperty->GetFloatingPointPropertyValue(ValuePtr);

		}
	else if (NumProperty->IsInteger()) {
		for (int i = 0; i < Count; i++) {
			void* ElemPtr = ArrayHelper.GetRawPtr(i);
			const uint8* ValuePtr = NumProperty->ContainerPtrToValuePtr<uint8>(ElemPtr);
			Sum += NumProperty->GetSignedIntPropertyValue(ValuePtr);
		}
	}
	// TODO: else if(enum类型)

	return Sum / Count;
}
2019-08-16 21:09:15 Neil3D 阅读数 343
  • 从这里开始虚幻4-第2辑-蓝图 v4.18

    本课程系列取名英译系列,是录制人员参考国外英文原版经典教程,结合中国人的习惯录制而成。希望能够给大家以帮助。从这里开始虚幻4系列教程,是Unreal的官方发布的入门教学,非常经典,是学习Unreal的佳入口。

    2243 人正在学习 去看看 杨石兴

前面一篇文章中介绍了扩展蓝图的几种方法,其中最灵活的就是自定义K2Node了,也就是通过定义一个class UK2Node的派生类来给蓝图添加新的节点。这种方法开发起来一起是最麻烦的,一般只有在需要“动态增加Pin”的时候才使用这种方式。

下面我们就一步一步的来实现一个最简单的“Hello World蓝图节点”,通过这个节点我们来看看扩展K2Node的关键步骤。在后面一篇博客再去实现“动态增加输入项”的节点

项目创建

我们创建一个C++: Basic Code工程,然后,手动添加一个Editor模块,如下图所示:
在这里插入图片描述
手动添加这个模块的操作如下:

  1. 修改 .uproject 文件,在Modules字段,添加新的项目,命名为:MyBlueprintNodeEditor,Type设置为:Editor;
  2. 在Source目录下建立一个子目录,命名为: MyBlueprintNodeEditor,这个子目录就是保存这个模块的所有源文件了;
  3. 在这个子目录下创建一个:MyBlueprintNodeEditor.Build.cs,用来实现UBT对这个模块的build的配置;并把“BlueprintGraph”模块添加的PrivateDependencyModuleNames中;
  4. 在“MyBlueprintNodeEditor.Target.cs”文件中添加这个模块:
ExtraModuleNames.AddRange( new string[] {
          "MyBlueprintNode",
          "MyBlueprintNodeEditor"
      } );

为什么要添加一个Editor模块呢?因为class UK2Node这个类属于一个名为“BlueprintGraph”的Editor模块,而蓝图的Graph编辑,Compile之后生成一个class UBlueprintGeneratedClass的实例,而UK2Node这些对象的实例在Runtime是不需要的。

创建新的节点类型

首先,我们来创建一个class UK2Node的派生类,命名为:class UBPNode_SayHello

UCLASS()
class MYBLUEPRINTNODEEDITOR_API UBPNode_SayHello : public UK2Node
{
	GENERATED_BODY()
	
};

有了这个类之后,在Blueprint编辑器里面还不能创建它。我们需要把这个节点的创建操作添加到右键菜单中,并且稍微美化一下这个节点的显示,通过以下几个函数来完成。

UCLASS()
class MYBLUEPRINTNODEEDITOR_API UBPNode_SayHello : public UK2Node
{
	GENERATED_BODY()

public:
	// UEdGraphNode interface
	virtual FText GetTooltipText() const override { return FText::FromString(TEXT("a hello world node")); }
	virtual FText GetNodeTitle(ENodeTitleType::Type TitleType) const override { return FText::FromString(TEXT("Say Hello World")); }
	// End of UEdGraphNode interface

	// UK2Node interface
	virtual FText GetMenuCategory() const { return FText::FromString(TEXT("MyBlueprintNodes")); }
	virtual void GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const override;
	// End of UK2Node interface
};

这几个函数的名字已经足够说明它们的作用了,其中最重要的就是“GetMenuActions()”这个了,它的实现代码如下:

void UBPNode_SayHello::GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const
{
	UClass* ActionKey = GetClass();

	if (ActionRegistrar.IsOpenForRegistration(ActionKey))
	{
		UBlueprintNodeSpawner* NodeSpawner = UBlueprintNodeSpawner::Create(GetClass());
		check(NodeSpawner != nullptr);

		ActionRegistrar.AddBlueprintAction(ActionKey, NodeSpawner);
	}
}

以上简单代码之后,我们就可以通过右键菜单创建一个空的节点了,如下图所示:
在这里插入图片描述

实现节点连接

接下来,需要把这个Node连接到整个Graph,才能使它能够被调用。我们这个节点将通过Pin来连接到整个蓝图(Pure Node没有Pin)。通过override父类的AllocateDefaultPins()函数,我们来添加需要的两个pin:

void UBPNode_SayHello::AllocateDefaultPins() {

	CreatePin(EGPD_Input, UEdGraphSchema_K2::PC_Exec, UEdGraphSchema_K2::PN_Execute);
	CreatePin(EGPD_Output, UEdGraphSchema_K2::PC_Exec, UEdGraphSchema_K2::PN_Then);
}

我们添加了一个Input Pin、一个Output Pin,实现效果如下图所示:
在这里插入图片描述

实现节点的功能

上面这个节点编译就会报错,因为我们还没有实现最核心的蓝图节点编译函数。在这里,我们通过重载“ExpandNode()”来实现这个节点的功能。我个人目前理解这个函数的名字含义是:通过把这个节点展开成一个已有节点类型组成的Sub Graph来实现本节点的功能。先看代码吧:

void UBPNode_SayHello::ExpandNode(FKismetCompilerContext & CompilerContext, UEdGraph * SourceGraph) {
	Super::ExpandNode(CompilerContext, SourceGraph);

	UEdGraphPin* ExecPin = GetExecPin();
	UEdGraphPin* ThenPin = GetThenPin();
	if (ExecPin && ThenPin) {

		// create a CallFunction node
		FName MyFunctionName = GET_FUNCTION_NAME_CHECKED(UMyBlueprintFunctionLibrary, SayHello_Internal);

		UK2Node_CallFunction* CallFuncNode = CompilerContext.SpawnIntermediateNode<UK2Node_CallFunction>(this, SourceGraph);
		CallFuncNode->FunctionReference.SetExternalMember(MyFunctionName, UMyBlueprintFunctionLibrary::StaticClass());
		CallFuncNode->AllocateDefaultPins();

		// move pins
		CompilerContext.MovePinLinksToIntermediate(*ExecPin, *(CallFuncNode->GetExecPin()));
		CompilerContext.MovePinLinksToIntermediate(*ThenPin, *(CallFuncNode->GetThenPin()));
	}

	// break any links to the expanded node
	BreakAllNodeLinks();
}

这段代码通过新建一个UK2Node_CallFunction节点,然后把Pin重新绑定来实现所需的功能;UK2Node_CallFunction节点可以调用任意的UFUNCTION,在这里我们调用一个自己的简单函数:UMyBlueprintFunctionLibrary::SayHello_Internal(),这个函数实现很简单,需要注意的是在它的UFUNCTION声明里面,我知道了它为“BlueprintInternalUseOnly”,具体看代码:

UCLASS()
class MYBLUEPRINTNODE_API UMyBlueprintFunctionLibrary : public UBlueprintFunctionLibrary
{
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true"))
	static void SayHello_Internal();
};

结束语

上面这个例子的完整UE4 Project和源代码都已经上传到我的GitHub供大家参考:https://github.com/neil3d/UnrealCookBook/tree/master/MyBlueprintNode

通过这个例子,我们已经掌握了最基本的K2Node扩展的方式,其实很简单,主要是重写父类class UK2Node以及父类的父类class UEdGraphNode的一些关键方法。当然,目前这个节点和标准的UFUNCTION节点没什么差别,在下一篇博客中我们将实现一个动态分配Pin的节点,那才能体现这种方法的价值。

unreal4 源码引言

阅读数 261

博文 来自: sgnyyy
没有更多推荐了,返回首页