BlueRoseNote/03-UnrealEngine/Gameplay/GAS/GameFeatures学习笔记.md
2023-06-29 11:55:02 +08:00

27 KiB
Raw Permalink Blame History

GameFeatures学习笔记

主要参考了大钊的系列文章:

通过插件创建向导新创建的GameFeature会放在GameFeatures文件夹中,也必须放在GameFeature文件夹中。并且会创建GameFeatureData并且想AssetManager注册。GameFeatures的运行逻辑为调用ToggleGameFeaturePlugin=>触发GameFeatureAction=>做出对应的操作。 做出对应Action操作的Actor对象需要调用UGameFrameworkComponentManager的AddReceiver()与RemoveReceiver()进行注册与反注册。

  • GameFeature缩写为GF就是代表一个GameFeature插件。
  • CoreGame特意加上Core是指的把游戏的本体Module和GameFeature相区分开即还没有GF发挥作用的游戏本体。
  • UGameFeatureData缩写为GFD游戏功能的纯数据配置资产用来描述了GF要做的动作。
  • UGameFeatureAction单个动作缩写GFA。引擎已经内建了几个Action我们也可以自己扩展。
  • UGameFeaturesSubsystem缩写为GFSGF框架的管理类全局的API都可以在这里找到。父类为UEngineSubsystem。
  • UGameFeaturePluginStateMachine缩写为GFSM每个GF插件都关联着一个状态机来管理自身的加载卸载逻辑。
  • UGameFrameworkComponentManager缩写为GFCM支撑AddComponent Action作用的管理类记录了为哪些Actor身上添加了哪些Component以便在GF卸载的时候移除掉。

初始化

UGameFeaturesProjectPolicies

GameFeature的加载规则类。UE实现一个了默认类为UDefaultGameFeaturesProjectPolicies。默认是会加载所有的GF插件。如果我们需要自定义自己的策略比如某些GF插件只是用来测试的后期要关闭掉就可以继承重载一个自己的策略对象在里面可以实现自己的过滤器和判断逻辑。

  • GetPreloadAssetListForGameFeature返回一个GF进入Loading要预先加载的资产列表方便预载一些资产比如数据配置表之类的。
  • IsPluginAllowed可重载这个函数来进一步判断某个插件是否允许加载可以做更细的判断。

可以在项目设置中修改所使用的类。大钊介绍了Additional Plugin Metadata Keys的用法

举个例子假设2.0版本的游戏要禁用掉以前1.0版本搞活动时的一个GF插件可以继承定义一个我们自己的Policy对象然后在Init里的过滤器里实现自己的筛选逻辑比如截图里就示例了根据uplugin里的MyGameVersion键来指定版本号然后对比。这里要注意的是要先在项目设置里配置上Additional Plugin Metadata Keys才能把uplugin文件里的自定义键识别解析到PluginDetails.AdditionalMetadata里才可以进行后续的判断。至于要添加什么键就看各位自己的项目需要了。

void UMyGameFeaturesProjectPolicies::InitGameFeatureManager()
{
    auto AdditionalFilter = [&](const FString& PluginFilename, const FGameFeaturePluginDetails& PluginDetails, FBuiltInGameFeaturePluginBehaviorOptions& OutOptions) -> bool
    {   //可以自己写判断逻辑
        if (const FString* myGameVersion = PluginDetails.AdditionalMetadata.Find(TEXT("MyGameVersion")))
        {
            float verison = FCString::Atof(**myGameVersion);
            if (verison > 2.0)
            {
                return true;
            }
        }
        return false;
    };

    UGameFeaturesSubsystem::Get().LoadBuiltInGameFeaturePlugins(AdditionalFilter);  //加载内建的所有可能为GF的插件
}

GF插件的识别是从解析.uplugin文件开始的。我们可以手动编辑这个json文件来详细的描述GF插件。这里有几个键值得一说

  • BuiltInAutoState这个GF被识别后默认初始的状态共有4种。如果你设为Active就代表这个GF就是默认激活的。关于GF的状态我们稍后再详细说说。
  • AdditionalMetadata这个刚才已经讲过了被Policy识别用的。
  • PluginDependencies我们依然可以在GF插件里设置引用别的插件别的插件可以是普通的插件也可以是另外的GF插件。这些被依赖的插件会被递归的进行加载。这个递归加载的机制跟普通的插件机制也是一致的。
  • ExplicitlyLoaded=true必须为true因为GF插件是被显式加载的不像其他插件可能会默认开启。
  • CanContainContent=true必须为true因为GF插件毕竟至少得有GFD资产。

