0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[UE5] 新機能でよりリアルなペンライトを振る群衆を作ってみた Part 2.5:VATとSkeletal Meshのアニメーション制御

0
Posted at

1 はじめに

Part 1とPart 2はこちら:

本来は、Part 1のPCG生成とPart 2のVertex・Bone Animation Textureを紹介した後、MetaLightsを用いたライティング実装の部分に進むつもりでした。ですが、記事を書き始めたら、やはりそれを実装する前に、ライティングも含めて、ペンライト演出全体の制御システムについて説明しなければならない部分が多いので、このPart 2.5の形になりました。

具体的に、今回は前回で作成した、マテリアルとテクスチャのみでStatic Meshを動かすVATアニメーションをマテリアル上で制御する仕組みを実装します。そして、遠距離でのVATと近距離でのSkeletal Mesh+ライトを共存させ、低コストとクオリティを両立するための切り替えシステムの構成も紹介したいと思います。


環境

Windows 11 25H2
Unreal Engine 5.7.2/5.7.3

プロジェクトのソースコードとアセットはこちら(GitHub)

2 PenLight Managerクラス

アニメーションにしても、次回で触れるライティングにしても、制御するにはコントローラー・マネージャーが必要です。そこでまずは、ペンライト演出全体を制御するマネージャーActorを作っておきます。

シングルトンのマネージャーを作るため、Unreal EngineはSubsystemクラスを提供していますが、Editor内でのパラメータ調整のしやすさの点から、今回はSubsystemを使わず、普通のAActorクラスで実装します。

要するに、PenLight Managerをアニメーションに関する情報(ペンライトを振るオン・オフ、振りスピード、現在のフレームなど)のSingle Source of Truthにします。

ここのコードは抜粋です。フルソースコードはGitHubでご確認ください。

/* PenLightManager.h */
DECLARE_MULTICAST_DELEGATE_OneParam(FOnPenLightManagerInitialized, TWeakObjectPtr<APenLightManager>);

UCLASS(Blueprintable)
class PENLIGHTCROWD_API APenLightManager : public AActor
{
	GENERATED_BODY()

public:
	// Sets default values for this actor's properties
	APenLightManager();
	
	static TWeakObjectPtr<APenLightManager> GetPenLightManager(const UObject* WorldContextObject);
	static FOnPenLightManagerInitialized& OnPenLightManagerInitialized() { return PenLightManagerInitializedEvent; }
	
	// Called every frame
	virtual void Tick(float DeltaTime) override;
	
	UFUNCTION(BlueprintCallable, Category="Pen Light Manager")
	bool GetIsInitialized() const { return bIsInitialized; }
	
	UFUNCTION(BlueprintCallable, Category="Pen Light Manager")
	void RegisterAudienceSection(AAudienceSection* NewAudienceSection);
	UFUNCTION(BlueprintCallable, Category="Pen Light Manager")
	void UnregisterAudienceSection(const AAudienceSection* AudienceSection);
	
	UFUNCTION(BlueprintCallable, Category="Pen Light Manager")
	void RegisterAudience(AActor* NewAudienceActor);
	UFUNCTION(BlueprintCallable, Category="Pen Light Manager")
	void UnregisterAudience(const AActor* AudienceActor);
	
	UFUNCTION(BlueprintPure, Category="Pen Light Manager")
	bool GetIsWaving() const { return bIsWaving; }
	UFUNCTION(BlueprintCallable, Category="Pen Light Manager")
	void SetIsWaving(const bool bNewIsWaving);
	
	UFUNCTION(BlueprintPure, Category="Pen Light Manager")
	float GetCurrentSessionStartTime() const { return CurrentSessionStartTime; }
	UFUNCTION(BlueprintPure, Category="Pen Light Manager")
	float GetCurrentSessionElapsedTime() const { return CurrentSessionElapsedTime; }
	UFUNCTION(BlueprintPure, Category="Pen Light Manager")
	int32 GetCurrentMasterAnimFrame() const { return CurrentMasterAnimFrame; }
	
	UFUNCTION(BlueprintPure, Category="Pen Light Manager")
	float GetPenLightBPM() const { return PenLightBPM; }
	UFUNCTION(BlueprintCallable, Category="Pen Light Manager")
	void SetPenLightBPM(const float NewPenLightBPM);
	
