LoginSignup
40
26

More than 3 years have passed since last update.

CharacterMovementをマルチスレッド化してみよう

Last updated at Posted at 2020-12-17

Unreal Engine 4 (UE4) その2 Advent Calendar 2020の18日目の記事です

アドベントカレンダーのリンク
Unreal Engine 4 (UE4) Advent Calendar 2020
Unreal Engine 4 (UE4) その2 Advent Calendar 2020
Unreal Engine 4 (UE4) その3 Advent Calendar 2020

はじめに

UE4はマルチプラットフォーム対応を大きな特徴として持つゲームエンジンです。そのためエンジンの基本的な機能としてハードウェア毎に違うCPUなどの演算資源を使い切るというような思想は持ち合わせておらず。より少ない演算で最大の結果が得られることを目標として持っています。

UE4はデフォルトでは大半のアクターやコンポーネントをGameThread上で実行します。そのためあまり気にせずにアプリケーションを実装していくとGameThreadとRenderThread(+プラットフォームによってRHIThread)以外は割とCPUが遊んでいるようなプロファイル結果をみることが往々にしてあります。(※開発後期に思い付きでマルチスレッド化するのは中々上手く行きません)
しかしマルチスレッディングのサポートが全くないわけではなく、開発者は綿密なアプリケーションの設計の元CPU資源をより効率的に利用するために、アクターやコンポーネントなどをGameThread以外にオフロードし動作させ、CPUの演算資源を有効活用し使い切るようなタイトルを作ることももちろん可能です!

今回はキャラクターの移動やそれに伴うモーションのハンドリングなどに関係し、いつの間にかGameThreadの大きな負荷となりつつも中々最適化が難しいCharacterMovementComponentのマルチスレッド化について調査したので顛末を紹介したいと思います!

注意

  1. 本記事はエンジンの修正が必要になる部分が多々あり、それぞれに副作用があります。
  2. この記事中の変更点をすべて入れたとしても、ゲームで追加したコードが正しく動作しなくなることは確実に発生します。それぞれの問題点に対して自身で対処する必要があります。
  3. UnrealEngine 4.26 + PhysX を Windows上で動かして検証しています。

単語紹介

まずは本文で触れられる単語を簡単に紹介します。ツヨツヨ開発者の方は飛ばしてください。

スレッド達

今回関係しそうな一部のスレッドを紹介

GameThread

ほとんどのゲームロジックを実行するスレッドで、大半の場合常に忙しそうにしています。このスレッドの処理時間が超過すると、たとえGPUに余力があってもフレームレートはあがりません。高いフレームレートを維持するためには、キャラクターやエフェクトの生成、コリジョン処理などの一瞬発生する負荷の高い処理も含め一定時間内にこのスレッドに積まれたタスクを終了する必要があります。

TaskGraphTread

ハードウェアが持つ論理プロセッサ数に比例して自動的に作られるワーカー(お手伝い)スレッド、特にタスクが詰まれなければ何もしない

RenderThread

ゲームスレッドで処理された状態を受け取り、GPUに描画させるためにいろいろなことを処理するスレッド
movable(動的)なアクターが増えると処理が重くなっていく。

Actor

アクター。一つないし複数のコンポーネントを内部に持ち、ワールドに配置されるオブジェクトです。
C++のアクターまたはアクターを継承したクラスからブループリントを作成することが可能で、C++で出来たアクターと同じようにワールド内で動作します。
FTickFunction型の変数 PrimaryActorTick をもち、有効化されていれば自動的にタスクグラフに登録されてGameThreadまたはTaskGraphで実行される

Component

アクターを構成するパーツ。可視化されるコンポーネントや可視化されない処理だけを含んだコンポーネントなど多彩なパーツがあり、それらを組み合わせてアクターを構成する。
アクターと同じくFTickFunction型の変数 PrimaryComponentTick を持ち、有効ならば自動的にタスクグラフに登録される。

CriticalSection

同期オブジェクトの一種。このCriticalSectionオブジェクトを引数にFScopedLockを設定すると、スコープの有効範囲内のコードは常に一つのスレッドだけが処理を実行できるようになります。別のスレッドがすでに処理を実行中だった場合、スコープの先頭で他のスレッドは待機します。

CharacterMovmentComponent編

CharacterMovementのオーバーライド

さて早速マルチスレッド化に挑戦していきましょう。CharacterMovementを継承したクラスを宣言しタスクグラフスレッドへのオフロードを許可させます。
エディタからCharacterMovementComponentを親クラスに持つコンポーネントを追加し、コンストラクタにコンポーネントのPrimaryComponentTick.bRunOnAnyThreadをtrueにしましょう。
このフラグを立てておくとTaskGraphが勝手に判断してタスクグラフスレッドにタスクを登録してくれます!

MyCharacterMovement.cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "MyCharacterMovementComponent.h"

UMyCharacterMovementComponent::UMyCharacterMovementComponent(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    PrimaryComponentTick.bRunOnAnyThread = true;
}