状态机

对每一个GF而言我们在使用的过程中能够使用到的GF状态就4个Installed、Registered、Loaded、Active。这4个状态之间可以双向转换以进行加载卸载。

也要注意GF状态的切换流程是一条双向的流水线可以往加载激活的方向前进在下图上是用黑色的箭头来表示也可以往失效卸载的方向走图上是红色的线表示。而箭头上的文字其实就是一个个GFS类里已经提供的GF加载卸载API。 双向箭头中间的圆角矩形表示的是状态绿色的状态是我们能看见的但其实内部还有挺多过渡状态的。过渡状态的概念后面也会解释。值得注意的是UE5预览版增加了一个Terminal状态可以把整个插件的内存状态释放掉。

在LoadBuiltInGameFeaturePlugin的最后一步GFS会为每一个GF创建一个UGameFeaturePluginStateMachine对象用来管理内部的GF状态切换。

void UGameFeaturesSubsystem::LoadBuiltInGameFeaturePlugin(const TSharedRef<IPlugin>& Plugin, FBuiltInPluginAdditionalFilters AdditionalFilter)
{
    //...省略其他代码
    UGameFeaturePluginStateMachine* StateMachine = GetGameFeaturePluginStateMachine(PluginURL, true);//创建状态机
    const EBuiltInAutoState InitialAutoState = (BehaviorOptions.AutoStateOverride != EBuiltInAutoState::Invalid) ? BehaviorOptions.AutoStateOverride : PluginDetails.BuiltInAutoState;
    const EGameFeaturePluginState DestinationState = ConvertInitialFeatureStateToTargetState(InitialAutoState);

    if (StateMachine->GetCurrentState() >= DestinationState)
    {
        // If we're already at the destination or beyond, don't transition back
        LoadGameFeaturePluginComplete(StateMachine, MakeValue());
    }
    else
    {
        StateMachine->SetDestinationState(DestinationState, FGameFeatureStateTransitionComplete::CreateUObject(this, &ThisClass::LoadGameFeaturePluginComplete));//更新到目标的初始状态
    }

    //...省略其他代码
}

FGameFeaturePluginState为所有状态机的基础结构体所有派生结构体都会实现所需的虚函数。

struct FGameFeaturePluginState
{
	FGameFeaturePluginState(FGameFeaturePluginStateMachineProperties& InStateProperties) : StateProperties(InStateProperties) {}
	virtual ~FGameFeaturePluginState();

	/** Called when this state becomes the active state */
	virtual void BeginState() {}

	/** Process the state's logic to decide if there should be a state transition. */
	virtual void UpdateState(FGameFeaturePluginStateStatus& StateStatus) {}

	/** Called when this state is no longer the active state */
	virtual void EndState() {}

	/** Returns the type of state this is */
	virtual EGameFeaturePluginStateType GetStateType() const { return EGameFeaturePluginStateType::Transition; }

	/** The common properties that can be accessed by the states of the state machine */
	FGameFeaturePluginStateMachineProperties& StateProperties;

	void UpdateStateMachineDeferred(float Delay = 0.0f) const;
	void UpdateStateMachineImmediate() const;

	void UpdateProgress(float Progress) const;

private:
	mutable FTSTicker::FDelegateHandle TickHandle;
};

初始化状态机时候会创建所有状态UGameFeaturePluginStateMachine::InitStateMachine())。

状态机更新流程

SetDestinationState()=>UpdateStateMachine()

UpdateStateMachine()除了判断与Log之外主要执行了:

  • 调用当前状态的UpdateState()
  • 调用当前状态的EndState()
  • 调用新状态的BeginState()

检查存在性

首先GF插件的最初始状态是Uninitialized很快进入UnknownStatus标明还不知道该插件的状态。然后进入CheckingStatus阶段这个阶段主要目的就是检查uplugin文件是否存在一般的GF插件都是已经在本地的可以比较快通过检测。

