Standalone HLODs at runtime with dynamic level instances?

Hello Epic friends,

Goal: Spawn a level instance at runtime, see it’s prebuilt HLOD working when I am far from it.

Details:

I’m testing out the Sub-World Partition “Standalone HLOD” feature,

I was able to get it working when I manually place level instance actors in my “Main World”.

I could switch the levels in editor and see the correct HLODs in the distance, very nice!

I would like to use this same feature for Level Instances that I dynamically add to the world with “LoadLevelInstance”.

Currently the World Partition streaming for the objects in the sub world appears to do the correct thing but I don’t see the Standalone HLODS in game.

Am I possibly doing something wrong or is this not supported right now?

(I do see that a lot of UWorldPartitionStandaloneHLODSubsystem is marked as editor only )

Any advice on pursuing this type of functionality?

Perhaps a good place to look at injecting the standalone hlods at runtime myself in code?

Perhaps other features that exist (I do know about data layer usage for similar things but I would like to dynamically spawn these level instances)

Thanks for any advice.

Hello!

Standalone HLODs will only work automatically with embedded LevelInstances. You could likely use the UWorldPartition::RegisterWorldAssetStreaming API for dynamically loaded LIs. It allows users to dynamically inject streaming levels into the runtime grid and it supports injecting HLODs as well.

Regards,

Martin

Thank you Martin, I will look into that api!

[mention removed]​

Following up on my testing of the RegisterWorldAssetStreaming api:

I was able to get it working (mostly).

Now when I play,

  • I do see an HLOD when far away. good.
  • I fly close and see the actual objects load in. good!
  • problem: however my injected HLOD never seems to unload / hide.
    • I see it overlapping my newly loaded objects.

Questions:

  • Any thoughts on what would cause the HLODWorldAsset to not unload as I get close and the WorldAsset is loaded?
    • with bounds it was not clear if that should be in world bounds of the placed asset, or the zero centered bounds of my uworlds. I assume in world bounds. Changing this did not help with my problem.
  • For the “AddHLODWorldAsset” are there any restrictions on what types of actors can be in the referenced UWorlds sent as HLODs?

Note for documentation:

I initially got stuck on the naming convention for hlod grids but was able to figure that out in the end with the ‘:’ separated name example:

“Params.AddHLODWorldAsset(InHLODAssetInstanced, “MainGrid:HLODLayer_Instanced”);”

Note:

  • I am using WorldPartitionRuntimeHashSet
    • I do see a few differences in the code setup for the spatialhash version

Hey Clint,

Regarding the injected HLOD not hiding, could you please set LogHLODRuntimeSubsystem verbosity to Verbose and check if there are any lines in the log related to your injected WorldAsset and/or HLODWorldAsset?

As for the actor types used in worlds sent as HLODs - for things to work properly they must be AWorldPartitionHLOD or derived actors. Technically it can be any class that implements IWorldPartitionHLODObject interface, but AWorldPartitionHLOD should be the most common.

Thanks,

Andrzej

Thanks Andrzej,

two follow ups

I have one version using manually created level instances for the hlod, but this is using regular actors not AWorldPartitionHLOD so not expected to work like you say.

And another where I used a standalone hlod generated from my level instance, this one does show up as AWorldPartitionHLOD class

---------------------

Version 1: (manual non hlod actor instance used as an hlod)

With verbose, I do see my injected cells

LogHLODRuntimeSubsystem: Verbose: Registering external cell 2B97D3493ED002218FAD1D32529A229D - InjectedCell_2B97D3493ED002218FAD1D32529A229D8

LogHLODRuntimeSubsystem: Verbose: Registering external cell 03B1C032727CCDA58C4C27AC13EE8650 - InjectedCell_03B1C032727CCDA58C4C27AC13EE86508

and then I see those unregistered at close, but never hidden as I get close

----------------------------

Version 2: (standalone hlod level used as my hlod with AWorldPartitionHLOD)

In this one I see the same

LogHLODRuntimeSubsystem: Verbose: Registering external cell C4A6DFB05994402EBBF657EA4C3E2B38 - InjectedCell_C4A6DFB05994402EBBF657EA4C3E2B3811

LogHLODRuntimeSubsystem: Verbose: Registering external cell 922DCC530205F4D301F5BCB85ECA2A35 - InjectedCell_922DCC530205F4D301F5BCB85ECA2A3511

but then I also see a

LogHLODRuntimeSubsystem: Verbose: Registering cell 2EA4CFC9A000A3C9438A1E15C03855B4 - SimpleLI_HLOD1_HLODLayer_Merged_Persistent

