Unreal Engine tire mark, tread mark tutorial

In Tank Brawl 2, we want tanks to leave track marks, jeeps leave tire marks on the ground. We looked at using the particle system ( include the ribbon module) to do it but it was difficult to make the track mark look continues and good. So we developed our own system where we would spawn polygons in real time as the track move.

To use this all you need to do is copy & paste UTrailMarkComponent’s cpp and h file into your game’s c++ project and updating it’s m_trailAlpha to turn on and off the track mark when required ( e.g when vehicle is in air, we don’t want the track mark visible ). You can also tweak m_numQuad, m_quadLength, m_quadWidth, m_textureLength to match your need as explained below.

The UTrailMarkComponent is attached to the tank’s track. When the component moves, we will update its custom vertex buffer in a way that will lay out the track mark on the ground like this:

UTrailMarkComponent derived from UMeshComponent, which allowing us to create and modify our own procedural mesh and render them on screen. As Unreal Engine only allowing update the whole vertex buffer one at a time ( no partial update ), The UTrailMarkComponent works by first create a full vertex buffer and index buffer of m_numQuad number of quads. Look inside UTrailMarkComponent::Initialise(int32 NumQuad) to see how we setup the vertex and index buffer. Initially all those vertices have its alpha values = 0, so nothing is visible. At first when the track mark is first visible, we update the first quad alpha to be 1, and put its first 2 vertices at the current track’s location and when UTrailMarkComponent moved to a new position, we update the 2 vertices at the end of the current quad to match the new position ( Look inside UTrailMarkComponent::calculateVertices for detail ). If the current quad’s length becomes longer m_quadLength, we will stop updating this quad and leave it unchanged and move to the next quad and continue this process. When we run out of quads to use, we go back to the begining and reuse the first quad ( so the very earliest track mark will disappear once the whole track mark becomes too long ).You can look at UTrailMarkComponent::updateTrail function to see this process in detail.

UTrailMarkComponent::calculateFirstVertices. This function calculate the position and uv of the first 2 vertices of the quad that start the trail ( i.e the first quad of the track mark or the first quad after vehicle is in air and land on ground ). The uv of those 2 vertices is 0, so the track texture start from begining when a new continues track trail starts.

UTrailMarkComponent::calculateVertices: Calculate position and uv for current’s quad last 2 vertices, the uv of these 2 will depends on the current distance from the track mark start.

Below are the the .h and .cpp file for the UTrailMarkComponent that can just copy & paste into your game’s c++ project and start using it straight away. All you need to do is updating it’s m_trailAlpha to turn on and off the track mark when required ( e.g when vehicle is in air, we don’t want the track mark visible ). A majority of the code in UTrailMarkComponent is to deal with setting up the dynamic vertex buffer, that we copied from Unreal Engine’s CustomMeshComponent: These includes:

class FCustomMeshSceneProxy : public FPrimitiveSceneProxy.
class FCustomMeshVertexFactory : public FLocalVertexFactory
class FCustomMeshIndexBuffer : public FIndexBuffer
class FCustomMeshVertexBuffer : public FVertexBuffer

TrailMarkComponent.h



#pragma once

#include "CoreMinimal.h"
#include "UObject/ObjectMacros.h"
#include "Interfaces/Interface_CollisionDataProvider.h"
#include "Components/MeshComponent.h"
#include "PhysicsEngine/ConvexElem.h"
#include "TrailMarkComponent.generated.h"

class FPrimitiveSceneProxy;

/**
*	Struct used to specify a tangent vector for a vertex
*	The Y tangent is computed from the cross product of the vertex normal (Tangent Z) and the TangentX member.
*/
USTRUCT(BlueprintType)
struct FTrailMarkTangent
{
	GENERATED_USTRUCT_BODY()

	/** Direction of X tangent for this vertex */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Tangent)
	FVector TangentX;
	
	FTrailMarkTangent()
		: TangentX(1.f, 0.f, 0.f)
	{}

	FTrailMarkTangent(float X, float Y, float Z)
		: TangentX(X, Y, Z)
	{}

	FTrailMarkTangent(FVector InTangentX, bool bInFlipTangentY)
		: TangentX(InTangentX)
	{}
};

/** One vertex for the mesh, used for storing data internally */
USTRUCT(BlueprintType)
struct FTrailMarkVertex
{
	GENERATED_USTRUCT_BODY()