	UFUNCTION(BlueprintPure, Category="Pen Light Manager")
	int32 GetPenLightAnimFrameRate() const { return PenLightAnimFrameRate; }
	UFUNCTION(BlueprintCallable, Category="Pen Light Manager")
	void SetPenLightAnimFrameRate(const int32 NewPenLightAnimFrameRate);
	
	UFUNCTION(BlueprintPure, Category="Pen Light Manager")
	FVector GetRandomSeed() const { return RandomSeed; }
	
	UFUNCTION(BlueprintPure, Category="Pen Light Manager")
	int32 GetMaxAnimOffsetFrames() const { return MaxAnimOffsetFrames; }
	UFUNCTION(BlueprintCallable, Category="Pen Light Manager")
	void SetMaxAnimOffsetFrames(const int32 NewMaxAnimOffsetFrames);
	
#if WITH_EDITOR
	UFUNCTION(CallInEditor, Category="Pen Light Manager")
	void RefreshAllAudienceSections();
#endif

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Pen Light Manager")
	UMaterialParameterCollection* MPCBoneAnimation;

protected:
	// Called when the game starts or when spawned
	virtual void BeginPlay() override;
	
#if WITH_EDITOR
	virtual void PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) override;
#endif

private:
	static TWeakObjectPtr<APenLightManager> PenLightManagerInstance;
	static FOnPenLightManagerInitialized PenLightManagerInitializedEvent;
	UPROPERTY()
	bool bIsInitialized = false;
	
	void OnIsWavingChanged();
	UFUNCTION()
	void OnPenLightBPMChanged();
	UFUNCTION()
	void OnPenLightAnimFrameRateChanged();
	UFUNCTION()
	void OnMaxAnimOffsetFramesChanged();

	UPROPERTY()
	UWorld* WorldPtr;
	
	UPROPERTY(EditAnywhere, Category="Pen Light Manager")
	bool bIsWaving = false;
	
	UPROPERTY()
	float CurrentSessionStartTime = 0.0f;
	UPROPERTY()
	float CurrentSessionElapsedTime = 0.0f;
	UPROPERTY()
	int32 CurrentMasterAnimFrame = 0;
	UPROPERTY()
	int32 PrevMasterAnimFrame = 0;
	
	UPROPERTY()
	TMap<FName, TWeakObjectPtr<AAudienceSection>> AudienceSections;
	UPROPERTY()
	TMap<FName, TWeakObjectPtr<AActor>> Audiences;

	UPROPERTY(BlueprintReadOnly, Category="Pen Light Manager", meta=(AllowPrivateAccess="true"))
	UMaterialParameterCollectionInstance* MPCBoneAnimationInstance;
	
	UPROPERTY(EditAnywhere, Category="Pen Light Manager", meta=(AllowPrivateAccess="true"))
	float PenLightBPM = 60.0f;
	UPROPERTY(EditAnywhere, Category="Pen Light Manager", meta=(AllowPrivateAccess="true"))
	int32 PenLightAnimFrameRate = 30;
};
/* PenLightManager.cpp */
TWeakObjectPtr<APenLightManager> APenLightManager::GetPenLightManager(const UObject* WorldContextObject)
{
	if (PenLightManagerInstance.IsValid())
	{
		return  PenLightManagerInstance;
	}
	else
	{
		if (WorldContextObject)
		{
			TArray<AActor*> PenLightManagers;
			UGameplayStatics::GetAllActorsOfClass(WorldContextObject, APenLightManager::StaticClass(), PenLightManagers);
			if (ensureMsgf(PenLightManagers.Num() != 0, TEXT("No PenLightManager found in the level.")))
			{
				ensureMsgf(PenLightManagers.Num() >= 1, TEXT("More than one PenLightManager found in the level."));
				PenLightManagerInstance = Cast<APenLightManager>(PenLightManagers[0]);
			}
		}
		return PenLightManagerInstance;
	}
}