LogHLODRuntimeSubsystem: Verbose: Registering Standalone HLOD SimpleLI_HLODLayer_Merged/SimpleLI_HLODLayer_Instanced_L0_X0_Y0 referencing currently not loaded cell ‘EC546421CF5322A3451FCD7B9F65A68D’

none of those cells show up again other than when they are unregistered on stop PIE

-----------------------

I’m guessing that using a standalone hlod is maybe problematic here too…or maybe that callout of

“referencing currently not loaded cell ‘EC546421CF5322A3451FCD7B9F65A68D’”

is problematic.

----------------

Question:

  • How should I go about creating our AWorldPartitionHLOD for use. I was relying on the standalone hlod process to generate it for me, but do I need to create a custom hlod baker for our level instances instead, is there another way to get
    • 1. my regular authored level instance, just the normal level instance
    • and 2. the AWorldPartitionHLOD version of that.

Thanks,

Clint

Hey Clint,

As you mentioned, I wouldn’t expect Version 1 to work, so that’s expected.

Regarding Version 2, I’m curious if that cell EC546421CF5322A3451FCD7B9F65A68D shows up in the Streaming Generation log for either your main world or the injected world? You should be able to find them in \Saved\Logs\WorldPartition\

Why I think it doesn’t work now:

When injecting the WorldAsset and the HLODWorldAsset, they’re injected as WP streaming cells. HLOD cell has SourceCell set as the main World cell and that’s how we’re supposed to know when to hide the injected HLOD cell.

Later, in AWorldPartitionHLOD::GetSourceCellGuid, if the actor doesn’t have SourceCellGuid set, SourceCellGuid is established from the cell it belongs to. In your case, I suspect that your HLOD Actors have SourceCellGuid set (which was set during standalone HLOD generation process), so the Cell’s SourceCellGuid is not considered in this case. So that HLOD actor has source Cell set to a cell that probably doesn’t exist.

You might try resaving all HLOD actors in your standalone HLOD level and clearing SourceCellGuid (and maybe StandaloneHLODGuid at the same time) while resaving and see if that helps. With that change you might be able to see SourceCellGuid set to Cell’s SourceCellGuid in AWorldPartitionHLOD::GetSourceCellGuid which should make your injected HLOD be hidden at the right time.

I understand that the above might be confusing, so if anything is not clear, please let me know.

One thing to mention is that Standalone HLOD feature wasn’t really created with WorldAsset injection in mind and it’s a solution for HLOD in LevelInstances with Level Behavior set to Standalone. However, with some changes maybe it can be used for World Asset injection as well. If not, the yes, creating a custom HLOD builder is probably the way to go.

Thanks,

Andrzej

thanks for the advice Andrzej, will try this week.

verifying that the referenced cell did not show in in the streaming generation logs, presumably because it references the old level that I made the standalone hlod from.

I did put a break in PreSave for my hlodactor, cleared out the SourceCellGuid and StandaloneHLODGuids. then verified they were both empty on reload.

Now, it hits this log line

UE_LOG(LogHLODRuntimeSubsystem, Verbose, TEXT(“Found HLOD %s referencing nonexistent cell ‘%s’”), *InWorldPartitionHLOD->GetHLODNameOrLabel(), *InWorldPartitionHLOD->GetSourceCellGuid().ToString());

only printing out the empty sourcecell guid. all 0000s

I suspect two things are going on.

  1. basing all of this off of a standalone hlod is confusing things, like you said not planned to work with that
  2. there’s some missing knowledge on my part about creating HLODs for use here. I’ve only ever used the built in hlod tools and not made one from scratch myself or adjusted the system.
    1. Right now the standalone hlod is inside of a level that is flagged as streaming.
    2. For the HLOD I send to RegisterWorldAssetStreaming what should that look like?
      1. should it be a level with a single hlod actor inside of it.
      2. should it be a standalone package that is an hlodactor not in a level
      3. something else?

I think also right now my test example has gotten a little messy with varying hlod layer names and grids etc.

I’m going to start over from scratch making a cleaner test case.

If you can advise me on what the hlod that goes into this should look like a bit more that would help.

  • a ULevel with the hlod actor inside of it, streaming or not streaming
  • something else

Thanks!

Clint

Follow up, looks like I got it working just now:

key changes:

  • make simple ulevel with a custom hlod actor in it.
  • make sure that my main world partition level’s hlodsetup is marked as spatially loaded.

[Image Removed]

details:

In this setup, I have my main World Partition Level

It has a runtime grid: MainGrid

it has an hlodsetup: HLODLayer that is marked as spatially loaded (when I had this not spatially loaded it did not work properly)

I them made a simple new ulevel (Not World Partition)

