What Is It?
Some values are expensive to derive from others: a world-space position built by walking up a hierarchy, a navmesh path, a combined mesh, a sorted leaderboard. The Dirty Flag pattern keeps a cached result plus a single bool isDirty that says "the inputs changed since I last computed this".
Writes are cheap: changing a local position just sets the flag (and, in a hierarchy, flags every descendant). Reads are lazy: the getter checks the flag, recomputes only if dirty, clears the flag and returns the cache. Ten writes in a frame cost one recompute instead of ten.
Unity itself does this inside Transform — moving a parent does not immediately recompute every child's localToWorldMatrix; it is marked and resolved when queried or rendered. Transform.hasChanged is the public face of the same idea.
When Is It Used?
Use it when a derived value is costly to compute, its inputs change in bursts, and it is read less often than it is written — or read many times between changes.
Classic fits are custom scene graphs, procedural mesh rebuilding, UI layout, pathfinding caches and stat systems where buffs stack into a final number.
Skip it when the computation is trivial (adding two numbers) or the value changes on every single read anyway; the flag bookkeeping then costs more than it saves.
Interactive Demo
Click the controls and watch the objects collaborate. The console mirrors what the C# code below would log.
Code
using UnityEngine;
namespace Patterns.Optimization.DirtyFlag
{
/// <summary>
/// Builds a Ship → Turret → Barrel hierarchy, wiggles it and reports how
/// many world positions were actually recomputed.
/// </summary>
public class DirtyFlagTester : MonoBehaviour
{
[SerializeField] private float wiggleSpeed = 2f;
private SceneGraph graph;
private SceneNode ship, turret, barrel;
private void Awake()
{
graph = new SceneGraph();
ship = graph.Create("Ship", null, Vector3.zero);
turret = graph.Create("Turret", ship, new Vector3(0f, 1f, 0f));
barrel = graph.Create("Barrel", turret, new Vector3(0f, 0f, 1.5f));
}
private void Update()
{
// Only the turret moves this frame; the ship stays clean.
turret.LocalPosition = new Vector3(Mathf.Sin(Time.time * wiggleSpeed), 1f, 0f);
// Reading the barrel recomputes Turret + Barrel, not Ship.
Vector3 muzzle = barrel.WorldPosition;
Debug.DrawRay(muzzle, Vector3.forward, Color.yellow);
}
private void LateUpdate()
{
if (Time.frameCount % 120 == 0)
Debug.Log($"Recomputes so far: {SceneNode.RecomputeCount}");
}
}
}using System.Collections.Generic;
using UnityEngine;
namespace Patterns.Optimization.DirtyFlag
{
/// <summary>
/// A node with a cached world position. Writes mark the node (and its
/// subtree) dirty; reads recompute lazily only when needed.
/// </summary>
public class SceneNode
{
public static int RecomputeCount { get; private set; }
private readonly List<SceneNode> children = new();
private Vector3 localPosition;
private Vector3 cachedWorld;
private bool isDirty = true;
public string Name { get; }
public SceneNode Parent { get; }
public SceneNode(string name, SceneNode parent, Vector3 local)
{
Name = name;
Parent = parent;
localPosition = local;
parent?.children.Add(this);
}
public Vector3 LocalPosition
{
get => localPosition;
set
{
if (localPosition == value) return;
localPosition = value;
SetDirty();
}
}
public Vector3 WorldPosition
{
get
{
if (isDirty)
{
cachedWorld = Parent == null ? localPosition : Parent.WorldPosition + localPosition;
isDirty = false;
RecomputeCount++;
}
return cachedWorld;
}
}
private void SetDirty()
{
if (isDirty) return; // subtree is already marked
isDirty = true;
foreach (var child in children)
child.SetDirty();
}
}
}using System.Collections.Generic;
using UnityEngine;
namespace Patterns.Optimization.DirtyFlag
{
/// <summary>
/// Owns all nodes and offers a "render" pass that reads every world
/// position. Clean nodes return their cache instantly.
/// </summary>
public class SceneGraph
{
private readonly List<SceneNode> nodes = new();
public IReadOnlyList<SceneNode> Nodes => nodes;
public SceneNode Create(string name, SceneNode parent, Vector3 local)
{
var node = new SceneNode(name, parent, local);
nodes.Add(node);
return node;
}
/// <summary>Simulates a renderer pulling every world position.</summary>
public void Render(System.Action<SceneNode, Vector3> draw)
{
foreach (var node in nodes)
draw(node, node.WorldPosition);
}
}
}Advantages & Disadvantages
+ Advantages
- Avoids redundant work: many writes collapse into one recompute.
- Lazy evaluation means values nobody reads are never computed at all.
- Simple to retrofit — a bool and a getter around existing code.
− Disadvantages
- Forgetting to set the flag on one write path gives you stale data that is very hard to spot.
- Recompute cost moves to the first read, which can cause an unexpected spike at a bad moment.
- Hierarchies need careful propagation so children are marked whenever an ancestor changes.
Tips
- 01Funnel every write through a setter that calls
SetDirty(); make the backing field private so nothing can bypass it. - 02When propagating to children, stop early if a node is already dirty — its subtree must already be marked.
- 03If a spike on first read is a problem, resolve dirty values at a predictable point such as
LateUpdateinstead of inside the getter. - 04Use
Transform.hasChanged(and reset it yourself) to skip work in scripts that only care when an object actually moved. - 05Expose a debug counter of recomputes while profiling; it makes the savings — or a missing
SetDirty— obvious.