[UE5]数据驱动实践
Epslin
编辑于 2024年06月30日 17:48
收录于文集
共12篇

概括一下

本文主要参考大钊老师早期在官方直播中的内容(网页链接​)结合在工作实践中的一些经验,讨论各种数据驱动容器的适用场合,对其中DataAsset、CVar、WorldSettings、Config四种内容进行使用演示。

示例项目GitHub链接:https://github.com/jiadevr/DataDrivenExample

希望用数据驱动解决什么问题

在项目的开发过程中我们可能会遇到这样的问题或需求:

  • 在业务模块开发过程中,如果将业务模块表维护集中在路由类中,团队提交版本控制时难以分辨究竟是业务表修改还是路由框架的变动,对版本控制造成极大的心智负担。

  • 关卡A和关卡B的框架内容完全相同,但业务功能不同,虽然我们可以通过继承两个子类分别实现,但这种仅改变部分属性的继承关系造成了浪费,也为后续维护增加困难。

  • 在之前的文章中提到的UltraDynamicSky中需要为美术大哥保存多种天气预设,如果直接写Sky类内,可能会因为一次无意的修改造成数据丢失。

  • 各个数据配置十分散乱,业务开发过程的频繁修改容易错、漏,即使配合Utilities也需要进行很多繁琐的查找工作。

  • 在产品化过程中,需要保证框架的普适性,实现改动数据内容和业务功能就能快速完成开发的需求。

基于上述原因,我们在项目的开发中使用数据驱动提供单一化、轻量化、易保存、易查找的支持,满足项目精简、易于维护、便于拓展的需求,并提高团队开发效率。

数据驱动如何解决这些问题

数据驱动的核心是把容易变动的数据和程序逻辑拆分开,程序按需从指定的数据容器中获取数据,再根据数据执行对应的逻辑。

UE中常用的数据驱动方式主要包括以下几类:InstanceEditable变量、DataTable、DataAsset、WorldSettings、Config、CVar,它们的适用场合如下:

UE中常见的数据驱动方式比较

其中InstanceEditable变量、DataTable在UE初学阶段屡见不鲜,它们的使用也都十分简单,这里不赘述。下文主要围绕DataAsset、WorldSettings、Config、CVar展开讨论和演示,我们准备测试这样一组场景:

基于TopDown模板,使用DataAsset给控制的Pawn添加不同的物件,使用CVar比较多种DataAsset预设,使用WorldSetting配置预设不同世界默认值,使用Config控制这个功能的开启和关闭。

前期准备工作

基于TopDown模板创建工程,BP_TopDownCharacter的骨骼网格体头顶放一个StaticMeshComponent,后续的操作的对象都是这个StaticMeshComponent。

使用DataAsset驱动程序内容

基于PrimaryDataAsset创建C++类,这里命名为MeshDataAsset,用于记录不同预设头顶StaticMeshComponent的StaticMesh资产、Component的各方向缩放、均一的二次缩放值、材质;并提供一个函数获得最终缩放值(各向缩放*二次缩放值),具体代码如下:

代码块
C++
自动换行
复制代码
//MeshDataAsset.h
#pragma once  
  
#include "CoreMinimal.h"  
#include "Engine/DataAsset.h"  
#include "MeshDataAsset.generated.h"  
  
UCLASS(BlueprintType)  
class DATADRIVENEXAMPLE_API UMeshDataAsset : public UPrimaryDataAsset  
{  
    GENERATED_BODY()  
  
public:  
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HeadingConfig")  
    TObjectPtr<UStaticMesh> HeadingMesh = nullptr;  
  
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HeadingConfig")  
    FVector BaseScale = FVector(0.25f);  
  
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HeadingConfig")  
    float UniformScale = 1.0f;  
  
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HeadingConfig")  
    TObjectPtr<UMaterialInterface> MeshMaterial=nullptr;  
  
    UFUNCTION(BlueprintCallable, Category="HeadingConfig")  
    FVector GetScaleResult() const { return BaseScale * UniformScale; }  
};
复制成功

随后在Miscellaneous中创建DataAsset,选择刚创建的MeshDataAsset类创建DataAsset实例

创建DataAsset实例

对实例进行配置,分别指定各变量值:

DataAsset实例配置