次に実装したMyCharacterMovementをキャラクターに適用します。先ほどと同様にCharacterを継承したクラスを追加し(すでにあればそちらに追記でOKです)、
コンストラクタに「親クラスで指定しているコンポーネントの代わりにこっちのイケてるコンポーネントを使ってくれ!」という指示を与えます。
コンストラクタの引数に与えられたObjectInitializerを使って、SetDefaultSubobjectClass<入れ替えたいコンポーネントクラス名>(親クラスが宣言したコンポーネントのFName) を呼び出します。

MyCharacter.cpp
#include "MyCharacter.h"
#include "MyCharacterMovementComponent.h"
// Sets default values
AMyCharacter::AMyCharacter(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer.SetDefaultSubobjectClass<UMyCharacterMovementComponent>(ACharacter::CharacterMovementComponentName))
{
    // Set this character to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
    PrimaryActorTick.bCanEverTick = true;

}

これでMyCharacterやMyCharacterを継承したブループリントをスポーンするとマルチスレッド化したCharacterMovementが設定された状態でアクターが生まれるようになります。
In ThirdPersonCharacter

まずCharacterMovementをTaskGraphに移した場合必須の修正

この状態でゲームを動かすとUWorld::MarkActorComponentForNeededEndOfFrameUpdateが複数のスレッドから同時に呼ばれるため、以下のコードで配列にAddしている部分が破損します。ここが破損すると配列から失われてしまったアクターの移動処理が実行されなくなりその場に立ち尽くすようになります。

この配列を操作している部分を新たに宣言したCriticalSectionを引数にもつFScopedLockで囲みます。

LevelTick.cpp@892行目あたり
static FCriticalSection ComponentsThatNeedEndOfFrameUpdateCriticalSection;  //追加

void UWorld::MarkActorComponentForNeededEndOfFrameUpdate(UActorComponent* Component, bool bForceGameThread)
{
...
        FScopeLock  Lock( &ComponentsThatNeedEndOfFrameUpdateCriticalSection ); //追加
        if (bForceGameThread)
        {
            FMarkComponentEndOfFrameUpdateState::Set(Component, ComponentsThatNeedEndOfFrameUpdate_OnGameThread.Num(), EComponentMarkedForEndOfFrameUpdateState::MarkedForGameThread);
            ComponentsThatNeedEndOfFrameUpdate_OnGameThread.Add(Component);
        }
        else
        {
            FMarkComponentEndOfFrameUpdateState::Set(Component, ComponentsThatNeedEndOfFrameUpdate.Num(), EComponentMarkedForEndOfFrameUpdateState::Marked);
            ComponentsThatNeedEndOfFrameUpdate.Add(Component);
        }

SharedPtrのマルチスレッドセーフ化

つづいてCharacterMovementを変更してまず遭遇するようになるのが、非マルチスレッドセーフなSharedPointerが使われていることによるクラッシュです。
以下のようなコールスタックが見られます。

コールスタック
    [Inline Frame] UE4Editor-Engine.dll!SharedPointerInternals::FReferenceControllerOps<0>::ReleaseSharedReference(SharedPointerInternals::FReferenceControllerBase * ReferenceController) Line 352
    [Inline Frame] UE4Editor-Engine.dll!SharedPointerInternals::FSharedReferencer<0>::{dtor}() Line 470
    UE4Editor-Engine.dll!TSparseDynamicDelegate<FComponentHitSignature_MCSignature,UPrimitiveComponent,FComponentHitSignatureInfoGetter>::Broadcast<UPrimitiveComponent *,AActor *,UPrimitiveComponent *,FVector,FHitResult>(UPrimitiveComponent * <Params_0>, AActor * <Params_1>, UPrimitiveComponent * <Params_2>, FVector <Params_3>, FHitResult <Params_4>) Line 327
    UE4Editor-Engine.dll!AActor::InternalDispatchBlockingHit(UPrimitiveComponent * MyComp, UPrimitiveComponent * OtherComp, bool bSelfMoved, const FHitResult & Hit) Line 2543
    UE4Editor-Engine.dll!AActor::DispatchBlockingHit(UPrimitiveComponent * MyComp, UPrimitiveComponent * OtherComp, bool bSelfMoved, const FHitResult & Hit) Line 2551
    UE4Editor-Engine.dll!UPrimitiveComponent::DispatchBlockingHit(AActor & Owner, const FHitResult & BlockingHit) Line 2432
    UE4Editor-Engine.dll!USceneComponent::EndScopedMovementUpdate(FScopedMovementUpdate & CompletedScope) Line 874
    UE4Editor-Engine.dll!FScopedMovementUpdate::~FScopedMovementUpdate() Line 3518
    UE4Editor-Engine.dll!UCharacterMovementComponent::PerformMovement(float DeltaSeconds) Line 2477
    UE4Editor-Engine.dll!UCharacterMovementComponent::ControlledCharacterMove(const FVector & InputVector, float DeltaSeconds) Line 5574
    UE4Editor-Engine.dll!UCharacterMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction * ThisTickFunction) Line 1351

