1 はじめに
今回は、Unreal Engine 5のPCG(プロシージャル生成)とChaos Destruction物理破壊機能を共に活用した、Epic Games公式サンプル「Cassini Sample」を基に作った演出を介して、リアルタイムでオブジェクトの生成と破壊するシステムの実装方法を紹介したいと思います。
1.1 シネマティックスDemo
(音あり注意)
キャラクター:ウルフちゃん - VRoid Project
バイクモデル:Hover bike - "The Rocket” - TuppsM(CC BY 4.0、変更あり)(SketchFab)
音楽:BGMer - http://bgmer.net
1.2 背景
この前に、PCGシステムの複数の新機能が展示された、Epic Games公式サンプルプロジェクト「Cassini Sample」を触って勉強しました。その中でも、特にこの宇宙ステーションが気に入りました。PCGによってランダムで配置されたオブジェクトが、異常状態で無重力になった宇宙ステーション空間のカオスさの表現に不可欠だと思います。
そして思いついたのは、このシーンをもっとカオスで、迫力があるものにする方法があるのかとのことです。
カオスといえば、UE5には、名前自体が「カオス」の機能があります。
このChaos Physicsの中のChaos Destruction破壊システムを用いて、破片が派手に飛び散る演出ができ、散乱したオブジェクトと相性が良いと感じます。そのため、今回はChaos DestructionとPCGシステムの組み合わせを試すことになりました。
環境
Unreal Engine 5.7.4
Windows 11 25H2
2 Chaos Destructionについて
Chaos PhysicsはUEの物理シミュレーションシステムです。この物理システムから、流体や、ラグドール、乗り物などのシミュレーション機能がいくつか派生しました。Chaos Destructionもその一つで、メッシュの破壊と破片の物理表現を実現できる機能です。
2.1 Chaos Destructionで破壊できるオブジェクトを作成
まずは、元のCassini Sampleにあるオブジェクトを破壊できるようにします。Chaos Destructionの基本操作(Geometry Collectionの作成と設定など)は省略します。Epic Gamesのドキュメントをご参照ください。
ここは、今回出会った、Cassini Sampleのアセットに関する問題と解決策を紹介したいと思います。
一つ目は、Kitbashと物理シミュレーションの相性が悪いという問題です。元々の計画は、サンプルの現在のオブジェクトをそのまま「破壊できるバージョン」を作る予定でしたが、確認すると、ここで使用しているプロップは複数のメッシュを組み合わせて作ったもの、いわゆる「Kitbash」です。したがって、一つのプロップに、メッシュが重なった部分が多くあります。
これは、Chaos Destructionのドキュメントに明記されている不適切のセットアップです。交差したメッシュは、物理シミュレーションで意図しない挙動が出てしまいます。
つまり、現在のプロップはそのまま利用できない状態で、交差しないように再編集が必要です。私はそんなにモデリングの技術がないので、このルートを断念します。代わりに、プロジェクトのアセットに、見た目の雰囲気が近い単体のメッシュをいくつか選んで、これらに使うようにします。
しかし、ここでまた二つ目の問題に遭いました。このプロジェクトに、ほぼすべてのメッシュが、一つの材質や色など幅広くカスタマイズできるの、汎用マテリアルが使用しています。
そのマテリアルを使用したメッシュは、Geometry Collectionにして破壊する際に、見た目が変になります。
具体的な原因は分かりませんが、多分ワールド位置をベースにした汚れや傷のマークと、特殊な色の処理方式のせいだと考えています。
このマテリアルは本当に複雑すぎて、使えるように修正するつもりはないので、別の普通のマテリアルを代替しました(ここはTwinmotion公式の無料マテリアルを使用しています)。見た目は少し劣っていますが、破壊用オブジェクトに使うのは十分だと思います。
これで破壊の効果が正しくなります。
2.2 Field Systemで破壊する
Geometry Collectionを用意したら、次のステップは破壊する仕組みを作成します。Geometry Collection自体が、高所から落としたり、他のオブジェクトでぶつかったりとかでも壊れますが、これだと破壊の効果をコントロールしにくいため、破壊する「力」を単独で制御する必要があります。これを実現するため、Unreal Engineは「Field System」というシステムを提供しています。
このシステムの特徴といえば、「Strain」と「Velocity」を別々にコントロールできることです。「Strain」は、オブジェクトを壊せるか・どのくらい壊すかに関わる力の強さで、一方「Velocity」は、破片がどのような形で飛ぶかに影響します。これにより、よりアーティスティックな破壊効果が表現できます。
Field System自体はC++またはBlueprintを経由して制御できますが、Unreal Engine公式が、Field Systemの機能を一つの汎用Blueprintにまとめて、可視化もできる公式テンプレートを提供しています。Engine ContentのFieldNodesフォルダで確認できます。
今回は主にFS _MasterFieldを利用します。具体的な機能や使用方法は公式ドキュメントをご参照ください。
2.3 物理シミュレーションによる連続破壊
最後に、もう一つの懸念点は、飛び散った破片の衝突によるの二次破壊の実現できるのかという問題です。
インスピレーションの一つとしては、ゲーム『ドンキーコング バナンザ』で、シンクホールに墜落中に、下パンチで連続の破壊する演出です。
試してみると、問題なく実現できます。連続の破壊ができると、より多くのオブジェクトを破壊でき、もっとインパクトがあるビジュアルを期待できます。
3 PCGでGeometry Collectionを生成
オブジェクトのGeometry Collectionを用意したら、次はPCGでGeometry Collectionを生成します。ここでは、Cassini SampleのPCG Graph(Grammar)から、少し調整を加えて作成します。
3.1 PCG Data Assetに基づいたワークフロー
まずは、Cassini Sampleのプロップ生成では、PCG Data Assetが用いたワークフローを使用しています。この仕組みの詳細と別のアセットを利用するための実装方法については、この前の記事で紹介しましたので、ここで省略します。
要するに、Geometry CollectionごとにLevel Instanceを作成して、Level Instanceから個別のPCG Data Assetを作成します。さらに、そのLevel Instance Actorに、ModuleInfoのActor Componentを追加し、KitBuilder PCG Graphで全体のPCG Data Assetを作ります。
GrammarのGraphで元のData Assetをこれに置き換えると、新しいアセットを生成できます。
前回の記事と比べ、Level Instance作成後、PCG Data Assetを生成する前に、追加調整が必要です。一つ目は、Level Instance内で、Geometry CollectionのLinear DampingとAngular Damping値を少し高めに設定しましょう。これで破壊後破片の運動の減衰が速くなり、全体の物理シミュレーションの負荷を抑えることができます。
二つ目は、すぐ下のEnable Gravityをオフにします。今回は無重力状態の宇宙ステーション環境なので、オブジェクトが浮かぶようにします。
3.2 Spawn Actorノードの設定
元のPCG Graphは、Spawn Static Meshノードで、コストが低いInstanced Static Meshでプロップを大量生成しています。しかし、Spawn Static MeshノードはGeometry Collectionに対応していないため、今回はSpawn Actorノードに変えます。
ノードのTemplate Actor ClassをLevelInstanceにします。
加えて、Level Instance Actorが使用するLevel Assetを指定しなければなりません。これをPCGノードに設定するため、Level Instance ActorのLevel欄で、右クリックして、「Copy Internal Name」を選択し、プロパティ名を取得できます。ここのプロパティ名は、WorldAssetです。
これをSpawn Actorの「Spawned Actor Property Override Description」部分の、「Property Target」として入力します。そして「Input Source」は、Level Instanceアセットへの参照を持つAttribute「Source」にします。これで、それぞれのLevelをActorに設定できます。
完成したら、PCG Graphを再生成して、こんな感じになります。元のStatic MeshがLevel Instanceに変わります。
FS_MasterFieldを置くと、破壊されることを確認できます。
4 Field Systemで破壊演出を作る
破壊できる環境を用意しましたが、まだ破壊をもたらすActorがないので作りましょう。
4.1 キャラクターPawnの実装と設定
宇宙ステーションなので、スタイルが近いSF風の空飛ぶバイクのモデルを使用します。
キャラクターは、VRoid Projectさんが最近公開したオリジナルキャラ「ウルフちゃん」を使用しています。(無料配布のアセットはXAvatar形式なので、一度VRoid Studioでvrmファイルを出力して、VRM4UプラグインでUEにインポートします。)
キャラクターをバイクに配置します。アニメーションは得意ではないので、今回は固定のポーズだけをBlender内で作りました。
PawnのCollisionを配置します。今回のシーンは重なる「Overlap」ではなく、ぶつかるの「Hit」Eventが必要なので、Collisionの設定に「Simulation Generates Hit Events」をオンにし、「Generate Overlap Events」をオフにし、Collision Presetsを「BlockAll」にします。
Pawn移動のロジックについて、このプロジェクトはゲームプレイではなく動画撮影を想定しているので、複雑なコントローラーを実装せず、ひたすら前に移動することにしました。加えて、ホバー状態を表現するため、Perlin Noiseを使ってYZ方向のオフセットも実装しました。
4.2 Field Systemの生成
これにより、キャラクターPawnがGeometry Collectionと衝突するとき、Hit Eventが実行されます。ここでField Systemを出します。FS_MasterFieldの呼び方は、こちらの記事を参考にしました。
衝突の位置に、FS_MasterField Actorを生成します。そして、衝突の方向(Normal)に沿って、Field SystemのRotationを設定します。パラメータの設定が完了すると、CE_Trigger EventでこのFieldを実行でき、最後にActorを削除します。
これで、バイクとプロップが衝突するたびに、Geometry Collectionを破壊できます。ビジュアルのニーズにより、Field Systemのサイズや、Radial・Directional Magnitudeなどを調整しましょう。
5 PCGシステムをランタイムで動的配置する
この時点で、基本的な機能を備えていますが、システムとして使いやすさの問題があります。それは、この宇宙ステーションの長さです。総延長が短い場合は、バイクのスピード感を出したいため、一瞬で終わってしまいます。一方、Splineをすごく長くすると、Actor数が膨大になり、フレームレートが落ちます。
そのため、私はキャラクターの位置により、自動的に前に新しいセクションを生成し、そして後ろの見えなくなる部分を破棄するマネージャーActorを実装しました。これにより、ゲーム向けにせよ、動画レンダリングにせよ、理論的に時間制限なしで実行できるようになります。
5.1 パーティション化生成を使いない理由
PCGシステムには、パーティション化生成という機能があり、広い範囲をカバーするPCGを小さなセルに分割し、必要な部分だけを生成仕組みが提供されています。
一見、このプロジェクトの需要に完全に合っていますが、確認すると、PCG Volumeに基づいた生成のみに対応していて、今回のような、Splineから生成するPCG Graphは利用できないようです。そのため、仕方なく自分でPCG Actorの生成と削除を管理する形になりました。
5.2 Corridor Actorの実装
では早速実装しましょう。まずは、一つのセクションを持つCorridor Actorです。
Spline ComponentとPCG Componentを追加します。PCG Graph Assetや、Splineの長さなどは、マネージャーで管理しているので、OnConstruction()とBeginPlay()でマネージャーから渡されたパラメータから設定します。設定が完了したら、PCG->Generate()で生成します。
Corridor Actorを破棄する際に注意が必要です。PCGで生成されたオブジェクトを削除するEvent Cleanup()が処理時間がかかるため、EndPlay()で実行すると、まだ終わらないのにActor自体がDestroyされてしまうので、Geometry Collectionがそのままレベル内に残されます。
解決策は、マネージャーから、Destroy()ではなく、別のRemoveSection()Eventを呼びます。このEventでCleanup()を実行し、そしてTryDestroy() Eventのタイマーを設定します。これにより、PCGの片付けが完了しない限り、TryDestroy()が繰り返し実行され、完成した後にActorをDestroyします。
/* CorridorSection.h */
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "CorridorSection.generated.h"
class USplineComponent;
class UPCGComponent;
class UPCGGraphInterface;
UCLASS()
class CHAOSCORRIDOR_API ACorridorSection : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
ACorridorSection();
// Called every frame
virtual void Tick(float DeltaTime) override;
UFUNCTION()
void Initialize(TWeakObjectPtr<UPCGGraphInterface> InPCGGraph, float InSegmentLength, int32 InNumSegments);
UFUNCTION()
void RemoveSection();
UPROPERTY(EditAnywhere, Category="ChaosCorridor")
TWeakObjectPtr<UPCGGraphInterface> PCGGraph;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="ChaosCorridor")
float SegmentLength = 640.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="ChaosCorridor")
int32 NumSegments = 6;
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
virtual void OnConstruction(const FTransform& Transform) override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
private:
UFUNCTION()
void TryDestroy();
FTimerHandle CleanupTimerHandle;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "ChaosCorridor", meta = (AllowPrivateAccess = "true"))
TObjectPtr<USplineComponent> CorridorSpline;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "ChaosCorridor", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UPCGComponent> PCG;
UPROPERTY()
UPCGGraphInterface* GraphInterface;
};
/* CorridorSection.cpp */
#include "CorridorSection.h"
#include "Components/SplineComponent.h"
#include "PCGGraph.h"
#include "PCGComponent.h"
#include "Helpers/PCGGraphParametersHelpers.h"
// Sets default values
ACorridorSection::ACorridorSection()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
CorridorSpline = CreateDefaultSubobject<USplineComponent>("CorridorSpline");
RootComponent = CorridorSpline;
PCG = CreateDefaultSubobject<UPCGComponent>("PCG");
PCG->GenerationTrigger = EPCGComponentGenerationTrigger::GenerateOnDemand;
}
void ACorridorSection::Initialize(TWeakObjectPtr<UPCGGraphInterface> InPCGGraph, float InSegmentLength, int32 InNumSegments)
{
PCGGraph = InPCGGraph;
SegmentLength = InSegmentLength;
NumSegments = InNumSegments;
}
void ACorridorSection::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(Transform);
// Update Spline
CorridorSpline->ClearSplinePoints();
TArray<FSplinePoint> SplinePoints;
SplinePoints.Add(FSplinePoint(0.0f, FVector(0.0f, 0.0f, 0.0f)));
SplinePoints.Add(FSplinePoint(1.0f, FVector(SegmentLength * NumSegments, 0.0f, 0.0f)));
CorridorSpline->AddPoints(SplinePoints, true);
}
// Called when the game starts or when spawned
void ACorridorSection::BeginPlay()
{
Super::BeginPlay();
if (PCGGraph.IsValid())
{
PCG->SetGraph(PCGGraph.Get());
GraphInterface = Cast<UPCGGraphInterface>(PCG->GetGraphInstance());
UPCGGraphParametersHelpers::SetStringParameter(GraphInterface, FName("Grammar"), FString("{Straight,DoorL,DoorR,DoorD}*"));
UPCGGraphParametersHelpers::SetBoolParameter(GraphInterface, FName("AllHellBreaksLoose"), true);
PCG->Generate();
}
else
{
UE_LOG(LogTemp, Warning, TEXT("PCG Graph is not set"));
}
}
// Called every frame
void ACorridorSection::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
void ACorridorSection::RemoveSection()
{
PCG->Cleanup();
if (PCG->IsCleaningUp())
{
GetWorldTimerManager().SetTimer(CleanupTimerHandle, this, &ACorridorSection::TryDestroy, 0.5f, true);
}
}
void ACorridorSection::TryDestroy()
{
if (!PCG->IsCleaningUp())
{
GetWorldTimerManager().ClearTimer(CleanupTimerHandle);
this->Destroy();
}
}
void ACorridorSection::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
Super::EndPlay(EndPlayReason);
}
5.3 マネージャーActorの実装
次は、マネージャーActorのセットアップです。
CorridorSections TArrayで現在すべてのCorridor Sectionのポインタを管理しています。Pawnの位置が現在の先頭部分に近づいたら、先頭の位置に新しいセクションを生成し、そして一番古いセクションを削除します。(生成と削除のタイミングをずらすとフレームレートがもっと安定になるかもしれませんが、今回はレンダリング用なのであまり気にしませんでした。)
/* CorridorManager.h */
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Containers/Queue.h"
#include "CorridorManager.generated.h"
class ACorridorSection;
class USplineComponent;
class UPCGComponent;
class UPCGGraphInterface;
UCLASS()
class CHAOSCORRIDOR_API ACorridorManager : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
ACorridorManager();
// Called every frame
virtual void Tick(float DeltaTime) override;
UPROPERTY(EditAnywhere, Category="ChaosCorridor")
TWeakObjectPtr<UPCGGraphInterface> PCGGraph;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="ChaosCorridor")
float SegmentLength = 640.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="ChaosCorridor")
int32 NumSegments = 6;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="ChaosCorridor")
int ForwardsSegments = 3;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="ChaosCorridor")
int MaxBackwardsSegments = 5;
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
private:
UFUNCTION()
void AddCorridorSection();
UFUNCTION()
void RemoveOldestSection();
UPROPERTY()
TArray<ACorridorSection*> CorridorSections;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "ChaosCorridor", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UStaticMeshComponent> CorridorOrigin;
UPROPERTY()
APawn* PlayerPawn;
UPROPERTY()
FTransform PawnTransform;
UPROPERTY()
FTransform CurrentSectionTransform;
};
/* CorridorManager.cpp */
#include "CorridorManager.h"
#include "CorridorSection.h"
#include "CorridorStartSection.h"
#include "Kismet/GameplayStatics.h"
// Sets default values
ACorridorManager::ACorridorManager()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
CorridorOrigin = CreateDefaultSubobject<UStaticMeshComponent>(FName("CorridorOrigin"));
RootComponent = CorridorOrigin;
}
// Called when the game starts or when spawned
void ACorridorManager::BeginPlay()
{
Super::BeginPlay();
PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
//Spawn first Sections
for (int i = 0; i < ForwardsSegments; i++)
{
AddCorridorSection();
}
CurrentSectionTransform = CorridorSections[CorridorSections.Num() - ForwardsSegments]->GetActorTransform();
}
// Called every frame
void ACorridorManager::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
//Get current Pawn Position
PawnTransform = PlayerPawn->GetActorTransform();
float Distance = FVector::Dist(PawnTransform.GetLocation(), CurrentSectionTransform.GetLocation());
if (Distance >= 0.6f * SegmentLength * NumSegments)
{
// Create and Spawn New Segment
AddCorridorSection();
CurrentSectionTransform = CorridorSections[CorridorSections.Num() - ForwardsSegments]->GetActorTransform();
//Delete the oldest Section
if (CorridorSections.Num() >= ForwardsSegments + MaxBackwardsSegments)
{
RemoveOldestSection();
}
}
}
void ACorridorManager::AddCorridorSection()
{
FTransform SectionTransform;
if (CorridorSections.Num() == 0)
{
SectionTransform = FTransform(CorridorOrigin->GetComponentRotation() + FRotator(0.0f, 0.0f, 0.0f), CorridorOrigin->GetComponentLocation() + FVector(-100.0f, 0.0f, 0.0f), FVector(1.0f, 1.0f, 1.0f));
}
else
{
SectionTransform = FTransform(CorridorSections.Last()->GetActorRotation() + FRotator(0.0f, 0.0f, 0.0f), CorridorSections.Last()->GetActorLocation() + FVector(SegmentLength * NumSegments, 0.0f, 0.0f), FVector(1.0f, 1.0f, 1.0f));
}
ACorridorSection* NewSection = Cast<ACorridorSection>(UGameplayStatics::BeginDeferredActorSpawnFromClass(
this, ACorridorSection::StaticClass(), SectionTransform));
if (NewSection != nullptr)
{
NewSection->Initialize(PCGGraph, SegmentLength, NumSegments);
UGameplayStatics::FinishSpawningActor(NewSection, SectionTransform);
}
CorridorSections.Push(NewSection);
}
void ACorridorManager::RemoveOldestSection()
{
ACorridorSection* SectionToDelete = CorridorSections[0];
CorridorSections.RemoveAt(0);
SectionToDelete->RemoveSection();
}
これでCorridorを動的延長できるようになりました。
6 終わりに
今回はPCGシステムとChaos Destructionを利用して、ダイナミックなシネマティックスを作ってみた。Chaos Physicsシステムを触るのは初めてですが、物理シミュレーションによる自然かつ迫力があるビジュアル表現の可能性を感じましたので、今後も活用したいと考えています。















































