環境:UE4.27.2
はじめに
裏読みをする前にロードの処理を見直しておきましょう。
C++で組む時、アセットを以下のようにロードしてないですか?
StaticLoadObject(USkeletalMesh::StaticClass(), nullptr, TEXT("/Game/Character/Mesh"));
アセットをロードしてくれるとても便利な機能ですね。
ですが、このままだと色々問題があります。
- アセットのパスが変わるとコードも書き換えないといけない
- 参照が付かないためパッケージにアセットが含まれずエラーになる場合がある
- アセットのロードはとても重く、ヒッチの原因になります
まずはこれらの問題がおきないように対処してみましょう。
パスが変わっても大丈夫、パッケージにも含まれるようにする
絶対に必要なアセットの場合
ハード参照でBPに直接アセットをセットしてしまいましょう。
BPのロードと一緒にロードされるのでStaticLoadObjectする必要がなくなります。
UPROPERTY(EditDefaultsOnly)
USkeletalMesh* CustomMesh;
場合によって必要なかったり、使い分ける場合
ソフトオブジェクトポインタTSoftObjectPtr<>を使って必要なものだけLoadSynchronous()してやるとよいでしょう。
UPROPERTY(EditDefaultsOnly)
TSoftObjectPtr<USkeletalMesh> CustomMeshA;
UPROPERTY(EditDefaultsOnly)
TSoftObjectPtr<USkeletalMesh> CustomMeshB;
USkeletalMesh* CustomMesh;
if(条件)
{
CustomMesh = CustomMeshA.LoadSynchronous();
}
else
{
CustomMesh = CustomMeshB.LoadSynchronous();
}
アセットの数が沢山あって手動で設定するの面倒なんだけど
UPrimaryDataAssetを継承したクラスを作り、UpdateAssetBundleData関数で登録してあげると良いでしょう。
UCLASS()
class UPrimaryAssetFoobar : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
virtual FPrimaryAssetId GetPrimaryAssetId() const override { return FPrimaryAssetId(TEXT("Foobar"), GetFName()); }
#if WITH_EDITORONLY_DATA
virtual void UpdateAssetBundleData() override;
#endif
public:
// Keyにそのままアセット名を使っているけど本当はアセットパスの一部を抜き出してId化したほうがいい
UPROPERTY(VisibleAnywhere)
TMap<FName, TSoftObjectPtr<USkeletalMesh>> SkeletalMeshes;
};
void UPrimaryAssetFoobar::UpdateAssetBundleData()
{
Super::UpdateAssetBundleData();
if (!UAssetManager::IsValid()) { return; }
UAssetManager& Manager = UAssetManager::Get();
TArray<FAssetData> DirectoryAssets;
FName PackagePath = FName(*FPackageName::GetLongPackagePath(GetOutermost()->GetName()));
Manager.GetAssetRegistry().GetAssetsByPath(PackagePath, DirectoryAssets, true);
for (const FAssetData& AssetData : DirectoryAssets)
{
FSoftObjectPath AssetRef = Manager.GetAssetPathForData(AssetData);
if (AssetRef.IsNull()) { continue; }
// スケルタルメッシュを登録
if (AssetData.GetClass()->IsChildOf(USkeletalMesh::StaticClass()))
{
SkeletalMeshes.Emplace(AssetData.AssetName, AssetRef);
}
}
}
先程作ったクラスを使ってデータアセットを作ります。
置き場所はロードしたいアセットがあるディレクトリ、またはその上のディレクトリになるように置きます。
保存ボタンを押すと自動でアセットをセットしてくれます。
以降はアセットが増えてもプレイするときやこのアセットを開いた時に自動的に追加してくれます。
後はこのPrimaryAssetをロードして、その中から必要なアセットをLoadSynchronous()してあげればOKです。
アセットロード時にヒッチが発生しないように事前に裏読みする
裏読みとはゲーム中に次に必要なデータを事前にロードしておく処理のことです。
今回はキャラとマップについて説明します。
キャラの場合
AssetManagerの機能を使います。ロードできる単位はUWorldとPrimaryAssetです。
なので、さっき説明したPrimaryAssetを活用することにしましょう!
ただ、今のままだと全キャラロードしてしまうため部分的にロードできるように改造します。
アセットバンドルという機能を使えるように1行追加しましょう。(Unityのそれとは別物ですw)
// スケルタルメッシュを登録
if (AssetData.GetClass()->IsChildOf(USkeletalMesh::StaticClass()))
{
SkeletalMeshes.Emplace(AssetData.AssetName, AssetRef);
// 追加! バンドルにアセットのパスを追加
AssetBundleData.AddBundleAsset(AssetData.AssetName, AssetRef);
}
このようにバンドル名を設定してロードするとそのバンドルのアセットだけを非同期でロードしてくれます。
const FPrimaryAssetId AssetId(TEXT("Foobar"), TEXT("Primaryアセット名"));
TArray<FName> LoadBundles;
LoadBundles.Add(TEXT("SK_Mannequin"));
UAssetManager::Get().LoadPrimaryAsset(AssetId, LoadBundles);
おっと、プロジェクト設定に追加するのを忘れてはいけません。
スキャンするプライマリアセットタイプに新しいタイプを追加しましょう。
- PrimaryAssetTypeはGetPrimaryAssetId()で作った第一引数の文字(例だと"Foobar")を指定
- Directorysは検索するディレクトリ
- CookRuleは確実にパッケージに含めてほしいのでAlwaysCookにしておいたほうが安全
ロードされてるかどうかは以下のコマンドでチェックできます。
AssetManager.DumpLoadedAssets
マップの場合
スキャンするプライマリアセットタイプのMapのところを修正する必要があります。
- Is Editor Onlyのチェックを外す!!
- Directoriesにマップを置いてあるディレクトリを追加(必要あれば)
後はロードする処理を書きます。
これだけ!アセット名はフルパスでないと駄目っぽいので注意
UAssetManager::Get().LoadPrimaryAsset(FPrimaryAssetId(TEXT("Map"), TEXT("/Game/Maps/NewWorld")));
ですが、これだとパーシスタントレベルはロードされるのですがサブレベルがロードされません。
サブレベルもロードされるようにAssetManagerを改良していきましょう。
UCLASS()
class UCustomAssetManager : public UAssetManager
{
GENERATED_BODY()
public:
static UCustomAssetManager& Get();
void LoadLevel(FName AssetName);
private:
void LoadSubLevel(TSharedPtr<FStreamableHandle> Handle);
};
UCustomAssetManager& UCustomAssetManager::Get()
{
if (UCustomAssetManager* Singleton = Cast<UCustomAssetManager>(GEngine->AssetManager)) { return *Singleton; }
return *NewObject<UCustomAssetManager>();
}
void UCustomAssetManager::LoadLevel(FName AssetName)
{
const TSharedPtr<FStreamableHandle>& Handle = LoadPrimaryAsset(FPrimaryAssetId(TEXT("Map"), AssetName));
if (Handle.IsValid() == false) return;
// パーシスタントレベルのロードが終わったらサブレベルをロードする
if (Handle->HasLoadCompleted())
{
LoadSubLevel(Handle);
}
else
{
Handle->BindCompleteDelegate(FStreamableDelegate::CreateUObject(this, &UCustomAssetManager::LoadSubLevel, Handle));
}
}
void UCustomAssetManager::LoadSubLevel(TSharedPtr<FStreamableHandle> Handle)
{
const UWorld* PersistentLevel = Cast<const UWorld>(Handle->GetLoadedAsset());
if (PersistentLevel == nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("パーシスタントレベルの裏読みに失敗") );
return;
}
for (const ULevelStreaming* SubLevel : PersistentLevel->GetStreamingLevels())
{
LoadPrimaryAsset(FPrimaryAssetId(TEXT("Map"), SubLevel->GetWorldAssetPackageFName()));
}
}
使うときはこう
UCustomAssetManager::Get().LoadLevel(TEXT("/Game/Maps/NewWorld"));
ロードの完了イベントが必要な場合はUCustomAssetManagerを適時改造してやると良いでしょう。
最後に
裏読み中にブロックロードすると非同期の裏読みも含めてブロックロードしてめちゃくちゃ長時間固まります。その場合は
"FlushAsyncLoading:なんとか"
というログが出るのでログが出てる所にブレークを貼り原因をとなっているブロックロードを探します。
そのアセットも裏読み対応するか、個別にAsyncLoadにしてブロックロードが同時に発生しないように頑張りましょう!
(追記)
後で分かったのですがPIEの"Play Standarone"で起動した後、別プロセスのDedicatedServerに接続して
上記のやりかたでレベルを裏読みしてSeamlessTravelするとクラッシュするようです。多分エンジンのバグ。
以上。
参考記事