	/** Vertex position */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Vertex)
	FVector Position;

	/** Vertex normal */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Vertex)
	FVector Normal;

	/** Vertex tangent */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Vertex)
	FTrailMarkTangent Tangent;

	/** Vertex color */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Vertex)
	FColor Color;

	/** Vertex texture co-ordinate */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Vertex)
	FVector2D UV0;


	FTrailMarkVertex()
		: Position(0.f, 0.f, 0.f)
		, Normal(0.f, 0.f, 1.f)
		, Tangent(FVector(1.f, 0.f, 0.f), false)
		, Color(255, 255, 255)
		, UV0(0.f, 0.f)
	{}
};
/**
*	Component that allows you to specify custom triangle mesh geometry
*	Beware! This feature is experimental and may be substantially changed in future releases.
*/
UCLASS(hidecategories = (Object, LOD), meta = (BlueprintSpawnableComponent), ClassGroup = Rendering)
class ARM_API UTrailMarkComponent : public UMeshComponent, public IInterface_CollisionDataProvider
{
	GENERATED_UCLASS_BODY()
public:
	
		
	virtual void TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction) override;

	virtual FMatrix GetRenderMatrix() const override;

	
	//~ Begin UPrimitiveComponent Interface.
	virtual FPrimitiveSceneProxy* CreateSceneProxy() override;
	//~ End UPrimitiveComponent Interface.

	//~ Begin UMeshComponent Interface.
	virtual int32 GetNumMaterials() const override;
	//~ End UMeshComponent Interface.

	//~ Begin UObject Interface
	virtual void PostLoad() override;
	//~ End UObject Interface.


	UFUNCTION(BlueprintCallable, Category = "Arm", meta = (AutoCreateRefTerm = "Normals, UV0, VertexColors, Tangents"))
	void UpdateMesh(int32 startIndex, const TArray& Vertices, const TArray& Normals, const TArray& UV0, const TArray& VertexColors, const TArray& Tangents, bool pushToRenderThread);

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arm")
	float m_numQuad = 20;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arm")
	float m_quadLength = 150;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arm")
	float m_quadWidth = 150;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arm")
	float m_textureLength = 1500;

	float m_trailAlpha = 1.0f;
private:
	void Initialise(int NumQuad);
	void updateTrail(float DeltaTime, const FVector &pos, float alpha);

	//~ Begin USceneComponent Interface.
	virtual FBoxSphereBounds CalcBounds(const FTransform& LocalToWorld) const override;
	//~ Begin USceneComponent Interface.

	friend class FTrailMarkComponentSceneProxy;


	/** Vertex buffer for this section */
	UPROPERTY()
	TArray ProcVertexBuffer;

	/** Index buffer for this section */
	UPROPERTY()
	TArray ProcIndexBuffer;	

	/** Reset this section, clear all mesh info. */
	void Reset()
	{
		ProcVertexBuffer.Empty();
		ProcIndexBuffer.Empty();
	}

	void calculateFirstVertices(const FVector &pos, const FVector &dir, TArray& vertices, TArray& normals, TArray& uv0, TArray& vertexColors) const;
	void calculateVertices(const FVector &pos, const FVector &lastPos, float dist, float alpha, TArray& vertices, TArray& normals, TArray& uv0, TArray& vertexColors) const;
	
	// Trail update working
	enum State
	{
		None,
		First,
		Working
	};
	State m_state = None;
	float m_dist = 0;
	FVector m_lastPos;
	float m_alpha = 0;
	int m_currentVertIndex = 0;
};

TrailMarkComponent.cpp


#include "TrailMarkComponent.h"
#include "PrimitiveViewRelevance.h"
#include "RenderResource.h"
#include "RenderingThread.h"
#include "PrimitiveSceneProxy.h"
#include "Containers/ResourceArray.h"
#include "EngineGlobals.h"
#include "VertexFactory.h"
#include "MaterialShared.h"
#include "Materials/Material.h"
#include "LocalVertexFactory.h"
#include "Engine/Engine.h"
#include "SceneManagement.h"
#include "PhysicsEngine/BodySetup.h"
#include "DynamicMeshBuilder.h"
#include "PhysicsEngine/PhysicsSettings.h"

/*

DECLARE_CYCLE_STAT(TEXT("Create TrailMark Proxy"), STAT_TrailMark_CreateSceneProxy, STATGROUP_TrailMarkComponent);
DECLARE_CYCLE_STAT(TEXT("Create Mesh Section"), STAT_TrailMark_CreateMeshSection, STATGROUP_TrailMarkComponent);
DECLARE_CYCLE_STAT(TEXT("UpdateSection GT"), STAT_TrailMark_UpdateSectionGT, STATGROUP_TrailMarkComponent);
DECLARE_CYCLE_STAT(TEXT("UpdateSection RT"), STAT_TrailMark_UpdateSectionRT, STATGROUP_TrailMarkComponent);
DECLARE_CYCLE_STAT(TEXT("Get TrailMark Elements"), STAT_TrailMark_GetMeshElements, STATGROUP_TrailMarkComponent);
DECLARE_CYCLE_STAT(TEXT("Update Collision"), STAT_TrailMark_UpdateCollision, STATGROUP_TrailMarkComponent);
*/

/** Resource array to pass  */
class FTrailMarkVertexResourceArray : public FResourceArrayInterface
{
public:
	FTrailMarkVertexResourceArray(void* InData, uint32 InSize)
		: Data(InData)
		, Size(InSize)
	{
	}

	virtual const void* GetResourceData() const override { return Data; }
	virtual uint32 GetResourceDataSize() const override { return Size; }
	virtual void Discard() override { }
	virtual bool IsStatic() const override { return false; }
	virtual bool GetAllowCPUAccess() const override { return false; }
	virtual void SetAllowCPUAccess(bool bInNeedsCPUAccess) override { }

private:
	void* Data;
	uint32 Size;
};

/** Vertex Buffer */
class FTrailMarkVertexBuffer : public FVertexBuffer
{
public:
	TArray Vertices;