// Called when the game starts or when spawned
void APenLightManager::BeginPlay()
{
	Super::BeginPlay();
	
	WorldPtr = GetWorld();
	PenLightManagerInstance = this;
	
	if (ensureMsgf(MPCPenLight, TEXT("MPC Bone Animation is not set in PenLightManager")))
	{
		MPCBoneAnimationInstance = WorldPtr->GetParameterCollectionInstance(MPCBoneAnimation);
	}
	
	OnLightsOnChanged();
	OnPenLightIntensityChanged();
	OnEmissiveValueChanged();
	OnPenLightColorChanged();
	OnIsWavingChanged();
	OnPenLightBPMChanged();
	OnPenLightAnimFrameRateChanged();
	
	bIsInitialized = true;
	PenLightManagerInitializedEvent.Broadcast(this);
}

// Called every frame
void APenLightManager::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	
	if (bIsWaving)
	{
		CurrentSessionElapsedTime = UGameplayStatics::GetRealTimeSeconds(WorldPtr) - CurrentSessionStartTime;
		// Current animation frame, before individual offsets
		CurrentMasterAnimFrame = FMath::Floor(PenLightAnimFrameRate * FMath::Frac(CurrentSessionElapsedTime * PenLightBPM / 60.0f));
		if (CurrentMasterAnimFrame != PrevMasterAnimFrame)
		{
			PrevMasterAnimFrame = CurrentMasterAnimFrame;
			// Set Current Frame to Material Parameter Collection
			MPCBoneAnimationInstance->SetScalarParameterValue(FName("Frame"), CurrentMasterAnimFrame);
		}
		
	}
}

void APenLightManager::RegisterAudienceSection(AAudienceSection* NewAudienceSection)
{
	AudienceSections.Add(FName(*NewAudienceSection->GetName()), NewAudienceSection);
}

void APenLightManager::UnregisterAudienceSection(const AAudienceSection* AudienceSection)
{
	AudienceSections.Remove(FName(*AudienceSection->GetName()));
}

void APenLightManager::RegisterAudience(AActor* NewAudienceActor)
{
	Audiences.Add(FName(*NewAudienceActor->GetName()), NewAudienceActor);
	
	InterfaceSetEnabled(NewAudienceActor, bLightsOn);
	InterfaceSetPenLightIntensity(NewAudienceActor, PenLightIntensity);
	InterfaceSetPenLightColor(NewAudienceActor, PenLightColor);
}

void APenLightManager::UnregisterAudience(const AActor* AudienceActor)
{
	Audiences.Remove(FName(*AudienceActor->GetName()));
}

void APenLightManager::SetIsWaving(const bool bNewIsWaving)
{
	if (bIsWaving != bNewIsWaving)
	{
		bIsWaving = bNewIsWaving;
		OnIsWavingChanged();
	}
}

void APenLightManager::SetPenLightBPM(const float NewPenLightBPM)
{
	if (!FMath::IsNearlyEqual(PenLightBPM, NewPenLightBPM))
	{
		PenLightBPM = NewPenLightBPM;
		OnPenLightBPMChanged();
	}
}

void APenLightManager::SetPenLightAnimFrameRate(const int32 NewPenLightAnimFrameRate)
{
	if (PenLightAnimFrameRate != NewPenLightAnimFrameRate)
	{
		PenLightAnimFrameRate = NewPenLightAnimFrameRate;
		OnPenLightAnimFrameRateChanged();
	}
}

void APenLightManager::SetMaxAnimOffsetFrames(const int32 NewMaxAnimOffsetFrames)
{
	if (MaxAnimOffsetFrames != NewMaxAnimOffsetFrames)
	{
		MaxAnimOffsetFrames = NewMaxAnimOffsetFrames;
		OnMaxAnimOffsetFramesChanged();
	}
}

void APenLightManager::OnIsWavingChanged()
{
	if (bIsWaving)
	{
		CurrentSessionStartTime = UGameplayStatics::GetRealTimeSeconds(WorldPtr);
		// Set a new random seed vector for offset in MPC
		RandomSeed = FMath::VRand();
		MPCBoneAnimationInstance->SetVectorParameterValue(FName("RandomSeed"), RandomSeed);
	}
	else
	{
		CurrentSessionElapsedTime = 0.0f;
		CurrentMasterAnimFrame = 0;
	}
	
	MPCBoneAnimationInstance->SetScalarParameterValue(FName("bIsPlaying"), bIsWaving);
}

void APenLightManager::OnPenLightBPMChanged()
{
	MPCBoneAnimationInstance->SetScalarParameterValue(FName("PenLightBPM"), PenLightBPM);
}

