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の子クラスを作ったら、制御パラメータを簡単にデバッグできるセットアップが作れます。
そして、このデータを使用して、VATとSkeletal Meshそれぞれをどのように制御するかを紹介します。
3 VATアニメーションを制御する
Part 2でも述べたように、VATアニメーションはスケルトンを介していませんし、そもそもInstanced Actor状態のActorはStatic Meshとほぼ同然で、アニメーションを含む処理を実行できません。
つまり、VATをコントロールするにはマテリアル(シェーダー)経由する必要があります。前回で作成したVATのデフォルト状態では自動的に動くが、VATのMaterial Instanceを開き、AutoPlayパラメータをfalseにすると、「Frame」パラメータが指定できるようになって、手動でアニメーションをコントロールできます。
RuntimeでDynamic Material Instanceを作って、このパラメータをBlueprintから直接制御するのも解決法の一つですが、使うマテリアルの数が増えると処理が多くなり、またアニメーションのランダムオフセットも実現しづらいのは欠点です。そのため、今回はMaterial Parameter Collectionを活用して、VATのマテリアルの機能を拡張します。
3.1 Material Parameter Collection
Content BrowserでMaterial Parameter Collectionを新しく作ります。
MPCに、以下のScalarパラメータを追加します。(MaxAnimOffsetとRandomSeedは今回使っていません、理由は後で説明します。)
-
Frame:現在のアニメーションフレーム -
bIsPlaying:ペンライトを振るか否か -
PenLightBPM:ペンライトを振るスピード -
AnimFrameRate:アニメーションのフレームレート
レベル内の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です。
今回はAutoPlayを使用しませんので、上部の「Frame」だけに注目します。
「Frame」と「PrevFrame」の2つのパラメータで、アニメーションの再生位置を指定できるとわかります。そのため、こちらの入力を、Material Parameter Collectionのデータと接続する必要があります。
マテリアルにCollectionParameterノードを追加し、作ったMPCを選択すると、MPCパラメータを使用できます。
処理はこんな感じになります。bIsPlayingがtrueのとき、CurrentFrame=Frame, PreviousFrame=Frame-1とし、falseのときはCurrentFrame=PreviousFrame=Frame。そして、フレーム数をアニメーションの範囲内(ここでは0-29)に調整すれば完成です。
アニメーション停止中にCurrentFrame=PreviousFrameにする理由は、CurrentFrameとPreviousFrameが等しくないと、メッシュにモーションブラー効果がかかってしまうためです。
もう一つは、その上のNumFramesパラメータも、MPCにあるAnimFrameRateを置き換えます。
早速検証します。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
- ペンライト(Static Mesh)-visible: false
- Instanced Actors Component
これで、Instanced Actorの状態では、VisibleのVATメッシュだけが残ります。そして、ActorとしてHydrateされた際に、隠されたComponentが生成されます。
切り替えのロジックに関しては、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の力を発揮したライティングの実装を紹介したいと思います。