	virtual void InitRHI() override
	{
		const uint32 SizeInBytes = Vertices.Num() * sizeof(FDynamicMeshVertex);

		FTrailMarkVertexResourceArray ResourceArray(Vertices.GetData(), SizeInBytes);
		FRHIResourceCreateInfo CreateInfo(&ResourceArray);
		VertexBufferRHI = RHICreateVertexBuffer(SizeInBytes, BUF_Static, CreateInfo);
	}

};

/** Index Buffer */
class FTrailMarkIndexBuffer : public FIndexBuffer
{
public:
	TArray Indices;

	virtual void InitRHI() override
	{
		FRHIResourceCreateInfo CreateInfo;
		void* Buffer = nullptr;
		IndexBufferRHI = RHICreateAndLockIndexBuffer(sizeof(int32), Indices.Num() * sizeof(int32), BUF_Static, CreateInfo, Buffer);

		// Write the indices to the index buffer.		
		FMemory::Memcpy(Buffer, Indices.GetData(), Indices.Num() * sizeof(int32));
		RHIUnlockIndexBuffer(IndexBufferRHI);
	}
};

/** Vertex Factory */
class FTrailMarkVertexFactory : public FLocalVertexFactory
{
public:

	FTrailMarkVertexFactory()
	{}

	/** Init function that should only be called on render thread. */
	void Init_RenderThread(const FTrailMarkVertexBuffer* VertexBuffer)
	{
		check(IsInRenderingThread());

		// Initialize the vertex factory's stream components.
		FDataType NewData;
		NewData.PositionComponent = STRUCTMEMBER_VERTEXSTREAMCOMPONENT(VertexBuffer, FDynamicMeshVertex, Position, VET_Float3);
		NewData.TextureCoordinates.Add(
			FVertexStreamComponent(VertexBuffer, STRUCT_OFFSET(FDynamicMeshVertex, TextureCoordinate), sizeof(FDynamicMeshVertex), VET_Float2)
		);
		NewData.TangentBasisComponents[0] = STRUCTMEMBER_VERTEXSTREAMCOMPONENT(VertexBuffer, FDynamicMeshVertex, TangentX, VET_PackedNormal);
		NewData.TangentBasisComponents[1] = STRUCTMEMBER_VERTEXSTREAMCOMPONENT(VertexBuffer, FDynamicMeshVertex, TangentZ, VET_PackedNormal);
		NewData.ColorComponent = STRUCTMEMBER_VERTEXSTREAMCOMPONENT(VertexBuffer, FDynamicMeshVertex, Color, VET_Color);
		SetData(NewData);
	}

	/** Init function that can be called on any thread, and will do the right thing (enqueue command if called on main thread) */
	void Init(const FTrailMarkVertexBuffer* VertexBuffer)
	{
		if (IsInRenderingThread())
		{
			Init_RenderThread(VertexBuffer);
		}
		else
		{
			ENQUEUE_UNIQUE_RENDER_COMMAND_TWOPARAMETER(
				InitTrailMarkVertexFactory,
				FTrailMarkVertexFactory*, VertexFactory, this,
				const FTrailMarkVertexBuffer*, VertexBuffer, VertexBuffer,
				{
					VertexFactory->Init_RenderThread(VertexBuffer);
				});
		}
	}
};

/**
*	Struct used to send update to mesh data
*	Arrays may be empty, in which case no update is performed.
*/
class FTrailMarkSectionUpdateData
{
public:
	int32 m_startIndex;

	/** New vertex information */
	TArray NewVertexBuffer;
};

static void ConvertTrailMarkToDynMeshVertex(FDynamicMeshVertex& Vert, const FTrailMarkVertex& ProcVert)
{
	Vert.Position = ProcVert.Position;
	Vert.Color = ProcVert.Color;
	Vert.TextureCoordinate = ProcVert.UV0;
	Vert.TangentX = ProcVert.Tangent.TangentX;
	Vert.TangentZ = ProcVert.Normal;
	Vert.TangentZ.Vector.W = 0;
}

/** mesh scene proxy */
class FTrailMarkComponentSceneProxy : public FPrimitiveSceneProxy
{
public:


	FTrailMarkComponentSceneProxy(UTrailMarkComponent* Component)
		: FPrimitiveSceneProxy(Component)
		, MaterialRelevance(Component->GetMaterialRelevance(GetScene().GetFeatureLevel()))
	{
		if (Component->ProcIndexBuffer.Num() > 0 && Component->ProcVertexBuffer.Num() > 0)
		{			
			// Copy data from vertex buffer
			const int32 NumVerts = Component->ProcVertexBuffer.Num();

			// Allocate verts
			VertexBuffer.Vertices.SetNumUninitialized(NumVerts);

			// Copy verts
			for (int VertIdx = 0; VertIdx < NumVerts; VertIdx++) { const FTrailMarkVertex& ProcVert = Component->ProcVertexBuffer[VertIdx];
				FDynamicMeshVertex& Vert = VertexBuffer.Vertices[VertIdx];
				ConvertTrailMarkToDynMeshVertex(Vert, ProcVert);
			}

			// Copy index buffer
			IndexBuffer.Indices = Component->ProcIndexBuffer;

			// Init vertex factory
			VertexFactory.Init(&VertexBuffer);

			// Enqueue initialization of render resource
			BeginInitResource(&VertexBuffer);
			BeginInitResource(&IndexBuffer);
			BeginInitResource(&VertexFactory);

			// Grab material
			Material = Component->GetMaterial(0);
			if (Material == NULL)
			{
				Material = UMaterial::GetDefaultMaterial(MD_Surface);
			}
		}
	}