void APenLightManager::OnPenLightAnimFrameRateChanged()
{
	MPCBoneAnimationInstance->SetScalarParameterValue(FName("AnimFrameRate"), PenLightAnimFrameRate);
}

Blueprintの子クラスを作ったら、制御パラメータを簡単にデバッグできるセットアップが作れます。

Screenshot 2026-03-03 225009.png

そして、このデータを使用して、VATとSkeletal Meshそれぞれをどのように制御するかを紹介します。

3 VATアニメーションを制御する

Part 2でも述べたように、VATアニメーションはスケルトンを介していませんし、そもそもInstanced Actor状態のActorはStatic Meshとほぼ同然で、アニメーションを含む処理を実行できません。

つまり、VATをコントロールするにはマテリアル(シェーダー)経由する必要があります。前回で作成したVATのデフォルト状態では自動的に動くが、VATのMaterial Instanceを開き、AutoPlayパラメータをfalseにすると、「Frame」パラメータが指定できるようになって、手動でアニメーションをコントロールできます。

Screenshot 2026-03-03 220318.png
Screenshot 2026-03-03 220329.png

RuntimeでDynamic Material Instanceを作って、このパラメータをBlueprintから直接制御するのも解決法の一つですが、使うマテリアルの数が増えると処理が多くなり、またアニメーションのランダムオフセットも実現しづらいのは欠点です。そのため、今回はMaterial Parameter Collectionを活用して、VATのマテリアルの機能を拡張します。

3.1 Material Parameter Collection

Content BrowserでMaterial Parameter Collectionを新しく作ります。

Screenshot 2026-03-03 225231.png

MPCに、以下のScalarパラメータを追加します。(MaxAnimOffsetとRandomSeedは今回使っていません、理由は後で説明します。)

  • Frame:現在のアニメーションフレーム
  • bIsPlaying:ペンライトを振るか否か
  • PenLightBPM:ペンライトを振るスピード
  • AnimFrameRate:アニメーションのフレームレート

Screenshot 2026-03-03 225257.png

レベル内のPenLight Managerに作成したMPCを指定します。PenLight ManagerはMPCと同じパラメータが保有しています。ManagerからMPCBoneAnimationInstance->SetScalarParameterValue(FName, value)でこのMPCを利用しているすべてのVATマテリアルのパラメータを一斉に更新できるようになります。

APenLightManager::Tick で、現在のフレーム位置を計算して、MPCにその数値を更新します。これで、Actor機能がないVATメッシュをPenLight Managerと同期できます。

3.2 VAT Material Functionを変更

さて、VATのMaterial Functionを、MPCの値を利用できるように編集します。

前回Part 2で、Naniteに関するバグについて説明しましたが、元のMaterial Functionを直接編集すると、AnimToTextureプラグイン全体に影響しますので、プラグインのアセットをコピーして改変することをおすすめします。もし前回の記事と同じようにNaniteバグを解決してきたなら、既にコピーしたファイルを編集しても大丈夫です。

キャラクターのマテリアルから、MF_BoneAnimationを開き、そこにあるGetFrameSwitchを開きます。これはVATアニメーションの再生を制御するMaterial Functionです。

Screenshot 2026-03-03 230957.png
Screenshot 2026-03-03 231046.png

今回はAutoPlayを使用しませんので、上部の「Frame」だけに注目します。

Screenshot 2026-03-03 231248.png

「Frame」と「PrevFrame」の2つのパラメータで、アニメーションの再生位置を指定できるとわかります。そのため、こちらの入力を、Material Parameter Collectionのデータと接続する必要があります。

マテリアルにCollectionParameterノードを追加し、作ったMPCを選択すると、MPCパラメータを使用できます。

Screenshot 2026-03-04 094826.png
Screenshot 2026-03-04 094911.png

処理はこんな感じになります。bIsPlayingがtrueのとき、CurrentFrame=Frame, PreviousFrame=Frame-1とし、falseのときはCurrentFrame=PreviousFrame=Frame。そして、フレーム数をアニメーションの範囲内(ここでは0-29)に調整すれば完成です。

Screenshot 2026-03-03 231551.png

アニメーション停止中にCurrentFrame=PreviousFrameにする理由は、CurrentFrameとPreviousFrameが等しくないと、メッシュにモーションブラー効果がかかってしまうためです。

