Contact Us
Menu
Aether Sign-up
Contact Us

Pushing the boundaries of UE4 Rendering: 10k fluid objects at 60fps

Dec 27, 2018 4:00:00 PM

The Unreal Engine was first released in 1998, together with the first-person shooter game Unreal. That was also the first time I had any experience in creating gaming content. Although my first “creations” weren’t super cool - today, with some programming skills, I think I am able to share some tricks I’ve learned and used at Hadean. More specifically, I’d like to show you how I was able to create an Unreal Engine 4 rendering with 10,000+ movable objects.

The problem?

When I started to work at Hadean - Aether Engine was already in a good shape. What was missing was some kind of client application to show the results of our simulations. But how can I show so many models/particles whilst keeping a high framerate? The answer is UHierarchicalInstancedStaticMeshComponent (HISM).

Simply using HISM instead of Actors gives great progress:

    • We are not using expensive Actors for gameplay elements
    • StaticMeshInstance allows us to use only one mesh template per different static mesh object, which increases performance A LOT
    • HISM also allows us to have different LODs based on distance!

So, our work here is done? Not exactly. Using InstancedMesh actually gives a lot of other problems we need to solve:

  • How do we want to show more than 1 static mesh type?
  • How can we get the reference of a specific mesh instance?
  • What about the state of every instance?
  • What if static mesh instances have other components we want to show?

Multiple Static Mesh types

Because we would like to have our own state for HISM, it needs to be wrapped by our custom class, let’s do that first:

UCLASS()
class AHISMManager : public AActor {
	GENERATED_BODY()

	UPROPERTY(BlueprintReadOnly, Category = AgentManager)
   	UHierarchicalInstancedStaticMeshComponent* MeshPool;
}

So the class above will (hopefully) be a representation of a specific Actor. Now we need some kind of world context:

UENUM(BlueprintType)
enum Einstanced_mesh_type {
    Actor_Type_1,
    Actor_Type_2,
    Actor_Type_3,
};

class AHISMWorldContext: public AActor {
    UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = RepActor)
    TMap<tenumasbyte, AInstancedAgentManager*> Actors;
};

Which should solve our first problem: we are able to have multiple mesh types in our solution. I used enums to explicitly point to a type we want to use.

Get the reference to a specific instance

Based on our HISM implementation, every newly created Mesh Instance gets the next free index. By adding a new instanced mesh should we be able to get a reference to it by using the given index?

UCLASS()
class AHISMManager: public AActor {
    ....
    TMap<uint32, uint32=""> IdToInstanceMapping;
    void SpawnIM(FTransform initialTransform);
    void TransformIM(Transform transform, uint32 actorId};
    void DestroyIM(uint32 actorId);
    ....
}

void AHISMManager::SpawnIM(FTransform initialTransform, uint32 actorId) {     
   int32 index = MeshPool->AddInstanceWorldSpace(initialTransform);
   IdToInstanceMapping.Add(actorId, index);
}

void AHISMManager::TransformIM(FTransform transform, uint32 actorId ) {
   uint32 index= IdToInstanceMapping[actorId];
   MeshPool->UpdateInstanceTransform(index, transform, true, true, false);
}



void AHISMManager::DestroyIM(uint32 actorId) {
   uint32 index = IdToInstanceMapping[actorId];
   MeshPool->RemoveInstance(index);
}

So, having a custom way to identify a given mesh instance, we’re done? Not really. As a reminder, we want to keep our simulation big, i.e. more than 10k agents. For the given example, let’s assume we will have a lot of objects that will change their position each tick. By default, HISM is quite expensive if we want to update a transform for every mesh instance per tick. However, the HISM API allows us to mark render state dirty on demand; that is, we can update transformations first, after which we can call render state to make a single update for all dirty mesh instances. The only thing you have to do is to call UpdateInstanceTransform, and set 4th param to false (to point out that we don’t want to update render state for now).

MeshPool->UpdateInstanceTransform(index, transform, true, false, false);

If we call TransformIM once for every movable object per tick, we can additionally add:

UCLASS()
class AHISMManager: public AActor {
    ....
    void FinishUpdates();
    ....
}
 
void AHISMManager::FinishUpdates() {
    MeshPool>MarkRenderStateDirty();
}

Unfortunately HISM doesn’t guarantee that index order will be the same after removing mesh instances from the pool. However, by using a small hack we can make a workaround for this problem. Let’s extend our DestroyIM method a little bit:

UCLASS()
class AHISMManager: public AActor {
    ....
    TArray IdBuffer;
    ....
}