	virtual ~FTrailMarkComponentSceneProxy()
	{
		VertexBuffer.ReleaseResource();
		IndexBuffer.ReleaseResource();
		VertexFactory.ReleaseResource();
	}

	/** Called on render thread to assign new dynamic data */
	void UpdateSection_RenderThread(FTrailMarkSectionUpdateData* SectionData)
	{
		//SCOPE_CYCLE_COUNTER(STAT_TrailMark_UpdateSectionRT);

		check(IsInRenderingThread());

		// Check we have data 
		if (SectionData != nullptr && VertexBuffer.Vertices.Num() > 0)
		{			
			
			// Lock vertex buffer
			const int32 NumVerts = SectionData->NewVertexBuffer.Num();
			FDynamicMeshVertex* VertexBufferData = (FDynamicMeshVertex*)RHILockVertexBuffer(VertexBuffer.VertexBufferRHI, 0, NumVerts * sizeof(FDynamicMeshVertex), RLM_WriteOnly);
			//FDynamicMeshVertex* VertexBufferData = (FDynamicMeshVertex*)RHILockVertexBuffer(VertexBuffer.VertexBufferRHI, SectionData->m_startIndex, NumVerts * sizeof(FDynamicMeshVertex), RLM_WriteOnly);

			// Iterate through vertex data, copying in new info
			for (int32 VertIdx = 0; VertIdx<NumVerts; VertIdx++) { const FTrailMarkVertex& ProcVert = SectionData->NewVertexBuffer[VertIdx];
				FDynamicMeshVertex& Vert = VertexBufferData[VertIdx];
				ConvertTrailMarkToDynMeshVertex(Vert, ProcVert);
			}

			// Unlock vertex buffer
			RHIUnlockVertexBuffer(VertexBuffer.VertexBufferRHI);
			
			// Free data sent from game thread
			delete SectionData;
		}
	}
	
	virtual void GetDynamicMeshElements(const TArray& Views, const FSceneViewFamily& ViewFamily, uint32 VisibilityMap, FMeshElementCollector& Collector) const override
	{
		//SCOPE_CYCLE_COUNTER(STAT_TrailMark_GetMeshElements);
		
		if (IndexBuffer.Indices.Num() <= 0) return; // Set up wireframe material (if needed) const bool bWireframe = AllowDebugViewmodes() && ViewFamily.EngineShowFlags.Wireframe; FColoredMaterialRenderProxy* WireframeMaterialInstance = NULL; if (bWireframe) { WireframeMaterialInstance = new FColoredMaterialRenderProxy( GEngine->WireframeMaterial ? GEngine->WireframeMaterial->GetRenderProxy(IsSelected()) : NULL,
				FLinearColor(0, 0.5f, 1.f)
			);

			Collector.RegisterOneFrameMaterialProxy(WireframeMaterialInstance);
		}

		// Iterate over sections
		
		FMaterialRenderProxy* MaterialProxy = bWireframe ? WireframeMaterialInstance : Material->GetRenderProxy(IsSelected());

		// For each view..
		for (int32 ViewIndex = 0; ViewIndex < Views.Num(); ViewIndex++)
		{
			if (VisibilityMap & (1 << ViewIndex))
			{
				const FSceneView* View = Views[ViewIndex];
				// Draw the mesh.
				FMeshBatch& Mesh = Collector.AllocateMesh();				
				
				FMeshBatchElement& BatchElement = Mesh.Elements[0];
				BatchElement.PrimitiveUniformBuffer = CreatePrimitiveUniformBufferImmediate(GetLocalToWorld(), GetBounds(), GetLocalBounds(), true, UseEditorDepthTest());
				BatchElement.IndexBuffer = &IndexBuffer;
				BatchElement.FirstIndex = 0;
				BatchElement.NumPrimitives = IndexBuffer.Indices.Num() / 3;
				BatchElement.MinVertexIndex = 0;
				BatchElement.MaxVertexIndex = VertexBuffer.Vertices.Num() - 1;
				
				Mesh.bWireframe = bWireframe;
				Mesh.VertexFactory = &VertexFactory;
				Mesh.MaterialRenderProxy = MaterialProxy;
				
				Mesh.ReverseCulling = IsLocalToWorldDeterminantNegative();
				Mesh.Type = PT_TriangleList;
				Mesh.DepthPriorityGroup = SDPG_World;
				Mesh.bCanApplyViewModeOverrides = false;
				Collector.AddMesh(ViewIndex, Mesh);
			}
		}
	}

	virtual FPrimitiveViewRelevance GetViewRelevance(const FSceneView* View) const
	{
		FPrimitiveViewRelevance Result;
		Result.bDrawRelevance = IsShown(View);
		Result.bShadowRelevance = IsShadowCast(View);
		Result.bDynamicRelevance = true;
		Result.bRenderInMainPass = ShouldRenderInMainPass();
		Result.bUsesLightingChannels = GetLightingChannelMask() != GetDefaultLightingChannelMask();
		Result.bRenderCustomDepth = ShouldRenderCustomDepth();
		MaterialRelevance.SetPrimitiveViewRelevance(Result);
		return Result;
	}

	virtual bool CanBeOccluded() const override
	{
		return !MaterialRelevance.bDisableDepthTest;
	}

	virtual uint32 GetMemoryFootprint(void) const
	{
		return(sizeof(*this) + GetAllocatedSize());
	}

	uint32 GetAllocatedSize(void) const
	{
		return(FPrimitiveSceneProxy::GetAllocatedSize());
	}