在Pawn中创建函数从DataAsset中读取配置参数设置头顶的StaticMeshComponent,调用后显示配置成功。

读取DataAsset配置

调用成功

此时我们已经可以实现在编辑器中切换不同的参数,在不改动蓝图逻辑的基础上使上方的StaticMeshComponent呈现不同的缩放值。此时,基础的DataAsset数据驱动已经完成。

设置不同参数后显示效果

使用CVar进行在运行时比选方案

那么如果有很多种数据呈现方式需要进行,只能每次退出、更换、再运行么?这样迭代的效率显然太低,我们可以使用控制台变量(CVar)的方式切换不同的DataAsset。

首先我们创建四种不同的DataAsset配置,使用PropertyMatrix进行统一编辑:

Asset Actions-Bulk Edit via Property Matrix统一编辑DataAsset内容

到Character.cpp中配置CVar参数,在头文件和构造函数之间插入如下内容

代码块
C++
自动换行
复制代码
static TAutoConsoleVariable<Type> CVarParamName(  
    TEXT("Console Param"),  
    DefaultValue,  
    TEXT("Tip Text"),  
    ECVF_Default); 
复制成功

在当前示例中,配置int32类型的变量作为索引,通过输入【命令 0~3】切换CurrentHeadingConfig,配合蓝图调用切换显示效果,代码如下:

代码块
C++
自动换行
复制代码
//DataDrivenExampleCharacter.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "DataDrivenExampleCharacter.generated.h"

class UMeshDataAsset;

UCLASS(Blueprintable)
class ADataDrivenExampleCharacter : public ACharacter
{
	GENERATED_BODY()
//其他默认代码省略
public:
  virtual void BeginPlay() override;
protected:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HeadingConfig")
	TArray<UMeshDataAsset*> HeadingConfigArray;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HeadingConfig")
	TObjectPtr<UMeshDataAsset> CurrentHeadingConfig=nullptr;

	UFUNCTION(BlueprintCallable,Category="HeadingConfig")
	bool ChangeConfigWithIndex(const int32 TargetIndex);

	UFUNCTION(BlueprintCallable,Category="HeadingConfig")
	bool ChangeConfigWithCVar();
}

//DataDrivenExampleCharacter.cpp
//默认头文件省略
static TAutoConsoleVariable<int32> CVarConfigIndex(
	TEXT("DA.SetCurrentConfigIndex"), 0,TEXT("Set Current Heading Config To Item In Array Of Given Index"),
	ECVF_Default);
//默认构造函数省略
void ADataDrivenExampleCharacter::BeginPlay()
{
	Super::BeginPlay();
	if (nullptr == CurrentHeadingConfig && HeadingConfigArray.IsValidIndex(0))
	{
		CurrentHeadingConfig = HeadingConfigArray[0];
	}
}

bool ADataDrivenExampleCharacter::ChangeConfigWithIndex(const int32 TargetIndex)
{
	if (!HeadingConfigArray.IsValidIndex(TargetIndex))
	{
		UE_LOG(LogTemp, Error, TEXT("Invalid Index %d"), TargetIndex);
		return false;
	}
	CurrentHeadingConfig = HeadingConfigArray[TargetIndex];
	return true;
}

bool ADataDrivenExampleCharacter::ChangeConfigWithCVar()
{
	int32 CVarIndex= CVarConfigIndex.GetValueOnGameThread();
	return ChangeConfigWithIndex(CVarIndex);
}
复制成功

在蓝图中的调用情况:

蓝图调用Cpp函数切换CurrentHeadingConfig并更新StaticMeshComponent

根据参数配置时的命令函行参数分别输入以下参数,并调用蓝图函数,实现在一次运行中不同配置的快速切换:

代码块
C++
自动换行
复制代码
DA.SetCurrentConfigIndex 1
DA.SetCurrentConfigIndex 2
DA.SetCurrentConfigIndex 3
DA.SetCurrentConfigIndex 0
复制成功

效果如下:

使用CVar切换不同预设

使用WorldSettings对Asset进行配置