加载CF C++模块

下一个阶段就是开始尝试加载GF的C++模块。这个阶段的初始状态是Installed这个我用绿色表示表明它是个目标状态区分于过渡状态。目标状态意思是在这个状态可以停留住直到你手动调用API触发迁移到下一个状态比如你想要注册或激活这个插件就会把Installed状态向下一个状态转换。卸载的时候往反方向红色的线前进。接着往下

  • Mounting阶段内部会触发插件管理器显式的加载这个模块因此会加载dll触发StartupModule。在以前Unmounting阶段并不会卸载C++因此不会调用ShutdownModule。意思就是C++模块一经加载就常驻在内存中了。但在UE5预览版中加上了这一步因此现在Unmounting已经可以卸载掉插件的dll了。
  • WaitingForDependencies会加载之前uplugin里依赖的其他插件模块递归加载等待所有其他的依赖项完成之后才会进入下一个阶段。这点其实跟普通的插件加载策略是一致的因此GF插件本质上其实就是以插件的机制在运作只不过有些地方有些特殊罢了。

加载GameFeatureData

在C++模块加载完成之后下一步就要开始把GF自身注册到GFS里面去。其中最重要的一步是在Registering的时候加载GFD资产会触发UGameFeaturesSubsystem::LoadGameFeatureData(BackupGameFeatureDataPath);的调用从而完成GFD加载。

预加载资产和配置

下一个阶段就是进行加载了。Loading阶段会开始加载两种东西一是插件的运行时的ini(如…/LearnGF/Saved/Config/WindowsEditor/MyFeature.ini)与C++ dll,另外一项是可以预先加载一些资产资产列表可以由Policy对象根据每个GF插件的GFD文件来获得因此我们也可以重载GFD来添加我们想要预先加载的资产列表。其他的资产是在激活阶段根据Action的执行按需加载的。

激活生效

在加载完成之后我们就可以激活这个GF了。Activating会为每个GFD里定义的Action触发OnGameFeatureActivating而Deactivating触发OnGameFeatureDeactivating。激活和反激活是Action真正做事的时机。

详细状态切换图

位于GameFeaturePluginStateMachine.h

/*
                         +--------------+
                         |              |
                         |Uninitialized |
                         |              |
                         +------+-------+
     +------------+             |
     |     *      |             |
     |  Terminal  <-------------~------------------------------
     |            |             |                             |
     +--^------^--+             |                             |
        |      |                |                             |
        |      |         +------v-------+                     |
        |      |         |      *       |                     |
        |      ----------+UnknownStatus |                     |
        |                |              |                     |
        |                +------+-------+                     |
        |                  |                                  |
        |      +-----------v---+     +--------------------+   |
        |      |               |     |         !          |   |
        |      |CheckingStatus <-----> ErrorCheckingStatus+-->|
        |      |               |     |                    |   |
        |      +------+------^-+     +--------------------+   |
        |             |      |                                |
        |             |      |       +--------------------+   |
        ----------    |      |       |         !          |   |
                 |    |      --------> ErrorUnavailable   +----
                 |    |              |                    |
                 |    |              +--------------------+
                 |    |
               +-+----v-------+                            
               |      *       |
     ----------+ StatusKnown  |
     |         |              |
     |         +-^-----+---+--+
     |           |         |
     |     ------~---------~-------------------------
     |     |     |         |                        |
     |  +--v-----+---+   +-v----------+       +-----v--------------+
     |  |            |   |            |       |         !          |
     |  |Uninstalling|   |Downloading <-------> ErrorInstalling    |
     |  |            |   |            |       |                    |
     |  +--------^---+   +-+----------+       +--------------------+
     |           |         |
     |         +-+---------v-+
     |         |      *      |
     ---------->  Installed  |
               |             |
               +-^---------+-+
                 |         |
		   ------~---------~--------------------------------
           |     |         |                               |
        +--v-----+--+    +-v---------+               +-----v--------------+
        |           |    |           |				 |         !          |
        |Unmounting |    | Mounting  <---------------> ErrorMounting      |
        |           |    |           |				 |                    |
        +--^-----^--+    +--+--------+				 +--------------------+
           |     |          |
           ------~----------~-------------------------------
                 |          |                              |
                 |       +--v------------------- +   +-----v-----------------------+
                 |       |                       |	 |         !                   |
                 |       |WaitingForDependencies <---> ErrorWaitingForDependencies |
                 |       |                       |	 |                             |
                 |       +--+------------------- +	 +-----------------------------+
                 |          |
           ------~----------~-------------------------------
           |     |          |                              |
        +--v-----+----+  +--v-------- +              +-----v--------------+
        |             |  |            |				 |         !          |
        |Unregistering|  |Registering <--------------> ErrorRegistering   |
        |             |  |            |				 |                    |
        +--------^----+  ++---------- +				 +--------------------+
                 |        |
               +-+--------v-+
               |      *     |
               | Registered |
               |            |
               +-^--------+-+
                 |        |
        +--------+--+  +--v--------+
        |           |  |           |
        | Unloading |  |  Loading  |
        |           |  |           |
        +--------^--+  +--+--------+
                 |        |
               +-+--------v-+
               |      *     |
               |   Loaded   |
               |            |
               +-^--------+-+
                 |        |
        +--------+---+  +-v---------+
        |            |  |           |
        |Deactivating|  |Activating |
        |            |  |           |
        +--------^---+  +-+---------+
                 |        |
               +-+--------v-+
               |      *     |
               |   Active   |
               |            |
               +------------+
*/

