๐ Lesson 3.5: Mini-Project โ A Burst-Compiled Parallel Field Simulation
Time to put the whole module together. You'll build a living ripple field: a grid of tens of thousands of points whose heights are recomputed every frame by a Burst-compiled IJobParallelFor, then drawn in a couple of GPU-instanced calls. It's small, it's fast, and it exercises every idea in Module 3 โ packed data, parallel jobs, and Burst โ as one working system.
๐ฏ What You'll Build
A single MonoBehaviour that:
- Lays out an NรN grid of points in packed
NativeArrays (Persistent, allocated once) - Each frame schedules a
[BurstCompile] IJobParallelForthat computes every point's transform from an animated wave function - Renders all the points with
Graphics.RenderMeshInstancedโ no GameObjects - Cleans up its native memory correctly on teardown
Estimated Time: 90 minutes ยท Prerequisite: Lessons 3.1โ3.4 (all of Module 3)
In This Lesson
The Plan
The per-frame loop is the same shape you learned in Lesson 3.2, now with a parallel Burst job in the middle:
No per-point GameObject, no Transform, no Update on thousands of components โ just arrays flowing through a job into a draw call. This is the data-oriented shape, and it's a stone's throw from how ECS does it (which is Module 4).
Step 1: Setup & the Grid
- Create an empty scene. Add an empty GameObject named
RippleField. - Make a new script
RippleField.csand add it to that object. - We'll expose a grid size, spacing, wave parameters, plus a mesh and material (assign a Cube mesh and a URP/Lit material with Enable GPU Instancing ticked).
In Start() we allocate two Persistent native arrays once โ the immutable grid coordinates and the per-frame output matrices โ and fill the grid:
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
public class RippleField : MonoBehaviour
{
[SerializeField] int gridSize = 160; // 160 x 160 = 25,600 points
[SerializeField] float spacing = 1f;
[SerializeField] float amplitude = 4f;
[SerializeField] float frequency = 0.25f;
[SerializeField] float scale = 0.8f;
[SerializeField] Mesh mesh;
[SerializeField] Material material;
NativeArray<float2> gridXZ; // packed, immutable โ allocate once
NativeArray<float4x4> matrices; // packed output โ reused each frame
int count;
void Start()
{
count = gridSize * gridSize;
gridXZ = new NativeArray<float2>(count, Allocator.Persistent);
matrices = new NativeArray<float4x4>(count, Allocator.Persistent);
float half = gridSize * 0.5f;
for (int i = 0; i < count; i++)
{
int x = i % gridSize, z = i / gridSize;
gridXZ[i] = new float2((x - half) * spacing, (z - half) * spacing);
}
}
// ... job + Update + cleanup below ...
}
Step 2: The Burst Wave Job
The job is a Burst-compiled IJobParallelFor. It reads a point's grid coordinate and the current time, computes an animated height, and writes a full transform matrix to that point's slot โ obeying the parallel-write rule (each index writes only matrices[index]):
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
[BurstCompile]
struct WaveJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float2> gridXZ; // shared read
public float time, amplitude, frequency, scale;
[WriteOnly] public NativeArray<float4x4> matrices; // this job's output
public void Execute(int index)
{
float2 p = gridXZ[index];
// an animated ripple: height from two travelling sine waves
float height = amplitude *
math.sin(p.x * frequency + time) *
math.cos(p.y * frequency + time);
float3 pos = new float3(p.x, height, p.y);
matrices[index] = float4x4.TRS(pos, quaternion.identity, scale);
}
}
Everything in Execute is Burst-friendly: float2/float3/float4x4 math and nothing managed. Burst will vectorise the sine/cosine work and inline the matrix build.
Step 3: Schedule & Render Each Frame
In Update() we schedule the job across all cores, complete it, then draw every matrix in instanced batches. Graphics.RenderMeshInstanced (Unity 6) accepts a NativeArray<Matrix4x4>; since float4x4 and Matrix4x4 share the same 64-byte column-major layout, we Reinterpret the array at zero cost:
void Update()
{
var job = new WaveJob {
gridXZ = gridXZ,
time = Time.time,
amplitude = amplitude,
frequency = frequency,
scale = scale,
matrices = matrices
};
// one iteration per point, batch of 64 โ parallel + Burst
JobHandle handle = job.Schedule(count, 64);
handle.Complete(); // results needed to draw this frame
// float4x4 and Matrix4x4 are layout-identical โ reinterpret, no copy
var drawMatrices = matrices.Reinterpret<Matrix4x4>();
var rp = new RenderParams(material);
Graphics.RenderMeshInstanced(rp, mesh, 0, drawMatrices);
}
That's the entire runtime: schedule, complete, draw. The main thread stays thin because the heavy work is on the workers, and the draw is a handful of instanced submissions rather than 25,600 individual ones.
โ ๏ธ Complete before you read the output
We Complete() before reinterpreting and drawing because we need this frame's matrices now. If you wanted to overlap more, you could schedule in Update and complete in LateUpdate โ but never hand the array to rendering while the job is still in flight (the golden rule from Lesson 3.2).
Step 4: Cleanup
Persistent native memory must be freed on teardown, or leak detection will (rightly) complain:
void OnDestroy()
{
if (gridXZ.IsCreated) gridXZ.Dispose();
if (matrices.IsCreated) matrices.Dispose();
}
Guarding with IsCreated avoids a double-dispose if the object is torn down before Start ran. That's the complete resource lifecycle: allocate once in Start, reuse every Update, free in OnDestroy.
The Full Script
Everything assembled:
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
public class RippleField : MonoBehaviour
{
[SerializeField] int gridSize = 160;
[SerializeField] float spacing = 1f, amplitude = 4f, frequency = 0.25f, scale = 0.8f;
[SerializeField] Mesh mesh;
[SerializeField] Material material;
NativeArray<float2> gridXZ;
NativeArray<float4x4> matrices;
int count;
void Start()
{
count = gridSize * gridSize;
gridXZ = new NativeArray<float2>(count, Allocator.Persistent);
matrices = new NativeArray<float4x4>(count, Allocator.Persistent);
float half = gridSize * 0.5f;
for (int i = 0; i < count; i++)
{
int x = i % gridSize, z = i / gridSize;
gridXZ[i] = new float2((x - half) * spacing, (z - half) * spacing);
}
}
void Update()
{
var handle = new WaveJob {
gridXZ = gridXZ, time = Time.time,
amplitude = amplitude, frequency = frequency, scale = scale,
matrices = matrices
}.Schedule(count, 64);
handle.Complete();
var rp = new RenderParams(material);
Graphics.RenderMeshInstanced(rp, mesh, 0, matrices.Reinterpret<Matrix4x4>());
}
void OnDestroy()
{
if (gridXZ.IsCreated) gridXZ.Dispose();
if (matrices.IsCreated) matrices.Dispose();
}
[BurstCompile]
struct WaveJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float2> gridXZ;
public float time, amplitude, frequency, scale;
[WriteOnly] public NativeArray<float4x4> matrices;
public void Execute(int index)
{
float2 p = gridXZ[index];
float height = amplitude *
math.sin(p.x * frequency + time) *
math.cos(p.y * frequency + time);
matrices[index] = float4x4.TRS(new float3(p.x, height, p.y),
quaternion.identity, scale);
}
}
}
Press Play and you'll see a rolling wave of 25,600 cubes running smoothly โ because none of it goes through the GameObject machinery. Open the Profiler and you'll find the WaveJob spread across the worker lanes each frame while the main thread barely registers it.
Tuning & Extending
- Push the count. Raise
gridSizeto 256 (65,536 points) and watch it stay smooth. Then toggle Jobs โธ Burst โธ Enable Compilation off and feel the frame time jump โ that's Burst earning its keep. - Batch count. Try 16, 64, 256 and profile. The sweet spot balances scheduling overhead against load balancing.
- Colour it. Pass a per-instance colour via a
MaterialPropertyBlock/ instanced property driven by height for the heat-map look. - Overlap frames. Move
Complete()toLateUpdateand do other work between schedule and complete.
โ What you built โ and where it goes
You animated tens of thousands of points every frame with a Burst parallel job and drew them without a single GameObject. That's the full Module 3 toolkit working as one system. In Module 4 you'll meet the framework that makes this pattern the default โ ECS stores your data as these packed arrays automatically, runs systems as Burst jobs, and renders through Entities Graphics. The 25,600-entity render that opens Lesson 4.1 is this very idea, promoted to real entities.
Quick Quiz
Question 1: Why are the grid and matrix arrays allocated with Allocator.Persistent in Start rather than in Update?
Question 2: Why can you pass matrices.Reinterpret<Matrix4x4>() to the renderer at no cost?
Question 3: Why does Update call handle.Complete() before rendering?
Summary
๐ Key Takeaways
- Packed
NativeArrays + a[BurstCompile] IJobParallelFor+ instanced rendering = tens of thousands of animated points, cheaply. - Allocate Persistent buffers once in
Start, reuse them everyUpdate, free them inOnDestroy. - Schedule across cores,
Complete()before reading, then draw withGraphics.RenderMeshInstanced. float4x4andMatrix4x4are layout-identical โReinterpretto hand job output to the renderer with no copy.- The whole thing avoids the GameObject machinery โ the same reason ECS scales, and your bridge into Module 4.
๐ What's Next?
You've built a data-oriented simulation by hand. In Module 4 we hand that pattern to a framework: the Entity Component System. Lesson 4.1 already introduced the mindset and showed the payoff render โ now you'll build it, starting with real components and archetypes.
๐ Module 3, complete
Data laid out for the cache, work spread across cores, code compiled to native SIMD. That's high-performance Unity โ and it's the foundation the DOTS/ECS module is built on.