続編:
1 はじめに
バーチャルライブを作るとき、演出を合わせて、観客がペンライトを振っている表現がよく使われています。現在は基本的にパーティクルシステム(UnrealだとNiagara)を用いて実装しますが、時々このアプローチに制限を感じています。
例えば:
- 多くの実装には、「観客」自体がなく、ペンライトだけが描画されています。そのため、ペンライトの間は真っ黒になり、画面の細部が欠けます。また、会場全体や、観客席から撮るなどのショットの実現は難しくなります。
- ペンライトはEmissive Materialで構成され、実際のライティングがありません。そのため、実際のライブ会場と違って、観客の周りや、会場の天井などがペンライトのカラーに影響されません。
- パーティクルシステムを会場の空間や席の配置と連動させるのは容易ではありません。
とはいえ、もちろんこの方法はメリットもあります。主に、CPUとGPUへの負荷は低いこと、そしてActorを一つ配置するだけで数千のペンライトを生成でき、作業の手間が省けることの二点が挙げられます。
しかし、最近Unreal Engineの新しいバージョンで導入されたいくつかの新機能によって、エンジンのあらゆる側面で、従来は複雑だった処理や表現でも、比較的軽く行えるようになりました。したがって、今回は4つの機能・プラグイン:「PCG」「MegaLights」「Instanced Actors」「AnimToTexture」を活用して、従来の方法のメリットを保ちつつ、、表現がよりリアルで豊かな「ペンライトシステム」の作りを試してみました。
ソースコード-GitHub
Demo
テスト環境(CPU:Ryzen 7950X、GPU:RTX 5080、レンダリング解像度:2560×1440、出力解像度:3840×2160、Standalone Gameモード)では、25000人規模の群衆を配置し、全部リアルタイムのライティングで、約50−60fpsのフレームレートを達成できます。モデルLODなどの最適化と組み合わせることで、さらなる改善の余地があると思います。
テスト用に簡単なアニメーションを1つだけ作りましたが、素材を用意すれば、メッシュ、アニメーション、ペンライトの数などのバリエーションも実現できます。
全体のプロセスは主に以下の3つの部分に分けられます:
- PCG(プロシージャル コンテンツ生成)で、会場と観客を同時に生成する
- Instanced ActorとAnimToTextureを駆使して、数千・数万の群衆を軽く動かす
- MegaLightsで広域から細部までライトを配置する
今回の記事はPart 1として、PCGフレームワークの部分を紹介したいと思います。
環境
Windows 11 25H2
Unreal Engine 5.7.0
使った機能のバージョンは以下のとおりです。Experimentalのプラグインは、以前・以降のUnrealバージョンで機能や操作方法が大きく異なる可能性があるのでご注意ください。
| プラグイン・機能 | バージョン | |
|---|---|---|
| Procedural Content Generation Framework (PCG) | 1.0 | UE 5.7でStableになった |
| Procedural Content Generation Framework (PCG) Instanced Actors Interop | 0.1 (Experimental) | |
| InstancedActors | 0.1 (Experimental) | |
| AnimToTexture | 2.1 (Experimental) | |
| MegaLights | 本体機能 (Beta) | UE 5.7でBetaになった |
2 モデルを用意する
まずは観客のモデル(Skeletal Mesh)とペンライトのモデル(Static Mesh)を準備します。この後はアニメーションや、VAT用の特殊なマテリアルが必要ですが、この記事の範囲ではこの二点があれば十分です。
私は観客のモデルとして、UE5デフォルトのMannequinを使いましたが、マテリアルの色とRoughnessなどを少し調節しました。その理由は、今回はリアルのライトを実装するので、観客モデルのマテリアルの特性によっても最終の照明効果に影響を与えることができるためです。Mannequin本来の滑らかで白いマテリアルのままにすると、GI(グローバルイルミネーション)による間接ライティングが強くなります。実際の人の肌と服に近いマテリアルを設定することにより、より正確なライティング調整がしやすくなると思います。
ペンライトは、Blenderで作ったシンプルなものです。
この二つのメッシュが入ったActorを作ります。MannequinのスケルトンにHandGripというSocketがすでに用意されているので、簡単にペンライトを手に付けることができます。
3 PCG Graphを実装
土台が整ったら、次はいよいよ本題に入り、PCGシステムを使って観客を大量複製していきたいと思います。
3.1 PCGとは
簡単にいうと、メッシュやActorなどを手作業で一つ一つ配置するのではなく、一定のルールに沿ってレベル内で自動的に生成するシステムです。フォリッジの構築、オープンワールドのRuntime生成、VFXなど、幅広い場面で活用できるツールです。UE 5.2で初めて導入され、UE 5.7でツール本体がstableになりました。
今回利用したのはPCGという巨大なシステムのごく一部だけであり、個人としてもいろいろな機能を十分に把握していないため、ここではPCGの詳細な紹介は割愛します。興味がある方はEpic Games公式のドキュメントや動画をご参照ください。
3.2 PCG Actor
まずは、新しいActorを作って、SplineComponentとPCGComponentを追加します。
Splineは「Seat Line」、つまり一列の席の長さと形を定める役割を担い、そしてPCGがSplineを読み込んで、Splineに沿って席と観客を生成する仕組みです。これにより、一つのActorで一つの「ブロック」(会場の通路に囲まれた連続した座席区域)を表現できます。
PCG ComponentにPCG Graphを指定する必要があります。ここで一旦新しく作成し、「Create Empty Graph」を選択して保存します。
ActorにPCG Graphと同じ変数を設定すると、ActorからPCGを制御できます。さらに、Editor関数であるPostEditChangePropertyを活用して、Editor内でActorの変数値を変更したときに、自動的にPCG Graphの変数値を更新することもできます。(ここはRuntimeでの変更には対応していません。理由は4.2で説明します。)
/* AudienceSection.h */
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "AudienceSection.generated.h"
class APenLightManager;
class USplineComponent;
class UPCGComponent;
UCLASS(Blueprintable)
class PENLIGHTCROWD_API AAudienceSection : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AAudienceSection();
#if WITH_EDITOR
UFUNCTION(BlueprintCallable, CallInEditor, Category="Audience Section")
void RegeneratePCG();
#endif
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Audience Section", meta=(clampMin="0.0", UIMin="0.0", UIMax="200.0", unit="cm"))
FVector SeatVector = FVector(60.0f, 70.0f, 40.0f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Audience Section", meta=(clampMin="1", UIMin="1", UIMax="100"))
int32 RowCount = 10;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Audience Section", meta=(clampMin="1.0", UIMin="1.0", UIMax="50.0"))
FVector2D RectLightScale = FVector2D(16.0f, 16.0f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Audience Section")
bool bIsCurved = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Audience Section", meta=(clampMin="0.0", UIMin="0.0", UIMax="200.0", unit="cm"))
float ExtensionStart = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Audience Section", meta=(clampMin="0.0", UIMin="0.0", UIMax="200.0", unit="cm"))
float ExtensionEnd = 0.0f;
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
#if WITH_EDITOR
virtual void PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
private:
UFUNCTION()
void OnPenLightManagerInitialized(TWeakObjectPtr<APenLightManager> PenLightManagerInstance);
UFUNCTION()
void UpdatePCGParameters();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Audience Section", meta=(AllowPrivateAccess="true"))
TObjectPtr<USplineComponent> SplineComponent;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Audience Section", meta=(AllowPrivateAccess="true"))
TObjectPtr<UPCGComponent> PCGComponent;
UPROPERTY()
TWeakObjectPtr<APenLightManager> PenLightManager;
};
/* AudienceSection.cpp */
#include "AudienceSection.h"
#include "PCGComponent.h"
#include "PCGGraph.h"
#include "Components/SplineComponent.h"
#include "PenLightManager.h"
// Sets default values
AAudienceSection::AAudienceSection()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = false;
SplineComponent = CreateDefaultSubobject<USplineComponent>(TEXT("SplineComponent"));
PCGComponent = CreateDefaultSubobject<UPCGComponent>("PCGComponent");
PCGComponent->GenerationTrigger = EPCGComponentGenerationTrigger::GenerateOnDemand;
}
// Called when the game starts or when spawned
void AAudienceSection::BeginPlay()
{
Super::BeginPlay();
// Get PenLightManager
TWeakObjectPtr<APenLightManager> PenLightManagerInstance = APenLightManager::GetPenLightManager(this);
if (PenLightManagerInstance.IsValid() && PenLightManagerInstance->GetIsInitialized())
{
// If initialized register this audience section
PenLightManager = PenLightManagerInstance;
PenLightManager->RegisterAudienceSection(this);
}
else
{
// If not initialized, subscribe to delegate and handle registration in the callback
APenLightManager::OnPenLightManagerInitialized().AddUObject(this, &AAudienceSection::OnPenLightManagerInitialized);
}
}
void AAudienceSection::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
PenLightManager->UnregisterAudienceSection(this);
Super::EndPlay(EndPlayReason);
}
#if WITH_EDITOR
void AAudienceSection::PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
UpdatePCGParameters();
// Only regenerate if not in game
if (!GetWorld()->IsGameWorld())
{
PCGComponent->Cleanup();
PCGComponent->Generate();
}
}
void AAudienceSection::RegeneratePCG()
{
UpdatePCGParameters();
PCGComponent->Cleanup();
PCGComponent->Generate();
}
#endif
void AAudienceSection::OnPenLightManagerInitialized(TWeakObjectPtr<APenLightManager> PenLightManagerInstance)
{
if (PenLightManagerInstance.IsValid() && PenLightManagerInstance->GetIsInitialized())
{
PenLightManager = PenLightManagerInstance;
PenLightManager->RegisterAudienceSection(this);
}
}
void AAudienceSection::UpdatePCGParameters()
{
UPCGGraphInstance* PCGGraph = PCGComponent->GetGraphInstance();
if (ensureMsgf(PCGGraph, TEXT("PCGComponent has no graph instance")))
{
PCGGraph->SetGraphParameter(FName(TEXT("SeatVector")), SeatVector.ComponentMax(FVector(10.0f, 10.0f, 0.0f)));
PCGGraph->SetGraphParameter(FName(TEXT("RowCount")), FMath::Max(RowCount, 1));
PCGGraph->SetGraphParameter(FName(TEXT("RectLightScale")), RectLightScale.ComponentMax(FVector2D(1.0f, 1.0f)));
PCGGraph->SetGraphParameter(FName(TEXT("bIsCurved")), bIsCurved);
PCGGraph->SetGraphParameter(FName(TEXT("PCGActor")), StaticCast<UObject*>(this));
PCGGraph->SetGraphParameter(FName(TEXT("ExtensionStart")), FMath::Max(ExtensionStart, 0.0f));
PCGGraph->SetGraphParameter(FName(TEXT("ExtensionEnd")), FMath::Max(ExtensionEnd, 0.0f));
}
}
3.3 観客の生成
ここの実装は、Unreal Fest Stockholm 2025でSimon Blakeneyさんが紹介された、PCGを用いたスポーツ会場の作成方法に基づいたものです。
blueprintUEはまだPCG Graphを完全に対応していないため、スクリーンショットを使って紹介します。具体の設定などはGitHubでソースコードをご参照ください。
要するに、生成の流れは:
- Splineを最初にサンプルする(形取り)
これでSplineから一列のポイントが得られます。
- PCG Sub Graph Loopを利用して、生成プロセスを列数(縦方向)回繰り返す
ここでは二つのPCG Sub Graphの作成が必要です。Loop Index Sub Graphではいろいろな処理をしているように見えますが、実は、Loop IndexのAttribute Setを生成するためだけのものです(for ループ中の「i」みたい)。Audience Gen Loopはループボディです。
- ループ内で、Seat Lineを列目に合わせて移動し、再度サンプルする。サンプルしたポイントでActor・Meshを生成する