AddComponents

主要逻辑位于ModularGameplay模块中的UGameFrameworkComponentManager。GFCM内部的实现还是蛮复杂和精巧的可以做到在一个GF激活后会把激活前已经存在场景中Actor还有激活后新生成的Actor都会被正确的添加上Component。这个顺序无关的逻辑是怎么做到的呢关键的逻辑分为两大部分:

AddReceiver的注册

核心逻辑位于AddReceiverInternal()。我觉得大钊这里搞错了。

  1. 判断Actor是否存在与当前关卡中。
  2. 递归这个Actor类的父类直到AActor。
  3. 从TMap<FComponentRequestReceiverClassPath, TSet<UClass*>> ReceiverClassToComponentClassMap中寻找这个类对应的Component UClass* 集。
  4. 如果Component类有效则调用CreateComponentOnInstance(),创建并且挂载到Actor上并将信息放入TMap<UClass*, TSet> ComponentClassToComponentInstanceMap中。
  5. 查询TMap<FComponentRequestReceiverClassPath, FExtensionHandlerEvent> ReceiverClassToEventMap并执行委托。

AddComponents

每个GFA在Activating的时候都会调用OnGameFeatureActivating。而对于UGameFeatureAction_AddComponents这个来说其最终会调用到AddToWorld。在判断一番是否是游戏世界是否服务器客户端之类的配置之后。最后真正发生发生的作用是Handles.ComponentRequestHandles.Add(GFCM->AddComponentRequest(Entry.ActorClass, ComponentClass));

AddComponentRequest会增加ReceiverClassToComponentClassMap中的项。

  1. 创建FComponentRequest并添加RequestTrackingMap中的项
  2. 获取LocalGameInstance以及World,并且在World中遍历指定的Actor(ReceiverClass)
  3. 如果Actor有效则调用CreateComponentOnInstance(),创建并且挂载到Actor上并将信息放入TMap<UClass*, TSet> ComponentClassToComponentInstanceMap中。