I put my static mesh into it, and then converted that to an HLOD object with a quick test function, and deleted the static mesh, so I only have the hlod.

(code as example, there are some bugs in this :slight_smile: )

void UTestWorldAssetPluginBPLibrary::TestMakeHLOD(UObject* WorldContextObject, const TArray<AActor*>& InSourceActors)
{
	if (InSourceActors.Num() == 0)
		return;
 
	AActor* Actor = InSourceActors[0];
	
	FActorSpawnParameters SpawnParams;
	SpawnParams.Name = *(FString(Actor->GetName()) + "HLODActor");
	SpawnParams.NameMode = FActorSpawnParameters::ESpawnActorNameMode::Required_Fatal;
 
	AWorldPartitionHLOD* HLODActor = WorldContextObject->GetWorld()->SpawnActor<AWorldPartitionHLOD>(AWorldPartitionHLOD::StaticClass(), SpawnParams);
	UStaticMeshComponent* Component = nullptr;
	UStaticMeshComponent* sourceMeshComponent = Cast<UStaticMeshComponent> (Actor->GetComponentByClass(UStaticMeshComponent::StaticClass()));
	if (sourceMeshComponent)
	{
		Component = NewObject<UStaticMeshComponent>();
		Component->SetStaticMesh(sourceMeshComponent->GetStaticMesh());
		Component->SetWorldLocation(Actor->GetActorLocation());
	}
	FVector Origin;
	FVector Extents;
	Actor->GetActorBounds(false, Origin, Extents, false);
	FBox boundingBox(Origin - Extents, Origin + Extents);
	HLODActor->SetHLODComponents({ Component });
	HLODActor->SetHLODBounds(boundingBox);
}

And then I’m registering the world asset streaming like so:

void UTestWorldAssetPluginBPLibrary::TestWorldAssetStreaming(UObject* WorldContextObject, TSoftObjectPtr<UWorld> InWorldAsset, TSoftObjectPtr<UWorld> InHLODAsset, FTransform Transform, FString MainGrid, FString HLODGrid, bool &Success, FGuid& OutGuid)
{
	if (UWorld* World = WorldContextObject->GetWorld())
	{
		if (UWorldPartition* WorldPartition = World->GetWorldPartition())
		{
			UWorld* LoadedWorld = InWorldAsset.LoadSynchronous();
			UWorld* LoadedHLOD = InHLODAsset.LoadSynchronous();
			if (LoadedWorld && LoadedHLOD)
			{
				UWorldPartition::FRegisterWorldAssetStreamingParams Params;
 
				//levels hlods and grids
				Params.SetWorldAsset(InWorldAsset, *MainGrid);
				Params.AddHLODWorldAsset(InHLODAsset, *(MainGrid +":" + HLODGrid));
				
				//guid
				Params.Guid = FGuid::NewGuid();
 
				//transform and bounds
				Params.SetTransform(Transform);
				float ChunkRadius = 20.4;
				const FVector HalfExtent = FVector(100.0, 100.0, 100.0) * ChunkRadius;
				const FVector Center = Transform.GetLocation();
				const FBox CellBounds(Center -HalfExtent, Center + HalfExtent);
				Params.SetBounds(CellBounds);
 
				//suffix for dedupe
				static int ChunkID = 0;
				ChunkID++;				
				FString Suffix = FText::AsNumber(ChunkID).ToString();
				Params.SetCellInstanceSuffix(Suffix);
 
				//use our bounds to determine proper grid and cell for placement
				Params.SetBoundsPlacement(true); 
 
				OutGuid = WorldPartition->RegisterWorldAssetStreaming(Params);
				Success = true;
			}
		}
	}
}

Now,

I see my mesh when close, and when I fly far away I see my HLOD pop up.

I think there is probably some issue where not spatially loading the last hlod setup should work but it doesn’t.

That’s great progress!

Just to go back to the previous approach with Standalone HLOD. I’m a bit surprised that clearing SourceCellGuid and StandaloneHLODGuids didn’t work, but there’s probably some other incompatibility happening, that I didn’t think of. Not sure if you’re still interested in pursuing that approach, but if you are then you could put a BP in AWorldPartitionHLOD::GetSourceCellGuid

if (!SourceCellGuid.IsValid())
{
	const UWorldPartitionRuntimeCell* Cell = Cast<UWorldPartitionRuntimeCell>(GetLevel()->GetWorldPartitionRuntimeCell());
	if (Cell && Cell->GetIsHLOD())
	{
		const_cast<AWorldPartitionHLOD*>(this)->SourceCellGuid = Cell->GetSourceCellGuid();
	}
}

