unreal 完整例子 蓝图_unreal 蓝图 - CSDN
  • 使用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;
    }
    
    展开全文
  • 这篇博客主要是深入理解蓝图整个流程的的底层机制,包括节点编辑、编译、字节码解释执行。理解了这些,对前面几篇所讲的蓝图扩展,可以有一个更清晰的认识。

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

    蓝图的发展历程

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

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

    参考资料

    展开全文
  • 本节书摘来异步社区《Unreal Engine 4蓝图可视化编程》一书中的第1章,第1.4节,作者: Brenden Sewell 译者: 陈东林 责编: 胡俊英,更多章节内容可以访问云栖社区“异步社区”公众号查看。 1.4 创建第一个蓝图 ...

    本节书摘来异步社区《Unreal Engine 4蓝图可视化编程》一书中的第1章,第1.4节,作者: Brenden Sewell 译者: 陈东林 责编: 胡俊英,更多章节内容可以访问云栖社区“异步社区”公众号查看。

    1.4 创建第一个蓝图

    现在游戏世界中放置了一个圆柱体,在当圆柱体被击中时,我们需要为圆柱体赋上前一节创建的材质。最后一个交互是游戏逻辑判断圆柱体被选择,然后将圆柱体的材质改变为红色材质。为了创建这一行为并添加到圆柱体上,我们需要创建一个蓝图。创建蓝图的方式有很多种,但是为了简便,我们可以创建蓝图并直接添加给圆柱体。为此,确保在场景中选中了CylinderTarget对象。单击细节面板顶端的蓝色蓝图/添加脚本(Blueprint/Add Script)按钮,将可看到路径选择窗口。

    在这个项目中, 我们把所有的蓝图存放在FirstPersonBP文件夹的子文件夹Blueprints下,因为这个蓝图是为CylinderTarget actor创建的,所以文件名为默认的Cylinder- Target_Blueprint即可,如图1.13所示。


    13

    现在内容浏览器(content browser)的FirstPersonBP >> Blueprints文件夹中可以看到CylinderTarget_Blueprint。双击打开该蓝图的蓝图编辑器,我们将看到圆柱体的视口视图,如图1.14所示。我们可以操作actor的一些默认属性或者增加更多的组件,它们都包括很多自有逻辑使得actor更加复杂。我们将在后续章节中探讨组件(components),现在,我们需要创建一个简单的蓝图并直接赋给actor。为此,单击视口标签旁边的事件图表(Event Graph)标签。

    图1.14 视口视图

    1.4.1 浏览事件图表面板

    事件图表(见图1.15)看起来应该很熟悉,因为它与我们之前使用的材质编辑器的视觉效果和功能上由很多相似之处。默认情况下,打开事件图表时会有3个未连接也未使用的事件节点。事件(Event)指游戏中的一些动作(action),它作为蓝图做某件事情的触发器。大多数蓝图遵循如下结构:事件(when)|条件(if)动作(do)。这个可以被文字描述为:当某件事情发生时,检查X、Y、Z是否为真,如果为真,完成这一系列的动作。举个例子,蓝图定义了我是否开枪,流程如下:当(when)扣动扳机,如果(if)子弹已上膛,进行(do)射击。

    在事件图表中,列出的3个默认事件节点是用的最多的事件触发器。当玩家(Player)第一次开始玩游戏时触发事件Begin Play。当另一个actor开始与蓝图控制的现有actor发生触碰或重叠时触发事件Actor Begin Overlap。事件Tick在游戏运行的每一帧触发与之关联的动作,帧率决定于电脑的配置,也影响着事件Tick触发动作的频率。

    我们希望每次射弹击中圆柱体时,都能触发“改变材质”这一动作。可以使用事件Actor Begin Overlap节点来探测射弹对象与目标的网格是否重叠。我们将通过仅当另一个actor触发目标actor时检测来简化这些。以一个简洁的页面开始吧,选中所有的默认事件,单击键盘的[delete]键把它们都删除。


    15

    1.4.2 检测事件Hit

    为了创建检测事件,在图表空白区域单击鼠标右键,搜索框中输入“hit”,如图1.16所示。找到事件Hit(Event Hit)节点并选择,当这个蓝图控制的actor被另一个actor触发的事件Hit。

    当在事件图表中添加了事件Hit节点后,会看到事件Hit节点上有许多颜色各异的输出引脚。首先注意到节点右上角的白色类似三角形的引脚,这是执行引脚(execution pin)。它定义了动作序列中下一步要执行的动作。将不同节点的执行引脚连接在一起便组成了所有蓝图的基本功能。既然拥有了触发器,那我们就需要找到一个动作,这个动作可以让我们能够改变actor的材质。


    16


    从执行引脚拖出一根引线至节点右端的空白区域,将自动出现一个搜索窗口,允许我们创建一个节点并将它与执行引脚相连。确认搜索框内勾选了情境关联(Context Sensitive)。这样将搜索结果限制在能够被添加的节点中。在搜索框内键入“set material”,选取Set Material(StaticMeshComponent)节点。

    注意 小贴士 

    如果在勾选了情境关联的情况下搜索不到想查找的节点,可尝试去勾选重新搜索。即使这个节点在情境关联搜索时查找不到,它仍有可能可以添加到蓝图逻辑当中。

    事件Hit节点中的动作(action)如图1.17所示。


    17

    1.4.3 转换材质

    当用户将Set Material节点放置到蓝图中后,便会注意到它已经与事件Hit节点的执行引脚连接。当蓝图的actor被另外的actor击中时,蓝图现在将会执行Set Material动作。然而,我们现在还没有设置响应Set Material时将要调用的材质。

    为了设置将调用的材质,单击Set Material节点中的选择资源(Select Asset),调出下拉列表,在搜索框内输入“red”来搜索之前创建的TargetRed材质。查找到该材质后单击它,将该资源添加到Set Material节点的材质区域,如图1.18所示。

    我们现在已经做好了改变目标圆柱体颜色的蓝图的准备工作,但是在保存(Save)蓝图之前,还需要编译一下。编译是将蓝图语言转换为告诉计算机该如何操作的机器指令。单击编辑器工具左上角的编译(Compile)按钮,然后单击保存。


    18

    既然我们已经设置了一个基础的游戏交互,运行测试一下,确保结果是否达到预期。关闭蓝图编辑器,在UE编辑器中单击播放按钮运行游戏,尝试跑动和射击,与我们之前创建的CylinderTarget actor发生碰撞,如图1.19所示。


    19

    1.4.4 升级蓝图

    当运行游戏的时候,会看到目标圆柱体在被子弹击中时,确实改变了颜色。这是游戏框架的初始,它可以用于接收敌人对于玩家(Player)动作的反馈。你或许也会注意到玩家碰到目标圆柱体时,圆柱体也会改变颜色,然而我们希望只有子弹击中目标时才改变目标颜色。这些不可预见的结果在游戏开发过程中很普遍,最好避免的方法就是频繁地测试我们正在创建的游戏。

    为了修正蓝图中的bug,让它仅对子弹击中时做出反应并改变目标颜色,回到Cylinder Target_Blueprint蓝图,继续查看事件Hit节点。

    在事件Hit节点上余下的输出引脚,它们是存储着关于事件数据的一些变量。这些数据可以被传递到其他节点。引脚的颜色代表着变量的数据类型,蓝色引脚传递对象(object),例如actor;红色引脚包含一个布尔型(true/false)变量。

    随着接触更复杂的蓝图,我们将学习更多的引脚类型,但现在,我们只需要关心蓝色的Other输出引脚。它包含其他actor的碰撞触发事件Hit的数据。这个在确保目标圆柱体只在被子弹击中时改变颜色很有用,而其他的actor碰到目标时不改变颜色。

    为了确保仅对子弹做出响应,断开事件Hit节点与SetMaterial节点之间的连接(断开引脚连接的方法之一为:按住[Alt]键,然后鼠标左键单击连线),从Other引脚拖出一根引线到空白区域,在搜索框内键入“projectile”,找到“类型转换为FirstPersonProjectile(Cast To FirstPersonProjectile)”并选择,如图1.20所示。

    FirstPersonProjectile是UE4引擎的第一人称模板中的蓝图,控制着子弹的行为。该节点使用类型转换来确保:只有在子弹actor击中与转换节点引用的对象相匹配的圆柱体时,才将动作附加到节点的执行引脚。


    20

    当节点出现时,可以看到在事件Hit的Other输出引脚和转换节点的Object引脚之间有一根蓝色的线相连(如果没有自动连接,可以手动连上)。连接FirstPersonProjectile节点的输出执行引脚和Set Material节点的输入执行引脚,如图1.21所示。


    21

    现在编译、保存,关闭蓝图界面后单击播放按钮进行测试。现在你会发现玩家碰到目标圆柱体时不会改变圆柱体颜色,只有子弹击中时才可以。

    展开全文
  • 通过派生class UK2Node,为蓝图添加自定义节点;这篇博客我们先实现一个最简单的自定义节点,下篇文章将完成“动态添加输入Pin”的蓝图节点。

    前面一篇文章中介绍了扩展蓝图的几种方法,其中最灵活的就是自定义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的节点,那才能体现这种方法的价值。

    展开全文
  • 前面两篇博客我们都是通过ExpandNode来实现蓝图节点的功能,这一篇来介绍另外一种重要的蓝图扩展方式,就是Node Handler。
  • UE4的蓝图就跟C++等编程语言在概念上是非常类似的。在蓝图中你可以定义变量、函数、宏等等,高级点的,它还可以被继承。这还不算,我们还可以定义蓝图接口,规范子类的行为。基本上C++中可以做的,蓝图也可以做到,...
  • 引擎中提供两种蓝图异步节点的实现方式,这里我们主要介绍 Blueprint Async Action 的实现方式。
  • 本节书摘来异步社区《Unreal Engine 4蓝图可视化编程》一书中的第2章,第2.1节,作者: Brenden Sewell 译者: 陈东林 责编: 胡俊英,更多章节内容可以访问云栖社区“异步社区”公众号查看。 第2章 升级玩家的技能 ...
  • Unreal UI蓝图信息传递

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

    2018-04-26 22:11:04
    在虚幻引擎4中你可以用Unreal Motion Graphics(UMG)创建UI。UMG允许您通过拖拽UI元素,例如拖拽按钮和文本标签快捷的构建UI。在本教程中,您将学习如何:1,创建显示信息标签(HUD),显示计数和时间。2,显示HUD3,...
  • 1.动画蓝图 包含 状态机 包含 混合空间BlendSpace,即状态机包含在动画蓝图的"动画图表中",而混合空间可用于在状态机中向某(没)一个状态输出最终POSE: 动画蓝图一共包含两个东西,除了上面提到的动画图表,还...
  • 这个教程是从UE4 Wiki上整理而来. 在C++中直接使用Interface大家应该很熟悉。只是简单先定义一个个有虚函数的基类,然后在子类中实现相应的虚函数。像这样的虚函数的基类一般概念上叫接口。那接下来看看UE4中怎样...
  • Unreal Engine 4 初学者教程:开始 原文:Unreal Engine 4 Tutorial for Beginners: Getting Started 作者:Tommy Tran 译者:kmyhy Unreal Engine 4 是一个游戏开发工具集,能够开发从 2D 手机游戏到 3A ...
  • unreal4 源码引言

    2018-11-15 17:59:37
    unreal GIS 模块unreal4 模块与GIS结合unreal4 蓝图介绍与场景搭建 unreal4 模块与GIS结合 unreal4 蓝图介绍与场景搭建 四叉树介绍与系统衍生
  • 本站文章均为 李华明Himi 原创,转载务必在明显处注明: 转载自【黑米GameDev街区】 原文链接: http://www.himigame.com/unreal-engine-game/2164.html首先Himi在这里解释下,为什么还是开篇… 原因主要有两点:...
  • 本人最近几天一直想写一个赛道构建的例子,一下使用UnrealReal4中spline,splinemesh组件。具体怎么用大家去看官方的wiki就行了,这里直接贴代码`。// Fill out your copyright notice in the Description page of ...
  • [UE4]动画蓝图的编辑全流程(Animation Blueprint) https://zhuanlan.zhihu.com/p/27448628 Keyword: UE4、Animation Blueprin、Montage Slot、Character Blueprint 不知从从哪个版本开始,动画蓝图的制作和老版本...
1 2 3 4 5 ... 16
收藏数 307
精华内容 122
关键字:

unreal 完整例子 蓝图