๐ง Lesson 3.1: Data-Oriented Thinking
Modules 3 and 4 are where Unity gets fast โ jobs, Burst, and DOTS. But none of those tools help unless you first change how you think about your game's data. This lesson has almost no Unity-specific API in it; instead it rewires the instinct that makes every technique that follows pay off. It's the shortest lesson in the module and the most important.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain why modern CPUs are limited by memory access, not arithmetic
- Describe a cache line and why cache misses dominate real-world performance
- Contrast Array of Structs (AoS) with Struct of Arrays (SoA) and pick the right one
- Split hot data from cold data to keep the cache full of what you actually touch
- See how this mindset leads directly into the Job System, Burst, and ECS
Estimated Time: 45 minutes ยท Prerequisite: Module 2 (especially Lesson 2.3 garbage-free code and 2.4 batching)
In This Lesson
The Memory Gap
Here is a fact that reshapes how you write performance-critical code: a modern CPU can perform an arithmetic operation in well under a nanosecond, but fetching a value from main memory (RAM) takes on the order of 100 nanoseconds. That's a factor of a hundred or more. The processor doesn't sit idle politely โ it stalls, waiting for data to arrive, doing nothing useful for hundreds of cycles.
This is the memory gap, and it has been widening for decades: compute got fast far quicker than memory got close. The practical consequence is blunt โ in most game code the CPU spends more time waiting for data than computing on it. If you want speed, the winning move is rarely "do less math." It's "wait for memory less often."
๐ก The reframing. Object-oriented design optimises for how humans organise concepts. Data-oriented design optimises for how the hardware reads memory. They frequently disagree, and on the hot path the hardware wins.
Cache Lines & Misses
To hide the memory gap, CPUs keep small, fast caches (L1/L2/L3) between the core and RAM. When you read one byte, the CPU doesn't fetch just that byte โ it pulls in the whole cache line around it, typically 64 bytes, on the bet that you'll want the neighbours next. If your next read is already in cache, that's a cache hit (a few cycles). If it isn't, that's a cache miss (the ~100 ns stall).
So performance hinges on a simple question: is the data I need next sitting right beside the data I'm using now? Walk an array front to back and the answer is yes on almost every step โ the CPU even prefetches ahead, so the data is waiting before you ask. Chase pointers to objects scattered across the heap and the answer is no, every step is a fresh miss.
AoS vs SoA
Say you have 10,000 entities and each frame you only need to advance their positions by their velocities. The natural OOP layout is an Array of Structs โ one array, each element a fat struct holding everything about the entity:
// Array of Structs (AoS) โ everything about an entity together
struct EntityAoS
{
public Vector3 position; // 12 bytes โ the only fields our loop needs
public Vector3 velocity; // 12 bytes โ
public Quaternion rotation;// 16 bytes
public float health; // 4 bytes
public int teamId; // 4 bytes
public FancyStats stats; // ...more cold data...
}
EntityAoS[] entities; // 10,000 fat structs, back to back
void MovePositions(float dt)
{
for (int i = 0; i < entities.Length; i++)
entities[i].position += entities[i].velocity * dt;
}
The loop only touches position and velocity โ 24 useful bytes โ but each struct might be 64+ bytes. So every cache line the CPU loads is mostly the fields you're ignoring (rotation, health, stats). You pay full memory bandwidth to drag along cold data you never read. The cache is full of garbage.
The data-oriented layout is a Struct of Arrays โ split each field into its own packed array:
// Struct of Arrays (SoA) โ one packed array per field
Vector3[] positions; // 10,000 positions, back to back
Vector3[] velocities; // 10,000 velocities, back to back
Quaternion[] rotations;
float[] healths;
// ...
void MovePositions(float dt)
{
for (int i = 0; i < positions.Length; i++)
positions[i] += velocities[i] * dt; // both arrays stream cleanly
}
Now the two arrays the loop touches are each fully packed. Every cache line is 100% useful data; the prefetcher streams both arrays perfectly; nothing cold is dragged along. Same result, a fraction of the memory traffic โ often several times faster, with no change to the arithmetic at all.
๐ Definition
AoS keeps all of one object's fields adjacent (great when you use most fields of one object at a time). SoA keeps all objects' values for one field adjacent (great when you use one or two fields across many objects โ the classic game-loop shape). DOTS is built on SoA: this is exactly the "packed component array" you saw in Lesson 4.1's chunk diagram.
Hot & Cold Data
The AoS problem has a name: mixing hot data (fields touched every frame โ position, velocity) with cold data (fields touched rarely โ the display name, the loot table, a reference to a UI panel). When they share a struct, reading the hot field always pays to load the cold ones too.
The fix is to separate them by access frequency. Keep the per-frame hot fields in their own tight arrays; push the rarely-touched cold fields elsewhere. SoA does this naturally, but the principle applies even in ordinary OOP code: a component that runs every frame should not carry a 200-byte payload it reads once at spawn.
โ ๏ธ Don't cargo-cult SoA everywhere
SoA wins when you iterate many objects touching few fields. If your code reads most of one object's fields together (a single player, a UI element, a config), AoS is clearer and just as fast. Data-oriented design is about matching layout to access pattern โ not blindly splitting every struct. Reach for SoA on the hot path over big collections; keep normal objects for everything else, exactly as you learned to reach for ECS selectively in Lesson 4.1.
Where This Leads: The DOTS Stack
Everything in the next two modules is machinery for making SoA, hot/cold-split data easy and safe to process at scale. Unity's Data-Oriented Technology Stack is three packages that build on each other:
(this lesson: SoA, cache-friendly layout)"] subgraph STACK["The DOTS packages"] direction TB MATH["Unity.Mathematics
SIMD-friendly math types (float3, quaternion)"] COLL["Unity.Collections
NativeArray & friends: unmanaged, packed, GC-free"] JOBS["C# Job System
run that data processing on many worker threads (Module 3)"] BURST["Burst compiler
compile jobs to tight SIMD native code (Module 3)"] ECS["Entities / ECS
a whole object model built on all of the above (Module 4)"] end DOD --> MATH --> COLL --> JOBS --> BURST --> ECS
Figure 2: The stack. Each layer assumes cache-friendly data underneath it โ which is why this mindset comes first.
The rest of Module 3 gives you the two workhorses: the Job System (Lessons 3.2โ3.4) to spread that array-processing across CPU cores safely, and Burst (Lesson 3.4) to compile it into vectorised native code. Module 4 then wraps all of it in ECS. But every one of those tools is only as fast as the data layout you feed it โ garbage in, cache misses out.
A First Taste: Unity.Mathematics
One concrete tool you'll use constantly is the Unity.Mathematics package โ a math library designed for this world. Its types (float3, float4, quaternion, float4x4) are lowercase, blittable value types the Burst compiler can turn into SIMD instructions, and its functions live in a static math class that reads like a shader:
using Unity.Mathematics; // note: float3, not Vector3
float3 position = new float3(2f, 0f, 5f);
float3 velocity = new float3(1f, 0f, 0f);
float dt = 0.016f;
position += velocity * dt; // component-wise, like Vector3
float dist = math.distance(position, float3.zero);
float3 dir = math.normalize(velocity);
quaternion rot = quaternion.RotateY(math.radians(90f));
You can convert freely with Vector3 (float3 p = transform.position; just works), but inside jobs and ECS you'll use the Unity.Mathematics types because Burst optimises them aggressively. Think of float3 as "Vector3 that a compiler can vectorise."
๐ You've already seen it. The real 25,600-entity render in Lesson 4.1 positioned every cube with afloat3and aUnity.Transforms.LocalTransform. That was data-oriented layout in action โ packed component arrays processed in bulk.
Hands-on Challenge
๐๏ธ Exercise 1: Count the wasted bytes
Objective: Feel the AoS cost numerically.
Take the EntityAoS struct above. Assume FancyStats is 32 bytes, so one struct is 12+12+16+4+4+32 = 80 bytes. A cache line is 64 bytes.
- The move loop reads only
position+velocity= 24 useful bytes per entity. Roughly what fraction of each loaded cache line is wasted on cold data? - In the SoA version, the loop streams a
Vector3[] positionsand aVector3[] velocities. What fraction of those cache lines is useful now? - Which layout lets the hardware prefetcher help you, and why?
โ Answer
(1) You want 24 of every ~80 bytes, so roughly 70% of each cache line is wasted dragging along rotation/health/stats โ and an 80-byte struct even straddles two 64-byte lines. (2) Essentially 100% useful: both arrays are nothing but the values you read. (3) SoA โ the prefetcher loves a single forward-marching array and can load the next lines before you ask; the scattered/striped AoS access defeats it.
๐๏ธ Exercise 2: Spot the right layout
For each case, choose AoS or SoA: (a) 50,000 particles, each frame update position from velocity; (b) one player character whose stats screen reads every field at once; (c) 5,000 enemies where a targeting system reads only each enemy's position and teamId.
โ Answers
(a) SoA โ many objects, two hot fields. (b) AoS โ one object, all fields together; splitting buys nothing. (c) SoA โ split position and teamId into their own arrays so the targeting scan streams cleanly and ignores the cold combat fields.
๐ฏ Quick Quiz
Question 1: Why is "do less arithmetic" often the wrong optimisation on the hot path?
Question 2: A loop over 10,000 objects touches only 2 of each object's 12 fields. Which layout is faster and why?
Question 3: Why use Unity.Mathematics float3 instead of Vector3 in jobs?
Summary
๐ Key Takeaways
- The memory gap means CPUs mostly wait on RAM (~100 ns) rather than compute (<1 ns); reducing cache misses beats reducing math.
- Reads pull a whole 64-byte cache line; packed sequential access = hits + prefetch, scattered pointer-chasing = a miss per step.
- SoA (one packed array per field) beats AoS when you iterate many objects touching few fields โ the game-loop shape.
- Separate hot (per-frame) from cold (rare) data so the cache holds only what you touch.
- The DOTS stack โ Mathematics โ Collections โ Jobs โ Burst โ ECS โ is all built on this layout;
float3isVector3that Burst can vectorise. - Match layout to access pattern; don't split every struct on reflex.
๐ What's Next?
You now have data laid out for the hardware. In Lesson 3.2: Your First Job โ IJob & Scheduling we take that packed data and process it on Unity's worker threads with the C# Job System, turning a serial loop into safe multithreaded work.
๐ง Think in arrays, not objects
When a system touches thousands of things, ask "what's the one array I'm really iterating, and is it packed?" That question is the seed of every optimisation in this half of the course.