private:

	UBodySetup* BodySetup;

	FMaterialRelevance MaterialRelevance;

	/** Material applied to this section */
	UMaterialInterface* Material;
	/** Vertex buffer for this section */
	FTrailMarkVertexBuffer VertexBuffer;
	/** Index buffer for this section */
	FTrailMarkIndexBuffer IndexBuffer;
	/** Vertex factory for this section */
	FTrailMarkVertexFactory VertexFactory;

};

//////////////////////////////////////////////////////////////////////////


UTrailMarkComponent::UTrailMarkComponent(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer)
{
	PrimaryComponentTick.TickGroup = TG_PrePhysics;
	PrimaryComponentTick.bCanEverTick = true;
	PrimaryComponentTick.bStartWithTickEnabled = true;
	SetComponentTickEnabled(true);
}

void UTrailMarkComponent::PostLoad()
{
	Super::PostLoad();

	Initialise(m_numQuad);
}


void UTrailMarkComponent::Initialise(int32 NumQuad)
{
	//SCOPE_CYCLE_COUNTER(STAT_TrailMark_CreateMeshSection);

	Reset();

	// Copy data to vertex buffer
	ProcVertexBuffer.Reset();
	ProcVertexBuffer.AddUninitialized(NumQuad * 4);
	for (int32 VertIdx = 0; VertIdx < ProcVertexBuffer.Num(); VertIdx++)
	{
		FTrailMarkVertex& Vertex = ProcVertexBuffer[VertIdx];

		Vertex.Position = FVector::ZeroVector;
		Vertex.Normal = FVector::UpVector;
		Vertex.UV0 = FVector2D(0.f, 0.f);
		Vertex.Color = FColor(255, 255, 255);
		Vertex.Tangent = FTrailMarkTangent();
	}

	// Copy index buffer (clamping to vertex range)
	ProcIndexBuffer.Reset();
	ProcIndexBuffer.AddUninitialized(NumQuad * 6);
	for (int32 quad = 0; quad < NumQuad; ++quad) { ProcIndexBuffer[quad * 6 + 0] = quad * 4 + 0; ProcIndexBuffer[quad * 6 + 1] = quad * 4 + 1; ProcIndexBuffer[quad * 6 + 2] = quad * 4 + 2; ProcIndexBuffer[quad * 6 + 3] = quad * 4 + 2; ProcIndexBuffer[quad * 6 + 4] = quad * 4 + 1; ProcIndexBuffer[quad * 6 + 5] = quad * 4 + 3; } MarkRenderStateDirty(); // New section requires recreating scene proxy } void UTrailMarkComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); updateTrail(DeltaTime, GetComponentLocation(), m_trailAlpha); } FMatrix UTrailMarkComponent::GetRenderMatrix() const { return FMatrix::Identity; } void UTrailMarkComponent::updateTrail(float DeltaTime, const FVector &pos, float alpha) { static float blendSpeed = 10; m_alpha = FMath::FInterpConstantTo(m_alpha, alpha, DeltaTime, blendSpeed); switch (m_state) { case None: if (m_alpha >= 0.001f)
		{
			m_state = First;
			m_lastPos = pos;
		}
		break;

	case First:
		if (m_alpha < 0.001f) { m_state = None; } else { FVector dir = pos - m_lastPos; if (dir.SizeSquared() > 0.001f)
			{
				if (dir.SizeSquared() < 5000 * 5000)
				{
					TArray vertices;
					TArray normals;
					TArray uv0;
					TArray vertexColors;
					vertices.SetNumUninitialized(2);
					normals.SetNumUninitialized(2);
					uv0.SetNumUninitialized(2);
					vertexColors.SetNumUninitialized(2);

					dir.Normalize();
					calculateFirstVertices(m_lastPos, dir, vertices, normals, uv0, vertexColors);
					UpdateMesh(m_currentVertIndex, vertices, normals, uv0, vertexColors, TArray(), false);
					m_state = Working;
					m_dist = 0;
				}
				m_lastPos = pos;
			}

		}
		break;

	case Working:
		if (m_alpha < 0.001f) { m_state = None; } else { float distToLastPos = FVector::Dist(pos, m_lastPos); if (distToLastPos > 0.001f)
			{
				TArray vertices;
				TArray normals;
				TArray uv0;
				TArray vertexColors;
				vertices.SetNumUninitialized(2);
				normals.SetNumUninitialized(2);
				uv0.SetNumUninitialized(2);
				vertexColors.SetNumUninitialized(2);

				calculateVertices(pos, m_lastPos, m_dist + distToLastPos, m_alpha, vertices, normals, uv0, vertexColors);
				UpdateMesh(m_currentVertIndex + 2, vertices, normals, uv0, vertexColors, TArray(), true);
				
				// Add new quad or not
				{
					static float minDist = 0.1;
					static float maxDist = 1;
					static float maxTime = 1;

					if (distToLastPos >= m_quadLength)
					{
						m_currentVertIndex += 4;
						m_currentVertIndex = m_currentVertIndex % ProcVertexBuffer.Num();
						UpdateMesh(m_currentVertIndex, vertices, normals, uv0, vertexColors, TArray(), false);
						m_dist += distToLastPos;
						m_lastPos = pos;
					}
				}
			}
		}
		break;
	}

}