PCG Audience Gen Loop Sub Graph
パラメータ:
- Seat Vector:座席の左右・前後間隔と高低差
- Row Count:座席列数
- Extension:両端の延伸。元の実装は直線(長方形)と曲線(おうぎ形)の座席配置にしか対応していませんでしたが、Extensionの追加によって、ライブ会場でよく見られている台形のブロックも生成できるようになりました。
3.4 会場の生成
観客の生成で使われたPCGポイントを再利用して、会場の床と座席も一緒に作ることができます。
直方体のメッシュを入力として、「Spawn Spline Mesh」ノードでSplineに沿った連続した床を生成します。
座席は「Spawn Static Mesh」を利用します。ここの座席モデルは、Nimikkoさんの「Concert Hall - Environment」アセットパックからお借りします。
会場と観客を組み合わせるとこの感じです。
3.5 見た目調整
現在は観客が整然と並んでいるせいで、群衆は少し無機質な見た目になっています。そのため、観客の生成ノード前にTransform Pointsでポイントの位置と回転をランダムにずらします。
さらに、Actorのスケールをランダムにしたら、観客の身長差・体型差も表現できます。しかし、ここはActor全体のスケールを変えるので、ペンライトのサイズや、アニメーションにも影響があります。そのため、元のサイズから大きく離れている場合、不自然さが生じてしまいます。本当に多様な群衆を構築したいなら、キャラクターモデルとアニメーションの種類を増やすのは根本的だと思います。
もう一つの調整は、Spline SamplerのFit to Curveパラメータの設定です。おうぎ形または台形の群衆を生成するとき、毎列の長さは違うので、Splineの最後尾にポイントが取れない空白が現れる可能性があり、ブロックの一端がジグザグになってしまいます。Fit to Curveをtrueにすると、Splineの起点と終点が絶対ポイントがあるようにポイントを調整してくれます。ですが、この場合は座席の間隔はSeat Vectorの定義通りになる保証がなくなるので、状況に応じてon/offに設定してください。