在选择适合的配置后,下一步就是对内容内容进行指定。但这边涉及到另一个问题:如果我们的Pawn在多个世界(OpenLevel)中分别使用不同的配置,由于Pawn生成由Gameplay框架管理、一般不在编辑器中实例化到场景。这使得我们不能通过InstanceEditable变量实现这种驱动。同时我们既不想把切换逻辑堆积在基类中造成版本管理困难、也不想派生出仅有成员变量变化的子类造成项目膨胀,那么应该如何组织数据内容?

此时就需要前文中我们提到的WorldSettings,WorldSettings具有随世界保存的特性,我们可以在每个关卡中分别配置使用的DataAsset,Pawn在生成时首先去访问这个世界的配置信息,再进行预设更新,实现世界不同的内容驱动。

首先按我们需要基于WorldSettings创建C++子类,并配置默认DataAsset,应当注意读取WorldSettings的部分一般需要在Super之前,代码如下:

代码块
C++
自动换行
复制代码
//DataDrivenWorldSettings.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/WorldSettings.h"
#include "DataDrivenWorldSettings.generated.h"

class UMeshDataAsset;

UCLASS()
class DATADRIVENEXAMPLE_API ADataDrivenWorldSettings : public AWorldSettings
{
	GENERATED_BODY()

public:
	UPROPERTY(EditAnywhere, Category="HeadingConfig")
	TObjectPtr<UMeshDataAsset> WorldDefaultHeadingConfig=nullptr;
};

//DataDrivenExampleCharacter.cpp
//未改动部分省略
void ADataDrivenExampleCharacter::BeginPlay()
{
	ADataDrivenWorldSettings* DataDrivenWorldSettings = Cast<ADataDrivenWorldSettings>(
		UGameplayStatics::GetGameMode(this)->GetWorld()->GetWorldSettings());
	if (DataDrivenWorldSettings)
	{
		UMeshDataAsset* WorldHeadingConfig = DataDrivenWorldSettings->WorldDefaultHeadingConfig;
		if (WorldHeadingConfig)
		{
			CurrentHeadingConfig = WorldHeadingConfig;
			UE_LOG(LogTemp, Warning, TEXT("CurrentHeadingConfig Was Set With World Settings Data"));
		}
	}
	else
	{
		UE_LOG(LogTemp, Warning,
		       TEXT("Read ADataDrivenWorldSettings Config Failed,Please Check Project World Settings! "))
		if (nullptr == CurrentHeadingConfig && HeadingConfigArray.IsValidIndex(0))
		{
			CurrentHeadingConfig = HeadingConfigArray[0];
			UE_LOG(LogTemp, Warning, TEXT("CurrentHeadingConfig Was Set With HeadingConfigArray Data"));
		}
	}
	
	Super::BeginPlay();
}
复制成功

在ProjectSettings-Engine-GeneralSettings-WorldSettingsClass中选择我们创建的子类,如下图所示:

ProjectSettings中的WorldSettings配置

重启编辑器后我们可以看到在配置GameModeOverride的WorldSettings面板中出现了新分组,其中的配置项和C++中暴露出的项一致:

WorldSettings面板中出现C++中的变量

不同世界配置后,在蓝图中配置调用,BeginPlay调用结果:

读取WorldSettings更新配置

不同实际分别配置后更新不同配置

由于WorldSettings保存的是资产实例的指针,仅在修改指向时会发生世界主关卡的更改,修改资产内的变量值仅改动资产,而DataAsset有具有实例单独保存的特性,可以很好的避免版本控制的冲突。

使用Config在项目外控制功能开闭

解决了多关卡内容的预设配置后,本文在编辑器内的管理方法就介绍完了,DataAsset、CVar、WorldSettings协同使用,再加上以及略过的InstanceEditable变量、DataTable,已经能解决实际项目中大部分的数据驱动需求。

在实际的项目发布过程中,还有两种比较小众的需求:

  • 某个功能出现了之前没有测试出的Bug,我们需要临时把对应的功能关掉,避免用户触发产生的后续问题。

  • 希望经过简单的配置切换不同呈现内容,比如在A地部署时使用时默认用a预设,临时带到B地的软件使用b预设。

