An optimal way of placing a large amount of animated objects in the background.
Video shows 10,000 vehicles running in 60 fps on WebGL.
batched-mesh-animation_02.mp4
Demo (Available on Web, Mobile and MetaQuest - Powered by Spatial Creator Toolkit)
https://www.spatial.io/s/Batched-Mesh-Animation-661c58eb49e39492db5a86f2
One batched mesh contains 100 vehicles, and each vehicle has 50 vertices, totaling 5,000 vertices in each batched mesh. To draw 10,000 vehicles, there are 100 draw calls and 500,000 vertices in total.
Mesh has been simplified for efficiency, given its small size in the background.

Each part is identified using UV coordinates.
- Headlight (uv.x < 0.5 && uv.y > 0.5)
- Backlight (uv.x > 0.5 && uv.y > 0.5)
- Glass (uv.x < 0.5 && uv.y < 0.5)
- Body (uv.x > 0.5 && uv.y < 0.5)
- Copy mesh data into NativeArrays
BatchedMeshGenerator.cs#L52-L56
// Copy vehicle mesh to NativeArray
NativeArray<float3> vehicleVertices = NativeArrayUtilities.GetNativeArrays(_vehicleMesh.vertices, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
NativeArray<float3> vehicleNormals = NativeArrayUtilities.GetNativeArrays(_vehicleMesh.normals, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
NativeArray<float2> vehicleUVs = NativeArrayUtilities.GetNativeArrays(_vehicleMesh.uv, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
NativeArray<int> vehicleIndices = NativeArrayUtilities.GetNativeArrays(_vehicleMesh.GetIndices(0), Allocator.TempJob, NativeArrayOptions.UninitializedMemory);- Create another NativeArrays that will contain 100 vehicle mesh
BatchedMeshGenerator.cs#L58-L62
// Mesh data
int vertexCount = _vehicleCount * vehicleVertices.Length;
int indexCount = _vehicleCount * vehicleIndices.Length;
var vertexBuffer = new NativeArray<Vertex>(vertexCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
var indexBuffer = new NativeArray<int>(indexCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);- Run jobs that copy vehicle mesh data and add more vertex attributes
BatchedMeshGenerator.cs#L178-L204
It generates a random color for each instance and puts it in the vertex color. It also creates three random values that will be used for random positioning and puts them into uv2.xy and uv3.x. Lastly, it adds the instance ID into uv3.y.
public void Execute(int particleIndex)
{
Unity.Mathematics.Random random = new Unity.Mathematics.Random((uint)particleIndex * 3498 + 1);
float3 hsv = new float3(
_colorReference.x + random.NextFloat(-_colorParameters.x, _colorParameters.x) * 360f,
_colorReference.y + random.NextFloat(-_colorParameters.y, _colorParameters.y),
_colorReference.z + random.NextFloat(-_colorParameters.z, _colorParameters.z));
float3 color = Utilities.Unity_ColorspaceConversion_HSV_Linear(hsv);
float2 uv2 = new float2(random.NextFloat(), random.NextFloat()); // random(x,y)
float2 uv3 = new float2(random.NextFloat(), particleIndex); // random(z), id
int currentVertexIndex = _vertexCountPerParticle * particleIndex;
for (int i = 0; i < _vertexCountPerParticle; i++)
{
Vertex vertexBuffer = new Vertex();
vertexBuffer.position = _sourceVertices[i];
vertexBuffer.normal = _sourceNormals[i];
vertexBuffer.color = color;
vertexBuffer.texCoord0 = _sourceUVs[i];
vertexBuffer.texCoord1 = uv2;
vertexBuffer.texCoord2 = uv3;
int index = currentVertexIndex + i;
_vertexBuffer[index] = vertexBuffer;
}
}- Generated batched mesh
Generated mesh has 5,000 vertices and each vertex contains position, normal, color, uv0, uv1 and uv2. (293 KB)
Utilize object position to ensure unique random values for each object.
uv1.x, uv1.y and uv2.x has different random values range between 0 and 1 and apply position random by applying random range.
float3 random = float3(uv1.x, uv1.y, uv2.x) + dot(objectPosition, float3(4567.89, 1234.34, 6832.72));
random = frac(random);
output = positionOS + random * randomRange;Animate the z position using _Time and frac to loop between -halfRange and halfRange.
float vehicleInterval = 1.0 / vehicleCount;
float z = uv2.y * vehicleInterval; // 0~1
// range animSpeed.x ~ animSpeed.y
float randomSpeed = frac(dot(random, float3(123.45, 234.56, 345.67)));
float vehicleSpeed = randomSpeed * (animSpeed.y - animSpeed.x) + animSpeed.x;;
vehicleSpeed = vehicleSpeed / 3.6; // km/h to m/s
z = frac(z + _Time.y / animateRange * vehicleSpeed); // animated, 0~1
z = -animateRange * 0.5 + z * animateRange; // -animateRange/2 ~ animateRange/2
output.z += z;
The UV coordinates uv.x and uv.y are used to mask each part of mesh, allowing for the application of different surface data such as color, brightness, metallic and smoothness.
BGVehiclesShader.shader#L129-L141
half isBodyOrBackLight = step(0.5, IN.uv0.x);
half isLight = step(0.5, IN.uv0.y);
half3 diffuse = dot(normalWS, half3(0, 1, 0)) * 0.5 + 0.5; // half lambert from top
half3 ambient = SampleSH(OUT.normalWS) * _AmbientIntensity;
half3 emission = lerp(_ColorFrontLight, _ColorBackLight, isBodyOrBackLight) * isLight * _Brightness;
half3 color = lerp(_ColorGlass.rgb, IN.color * _BaseColor.rgb, isBodyOrBackLight);
color *= diffuse;
color += ambient + emission;
OUT.surfaceColor = half4(color, 1);
half metallic = lerp(_MetallicGlass, _Metallic, saturate(isBodyOrBackLight + isLight));
half smoothness = lerp(_SmoothnessGlass, _Smoothness, isBodyOrBackLight);Drawing 10,000 animated vehicles costs the same as drawing 100 objects that have 5,000 vertices each.
This method is applied to a cross-platform game Neon Ghost on Spatial and it runs smooth with the number of vehicles in the background.
The game is available on web, mobile and Quest platforms.