理想的にはマルチスレッドセーフが求められるSharedPointerのテンプレート引数に ESPMode::ThreadSafe を設定するのが望ましいです。
が、正直沢山ありすぎて骨が折れるので、エンジンを書き換えてすべてのESPMode::Fast(ESPMode::NotThreadSafe)をテンプレート引数に持つSharedPointerをすべてThreadSafeにしてしまいます。
これによってすべてのSharedPointer中の参照カウンタがアトミック演算で処理されるように変更され複数スレッドからの同時処理による破損が防げますが、代わりに参照カウンタ操作のパフォーマンスが低下します。注意してください。

肝心の方法はTarget.csを変更して PLATFORM_WEAKLY_CONSISTENT_MEMORY 1 のマクロを設定するか、以下の様にエンジンを直接変更します。(どちらにせよリビルドが必要です)

SharedPointerInternals.h
/** Default behavior. */
//#define   FORCE_THREADSAFE_SHAREDPTRS PLATFORM_WEAKLY_CONSISTENT_MEMORY
#define FORCE_THREADSAFE_SHAREDPTRS 1 //FORCE_THREADSAFE_SHAREDPTRSを1に設定してFastに設定されているSharedPtrをThreadSafeとして扱う
#define THREAD_SANITISE_UNSAFEPTR 0