值得注意的是:

  1. RequestTrackingMap是TMap<FComponentRequest, int32>的类型可以看到Key是ReceiverClassPath和ComponentClassPtr的组合那为什么要用int32作为Value来计数呢这是因为在不同的GF插件里有可能会出现重复的ActorClass-ComponentClass的组合比如GF1和GF2里都注册了A1-C2的配置。那在卸载GF的时候我们知道也只有把两个GF1和GF2统统都卸载之后这个A1-C2的配置才失效这个时候这个int32计数才=0。因此才需要有个计数来记录生效和失效的次数。
  2. ReceiverClassToComponentClassMap会记录ActorClass-多个ComponentClass的组合其会在上文的AddReceiver的时候被用来查询。
  3. 同样会发现根据代码逻辑ensureMsgf这个报错也只在WITH_EDITOR的时候才生效。在Runtime下依然会不管不顾的根据GFA里的配置为相应ActorClass类型的所有Actor实例添加Component。因此这个时候我们明白AddReceiver的调用准确的说不过是为了GF生效后为新Spawn的Actor添加一个依然能添加相应组件的机会。
  4. 返回值为何是TSharedPtr又为何要Add进Handles.ComponentRequestHandles其实这个时候就涉及到一个逻辑当GF失效卸载的时候之前添加的那些Component应该怎么卸载掉所以这个时候就采取了一个办法UGameFeatureAction_AddComponents这个Action实例里不止一个不同的GF会生成不同的UGameFeatureAction_AddComponents实例记录着由它创建出来的组件请求当这个GF被卸载的时候会触发UGameFeatureAction_AddComponents的析构继而释放掉TArray<TSharedPtr> ComponentRequestHandles;而这是个智能指针只会在最后一个被释放的时候其实就是最后一个相关的GF被卸载时候,才会触发FComponentRequestHandle的析构继而在GFCM里真正的移除掉这个ActorClass-ComponentClass的组合然后在相应的Actor上删除Component实例。

一套UGameFrameworkComponent

UE5增加了UPawnComponent父类为UGameFrameworkComponentModularGameplay模块中的文件。主要是增加了一些Get胶水函数。

Component

Component里面一般会写的逻辑有一种是会把Owner的事件给注册进来比如为Pawn添加输入绑定的组件会在UActorComponent的OnRegister的时候把OwnerPawn的Restarted和ControllerChanged事件注册进来监听以便在合适时机重新应用输入绑定或移除。这里我是想向大家说明这也是编写Component的一种常用的范式提供给大家参考。

void UPlayerControlsComponent::OnRegister()
{
    Super::OnRegister();

    UWorld* World = GetWorld();
    APawn* MyOwner = GetPawn<APawn>();

    if (ensure(MyOwner) && World->IsGameWorld())
    {
        MyOwner->ReceiveRestartedDelegate.AddDynamic(this, &UPlayerControlsComponent::OnPawnRestarted);
        MyOwner->ReceiveControllerChangedDelegate.AddDynamic(this, &UPlayerControlsComponent::OnControllerChanged);

        // If our pawn has an input component we were added after restart
        if (MyOwner->InputComponent)
        {
            OnPawnRestarted(MyOwner);
        }
    }
}

Actor

  • 蓝图中在BeginPlay与EndPlay事件中调用GameFrameworkComponentManager的AddReceiver与RemoveReceived。
  • c++ 可以参考《古代山谷》的ModularGameplayActors。里面定义的是一些更Modular的Gameplay基础Actor重载的逻辑主要有两部分一是把自己注册给GFCM二是把自己的一些有用的事件转发给GameFeature的Component比如截图上的ReceivedPlayer和PlayerTick。

扩展

Action扩展

古代山谷额外实现的GFA有AddAbility、AddInputContextMapping、AddLevelInstance、AddSpawnedActor、AddWorldSystem、WorldActionBase

在实现Action的时候有一个值得注意的地方是因为GFS是继承于EngineSubsystem的因此Action的作用机制也是Engine的因此在编辑器里播放游戏和停止游戏这些Action其实都是在激活状态的。因此特意有定义了一个WordActionBase的基类专门注册了OnStartGameInstance的事件用来在游戏启动的时候执行逻辑。当然在真正用的时候还需要通过IsGameWorld来判断哪个世界是要发挥作用的世界。

GameFeatrues依赖机制

普通的插件一般是被CoreGame所引用用来实现CoreGame的支撑功能。而GameFeature是给已经运行的游戏注入新的功能的因此CoreGame理论上来说应该对GameFeature的存在和实现一无所知所以CoreGame就肯定不能引用GF而是反过来被GF所引用。资产的引用也是同理GF的资产可以引用CoreGame的但不能被反过来引用。

