Skip to main content

๐ŸŒŠ 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] IJobParallelFor that 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:

The per-frame pipeline of the ripple field Each Update: schedule a Burst parallel job that reads the packed grid coordinates and the current time and writes a transform matrix per point; complete the job; then render all matrices with GPU instancing. gridXZ (packed) NativeArray<float2> WaveJob (Burst) IJobParallelFor Execute(i): TRS from sin/cos(x,z,time) matrices NativeArray<float4x4> RenderMesh- Instanced GPU draw Every Update() Schedule(count, 64) โ†’ Complete() โ†’ draw
Figure 1: One frame. The grid coordinates and time go into a Burst parallel job; out come transform matrices; those go straight to instanced rendering.

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

  1. Create an empty scene. Add an empty GameObject named RippleField.
  2. Make a new script RippleField.cs and add it to that object.
  3. 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 gridSize to 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() to LateUpdate and 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 every Update, free them in OnDestroy.
  • Schedule across cores, Complete() before reading, then draw with Graphics.RenderMeshInstanced.
  • float4x4 and Matrix4x4 are layout-identical โ€” Reinterpret to 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.