void AHISMManager::DestroyIM(uint32 actorId) {
   uint32 index= IdToInstanceMapping[sac];
   uint32 indexToRemove = IdToInstanceMapping[actorId];
   uint32 actorLastIndex = IdBuffer.Num() - 1;
   uint32 actorToMove = IdBuffer[actorLastIndex];
   IdBuffer[indexToRemove ] = actorToMove;
   IdToInstanceMapping[actorToMove] = IdToInstanceMapping[actorId];
   IdToInstanceMapping.Remove(actorId);
   IdBuffer.RemoveAt(actorLastIndex);
}

void AHISMManager::SpawnIM(FTransform initialTransform, uint32 actorId) {    
    ....
    IdBuffer.Add(actorId);
    ....
}

What kind of trickery is this? IdBuffer is our custom way to keep mesh instances in order. Although it is quite straightforward what is happening in the SpawnIM method, a few additional words are required for the DestroyIM extension.

As removing a mesh instance in the middle of an array might cause all elements to be rearranged, we want to make sure we will remove always the last element. The operation that can be seen here is to actually keep mapping between last mesh instance in the array, by putting it in the place of actor that should be removed. After that, we are just removing the last array element! During this tick, positions of all mesh instances will be rearranged (as every single mesh instance will now represent a different actor), but from the point of view of the rendered scene, there will be no visual difference and the state of our mesh instances will not change.

Storing state of Mesh Instance

OK, so we have quite a fast representation of movable objects, but our goal is actually to keep some kind of gameplay logic bound to each mesh instance. How do we do that?

class FIMInstance {
    int32 Index;
    int32 Property1;
    FString Property2;

    void Method1;
    void Method2;
}

UCLASS()
class AHISMManager: public AActor {
    ....
    TMap<uint32, fiminstance=""> IdToInstanceMapping;
    FIMInstance& GetInstance(uint32 actorId);
    ....
}

void AHISMManager::SpawnIM(FTransform initialTransform, uint32 actorId, FIMInstance& ctx) {     
   ....
   IdToInstanceMapping.Add(actorId, ctx);
}

void AHISMManager::TransformIM(FTransform transform, uint32 actorId ) {
   uint32 index= IdToInstanceMapping[actorId].Index;
   ....
}



void AHISMManager::DestroyIM(uint32 actorId) {
   uint32 index = IdToInstanceMapping[actorId].Index;
   ....
}

FIMInstance& AHISMManager::GetInstance(uint32 actorId) {
   return IdToInstanceMapping[actorId];
}

By having our custom class per static mesh instance we can actually do whatever we want to. Everything is accessible by having a known type and the id of given object.

Static Mesh Instance and other Components

Is it possible to actually attach components to Mesh Instances? By default: no, but we can create our own functionality. As an example let’s make particle attachments to our models. If you want to, you can try to make a more generic solution on your own.

USTRUCT(BlueprintType)
struct FParticleDefinition {
    GENERATED_BODY()

    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = BoidInfo)
    UParticleSystem* ParticleDefinition;

    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = BoidInfo)
    FName SocketName;
};

USTRUCT(BlueprintType)
struct FParticleContext {
    GENERATED_BODY()

    bool bIsBoundToSocket = false;
    EParticle_type ParticleType;
    FTransform ParticleOffset;
    UParticleSystemComponent* ParticleComponent;
    uint32 AgentId;
};

First, let’s define some new structs. FParticleDefinition might be used directly from Unreal Editor to define spawn rules for UParticleSystemComponent. FParticleContext will be used to store the state of our particle per Mesh Instance.

Now, let’s define metadata for our AHISMManager, so SpawnBoid will know what particles should be attached to every Mesh Instance:

//Metadata used to spawn dynamic Particle Component that will be used by given mesh instance
USTRUCT(BlueprintType)
struct FParticleDefinition {
    GENERATED_BODY()


    //Particle system from within given particle component should be spawned
    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    UParticleSystem* ParticleDefinition;


    //If given particle should be spawned on a transform from given socket, provide socket name
    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    FName SocketName;
};


//Data bound to each particle component instance
USTRUCT(BlueprintType)
struct FParticleContext {
    GENERATED_BODY()


    //Should given particle calculate relative offset from center of mesh instance from mesh socket ?
    bool bIsBoundToSocket = false;

    //If bIsBoundToSocket = false, calculate relative offset from given transform
    FTransform ParticleOffset;

    //Particle component instance
    UParticleSystemComponent* ParticleComponent;

    //Owner of given particle
    uint32 AgentId;
};