別のクラッシュ例のコールスタック
callstack
>   UE4Editor-MessageLog.dll!FMessageLogListingViewModel::~FMessageLogListingViewModel() Line 24    C++ Symbols loaded.
    UE4Editor-MessageLog.dll!FMessageLogListingViewModel::`vector deleting destructor'(unsigned int)    C++ Symbols loaded.
    [Inline Frame] UE4Editor-Core.dll!SharedPointerInternals::FReferenceControllerOps<0>::ReleaseSharedReference(SharedPointerInternals::FReferenceControllerBase * ReferenceController) Line 352   C++ Symbols loaded.
    [Inline Frame] UE4Editor-Core.dll!SharedPointerInternals::FSharedReferencer<0>::{dtor}() Line 470   C++ Symbols loaded.
    UE4Editor-Core.dll!FMessageLog::~FMessageLog() Line 96  C++ Symbols loaded.
    UE4Editor-Engine.dll!FAnimInstanceProxy::PostUpdate(UAnimInstance * InAnimInstance) Line 531    C++ Symbols loaded.
    UE4Editor-Engine.dll!UAnimInstance::PostUpdateAnimation() Line 583  C++ Symbols loaded.
    UE4Editor-Engine.dll!UAnimInstance::UpdateAnimation(float DeltaSeconds, bool bNeedsValidRootMotion, UAnimInstance::EUpdateAnimationFlag UpdateFlag) Line 520    C++ Symbols loaded.
    UE4Editor-Engine.dll!USkeletalMeshComponent::TickAnimation(float DeltaTime, bool bNeedsValidRootMotion) Line 1133   C++ Symbols loaded.
    UE4Editor-Engine.dll!USkeletalMeshComponent::TickPose(float DeltaTime, bool bNeedsValidRootMotion) Line 1281    C++ Symbols loaded.
    UE4Editor-Engine.dll!UCharacterMovementComponent::TickCharacterPose(float DeltaTime) Line 10683 C++ Symbols loaded.
    UE4Editor-Engine.dll!UCharacterMovementComponent::PerformMovement(float DeltaSeconds) Line 2308 C++ Symbols loaded.
    UE4Editor-Engine.dll!UCharacterMovementComponent::ControlledCharacterMove(const FVector & InputVector, float DeltaSeconds) Line 5574    C++ Symbols loaded.
    UE4Editor-Engine.dll!UCharacterMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction * ThisTickFunction) Line 1351 C++ Symbols loaded.
    [Inline Frame] UE4Editor-Engine.dll!FActorComponentTickFunction::ExecuteTick::__l2::<lambda_a11669b3e3f4a9399f884e36456a23d1>::operator()(float) Line 1008  C++ Symbols loaded.
    UE4Editor-Engine.dll!FActorComponentTickFunction::ExecuteTickHelper<<lambda_a11669b3e3f4a9399f884e36456a23d1>>(UActorComponent * Target, bool bTickInEditor, float DeltaTime, ELevelTick TickType, const FActorComponentTickFunction::ExecuteTick::__l2::<lambda_a11669b3e3f4a9399f884e36456a23d1> & ExecuteTickFunc) Line 3573 C++ Symbols loaded.
    UE4Editor-Engine.dll!FActorComponentTickFunction::ExecuteTick(float DeltaTime, ELevelTick TickType, ENamedThreads::Type CurrentThread, const TRefCountPtr<FGraphEvent> & MyCompletionGraphEvent) Line 1010  C++ Symbols loaded.
    UE4Editor-Engine.dll!FTickFunctionTask::DoTask(ENamedThreads::Type CurrentThread, const TRefCountPtr<FGraphEvent> & MyCompletionGraphEvent) Line 284    C++ Symbols loaded.
    UE4Editor-Engine.dll!TGraphTask<FTickFunctionTask>::ExecuteTask(TArray<FBaseGraphTask *,TSizedDefaultAllocator<32>> & NewTasks, ENamedThreads::Type CurrentThread) Line 886 C++ Symbols loaded.
    [Inline Frame] UE4Editor-Core.dll!FBaseGraphTask::Execute(TArray<FBaseGraphTask *,TSizedDefaultAllocator<32>> & CurrentThread, ENamedThreads::Type) Line 524    C++ Symbols loaded.
    UE4Editor-Core.dll!FTaskThreadAnyThread::ProcessTasks() Line 1065   C++ Symbols loaded.
    UE4Editor-Core.dll!FTaskThreadAnyThread::ProcessTasksUntilQuit(int QueueIndex) Line 888 C++ Symbols loaded.
    [Inline Frame] UE4Editor-Core.dll!FTaskThreadBase::Run() Line 540   C++ Symbols loaded.
    UE4Editor-Core.dll!FTaskThreadAnyThread::Run() Line 965 C++ Symbols loaded.

ワールド外への落下

ここまでで移動するだけであればある程度移動してもクラッシュしなくなります。
しかしなにかの拍子にアクターがワールド外で落下すると以下のような経路を通ってSetCollisionEnabledが呼ばれてGameThread以外でコリジョン操作関数が呼ばれた事を検出してAssertで停止します。
アクターがワールド外に落下しないようにするか、ActorのFellOutOfWorldをオーバーライドし穏便な方法でアクターをDestroyする必要があります。
(この落下自体が起るのを防ぐのが真っ当な対処です。)

落下時のコールスタック
callstack
    UE4Editor-Engine.dll!FPrimitiveSceneProxy::SetCollisionEnabled_GameThread(const bool bNewEnabled) Line 681
    UE4Editor-Engine.dll!USkeletalMeshComponent::OnComponentCollisionSettingsChanged(bool bUpdateOverlaps) Line 244
    UE4Editor-Engine.dll!AActor::SetActorEnableCollision(bool bNewActorEnableCollision) Line 4037
    UE4Editor-Engine.dll!AActor::FellOutOfWorld(const UDamageType & dmgType) Line 2376
    UE4Editor-Engine.dll!AActor::CheckStillInWorld() Line 1421
    UE4Editor-Engine.dll!UCharacterMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction * ThisTickFunction) Line 1313
    [Inline Frame] UE4Editor-Engine.dll!FActorComponentTickFunction::ExecuteTick::__l2::<lambda_a11669b3e3f4a9399f884e36456a23d1>::operator()(float) Line 1008
    UE4Editor-Engine.dll!FActorComponentTickFunction::ExecuteTickHelper<<lambda_a11669b3e3f4a9399f884e36456a23d1>>(UActorComponent * Target, bool bTickInEditor, float DeltaTime, ELevelTick TickType, const FActorComponentTickFunction::ExecuteTick::__l2::<lambda_a11669b3e3f4a9399f884e36456a23d1> & ExecuteTickFunc) Line 3573
    UE4Editor-Engine.dll!FActorComponentTickFunction::ExecuteTick(float DeltaTime, ELevelTick TickType, ENamedThreads::Type CurrentThread, const TRefCountPtr<FGraphEvent> & MyCompletionGraphEvent) Line 1010
    UE4Editor-Engine.dll!FTickFunctionTask::DoTask(ENamedThreads::Type CurrentThread, const TRefCountPtr<FGraphEvent> & MyCompletionGraphEvent) Line 284
    UE4Editor-Engine.dll!TGraphTask<FTickFunctionTask>::ExecuteTask(TArray<FBaseGraphTask *,TSizedDefaultAllocator<32>> & NewTasks, ENamedThreads::Type CurrentThread) Line 886
    [Inline Frame] UE4Editor-Core.dll!FBaseGraphTask::Execute(TArray<FBaseGraphTask *,TSizedDefaultAllocator<32>> & CurrentThread, ENamedThreads::Type) Line 524
    UE4Editor-Core.dll!FTaskThreadAnyThread::ProcessTasks() Line 1065
    UE4Editor-Core.dll!FTaskThreadAnyThread::ProcessTasksUntilQuit(int QueueIndex) Line 888

以下のように関数冒頭のスレッドのチェックを書き換える方法もありますが、同時呼び出しの保護は行われていないため潜在的に問題がでることがあります。

//  check(IsInGameThread());
    check( IsInGameThread() || !IsInRenderingThread() );

この例は一例でTaskGraphスレッド内でコリジョンイベントなどの中から、コリジョンの操作やオブジェクト生成・破壊などのデフォルトで許可されていない動作を実行すると同様のクラッシュやアサートが発生することがあります。

ルートモーション対応 TaskGraphThread上でのUpdateAnimation

CharacterMovementにはアニメーションに保存されたルートボーンの移動量をアクター自体の移動量として扱う「ルートモーション」という仕組みがあります。
CharacterMovementをTaskGraphに移したうえでキャラクターにルートモーションを使うと、本来GameThreadで呼ばれるUSkeletalMeshComponent::TickAnimatonTaskGraphスレッド内でが呼ばれるようになります。
そうすると以下の様なコールスタックでまたもや停止します。

タスクグラフ中のアニメーション実行のコールスタック
callstack
    [Inline Frame] UE4Editor-CoreUObject.dll!FProperty::ContainerPtrToValuePtr(void *) Line 423
    UE4Editor-CoreUObject.dll!UObject::execLetValueOnPersistentFrame(UObject * Context, FFrame & Stack, void * const Z_Param__Result) Line 2477 
    [Inline Frame] UE4Editor-CoreUObject.dll!FFrame::Step(UObject *) Line 401
    UE4Editor-CoreUObject.dll!ProcessLocalScriptFunction(UObject * Context, FFrame & Stack, void * const Z_Param__Result) Line 1060
    UE4Editor-CoreUObject.dll!UObject::ProcessInternal(UObject * Context, FFrame & Stack, void * const Z_Param__Result) Line 1148
    UE4Editor-CoreUObject.dll!UFunction::Invoke(UObject * Obj, FFrame & Stack, void * const Z_Param__Result) Line 5588
    UE4Editor-CoreUObject.dll!UObject::ProcessEvent(UFunction * Function, void * Parms) Line 1985
    UE4Editor-Engine.dll!UAnimInstance::BlueprintUpdateAnimation(float DeltaTimeX) Line 1219
    UE4Editor-Engine.dll!UAnimInstance::UpdateAnimation(float DeltaSeconds, bool bNeedsValidRootMotion, UAnimInstance::EUpdateAnimationFlag UpdateFlag) Line 499
    UE4Editor-Engine.dll!USkeletalMeshComponent::TickAnimation(float DeltaTime, bool bNeedsValidRootMotion) Line 1133
    UE4Editor-Engine.dll!USkeletalMeshComponent::TickPose(float DeltaTime, bool bNeedsValidRootMotion) Line 1281    C++ Symbols loaded.
    UE4Editor-Engine.dll!UCharacterMovementComponent::TickCharacterPose(float DeltaTime) Line 10683 C++ Symbols loaded.
    UE4Editor-Engine.dll!UCharacterMovementComponent::PerformMovement(float DeltaSeconds) Line 2308 C++ Symbols loaded.
    UE4Editor-Engine.dll!UCharacterMovementComponent::ControlledCharacterMove(const FVector & InputVector, float DeltaSeconds) Line 5574    C++ Symbols loaded.

上記コールスタックを読み取る以下のようなフローで処理が実行されていることがわかります。

  1. CharacterMovement
  2. SkeletalMeshのTickPose/TickAnimation
  3. UAnimInstance::UpdateAnimation
  4. BlueprintUpdateAnimation
  5. ブループリントVMが起動

USkeletalMeshComponentのアニメーション更新処理はGameThreadから呼ばれることを前提としており、最終的にFProperty::ContainerPtrToValuePtrの引数にnullが入ってきてクラッシュします。
原因としてはexecLetValueOnPersistentFrame関数内でAnimブループリントを実行するためにのUberGraphのポインタの取得を試みますが、
以下のクラスのコメントにもあるように平行実行する場合はnullを返すようになっています。
つまりコメントにもあるようにAnimブループリントはマルチスレッド化を推奨していません。

AnimBlueprintGeneratedClass.cpp
uint8* UAnimBlueprintGeneratedClass::GetPersistentUberGraphFrame(UObject* Obj, UFunction* FuncToCheck) const
{
    if(!IsInGameThread())
    {
        // we cant use the persistent frame if we are executing in parallel (as we could potentially thunk to BP)
        return nullptr;
    }

    return Super::GetPersistentUberGraphFrame(Obj, FuncToCheck);
}

考えられる原因としては、Animブループリントも通常のブループリントと同様に自由度が高く他のアクターやそれが持つコンポーネントに対して自由に(書き込みすらも!)アクセスできてしまい、マルチスレッドセーフを維持するのが大変難しいことが根底にあると考えられます。
UpdateAnimationでは、AnimInstanceを持つアクターから必要なパラメータを取り出す処理ですが、この際にアクターのパラメータを書き換えたりしないように単純に値を取り出すだけにするのが賢明です。
またUpdateAnimation処理は毎Tick動くブループリントですが、こちらをすべてc++で書き切るようにするとパフォーマンス上のメリットもありつつこのアサートを回避することもできます。パフォーマンスを絞り出すようなタイトルであれば検討しても良いかもしれません。

※アニメーションブループリント中の「アニメーショングラフ」に関してはデフォルトでマルチスレッド動作しています。
Sample anim graph

今回は実験なので、手っ取り早くこのIsInGameThreadの分岐をコメントアウトしてしまいます!
(繰り返しますがヤンチャなUpdateAnimationを作るとクラッシュやメモリ破壊などを簡単に引き起こせるので注意してください!)

4.26の新機能PropertyAccessSystem (早期アクセス機能)

PropertyAccessSystem

まずは Unreal Engine 4.26 リリース ノート にどんなものかという概要があるのでどうぞ。

今まではAnimGraphで変数を使うためにUpdateAnimationで必要なパラメータを収集するのが常套手段でしたが、4.26からはプロパティアクセスシステムという超強力な機能を扱えるようになりました(ただし4.26では早期アクセス機能です)。

PropertyAccessSystem
これによってAnimGraphから直接スケルタルメッシュを持っているアクターやそのコンポーネントのプロパティに直接アクセスしつつ、処理速度も速くマルチスレッドセーフといいこと尽くめです。
これで必要なパラメータにアクセスできればUpdateAnimationに必要なパラメータが無くなりUpdateAnimationのマルチスレッドセーフを考慮する必要がなくなります。乗るしかないこのビックウェーブに!

コリジョンイベントによるクラッシュ

さらにタイトルを作るうえで欠かせないのがOnComponentHitなどのコリジョンイベントです。
これを処理するようにすると、以下のようなメモリアロケーション関係に関係するクラッシュが現れます。
(いろいろなパターンで発生するのでこのコールスタックと同じものが現れるとは限りません)

コリジョンイベントのコンフリクトによるクラッシュ例

複数のスレッドからメモリアロケーションを含む処理が同時に同じメンバ変数に対して起こるとクラッシュが発生します。
UE4Editor-Core.dll!rml::internal::Block::privatizePublicFreeList(bool)
UE4Editor-Core.dll!rml::internal::ExtMemoryPool::initTLS(void)
UE4Editor-Core.dll!scalable_aligned_malloc()
[Inline Frame] UE4Editor-Core.dll!FMallocTBB::TryMalloc(unsigned __int64) Line 72   
UE4Editor-Core.dll!FMallocTBB::Malloc(unsigned __int64 Size, unsigned int Alignment) Line 77    
UE4Editor-Core.dll!FMemory::Malloc(unsigned __int64 Count, unsigned int Alignment) Line 30  
[Inline Frame] UE4Editor-Engine.dll!MakeUnique(physx::PxGeometryHolder && <Args_0>) Line 753    
UE4Editor-Engine.dll!FPhysicsGeometryCollection_PhysX::FPhysicsGeometryCollection_PhysX(const FPhysicsShapeHandle_PhysX & ShapeRef) Line 2804   
UE4Editor-Engine.dll!FPhysicsInterface_PhysX::GetGeometryCollection(const FPhysicsShapeHandle_PhysX & InShape) Line 988 
UE4Editor-Engine.dll!UWorld::ComponentSweepMulti::__l2::<lambda>(const FPhysicsActorHandle_PhysX & Actor) Line 437  
[Inline Frame] UE4Editor-Engine.dll!UE4Function_Private::TFunctionRefBase<UE4Function_Private::FFunctionRefStoragePolicy,void __cdecl(FPhysicsActorHandle_PhysX const &)>::operator()(const FPhysicsActorHandle_PhysX &) Line 676   
UE4Editor-Engine.dll!FPhysicsCommand_PhysX::ExecuteRead(const FPhysicsActorHandle_PhysX & InActorHandle, TFunctionRef<void __cdecl(FPhysicsActorHandle_PhysX const &)> InCallable) Line 557 
UE4Editor-Engine.dll!UWorld::ComponentSweepMulti(TArray<FHitResult,TSizedDefaultAllocator<32>> & OutHits, UPrimitiveComponent * PrimComp, const FVector & Start, const FVector & End, const FQuat & Quat, const FComponentQueryParams & Params) Line 447    
UE4Editor-Engine.dll!UPrimitiveComponent::MoveComponentImpl(const FVector & Delta, const FQuat & NewRotationQuat, bool bSweep, FHitResult * OutHit, EMoveComponentFlags MoveFlags, ETeleportType Teleport) Line 2182    
[Inline Frame] UE4Editor-Engine.dll!USceneComponent::MoveComponent(const FVector & MoveFlags, const FQuat &) Line 1598  
UE4Editor-Engine.dll!UMovementComponent::MoveUpdatedComponentImpl(const FVector & Delta, const FQuat & NewRotation, bool bSweep, FHitResult * OutHit, ETeleportType Teleport) Line 531  
[Inline Frame] UE4Editor-Engine.dll!UMovementComponent::MoveUpdatedComponent(const FVector &) Line 489  
UE4Editor-Engine.dll!UMovementComponent::SafeMoveUpdatedComponent(const FVector & Delta, const FQuat & NewRotation, bool bSweep, FHitResult & OutHit, ETeleportType Teleport) Line 569  

問題としては下の図のように、複数のスレッドから同じアクターのVMを二つ以上同時に動作させる場合に発生します。
このように複数のアクターやオブジェクトを複数のスレッドから同時に扱うと容易にクラッシュを発生させるので細心の注意が必要です。

Conflict collision events

対処としては対象のオブジェクト毎にCriticalSectionなどの同期オブジェクトをつかって同時アクセスしないようにガードするのが一つの方法として挙げられます。
今回はオブジェクト毎ではなくシステム全体で一つのCriticalSectionで処理してしまいます。(コリジョンイベントに巨大なコードを書くとスレッドが同期待ちでストールするので注意してください)

PrimitiveComponent.cpp
static FCriticalSection GBlockingHitEventCS;  //added

void UPrimitiveComponent::DispatchBlockingHit(AActor& Owner, FHitResult const& BlockingHit)
{
    SCOPE_CYCLE_COUNTER(STAT_DispatchBlockingHit);
    FScopeLock Lock( &GBlockingHitEventCS );    //added
    UPrimitiveComponent* const BlockingHitComponent = BlockingHit.Component.Get();
    if (BlockingHitComponent)
    {
...
    }
}

マルチスレッドのスレッドコンフリクトが起りえる部分を同期オブジェクトで囲うことで以下のように同時処理を回避します。
With a sync object

ここまでである程度ゲームに必要な処理も含めCharacterMovementが動くようになりました。パチパチパチパチ。
ある程度目標は達したのですがもうちょっと頑張ってマルチスレッド化をさらに進めます。

ついでにAnimationCompletionTask編

アニメーション処理が終わった後のゲームスレッドでの終了処理を行うのがAnimationCompletionTaskです。バウンズの更新や子コンポーネントの移動処理などを行っています。
こちらもスケルタルメッシュの数が増えてくるとGameThread上で大きな負荷になってくるのでマルチスレッド化してみます。
AnimationCompletionTaskをマルチスレッド化するには対象のタスククラスのGetDesiredThreadを書き換えます。

SkeletalMesh.cpp
    static ENamedThreads::Type GetDesiredThread()
    {
        if (GParallelAnimCompletionTaskHighPriority)
        {
            return ENamedThreads::AnyHiPriThreadHiPriTask;  //add
//          return static_cast<ENamedThreads::Type>(ENamedThreads::GameThread | ENamedThreads::HighTaskPriority);
        }
        return ENamedThreads::AnyHiPriThreadNormalTask; //add
//      return ENamedThreads::GameThread;
    }

こうすることでSkeletalMeshから発行されたアニメーション終了タスクがTaskGraphThreadで動作するようになります。
早速動かしてみると、物理シミュレーション部の姿勢にアクセスする部分でやっぱりassertが発生します。
こちらも複数のスレッドから書き込みを含む同時にアクセスが発生した場合、クラッシュを含む致命的なエラーが発生し得ることに注意が必要です。

BodyInstance.cpp
const FTransform& FBodyInstance::GetRelativeBodyTransform(const FPhysicsShapeHandle& InShape) const
{
    check(IsInGameThread()); <-----------------------------
    const FBodyInstance* BI = WeldParent ? WeldParent : this;

マルチスレッド化したAnimationCompletionTaskのクラッシュ例
コールスタック
UE4Editor-Engine.dll!FBodyInstance::GetRelativeBodyTransform(const FPhysicsShapeHandle_PhysX & InShape) Line 1558
UE4Editor-Engine.dll!FBodyInstance::UpdateBodyScale::__l2::<lambda>(const FPhysicsActorHandle_PhysX & Actor) Line 2207
[Inline Frame] UE4Editor-Engine.dll!UE4Function_Private::TFunctionRefBase<UE4Function_Private::FFunctionRefStoragePolicy,void __cdecl(FPhysicsActorHandle_PhysX &)>::operator()(FPhysicsActorHandle_PhysX &) Line 676
UE4Editor-Engine.dll!FPhysicsCommand_PhysX::ExecuteWrite(FPhysicsActorHandle_PhysX & InHandle, TFunctionRef<void __cdecl(FPhysicsActorHandle_PhysX &)> InCallable) Line 674
UE4Editor-Engine.dll!FBodyInstance::UpdateBodyScale(const FVector & InScale3D, bool bForceUpdate) Line 2393
UE4Editor-Engine.dll!USkeletalMeshComponent::UpdateKinematicBonesToAnim::__l52::<lambda>() Line 644
[Inline Frame] UE4Editor-Engine.dll!UE4Function_Private::TFunctionRefBase<UE4Function_Private::FFunctionRefStoragePolicy,void __cdecl(void)>::operator()() Line 676
UE4Editor-Engine.dll!FPhysicsCommand_PhysX::ExecuteWrite(USkeletalMeshComponent * InMeshComponent, TFunctionRef<void __cdecl(void)> InCallable) Line 630
UE4Editor-Engine.dll!USkeletalMeshComponent::UpdateKinematicBonesToAnim(const TArray<FTransform,TSizedDefaultAllocator<32>> & InSpaceBases, ETeleportType Teleport, bool bNeedsSkinning, EAllowKinematicDeferral DeferralAllowed) Line 675
UE4Editor-Engine.dll!USkeletalMeshComponent::PostAnimEvaluation(FAnimationEvaluationContext & EvaluationContext) Line 2622
UE4Editor-Engine.dll!USkeletalMeshComponent::CompleteParallelAnimationEvaluation(bool bDoPostAnimEvaluation) Line 3858
UE4Editor-Engine.dll!FParallelAnimationCompletionTask::DoTask(ENamedThreads::Type CurrentThread, const TRefCountPtr<FGraphEvent> & MyCompletionGraphEvent) Line 170
UE4Editor-Engine.dll!TGraphTask<FParallelAnimationCompletionTask>::ExecuteTask(TArray<FBaseGraphTask *,TSizedDefaultAllocator<32>> & NewTasks, ENamedThreads::Type CurrentThread) Line 886
[Inline Frame] UE4Editor-Core.dll!FBaseGraphTask::Execute(TArray<FBaseGraphTask *,TSizedDefaultAllocator<32>> & CurrentThread, ENamedThreads::Type) Line 524
UE4Editor-Core.dll!FTaskThreadAnyThread::ProcessTasks() Line 1065
UE4Editor-Core.dll!FTaskThreadAnyThread::ProcessTasksUntilQuit(int QueueIndex) Line 888

今回はオウンリスクでこのアサートを外します。
これで今回のグレイマンやGaiden君は問題なく動作するようになりました。

パフォーマンス測定

●条件
- Ryzen3900x(12コア 24スレッド)のPCで計測しています。
- 200体のキャラクターを生成しています。(Insightの計測結果)デフォルトの設定で使われるPerObjectShadowを使うと膨大な数のシャドウマップのレンダリングにより、レンダリングスレッドがボトルネックになりますが、カスケードシャドウを有効化することによってレンダリングスレッドの負荷を大きく下げることができるのでこちらを適用しています。
-Developmentビルドのエディタからスタンドアロン起動してパフォーマンスを計測しています。

マルチスレッド化前

PrePhysicsで10msほど、TaskGraphThreadではParallelAnimationEvaluationTaskを除いてほとんどタスクが実行されていない

Before multi threaded character movement

※こちらの動画は400体キャラクターを出して動かしています。※200体だと60fps弱で動いてしまったため急遽400体に

マルチスレッド化後

各タスクがTaskGraphに分配されるようになりPrePhysicsで3.6msまで時間が軽減した
ただしCharacterMovementの個々の処理に関しては物理ワールドへのアクセスの同期待ちによるストールが発生しており大半がストール状態となってしまっている

After multi threaded character movement

※こちらの動画は400体キャラクターを出して動かしています。

まとめ

莫大な数のアクターを制御するなど多数のタスクを実行する場合マルチスレッド化を行うことで演算資源を有効活用して処理時間を短縮できる可能性があります。
ただしマルチスレッド化を安易に行うと本記事にもあるように、何かやるたびに簡単にクラッシュを引き起こすようなバグやなかなか再現しないようなレアなバグを生む可能性があることを念頭に置いておいてください。
複雑な実装を含めたままタイトルの開発が進行し、開発の終盤にパフォーマンス改善が必要になってマルチスレッド化を検討するようなパターンはかなり成功率が低くなることが想定されます。
こんな記事を書いておいてなんですが、プロジェクトに本当にリスクのある最適化が必要かどうかよく吟味してからチャレンジしてください。

ベストなマルチスレッド化方法としては、初めからマルチスレッド化を前提としたコンポーネントを作成し、タイトル固有の処理をそちらに切り出すのが最もお勧めです。
このタスクが終わった後に別のタスクを動かしたいといった依存関係を設定したい場合はAddTickPrerequisiteActor,AddTickPrerequisiteComponentを使って依存関係を設定します。

マルチスレッド化する際のポイント

  • ブループリント自体は同時に同じオブジェクトに触れなければタスクグラフで実行可能
  • 1つのアクターやコンポーネントを複数のスレッドから同時に書き込みを含んだ同時アクセスが発生するとクラッシュを引き起こす
  • 同時アクセスするタスクがすべて読み込みアクセス(≒const変数へのアクセス)なら(多分)アクセスしても問題無いがvolatileな変数や挙動がある可能性は否定できない
  • 一つのタスクを実行するとき、タスクが対象としているアクターやコンポーネント以外のオブジェクトにアクセスする場合は常に細心の注意が必要
  • できればタスクグラフで実行する処理内で外部のオブジェクトにアクセスしないような安全な実装が望まれる
  • または外部オブジェクトへのアクセスは正しく同期オブジェクトでの保護を行い、保護中の処理を可能な限り速やかに行うような実装も検討してもよい
40
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
26