My idea was assuming that the above code would trigger and get a new SourceCellGuid from Cell’s SourceCellGuid. Would be interesting to know why that’s not happening.

If you don’t want to pursue that approach, then don’t worry about it :slight_smile: This might not be the last obstacle, since like I mentioned those systems were not really meant to work together.

Regardless of the above, good progress with the new setup! The injection system was meant to work with small non-WP levels, so it makes sense that it was easier to get that working. Not sure why it doesn’t work with non-spatially loaded HLOD. What Engine version are you using? There were some changes to how non-spatially loaded HLODs are handled between 5.6 and 5.7, so I’ll need to know that, before I can make some suggestions.

To answer your other question, how to create those HLOD levels.

  • To start with, you can look at UWorldPartitionHLODSourceActors. That’s an interface that’s supposed to provide information about source actors that allows HLOD Actor to build an HLOD representation of the SourceActors. You can provide SourceActors object by using HLODActor->SetSourceActors. With that properly setup, you should be able to do HLODActor->BuildHLOD which should create appropriate components with HLOD representation.
  • If you’d like to use small non-WP levels with the injection system, there is something in the engine that can make HLOD generation for those levels easier - UWorldPartitionHLODSourceActorsFromLevel
  • UWorldPartitionHLODSourceActorsFromLevel is supposed to generate an HLOD representation of a single level that can be provided by SetSourceLevel.
  • I might be missing some details, but in general you’d want to do something like the following
  1. Create your Level that you want to inject into main WP world
  2. Create another Level which will be your HLOD level
  3. Spawn a WorldPartitionHLODActor in that level
  4. Create a UWorldPartitionHLODSourceActorsFromLevel object and use SetSourceLevel to set your level that you want to build HLOD for as source level (the one from #1)
  5. Use HLODActor->SetSourceActors to set your object from #4 as source actors
  6. You might need to setup some additional parameters on your HLOD Actor
  7. You should be able to use HLODActor->BuildHLOD to get an HLOD representation of your level

Hope that helps! Let me know if you have any other questions.

Thanks as always for the detailed follow ups!

“Regardless of the above, good progress with the new setup! The injection system was meant to work with small non-WP levels, so it makes sense that it was easier to get that working. Not sure why it doesn’t work with non-spatially loaded HLOD. What Engine version are you using? There were some changes to how non-spatially loaded HLODs are handled between 5.6 and 5.7, so I’ll need to know that, before I can make some suggestions.”

  • “small non-WP”
    • I was using a regular WP level for actual level instance, and then a non WP level for the HLOD, that combo did appear to work.
    • Is there any reason that you would advise against using a WP level for the level instance? I suspect if we go this path for our task, I could get by with no WP on the level instances, but would likely want it. I also expect I’d want to extend the HLOD to two levels for perf if we do have larger instances. Will see!
  • Engine version,
    • I was doing the tests in vanilla 5.6, we are on 5.4 and planning to move to 5.7 soon so I can move the test to 5.7.

It might work with WP levels as well, but you won’t get the benefits of WP (partial streaming). When using RegisterWorldAssetStreaming, the WorldAsset content is put in a single streaming cell so it’s either fully loaded or not loaded at all. Depending on your memory/performance constraints this might be a problem, if you try to use it with large WP worlds.

Having two HLOD levels should be supported.

And regarding that non-spatially loaded HLOD Layer, after looking at some of the code again, I think that might not be supported at the moment.

In UWorldPartitionRuntimeHashSet::RegisterWorldAssetStreaming there is something like this:

if (RuntimeCell->CreateAndSetLevelStreaming(WorldAsset, InParams.Transform))
{
	StreamingData.SpatiallyLoadedCells.Add(RuntimeCell);
	StreamingObject->RuntimeStreamingData.Emplace(MoveTemp(StreamingData));
}

I think when creating a cell for the non-spatially loaded HLOD, the RuntimeCell should be added to NonSpatiallyLoadedCells instead of SpatiallyLoadedCells. Right now it looks like we might be assuming that all injected cells (main+HLOD) are spatially loaded. I haven’t had a chance to test and verify that yet, but I’ll try to do it at some point.

In the meantime, if you want to get it working maybe you can try changing that code locally.

Thanks for the assist and all the info!

  • interesting on the WP, I could have sworn I saw partial streaming working, though I’m only testing in PIE right now.

Really appreciate the help, I think I am all good for the moment and will follow up with new questions as I get deeper into implementation.

Regarding partial streaming, I haven’t tried it myself and my assumptions were based on looking at the code and it’s possible that I’ve missed something. Maybe something to keep in mind and to double check if you want to use it with larger worlds.

Sounds good, feel free to create new questions (and maybe link this one for context) if anything new comes up.