もう一つは、その上のNumFramesパラメータも、MPCにあるAnimFrameRateを置き換えます。

Screenshot 2026-03-03 231413.png

早速検証します。PenLight ManagerでVATアニメーションのオン/オフや再生速度などを制御できるのを確認できます。

4 Skeletal MeshとVAT Meshを切り替える

ここまではVATでなかなかいい効果が出ていますが、最終的にライトをペンライトの位置と一緒に動かしたいので、通常のSkeletal Meshも実装する必要があります。

もちろん、Skeletal Meshを使わず、観客のスケルトンだけを利用して、ペンライトを付けることも実現できますが、低LODのVATメッシュは近距離でのビジュアルがあまり期待できないという問題もあります。そのため、どうせスケルトンを使うなら、やはりアニメーションとメッシュをともにSkeletal Meshに切り替えるほうが手っ取り早いと思います。スケルトンとSkeletal Meshはデフォルトで非表示になっているので、Instanced Actorには無駄な処理コストも発生しません。

4.1 観客Actorの設定

Part1で作った観客のActorを編集します。以下のComponentを追加します。

  • キャラクター(Static Mesh VAT)
    • ペンライト(Static Mesh VAT)
    • キャラクター(Skeletal Mesh)-visible: false
      • ペンライト(Static Mesh)-visible: false
        • Point Light -visible: false
  • Instanced Actors Component

これで、Instanced Actorの状態では、VisibleのVATメッシュだけが残ります。そして、ActorとしてHydrateされた際に、隠されたComponentが生成されます。

Screenshot 2026-03-03 234219.png

切り替えのロジックに関しては、Instanced ActorからActorに転換される時点(つまり、BeginPlay())ですぐSkeletal Meshに切り替えるのも無難ですが、例えInstanced ActorのHydrate半径が500cmの場合でも、数百のSkeletal Meshがあります。これは結構重いです。実際の効果を確認したら、そんなに広い範囲の高LODメッシュとリアルなライトは必要ではないと感じます。加えて、Pawn周囲全方向のInstanced ActorがActorに変換されますが、カメラ背後のライトやSkeletal Meshをレンダリングする必要がありません。そのため、処理の浪費を減らすため、カスタムの切り替え条件を実装します。

具体的に、APenLightAudience::Tickで、PlayerCameraManagerから現在のカメラ位置と方向を読み取ります。Actorが一定の距離内(ここは200cm)かつカメラの前方向にいる際だけ、SwitchLOD()でVATメッシュからSkeletal Mesh+Point Lightに切り替えます。これにより必要なSkeletal Meshとアニメーション処理が抑えられ、ビジュアルクオリティとパフォーマンスの両立ができます。

関係ある部分だけの抜粋です。

/* PenLightAudience.h */
UCLASS()
class PENLIGHTCROWD_API APenLightAudience : public AActor, public IPenLightInterface
{
	GENERATED_BODY()

public:
	// Sets default values for this actor's properties
	APenLightAudience();
	// Called every frame
	virtual void Tick(float DeltaTime) override;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="PenLight Audience")
	bool bIsUsingHighLOD = false;