void UTrailMarkComponent::UpdateMesh(int32 startIndex, const TArray& Vertices, const TArray& Normals, const TArray& UV0, const TArray& VertexColors, const TArray& Tangents, bool pushToRenderThread)
{
	//SCOPE_CYCLE_COUNTER(STAT_TrailMark_UpdateSectionGT);
		
	const int32 NumVerts = Vertices.Num();

	// Iterate through vertex data, copying in new info
	for (int32 VertIdx = 0; VertIdx < NumVerts; VertIdx++)
	{
		FTrailMarkVertex& ModifyVert = ProcVertexBuffer[startIndex + VertIdx];
				
		ModifyVert.Position = Vertices[VertIdx];
		ModifyVert.Normal = Normals[VertIdx];
		if (VertIdx < Tangents.Num())
			ModifyVert.Tangent = Tangents[VertIdx];
		if (VertIdx < UV0.Num())
			ModifyVert.UV0 = UV0[VertIdx];
		if (VertIdx < VertexColors.Num()) ModifyVert.Color = VertexColors[VertIdx]; } if (SceneProxy && pushToRenderThread) { // Create data to update section FTrailMarkSectionUpdateData* SectionData = new FTrailMarkSectionUpdateData; SectionData->m_startIndex = startIndex;
		
		SectionData->NewVertexBuffer = ProcVertexBuffer;

		/*
		// Copy data to vertex buffer
		SectionData->NewVertexBuffer.Reset();
		SectionData->NewVertexBuffer.AddUninitialized(NumVerts);
		for (int32 VertIdx = 0; VertIdx < NumVerts; VertIdx++) { FTrailMarkVertex& ModifyVert = SectionData->NewVertexBuffer[VertIdx];

			ModifyVert.Position = Vertices[VertIdx];
			ModifyVert.Normal = Normals[VertIdx];
			if (VertIdx < Tangents.Num())
				ModifyVert.Tangent = Tangents[VertIdx];
			if (VertIdx < UV0.Num())
				ModifyVert.UV0 = UV0[VertIdx];
			if (VertIdx < VertexColors.Num()) ModifyVert.Color = VertexColors[VertIdx]; } */ // Enqueue command to send to render thread ENQUEUE_UNIQUE_RENDER_COMMAND_TWOPARAMETER( FTrailMarkSectionUpdate, FTrailMarkComponentSceneProxy*, TrailMarkSceneProxy, (FTrailMarkComponentSceneProxy*)SceneProxy, FTrailMarkSectionUpdateData*, SectionData, SectionData, { TrailMarkSceneProxy->UpdateSection_RenderThread(SectionData);
			}
		);
	}
}

FPrimitiveSceneProxy* UTrailMarkComponent::CreateSceneProxy()
{
	//SCOPE_CYCLE_COUNTER(STAT_TrailMark_CreateSceneProxy);

	return new FTrailMarkComponentSceneProxy(this);
}

int32 UTrailMarkComponent::GetNumMaterials() const
{
	return 1;
}


FBoxSphereBounds UTrailMarkComponent::CalcBounds(const FTransform& LocalToWorld) const
{
	FBoxSphereBounds NewBounds;
	NewBounds.Origin = FVector::ZeroVector;
	NewBounds.BoxExtent = FVector(HALF_WORLD_MAX, HALF_WORLD_MAX, HALF_WORLD_MAX);
	NewBounds.SphereRadius = FMath::Sqrt(3.0f * FMath::Square(HALF_WORLD_MAX));
	return NewBounds;
}