对于这种已经打包出的内容,我们的一个选择就是使用Config。这部分详细内容可以参考砥才人大佬的博客(https://www.cnblogs.com/shiroe/p/14771446.html)

对应到我们项目中,我们可以在Config中编写对应的变量,在运行时读取Config的配置参数,执行不同的逻辑。这里我们创建三个变量演示上边的情况,变量含义和名称如下:

  • 是否开启更换功能的bool类型(bIsChangable)

  • 是否使用Config配置预设(bUseConfigIndex)

  • Config配置的序号(PresetIndex)

代码如下:

代码块
C++
自动换行
复制代码
//DataDrivenExampleCharacter.h
//未修改部分省略
protected:
	UFUNCTION(BlueprintCallable,BlueprintPure,Category="HeadingConfig")
	bool IsChangeable();


//DataDrivenExampleCharacter.cpp
//未修改部分省略
void ADataDrivenExampleCharacter::BeginPlay()
{
	bool bUseConfigIndex = false;
	bool bGetSuccessfully = GConfig->GetBool(TEXT("DataDriven"),TEXT("bUseConfigIndex"), bUseConfigIndex, GGameIni);
	int32 PresetIndexInConfig = 0;
	bGetSuccessfully &= GConfig->GetInt(TEXT("DataDriven"),TEXT("PresetIndex"),PresetIndexInConfig,GGameIni);
	if (bUseConfigIndex&&bGetSuccessfully&&ChangeConfigWithIndex(PresetIndexInConfig))
	{
		UE_LOG(LogTemp,Display,TEXT("Change Preset To Index %d,Driven By Ini"),PresetIndexInConfig);
	}
	else
	{
		ADataDrivenWorldSettings* DataDrivenWorldSettings = Cast<ADataDrivenWorldSettings>(
			UGameplayStatics::GetGameMode(this)->GetWorld()->GetWorldSettings());
		if (DataDrivenWorldSettings)
		{
			UMeshDataAsset* WorldHeadingConfig = DataDrivenWorldSettings->WorldDefaultHeadingConfig;
			if (WorldHeadingConfig)
			{
				CurrentHeadingConfig = WorldHeadingConfig;
				UE_LOG(LogTemp, Warning, TEXT("CurrentHeadingConfig Was Set With World Settings Data"));
			}
		}
		else
		{
			UE_LOG(LogTemp, Warning,
			       TEXT("Read ADataDrivenWorldSettings Config Failed,Please Check Project World Settings! "))
			if (nullptr == CurrentHeadingConfig && HeadingConfigArray.IsValidIndex(0))
			{
				CurrentHeadingConfig = HeadingConfigArray[0];
				UE_LOG(LogTemp, Warning, TEXT("CurrentHeadingConfig Was Set With HeadingConfigArray Data"));
			}
		}
	}
	Super::BeginPlay();
}

bool ADataDrivenExampleCharacter::IsChangeable()
{
	bool bIsChangeable = true;
	bool bGetSuccessfully = GConfig->GetBool(TEXT("DataDriven"),TEXT("bIsChangeable"), bIsChangeable, GGameIni);
	return bIsChangeable;
}
复制成功

然后我们在项目的Config文件夹下DefaultGame.ini添加分段和配置参数

代码块
C++
自动换行
复制代码
//DefaultGame.ini
//未修改部分省略
[DataDriven]
bIsChangeable=true
bUseConfigIndex=true
PresetIndex=3
复制成功

在蓝图函数中接入IsChangeable()

蓝图函数接入IsChangeable()读取Config

修改参数后运行,效果如下:

不同Config参数驱动效果

打包后的内容对应修改位于\Saved\Config\Windows路径下的Game.ini,这个文件只会保留和打包时配置不同的参数,因此可能发现把三个参数都填入、重启、再次打开,参数只剩下了一个,但运行结果正确。

至此,我们完成了Config在打包后的游戏外对功能开启和预设切换,实现了组合DataAsset、CVar、WorldSettings和Config的数据驱动方案,这种方案能比较恰当的理工各个数据驱动容器自身的优势,满足项目灵活开发、数据安全保存的要求。希望上述内容对初学的朋友有一些帮助。

后续计划

在Config中还有一个瑕疵:在编辑器开启状态下修改参数,直接再次PIE,Config的修改内容不生效,但重启编辑器后可行。在尝试过程中在数据读取前调用Flush和Reload但均没有解决问题,后续如果有进展会在文章中补充。