private:
	UFUNCTION()
	void SwitchLOD();
	
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="PenLight Audience", meta=(AllowPrivateAccess="true"))
	TObjectPtr<USkeletalMeshComponent> Character;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="PenLight Audience", meta=(AllowPrivateAccess="true"))
	TObjectPtr<UStaticMeshComponent> PenLight;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="PenLight Audience", meta=(AllowPrivateAccess="true"))
	TObjectPtr<UPointLightComponent> PointLight;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="PenLight Audience", meta=(AllowPrivateAccess="true"))
	TObjectPtr<UStaticMeshComponent> CharacterVAT;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="PenLight Audience", meta=(AllowPrivateAccess="true"))
	TObjectPtr<UStaticMeshComponent> PenLightVAT;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="PenLight Audience", meta=(AllowPrivateAccess="true"))
	TObjectPtr<UPenLightPCGData> PenLightPCGData;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="PenLight Audience", meta=(AllowPrivateAccess="true"))
	TObjectPtr<UInstancedActorsComponent> InstancedActorsComponent;
	
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="PenLight Audience", meta=(AllowPrivateAccess="true"))
	TWeakObjectPtr<APenLightManager> PenLightManager;
	
	UPROPERTY()
	FVector ActorScale;
	UPROPERTY()
	FVector OffsetScale;
	UPROPERTY()
	float TransitionTime = 0.2f;
	UPROPERTY()
	float TransitionStartTime = 0.0f;
	UPROPERTY()
	bool bIsTransitioning = false;
	
	UPROPERTY()
	FRotator PenLightRotationOffset = FRotator::ZeroRotator;
	
	// Animation Parameters for High LOD
	UPROPERTY()
	int32 MaxAnimOffsetFrames = 0;
	UPROPERTY()
	FVector RandomSeed = FVector::ZeroVector;
	UPROPERTY()
	int32 PenLightAnimFrameRate = 30;
};
/* PenLightAudience.cpp */
// Sets default values
APenLightAudience::APenLightAudience()
{
	// 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;
	PrimaryActorTick.TickInterval = 0.0333f;
	
	//bIsFinishedSetup = false;
	
	CharacterVAT = CreateDefaultSubobject<UStaticMeshComponent>("CharacterVAT");
	CharacterVAT->SetGenerateOverlapEvents(false);
	CharacterVAT->SetComponentTickEnabled(false);
	RootComponent = CharacterVAT;
	PenLightVAT = CreateDefaultSubobject<UStaticMeshComponent>("PenLightVAT");
	PenLightVAT->SetGenerateOverlapEvents(false);
	PenLightVAT->SetComponentTickEnabled(false);
	PenLightVAT->SetupAttachment(CharacterVAT);
	PenLightVAT->AddLocalOffset(FVector(0.0f, 0.0f, 100.0f));
	Character = CreateDefaultSubobject<USkeletalMeshComponent>("Character");
	Character->SetAnimationMode(EAnimationMode::AnimationSingleNode);
	Character->bPauseAnims = true;
	Character->SetGenerateOverlapEvents(false);
	Character->bUpdateOverlapsOnAnimationFinalize = false;
	Character->SetupAttachment(CharacterVAT);
	PenLight = CreateDefaultSubobject<UStaticMeshComponent>("PenLight");
	PenLight->SetGenerateOverlapEvents(false);
	PenLight->SetupAttachment(Character, FName("HandGrip_L"));
	PointLight = CreateDefaultSubobject<UPointLightComponent>("PointLight");
	PointLight->SetupAttachment(PenLight, FName("Light"));
	PointLight->SetIntensityUnits(ELightUnits::Lumens);
	PointLight->SetIntensity(10.0f);
	
	PenLightPCGData = CreateDefaultSubobject<UPenLightPCGData>("PenLightPCGData");
	InstancedActorsComponent = CreateDefaultSubobject<UInstancedActorsComponent>("InstancedActorsComponent");
	
	// Hide High LOD components by default
	bIsUsingHighLOD = false;
	CharacterVAT->SetVisibility(true);
	PenLightVAT->SetVisibility(true);
	Character->SetVisibility(false);
	PenLight->SetVisibility(false);
	PointLight->SetVisibility(false);
}