void UTrailMarkComponent::calculateFirstVertices(const FVector &pos, const FVector &dir, TArray& vertices, TArray& normals, TArray& uv0, TArray& vertexColors) const
{
	FVector right = FVector::CrossProduct(dir, FVector::UpVector);

	// Position
	vertices[0] = pos + right * m_quadWidth * 0.5f;
	vertices[1] = pos - right * m_quadWidth * 0.5f;


	uv0[0].X = 0;
	uv0[1].X = 0;

	uv0[0].Y = 0;
	uv0[1].Y = 1;
	normals[0] = FVector::UpVector;
	normals[1] = FVector::UpVector;

	vertexColors[0] = FColor(255, 255, 255, 0);
	vertexColors[1] = FColor(255, 255, 255, 0);

}
void UTrailMarkComponent::calculateVertices(const FVector &pos, const FVector &lastPos, float dist, float alpha, TArray& vertices, TArray& normals, TArray& uv0, TArray& vertexColors) const
{
	FVector dir = pos - lastPos;
	dir.Normalize();
	FVector right = FVector::CrossProduct(dir, FVector::UpVector);

	// Position
	vertices[0] = pos + right * m_quadWidth * 0.5f;
	vertices[1] = pos - right * m_quadWidth * 0.5f;

	// UVs, world time, intensity
	float u = dist / m_textureLength;
	uv0[0].X= u;
	uv0[1].X = u;

	uv0[0].Y = 0;
	uv0[1].Y = 1;
	normals[0] = FVector::UpVector;
	normals[1] = FVector::UpVector;

	vertexColors[0] = FColor(255, 255, 255, alpha *255.0f);
	vertexColors[1] = FColor(255, 255, 255, alpha *255.0f);
}

Tank animation setup – Move, brake, turn, shoot

In Tank Brawl 2, player uses right stick to control the turret and left stick to control the tank movement. The result is that the turret rotation is controlled by code whereas all other component movements are control by animation assets such as shooting, braking, turning, accelerating

Turret join controlled by player right stick input

Notice how the tank recoils correctly regardless of which direction the turret is facing. We achieve this by setting up the whole tank as one skeletal mesh and its turret base have three joins: PreCode -> Code -> PostCode.

Tank’s skeletal join tree

The Code joins rotation is completely controlled by code ( i.e player’s input right stick ), all other joins are controlled normally by animation assets as shown in this root anim-graph

Tank root animation graph

How does this help ? Well, imagine if player turns the turret 90 degree right. Then we look at the following scenario:

– Tank Brake, Turret nudge forward relative to tank hull. Artist will need to animate this nudge movement on the PreCode Turret joins as we want the turret always nudge to the tank’s front regardless of its angle.

– Tank Shoot: The whole turret recoils back ward relative to where it’s facing. In this case the artist will put this recoil movement in the “PostCode” turret joins.

The State Machine

Inside the state machine is where we trigger all the animations.

Tank’s animation state machine

The transition to Firing, Turning Left..etc state are triggered whenever the tank turn, shoot or brake and when the relevant animation finishes we return to the “Check” conduit to transition again to the appropriate state.

Fire Animation

Inside the Firing state, we play this animation graph:

Tank firing state

To make the tank hull recoils correctly when cannon fire with turret angled at any direction, artist create four recoil animation for the hull: Recoil Forward, Recoil Back, Recoil Left and Recoil Right. When the tank fire we calculate the angle of the turret facing compare to tank’s hull forward direction and then blend between 2 of the 4 animations above. For example if the turret is facing 45 degree North-East we blend 50% of Front Recoil and 50% of Right Recoil and 0% all the other 2.

Destruction variations system

Our tank destruction system.

The first system we polish in the game is our tank destruction animation. We currently have 4 types of destruction described below.

When tank hit by machine guns, individual parts fall off and physically simulated. The rest of the tank with missing parts still moving and animated correctly.

Tank spitted in half. The pieces are pushed along the ground, blowing up dirt and soil and leaving big scars trail on the ground.

A brutal hard hit make tank explode instantly. Small parts violently flying out fast while big parts goes relatively slow help player recognize the tank shape when it explodes.

Tank hit and pushed back then its own ammo compartment explodes. This create a delayed explosion that let player have time to notice it and this amplify the satisfaction when you destroy a tank.

Unreal Engine destructible skeletal mesh

Tanks in Tank Brawl 2 use skeletal meshes with shooting, moving, braking ..etc animations to make them good. When shooting at those tanks, part of the skeletal mesh fall off and the rest still animate correctly.

Unreal Engine doesn’t support this natively so after experimenting with many different techniques we finally settle with using a dynamic texture system which is fast and relatively simple to implement. In this technique, we make the  skeletal mesh use a material that have opacity channel controlled by a 10×10  grey scale dynamic texture, so we can turn off part of it when a piece is chucked off, we also need to create a separate actor with a static mesh component of that part at same location to replace turned off part of the skeletal mesh but since it is a separate actor, it can be physically simulated ( fall and bounce on ground ). Below is the part the skeletal mesh’s material showing its Opacity setup:

Skeletal mesh material with Opacity controlled by a dynamic texture looked up by UV 1 ( instead of 0 )

You can see that the texture is a Param2D type which when the game run, we will replace the default greyscale white texture tenTenWhite.png  it with a dynamic 10×10 grey scale texture that we will create in code. This dynamic texture is looked up by a separate UV channel  ( Shown here as TexCoord(1) instead of TexCoord(0)) of the skeletal mesh. When creating the skeletal mesh asset in 3D modelling model, you need to setup this  UV1 channel so that each individual part of the mesh is mapped into a single cell inside the 10×10 grid like below:

 

UV mapping of the skeletal mesh into the dynamic texture.

Here you can see that the whole skeletal mesh have 36 parts, each have uv setup so each  is whole inside a cell within a 10×10 grid, so they can be individually turn off/on by changing the alpha value of the corresponding pixel inside the 10×10 dynamic texture.