//Metadata used to spawn dynamic Mesh Instance
STRUCT(BlueprintType, Blueprintable)
struct FMetadata
{
  //Mesh context used to spawn mesh instance
  UPROPERTY(BlueprintReadWrite, EditAnywhere)
  UStaticMesh* InstanceMeshType;


  //Metadata used to spawn dynamic Particle Component that will be used by given mesh instance
  UPROPERTY(BlueprintReadWrite, EditAnywhere)
  TArray ParticleDefinitions;
}

class FIMInstance {
    ....
    TArray Particles;
    ....
}
 
UCLASS()
class AHISMManager: public AActor {
    ....
    UPROPERTY(BlueprintReadOnly)
    FMetadata Metadata;
    ....
}

void AInstancedAgentManager::SpawnIM(FTransform initialTransform, uint32 actorId)
{
    ....
    //Attach particle components to mesh instance based on metadata rules
    for (FParticleDefinition& newDefinition: Metadata.ParticleDefinitions)
    {
        auto ParticleComponent = GameplayStatics::SpawnEmitterAtLocation(GetWorld()
         , newDefinition, initialTransform);

         FParticleContext newParticle;
         newParticle.ParticleComponent = ParticleComponent;
         newParticle.BoidId = actorId;

         if (MeshPool>GetStaticMesh())
         {
            //Use sockets in static mesh to define relative offset for attached components. Relative offset needs to be updated per tick
            FName socketName = newDefinition.SocketName;
            auto foundSocket = BoidPool->GetStaticMesh()->FindSocket(socketName);

            FTransform socketTransform;
            socketTransform.SetLocation(foundSocket->RelativeLocation);
            FRotator CachedRotator = foundSocket->RelativeRotation.GetNormalized();
            socketTransform.SetRotation(CachedRotator.Quaternion());
            socketTransform.SetScale3D(foundSocket->RelativeScale);
            newDefinition.ParticleOffset = socketTransform;
         }
    }
    auto& Instance = IdToInstanceMapping[actorId];
    Instance.Particles.Add(context);
    FTransform instanceTransform = Instance.GetCurrent();
    FTransform particleTransform = context.bIsBoundToSocket
      ? context.ParticleOffset * instanceTransform : instanceTransform;

    context.ParticleComponent->SetWorldTransform(particleTransform);
    context.ParticleComponent->SetAbsolute(true, true, true);
    context.ParticleComponent->bAutoDestroy = false;
    ....
}

void AHISMManager::TransformIM(FTransform initialTransform, uint32 actorId) {   
    ....
    for (FParticleContext& context : Contexts)
    {
        context.ParticleComponent->SetWorldLocationAndRotationNoPhysics(
        particleTransform.GetTranslation(),
        particleTransform.GetRotation().Rotator());
   }
   ....
} 

 void AInstancedAgentManager::DestroyIM(uint32 actorID) {
    ....
    for (auto& context : Instance.Particles)
    {
        if (IsValid(context.ParticleComponent))
        {
            context.ParticleComponent->Deactivate();
            context.ParticleComponent->DestroyComponent();
        } 
    }
    Instance.Particles.Reset(0);
    ....
}

SpawnIM has been extended to Spawn emitters at the location of sockets in the Static Mesh, from within Mesh Instance has been created.

The rest is quite straightforward. We need to extend TransformIM and DestroyIM to take the new components into consideration.

void AHISMManager::TransformIM(FTransform initialTransform, uint32 actorId) {   
    ....
    for (FParticleContext& _context : Contexts)
    {
        _context.ParticleComponent->SetWorldLocationAndRotationNoPhysics(
        _particleTransform.GetTranslation(),
        _particleTransform.GetRotation().Rotator());
   }
   ....
} 

 void AInstancedAgentManager::DestroyIM(uint32 actorID) {
    ....
    for (auto& _context : Instance.Particles)
    {
        if (IsValid(_context.ParticleComponent))
        {
            _context.ParticleComponent->Deactivate();
            _context.ParticleComponent->DestroyComponent();
        } 
    }
    Instance.Particles.Reset(0);
    ....
}

What next?

So far we have static mesh instances that can keep their own state and logic and can have their own Particle Components. This is actually a good start, however, there are a lot of other improvements that we can do. For example, the movement might be updated within a fixed framerate. With proper interpolation, a lot of mesh instances will not show any differences in their transform.

We can also decrease the sampling rate based on the distance from the player. However, I’ll leave these improvements for the next blog.

These techniques have been used to create our current demo.

You May Also Like

These Stories on simulation

Subscribe by Email

No Comments Yet

Let us know what you think