// Called every frame
void APenLightAudience::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	
	// Calculate Animation Position for High LOD the same way as in VAT material
	const int32 CurrentFrame = FMath::RoundToInt(static_cast<float>(PenLightManager->GetCurrentMasterAnimFrame()) +
								FMath::Lerp(-MaxAnimOffsetFrames, MaxAnimOffsetFrames, 
									FMath::Clamp(FMath::Frac(FMath::Abs(FVector::DotProduct(
											GetActorLocation(), RandomSeed))), 0.0f, 1.0f))
								+ static_cast<float>(PenLightAnimFrameRate)) % PenLightAnimFrameRate;
	const float AnimPos = static_cast<float>(CurrentFrame) / static_cast<float>(PenLightAnimFrameRate);
	Character->SetPosition(AnimPos);
	
	// Get distance to camera
	const FVector CameraLocation = PlayerCameraManager->GetCameraLocation();
	const FRotator CameraRotation = PlayerCameraManager->GetCameraRotation();
	// Use High LOD if within certain distance and in front of camera
	const FVector ActorLocationOffset = FVector(0.0f, 0.0f, 170.0f);
	const float DistanceToCamera = FVector::Dist(GetActorLocation() + ActorLocationOffset, CameraLocation);
	const bool bIsInFrontOfCamera = FVector::DotProduct((GetActorLocation() - CameraLocation).GetSafeNormal(), CameraRotation.Vector()) > 0.0f;
	const bool bShouldUseHighLOD = DistanceToCamera <= 200.0f && bIsInFrontOfCamera;
	if (bShouldUseHighLOD != bIsUsingHighLOD)
	{
		bIsUsingHighLOD = bShouldUseHighLOD;
		bIsTransitioning = true;
		TransitionStartTime = UGameplayStatics::GetRealTimeSeconds(WorldPtr);
		
		if (bIsUsingHighLOD) // Low LOD to High LOD
		{
			UpdateAnimParameters();
			SwitchLOD();
		}
		else  // High LOD to Low LOD
		{
			// Defer switch until HLOD Penlight finish scaling back
		}
	}
	
	if (bIsTransitioning)
	{
		const float ElapsedTime = UGameplayStatics::GetRealTimeSeconds(WorldPtr) - TransitionStartTime;
		if (ElapsedTime < TransitionTime)
		{
			if (bIsUsingHighLOD)
			{
				PenLight->SetRelativeScale3D(FMath::Lerp(FVector::OneVector, OffsetScale, 
										FMath::Clamp(ElapsedTime / TransitionTime, 0.0f, 1.0f)));
			}
			else
			{
				PenLight->SetRelativeScale3D(FMath::Lerp(OffsetScale, FVector::OneVector, 
										FMath::Clamp(ElapsedTime / TransitionTime, 0.0f, 1.0f)));
			}
		}
		else // Finished transition
		{
			if (bIsUsingHighLOD)
			{
				PenLight->SetRelativeScale3D(OffsetScale);
			}
			else
			{
				PenLight->SetRelativeScale3D(FVector::OneVector);
				SwitchLOD();
			}
			bIsTransitioning = false;
		}
	}
}

void APenLightAudience::SwitchLOD()
{
	// Update visibility
	Character->SetVisibility(bIsUsingHighLOD);
	PenLight->SetVisibility(bIsUsingHighLOD);
	PointLight->SetVisibility(bIsEnabled && bIsUsingHighLOD);
	
	CharacterVAT->SetVisibility(!bIsUsingHighLOD);
	PenLightVAT->SetVisibility(!bIsUsingHighLOD);
}

4.2 PenLight Managerと連動する

Skeletal MeshのアニメーションもPenLight Managerと同期する必要があります。VATと違って、こちらはC++/BPで処理を実行できますので、普通の実装でいけます。BeginPlay()でPenLight Managerの参照を取得し、UpdateAnimParameters()でパラメータを同期します。そして毎フレーム、GetCurrentMasterAnimFrame()で現在のフレームを問い合わせます。

/* PenLightAudience.h */
UCLASS()
class PENLIGHTCROWD_API APenLightAudience : public AActor, public IPenLightInterface
{
	GENERATED_BODY()

public:
	// Sets default values for this actor's properties
	APenLightAudience();
	// Called every frame
	virtual void Tick(float DeltaTime) override;
	
protected:
	// Called when the game starts or when spawned
	virtual void BeginPlay() override;
	
	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

private:
	UFUNCTION()
	void OnPenLightManagerInitialized(TWeakObjectPtr<APenLightManager> PenLightManagerInstance);
	UFUNCTION()
	void UpdateAnimParameters();
	