可继承扩展的类

  • UGameFeaturesProjectPolicies决定是否加载GF的策略CoreGame
  • UGameFeatureStateChangeObserver注册到GFS里监听GF状态变化CoreGame
  • GameFeatureData为GF添加更多描述数据 UGameFeaturesSubsystemSettings::DefaultGameFeatureDataClass CoreGame
  • GameFeatureAction更多ActionCoreGame&GF
  • GameFrameworkComponent更多Component CoreGame&GF
  • ModularActor更多Actor CoreGame&GF
  • IAnimLayerInterface改变角色动画 CoreGame&GF

GameFeature模块协作

一些朋友可能会问一个问题一个游戏玩法用到的模块还是挺多的GameFeature都能和他们协作进行逻辑的更改吗下面这个图我列了一些模块和GameFeature的协作交互方式大家可以简单看一下。如果有别的模块需要交互还可以通过增加新的Action类型来实现。

CoreGame预留好逻辑注入点

GameFeature的出现虽然说大大解耦了游戏玩法和游戏本体之间的联系CoreGame理论上来说应该对GF一无所知极致理想情况下CoreGame也不需要做任何改变。但GF明显还做不到这点还是需要你去修改CoreGame的一些代码事先预留好逻辑的注入点。要修改的点主要有这些

原先的Gameplay Actor修改为从之前的Modular里继承或者自己手动添加AddReceiver的调用。 在动画方面可以利用Anim Layer Interface在GF里可以定义不同的动画蓝图来实现这个接口。之后在组件里Link Anim Class Layers绑定上就可以修改动画了。 关于数据配置既可以用AddDataRegistrySource添加数据源到CoreGame里现有的DataRegistry里也可以在GF里用AddDataRegistry新增新的数据注册表。 至于其他的模块部分例如UI、输入这些之前已经讲过可以用Action和Component的配合来做。

移植步骤: 创建Plugins/GameFeatures目录 创建.uplugin文件 资产 移动资产到新的GF目录 在原目录Fixup redirectors 修复所有资产验证问题 代码 创建.Build.cs 迁移项目代码到GF下的Public/Private 修复include路径错误 修复代码引用错误 在DefaultEngine.ini里加CoreRedirects +ClassRedirects=(OldName="/Script/MyGame.MyClass", NewName="/Script/MyFeature.MyClass"

Rethink in GF

  • 首先面对一个项目你需要去思考哪些游戏功能应该可以拆分成GF哪些是游戏的基本机制哪些是可以动态开关的功能就是确定哪些东西应该抽出来形成一个GF。
  • Actor逻辑拆分到Component重新以(组件==功能)的方式来思考而不是所有逻辑都堆在Actor中。然后这些逻辑在之前往往是直接写在各种Actor身上现在你需要重新以(组件==功能)的方式来思考在以前我们提倡Component是用来实现相对机械的独立于游戏逻辑的基础功能的现在我们为了适应GameFeature就需要把一部分Component用来单纯的当做给Actor动态插拔的游戏逻辑实现了。因此你需要把这些逻辑从Actor拆分到Component中去并做好事件回调注册和转发的相应胶水衔接工作。
  • 在Gameplay的各个方面考虑注入逻辑的可能数据、UI、Input、玩法等。如果你是在做新的项目或者玩法你在一开始设计的时候就要预想到未来支持GameFeature的可能留好各种逻辑注入点。
  • 进行资产区域划分哪些是CoreGame哪些是GameFeature。当然在资产部分要进行区域划分哪些是CoreGame用到的资产哪些是只在Game Feature里使用的资产各自划分到不同的文件夹里去。
  • 考虑GF和CoreGame的通用通信机制事件总线、分发器、消息队列等。在某些时候你可能光是利用Action和Component还不够为了解耦CoreGame和GF你可能还会需要用到一些通用的通信机制比如事件总线分发器消息队列之类的。
  • PAK和DLC是对游戏主体的补充GF是动态装载卸载功能虽然也可通过PAK动态加载。自然的有些人可能会想到GF跟Pak和dlc有一点点像是不是可以结合一下呢。这里也注意要辨析一下pak和dlc是在资产级别的补丁一般来说是在资产内容上对游戏本体的补充。而GF是在逻辑层面强调游戏功能的动态开关。但是GF也确实可以打包成一个pak来进行独立的分发下载加载因此GF也是可以配合热更新来使用的。