Initially the dynamic texture is all white which means the skeletal mesh will be rendered with all the part fully visible. When we want to make a part fall off, we do 2 things:

– Modify the dynamic texture so that the pixel at the part location (inside the UV map above ) change from 255 to 0. This will make the part disappeared from the skeletal mesh.

– Create a separate physic simulated actor with a static mesh represent the fall off part at the exact location that match its previous location in the skeletal mesh. This make the part appear to fall off and bouncing on the ground. So in addition to the 3D model for the whole tank ( skeletal mesh ) you also need one model (static mesh) for each part that can be fall off.

Below are c++ code for turning off parts of the skeletal mesh

Code TankActor.h


class ATankActor
{
	static const int m_textureSize = 10;

	UPROPERTY()
	UTexture2D* m_dynamicPartMaskTexture;

	UPROPERTY()
	UMaterialInstanceDynamic *m_dynamicMaterialInstance;

	FUpdateTextureRegion2D m_wholeTextureReagion;
	uint8 m_pixelArray[m_textureSize * m_textureSize] = { 255 };
}

Code TankActor.cpp

In our system, each part is identified by an index, and our array  m_cluster->m_parts[index].m_indexIntoPixelArray  tell us which index UV location ( the cell ) the part locate inside the 10×10 dynamic texture.  The m_indexIndoPixelArray is just computed by U*10 + V . Which you need to setup according to your need.



void ATank::turnOffPart(int index, class USkeletalMeshComponent* skelMesh)
{	
	// Create the dynamic texture if it wasn't created
	if (!m_dynamicPartMaskTexture)
	{
		first = true;
		m_dynamicPartMaskTexture = UTexture2D::CreateTransient(m_textureSize, m_textureSize, PF_G8);
		m_dynamicPartMaskTexture->CompressionSettings = TextureCompressionSettings::TC_Grayscale;
		m_dynamicPartMaskTexture->SRGB = 0;
		m_dynamicPartMaskTexture->UpdateResource();
		m_dynamicPartMaskTexture->Filter = TF_Nearest;

		for (int i = 0; i < m_textureSize * m_textureSize; ++i) m_pixelArray[i] = 255; } // Create the dynamic material instance if it wasn't created and // aslo set the material to use the dynamic texture created above if (!m_dynamicMaterialInstance) { m_dynamicMaterialInstance = skelMesh->CreateAndSetMaterialInstanceDynamic(1);
		m_dynamicMaterialInstance->SetTextureParameterValue("componentMaskTxt", m_dynamicPartMaskTexture);

	}

	// Mark the pixel to be invisible
	m_pixelArray[m_cluster->m_parts[index].m_indexIntoPixelArray] = 0;

	// Copy data over to the dynamic texture
	UpdateTextureRegions(m_dynamicPartMaskTexture, 0, 1, &m_wholeTextureReagion, m_textureSize, 1, m_pixelArray, false);
}

namespace
{
	void UpdateTextureRegions(UTexture2D* Texture, int32 MipIndex, uint32 NumRegions, FUpdateTextureRegion2D* Regions, uint32 SrcPitch, uint32 SrcBpp, uint8* SrcData, bool bFreeData)
	{
		if (Texture->Resource)
		{
			struct FUpdateTextureRegionsData
			{
				FTexture2DResource* Texture2DResource;
				int32 MipIndex;
				uint32 NumRegions;
				FUpdateTextureRegion2D* Regions;
				uint32 SrcPitch;
				uint32 SrcBpp;
				uint8* SrcData;
			};

			FUpdateTextureRegionsData* RegionData = new FUpdateTextureRegionsData;

			RegionData->Texture2DResource = (FTexture2DResource*)Texture->Resource;
			RegionData->MipIndex = MipIndex;
			RegionData->NumRegions = NumRegions;
			RegionData->Regions = Regions;
			RegionData->SrcPitch = SrcPitch;
			RegionData->SrcBpp = SrcBpp;
			RegionData->SrcData = SrcData;

			ENQUEUE_UNIQUE_RENDER_COMMAND_TWOPARAMETER(
				UpdateTextureRegionsData,
				FUpdateTextureRegionsData*, RegionData, RegionData,
				bool, bFreeData, bFreeData,
				{
					for (uint32 RegionIndex = 0; RegionIndex < RegionData->NumRegions; ++RegionIndex)
					{
						int32 CurrentFirstMip = RegionData->Texture2DResource->GetCurrentFirstMip();
						if (RegionData->MipIndex >= CurrentFirstMip)
						{
							RHIUpdateTexture2D(
								RegionData->Texture2DResource->GetTexture2DRHI(),
								RegionData->MipIndex - CurrentFirstMip,
								RegionData->Regions[RegionIndex],
								RegionData->SrcPitch,
								RegionData->SrcData
								+ RegionData->Regions[RegionIndex].SrcY * RegionData->SrcPitch
								+ RegionData->Regions[RegionIndex].SrcX * RegionData->SrcBpp
							);
						}
					}
			if (bFreeData)
			{
				FMemory::Free(RegionData->Regions);
				FMemory::Free(RegionData->SrcData);
			}
			delete RegionData;
				});
		}
	}
}