Fit to Curve = false

Fit to Curve = true
4 Instanced Actorsの基本設定
PCGの「Spawn Actor」ノードで観客を生成できますが、このアプローチをより大きな規模に拡張することはできません。Actorの機能と処理を最低限に抑えたとしても、数千または数万の規模に増やすとCPUコストが重くなります。
そのため、観客Actorを普通のActorからInstanced Actorへ移行する必要があります。
4.1 Instanced Actorsとは
UE5のCity ExampleでショーケースされたMassシステムは、オープンワールドなどのシナリオで必要となる大規模なエンティティ(NPC、車など)をデータ駆動で制御するシステムです。
Instanced ActorsはこのMassシステムの一部で、ActorをMass Entity化し、そしてMass Entityと普通のActorを自動的に切り替えられるシステムです。Massシステムから生まれたものですが、Massを利用しなくても単体で使用できます。
Instanced Actor化したActorは、近くにいるときは普通のActorとまったく同じですが、一定の距離まで離れるとActorがDestroyされ、自動的にCPU負荷の軽いInstanced Static Meshに変わります。
4.2 PCGでInstanced Actorsを生成する
注意:PCGシステム自体は、Runtimeでの生成が可能です。しかし、この記事を書いた時点では、Instanced ActorsのRuntime生成には対応していません。そのため、プレイする前にEditorで生成しておく必要があります。同じ理由で、3.2で実装したPCG ActorもRuntimeでのパラメータ変更に対応する意味がありません。
Instanced ActorsとInstanced Actors Interopの二つのプラグインを有効にしたら、PCG GraphでSpawn Instanced Actorノードを利用できます。ここで直に観客のActorを設定して生成しようと、一応動作はしますが、Warningがいっぱい出てます。その原因は、Instanced Actorsを利用するにはあらかじめActorのClassと設定をあらかじめ登録しておく必要があります。
4.3 ActorをRegisterする
Instanced Actorsの登録に関する方法は、Midnight Ghoul Gamesさんの動画が大変参考になりました。
まずは、Actor側にInstanced Actors Componentを追加します。
加えて、Data TableとData Registry二種のアセットが必要です。Data Tableから作っていきます。
新しいData Tableを作成するとき、Row StructureでInstancedActorsClassSettingsを選択します。Data Tableの編集画面で、「Add」でRowを追加します。「Row Name」に、Instanced Actorsとして使いたいActorのClass Name(後ろに「_C」を付けるが必要)を入れます。Override Settingsは今は触る必要がありませんが、Instanced Actorsに関する設定、例えばActorからInstanced Static Meshに切り替わる距離などをここで編集できます。
Data Tableを保存してから、Data Registryを新しく作成します。そして、Project Settings→Data Registryで、このData Registryアセットの場所をDirectories to Scanに追加します。
編集画面で「Registry Type」と「Item Struct」にInstancedActorsClassSettingsを設定し、「Data Sources」を追加し、先に作成したData Tableを選択します。「Refresh」したら、Data Table内で登録したActorの設定が表示されます。
これによって、PCGを再度生成するとき、Warningがなくなるはずです。
4.4 Instanced Actorsの効果
Instanced Actorsを導入すると、観客を大勢生成しても、レベルのOutlinerに観客Actorが一つも見つかりません。代わりに、InstancedActorsManagerというActorが現れます。レベルをプレイして、群衆に近づくと、本来のActorが自動的に出現します。
Outlinerから選択すると、Player Pawnの1000cm圏内が通常のActorで、それ以外がInstanced Actorであることをはっきりと確認できます。
Instanced Actorsのおかげで、画像のレベルではおよそ6000人の群衆を生成したにもかかわらず、実際のレベルに存在するActorの数は450だけです。これによりCPUの負担が大きく軽減されます。
5 おわりに
今回はPCGとInstanced Actorを活用したライブ会場(の一部)と観客たちの作り方を紹介しました。PCGシステムの力を発揮して、簡単で大量の群衆を生成できるだけではなく、実際の会場の座席配置まで再現することができます。
次回は、AnimToTextureプラグインを利用してVAT(Vertex Animation Texture)を作成し、Static MeshになったInstanced Actorを、Skeletal Meshのように動かす方法を紹介したいと思います。








