	UPROPERTY()
	UWorld* WorldPtr;
	
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="PenLight Audience", meta=(AllowPrivateAccess="true"))
	TWeakObjectPtr<APenLightManager> PenLightManager;
	
	UPROPERTY()
	FRotator PenLightRotationOffset = FRotator::ZeroRotator;
	
	UPROPERTY()
	TWeakObjectPtr<APlayerCameraManager> PlayerCameraManager;
	
	// Animation Parameters for High LOD
	UPROPERTY()
	int32 MaxAnimOffsetFrames = 0;
	UPROPERTY()
	FVector RandomSeed = FVector::ZeroVector;
	UPROPERTY()
	int32 PenLightAnimFrameRate = 30;
};
/* PenLightAudience.cpp */
// Called when the game starts or when spawned
void APenLightAudience::BeginPlay()
{
	Super::BeginPlay();
	
	WorldPtr = GetWorld();
	
	if (ensureMsgf(UGameplayStatics::GetPlayerCameraManager(WorldPtr, 0), TEXT("No PlayerCameraManager found.")))
	{
		PlayerCameraManager = UGameplayStatics::GetPlayerCameraManager(WorldPtr, 0);
	}
	
	// Get PenLightManager
	TWeakObjectPtr<APenLightManager> PenLightManagerInstance = APenLightManager::GetPenLightManager(this);
	if (PenLightManagerInstance.IsValid() && PenLightManagerInstance->GetIsInitialized())
	{
		// If initialized register this audience
		PenLightManager = PenLightManagerInstance;
		PenLightManager->RegisterAudience(this);
	}
	else
	{
		// If not initialized, subscribe to delegate and handle registration in the callback
		APenLightManager::OnPenLightManagerInitialized().AddUObject(this, &APenLightAudience::OnPenLightManagerInitialized);
	}
	
	UpdateAnimParameters();
	
	// Enable Tick
	SetActorTickEnabled(true);
}

void APenLightAudience::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	// Unregister audience
	PenLightManager->UnregisterAudience(this);
	
	Super::EndPlay(EndPlayReason);
}

void APenLightAudience::OnPenLightManagerInitialized(TWeakObjectPtr<APenLightManager> PenLightManagerInstance)
{
	if (PenLightManagerInstance.IsValid() && PenLightManagerInstance->GetIsInitialized())
	{
		PenLightManager = PenLightManagerInstance;
		PenLightManager->RegisterAudience(this);
	}
}

// Called every frame
void APenLightAudience::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	
	// Calculate Animation Position for High LOD the same way as in VAT material
	const int32 CurrentFrame = FMath::RoundToInt(static_cast<float>(PenLightManager->GetCurrentMasterAnimFrame()) +
								FMath::Lerp(-MaxAnimOffsetFrames, MaxAnimOffsetFrames, 
									FMath::Clamp(FMath::Frac(FMath::Abs(FVector::DotProduct(
											GetActorLocation(), RandomSeed))), 0.0f, 1.0f))
								+ static_cast<float>(PenLightAnimFrameRate)) % PenLightAnimFrameRate;
	const float AnimPos = static_cast<float>(CurrentFrame) / static_cast<float>(PenLightAnimFrameRate);
	Character->SetPosition(AnimPos);
	
	if (bShouldUseHighLOD != bIsUsingHighLOD)
	{
		bIsUsingHighLOD = bShouldUseHighLOD;
		bIsTransitioning = true;
		TransitionStartTime = UGameplayStatics::GetRealTimeSeconds(WorldPtr);
		
		if (bIsUsingHighLOD) // Low LOD to High LOD
		{
			UpdateAnimParameters();
			SwitchLOD();
		}
		else  // High LOD to Low LOD
		{
			// Defer switch until HLOD Penlight finish scaling back
		}
	}
}

void APenLightAudience::UpdateAnimParameters()
{
	RandomSeed = PenLightManager->GetRandomSeed();
	MaxAnimOffsetFrames = PenLightManager->GetMaxAnimOffsetFrames();
	PenLightAnimFrameRate = PenLightManager->GetPenLightAnimFrameRate();
}

観客の見た目が揃いすぎないように、Actorごとにアニメーションフレームやキャラクターサイズなどをランダムにオフセットしています。しかし、本来はVATにも同じオフセットを追加するつもりでしたが、原因がわからずでVATメッシュがぼやけてしまうので、一旦諦めています。そのため、現在はInstanced ActorとActorを切り替える際に、少しの誤差が発生します。

直接的な解決策ではないが、キャラクターモデルとアニメーションの種類を増やすと、見た目の多様性が自然に増えるので、オフセットを使用する必要もなくなるはずです。

アニメーションを止めずにVATとSkeletal Meshを切り替える仕組みが完成します。ここはVATメッシュのCast Shadowをオフにしましたので、シャドウの有無から切り替えの状態も確認できます。

5 おわりに

今回は主にペンライト演出の制御に関する仕組みについて補足しました。この部分は基本的にUnreal Engineにおける一般的な実装で、新機能とはあまり直接関係ありませんが、全体のわかりやすさのため説明の必要があると感じました。

そして、次回はようやくPart 3に入り、MegaLightsとPCGの力を発揮したライティングの実装を紹介したいと思います。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?