unreal 蓝图使用_unreal 蓝图 - CSDN
精华内容
参与话题
  • 之前几个篇章中主要讲到如何使用C++的代码实现相关游戏的相关功能,我们已经学习到了Actor是什么东西, 经常会使用Empty Actor这个东西,那么它到底是什么,我们可以这样假设Actor是我们定义一个物体,这个物体但是...

    之前几个篇章中主要讲到如何使用C++的代码实现相关游戏的相关功能,我们已经学习到了Actor是什么东西,

    经常会使用Empty Actor这个东西,那么它到底是什么,我们可以这样假设Actor是我们定义一个物体,这个物体但是现在看不到,也摸不着,但是有些整体的属性,相当于于一个人的灵魂吗,有灵魂之后才能有物体,有感知,有行动,已经对外界做出相关的反应,不知道这个比喻恰不恰当。^_^

    这篇中我们将讲述如何使用Blueprint制作一个发射台,当玩家(游戏里指的player,使用的pawn类,这个类具体的用法可以自己查阅,不过我们现在应该更关心Actor类。)跑到这个发射台之后,就会被弹起,应该弹3000高吧,估计是3m的样子,这里的单位不知道是什么,反正从图形上来看是挺高的。

    这个工程只是添加了一个发射台,其余的什么其它事情都没有干。

    玩家是模板已经建立好的,可以通过A, D键控制左右的移动。

    (1)新建一个蓝图的工程,这里使用的Side Scroller 模板进行建立。

    英文讲述在

    https://docs.unrealengine.com/latest/INT/Engine/Blueprints/QuickStart/1/index.html

    命名,然后就会打开UEditor4进行编辑。

    首先选择一个Actor拖到Level Editor界面中,然后添加两个Component, 一个是Cube,一个是Box.

    增加cube可以让它显示出来,这里的通过Basic Shapes里的cube进行添加。

    另外一个是Box,可以理解为检测碰撞的盒子,就是其它的物体和这个盒子碰撞之后就会被弹起来。

    通过缩放,可以将它们的cube设计称(1, 1, 0,1), 将box设计称(1.25, 1.25, 1.5),就会显示称下面的样式。

    (2)到这里我们的弹跳台就设计完成了,那么接下来的工作就是设计它的工作逻辑,我们这里使用的是blueprint,在Add component的右边有ADD blueprint接口,将这个actor转换为blueprint,保存,打开命令新的文件。

    我们要画的蓝图是下面这个样子的

    是不是看起来很有趣?

    那么为什么要画成这个样子呢?

    首先点我们的Event Graph界面,然后点击Box component,那么会弹出下面的图谱。


    首先给Box 增加一个OnComponentBeginOverlap node(节点),任何联系上这个时间的node, 当有东西和这个Box 组件接触的时候会被执行。


    然后从other actor中拉出一条线出来,会有一个提示框,我们选择等号。

    选取另外一个Actor为pawn,那么执行会生成下面的图形

    这里的意思就是GetPlayerPawn函数的返回值是另外一个Actor。

    然后我们拖拽出执行的线条,我们通过Branch关键字进行搜索。可以收缩到branch分支。

    然后把equal分支和Condition分支连上,也就是说只有是pawn类的时候,这个才会执行,当然选择的条件应该是true。

    我们的发射台的功能执行是通过一个调用Launch Character函数工作的,Launch Charactor函数,它会在你制定的Character上增加速度,允许你任意方向。它作用于Charactor类,我们应该确保我们的Pawn(avatar)是一个Character(humanoid avatar).

    我们通过类型转换做到这点。类型转换允许你将你的输入转化为不停的类型,因此你可以使用特别类上的一些功能函数。如果你的输入的基类是那个类型,那么就会成功。

    任何在level上的物体都是Actor,因此可以转换,但是任何在Level上的物体不一定是pawn,所以不能随意转换。

    通过拖拽Get Player Pawn函数的Return Value接口,我们敲一下Cast,就会有提示Cast to Character。

    然后我们选择As Character,然后进行拖拽,选择Launch Character函数,那么就会执行相关的功能。

    最后我们设置Z轴的坐标为3000.

    然后编译,拖拽几个去相关的接口,然后play,可以看到我们的玩家在接触到LanuchPad的时候被弹得很高。


    最后布置了几道习题,目前只会做第一道,看起来效果还挺不错的。

    • Play a sound when your Character is launched using an Audio Component.

    • Create a variable to store your Launch Velocity, and expose it so you can set it on each copy in the level.

    • Add a Particle System Component to your Blueprint and use one of the Particle Systems from the Starter Content.

    • Add an Arrow Component and use its rotation to define the direction to launch the character.

    • Using Timelines, add some animation to the Box Mesh to represent it launching the character.


    就是在跳起来的时候加一个声音Sound Component,这个还比较好实现,

    蓝图长下面这个样子的。

    还是挺好实现相关的逻辑的。




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

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

    蓝图的发展历程

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

    就像很多其他的创新一样,它也是有一个渐进的过程的。它的萌芽就是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函数来完成具体的操作;

    参考资料

    展开全文
  • 通过派生class UK2Node和class SGraphNodeK2Base,为蓝图添加自定义节点,实现一个“动态添加输入Pin”的蓝图节点。

    通过本系列文章上篇的介绍,我们已经可以创建一个“没什么用”的蓝图节点了。要想让它有用,关键还是上篇中说的典型应用场景:动态添加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这个问题说明白之后,下篇将写什么呢?先卖个关子,且待下回分解吧~

    展开全文
  • 使用UFunction CustomThunk函数方式,实现蓝图模板功能节点,用来处理任意类型的数组,并探索实现细节背后的蓝图机制。

    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/tree/master/MyBlueprintNode

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

    在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;
    }
    
    展开全文
  • 通过派生class UK2Node,为蓝图添加自定义节点;这篇博客我们先实现一个最简单的自定义节点,下篇文章将完成“动态添加输入Pin”的蓝图节点。
  • 前面两篇博客我们都是通过ExpandNode来实现蓝图节点的功能,这一篇来介绍另外一种重要的蓝图扩展方式,就是Node Handler。
  • Unreal蓝图入门 节点

    2019-12-21 16:49:45
    最近开始摸索Unreal蓝图基础使用,一些基本的操作大家可以在官方网站中的教程中找到,所以我们直接跳到事件图表上来讲。 首先,Unreal有丰富的注释功能,按住键盘C可以创建出注释区域,在右侧的“细节”面板我们...
  • Unreal Engine4 蓝图入门

    2019-07-24 20:12:07
    微信公众号:UE交流学习 UE4开发群:344602753 蓝图Unreal Engine的特点,用C++编程固然好,但是效率要低很多,主要是国内资料比较少,所以不太容易学习,用蓝图编程可以节省开发成本,我认为蓝图编程也是可以解决...
  • 引擎中提供两种蓝图异步节点的实现方式,这里我们主要介绍 Blueprint Async Action 的实现方式。
  • Unreal Engine4 蓝图讲解

    2019-07-24 20:12:07
    UE4开发群:344602753 Unread Engine4的界面概况: UE4的效果可以说是比较好的,从整体架构上来说,和Unity...
  • C++公开给蓝图 UFUNCTION(BlueprintCallable, Category = "MyCppFunc") void MyFunc(); C++调用蓝图(在头文件声明,蓝图内实现即可,无需在cpp文件实现) UFUNCTION(BlueprintImplementableEvent, Category = ...
  • electron仿制UnrealEngine4蓝图功能模块 1. electron的下载和安装 2. 右键菜单的实现 累的吐血。。。 2017/9/22更新 菜单节点控制var treeMenuNode = { instantObject: function (name,type) { var ...
  • 本节书摘来异步社区《Unreal Engine 4蓝图可视化编程》一书中的第1章,第1.1节,作者: Brenden Sewell 译者: 陈东林 责编: 胡俊英,更多章节内容可以访问云栖社区“异步社区”公众号查看。 第1章 使用蓝图进行...
  • Unreal Engine 4蓝图可视化编程游戏引擎(例如虚幻引擎4)作为强大的商业游戏的制作工具,越来越受传统游戏工作室以外的新老游戏开发者所欢迎。虚幻引擎为过去10年中发布的许多最受欢迎的控制台和PC游戏提供了动力,...
  • Unreal Engine 4蓝图可视化编程 对应工程文件,照书自己做的仅供参考
  • 本节书摘来异步社区《Unreal Engine 4蓝图可视化编程》一书中的第1章,第1.3节,作者: Brenden Sewell 译者: 陈东林 责编: 胡俊英,更多章节内容可以访问云栖社区“异步社区”公众号查看。 1.3 材质 之前我们...
  • Unreal UI蓝图信息传递

    2016-08-11 17:32:26
    1. 通过设置两个蓝图的变量,并通过CreateUIWidget保存获取相应的UI界面,这样另一个蓝图就可以获取对象,从而通过变量进行沟通 2. 通过命令行Execute Console Command 3. 通过Event Dispatchers进行绑定时间,并...
  • Unreal 蓝图显示鼠标

    2016-08-11 18:13:11
  • 2.新建蓝图类 3.打开蓝图类 视口——>rendering 出现龙头,如下图,很好! 4.把“”“视口”右下角的input 中的Auto Receive input 设置为“”“player 0”,如下图: 5.在“”“事件图表”中...
1 2 3 4 5 ... 20
收藏数 2,350
精华内容 940
关键字:

unreal 蓝图使用