15.4 Draw Calls, Batching, LOD & Culling

Phase 15 · Optimization & Mobile · Study time: 20–35 h

Reducing what the GPU has to draw — batching, GPU instancing, levels of detail and culling.

Chapter 7.8 introduced the frame budget: 16.6 ms to produce one frame at 60 fps, split between CPU work and GPU work. Chapter 8.5 named the two numbers that usually blow that budget on a real scene — draw calls and triangles — and gave you LOD, batching, GPU instancing, and occlusion culling as a checklist to fix them. This chapter opens each of those techniques up properly: exactly what a draw call costs the CPU and why, how to sort renderers so materials stop breaking batches, the actual code for static batching, dynamic batching, and the SRP Batcher, the math behind picking LOD distances instead of guessing them, and a full walkthrough of frustum culling, occlusion culling, and per-layer distance culling. By the end you will be able to open the Frame Debugger on a real scene and explain, draw call by draw call, why each one is there and whether it needs to be.

1. What a Draw Call Actually Costs

A draw call is one command the CPU sends to the GPU: "render this mesh, with this material, at this transform, right now." The GPU side of that command is almost always fast — GPUs are built to chew through triangles by the million. The CPU side is where the real cost hides, and it has nothing to do with how many triangles the mesh has. Before the GPU can even start, the CPU has to:

Every one of those steps costs a small, roughly fixed amount of CPU time, whether the mesh behind it is a 12-triangle cube or a 50,000-triangle character. Draw one object with 50,000 triangles and the GPU works hard. Draw 5,000 objects with 10 triangles each — same total triangle count — and the CPU does 5,000 times the setup work for the same GPU workload. That is the core fact this whole chapter is built on: past a fairly low triangle count, draw call count matters more than triangle count.

CPU (one thread doing render setup) GPU (thousands of cores) -------------------------------------- ----------------------------- [validate state, build cmd 1] --draw call 1--> runs the triangles for object 1 [validate state, build cmd 2] --draw call 2--> runs the triangles for object 2 [validate state, build cmd 3] --draw call 3--> runs the triangles for object 3 ... ... [validate state, build cmd N] --draw call N--> runs the triangles for object N Each "[validate state, build cmd]" step costs CPU time even when the object behind it is tiny. That fixed per-call cost, repeated N times, is what actually eats the frame budget.
Tip This is exactly why "CPU-bound" and "GPU-bound" are useful labels for a slow frame (chapter 7.8 introduced the frame budget split). A scene that is slow because it has too many separate draw calls is CPU-bound: the GPU is sitting mostly idle, waiting for the next command. A scene that is slow because of huge overdraw or a giant texture is GPU-bound. The fix for one does almost nothing for the other, so knowing which one you have — using the Profiler, not a guess — comes first.

2. Worked Example: 1000 Objects vs 1 Combined Mesh

Numbers make this concrete. Picture 1000 small cubes arranged in a grid, each one its own GameObject with its own MeshRenderer, all sharing one material. Here is the naive way to build that scene:

using UnityEngine;

public class SpawnManyCubes : MonoBehaviour
{
    public Mesh cubeMesh;
    public Material cubeMaterial;
    public int count = 1000;

    void Start()
    {
        for (int i = 0; i < count; i++)
        {
            GameObject go = new GameObject("Cube_" + i);
            MeshFilter mf = go.AddComponent<MeshFilter>();
            MeshRenderer mr = go.AddComponent<MeshRenderer>();

            mf.mesh = cubeMesh;
            mr.sharedMaterial = cubeMaterial; // shared, not a private copy
            go.transform.position = new Vector3(i % 32, 0f, i / 32) * 1.2f;
        }
    }
}

Each of those 1000 cubes ends up as its own draw call, because each one is a separate MeshRenderer and Unity has no way to know ahead of time that they never move relative to each other. Now build the same visual result a completely different way: merge all 1000 cube meshes into one big mesh, once, at load time.

using UnityEngine;
using UnityEngine.Rendering;

public class CombineCubes : MonoBehaviour
{
    public Mesh cubeMesh;
    public Material cubeMaterial;
    public int count = 1000;

    void Start()
    {
        CombineInstance[] combine = new CombineInstance[count];

        for (int i = 0; i < count; i++)
        {
            Vector3 pos = new Vector3(i % 32, 0f, i / 32) * 1.2f;
            combine[i].mesh = cubeMesh;
            combine[i].transform = Matrix4x4.Translate(pos);
        }

        Mesh combinedMesh = new Mesh();
        combinedMesh.indexFormat = IndexFormat.UInt32; // 1000 cubes = way over 65535 verts
        combinedMesh.CombineMeshes(combine);

        GameObject go = new GameObject("CombinedCubes");
        go.AddComponent<MeshFilter>().mesh = combinedMesh;
        go.AddComponent<MeshRenderer>().sharedMaterial = cubeMaterial;
    }
}
Common mistake A default Mesh uses a 16-bit index format, which can only address 65,535 vertices. A cube has 24 vertices (4 per face, so sharp corners shade correctly), so 1000 cubes need 24,000 vertices — still under the limit today, but push the count up (or use a heavier source mesh) and CombineMeshes will silently produce a broken mesh unless you set indexFormat = IndexFormat.UInt32 first, which raises the limit to over 4 billion.

Both versions put the exact same triangles on screen. The only thing that changed is how many times the CPU had to say "GPU, draw this now":

draw calls CPU setup cost triangles drawn 1000 separate cubes 1000 1000 x (fixed) 24,000 1 combined mesh 1 1 x (fixed) 24,000 Same geometry, same pixels on screen. The combined version does the identical GPU work for a tiny fraction of the CPU work, because "fixed" only gets paid once instead of 1000 times.

Put a real number on "(fixed)" and the effect stops being abstract. A single draw call's CPU-side overhead is commonly somewhere in the range of 0.01 to 0.05 ms once state validation, matrix upload, and driver bookkeeping are included — the exact number depends on the platform, the driver, and how much state changed. At a middle-of-the-road 0.02 ms per call:

1000 draw calls x 0.02 ms = 20.0 ms of CPU time alone 1 draw call x 0.02 ms = 0.02 ms of CPU time Frame budget at 60 fps: 16.6 ms, for the *entire* frame -- game logic, physics, and rendering combined (chapter 7.8). 20.0 ms already blows the whole budget before the GPU has drawn a single pixel. 0.02 ms is effectively free.

This is a worked illustration, not a guaranteed measurement — your own numbers will depend on your target hardware. What does not change from platform to platform is the pattern: CPU draw call cost scales with how many times you call it, not with how much geometry is behind each call. That is the whole reason batching, instancing, and the SRP Batcher exist, and it is why the rest of this chapter treats "reduce draw calls" as a separate goal from "reduce triangles."

3. SetPass Calls: Why Changing Material Is the Expensive Part

Not every draw call costs the same amount, even before batching enters the picture. Right before a draw call, the GPU sometimes needs a SetPass call — a pipeline state change: binding a different shader program, binding different textures, or changing a render state like blend mode or depth testing. A SetPass call is considerably more expensive than the draw call that follows it, because it is reconfiguring how the entire GPU pipeline behaves, not just handing over one more object to draw.

The trigger for a SetPass call is a material change. Two draw calls in a row using the exact same material need zero SetPass calls between them — the GPU pipeline is already set up correctly. Two draw calls in a row using different materials need a SetPass call in between, even if the meshes are trivial. This is why the order you submit draw calls in matters, not just how many objects there are.

Unsorted (material changes almost every object): Rock/MatA --> Tree/MatB --> Rock/MatA --> Tree/MatB --> Rock/MatA SetPass: 1 2 3 4 5 5 SetPass calls total Sorted by material first: Rock/MatA --> Rock/MatA --> Rock/MatA --> Tree/MatB --> Tree/MatB SetPass: 1 (same) (same) 2 (same) 2 SetPass calls total Same 5 objects, same materials, same pixels on screen. Sorting alone cut the SetPass count from 5 to 2.

Unity's own renderer already sorts opaque objects to reduce state changes where it safely can (roughly front-to-back for opaque objects, to help early depth testing, and by render queue and shader for state changes), but it cannot always find the best order on its own, especially once custom render queues or lots of unique materials are involved. What you control directly is how many distinct materials exist in the first place — fewer unique materials means fewer possible SetPass transitions no matter what order things end up in. A quick way to see that number for a set of renderers:

using System.Linq;
using UnityEngine;

public static class BatchAudit
{
    public static void ReportUniqueMaterials(MeshRenderer[] renderers)
    {
        int uniqueMaterials = renderers
            .Select(r => r.sharedMaterial)
            .Distinct()
            .Count();

        Debug.Log("Renderers: " + renderers.Length);
        Debug.Log("Unique materials: " + uniqueMaterials);
        Debug.Log("Best case SetPass calls (if perfectly sorted): " + uniqueMaterials);
    }
}

Run BatchAudit.ReportUniqueMaterials against, say, 500 renderers that only actually use 12 different materials, and the console shows:

Renderers: 500
Unique materials: 12
Best case SetPass calls (if perfectly sorted): 12

That "12" is a ceiling on how good this scene can possibly get through sorting alone — it cannot go lower without either combining renderers into fewer draw calls (Sections 4 to 6) or reducing the number of distinct materials in the first place (Section 8).

Common mistake Reading or writing renderer.material (not sharedMaterial) anywhere, even just to peek at a color. The first touch of .material silently creates a private copy of that material for that one renderer. From that point on, Distinct() in the code above counts it separately, because it genuinely is a separate material now — and so does the batching system, which is exactly why that renderer stops batching with anything else.

4. Static Batching

Static batching combines the meshes of objects that are marked as never moving, into one big vertex buffer, once, either when the scene loads or at build time. Afterward, any group of those objects that shares a material draws as a single batch instead of one draw call per object. It is the right tool for level geometry: buildings, terrain props, rocks, fences — anything placed once and never repositioned, rotated, or scaled again at runtime.

Marking objects "Static" in the Inspector's Static checkbox is the normal way to opt in, but the same thing is available from code, useful when objects are generated procedurally at load time rather than placed by hand in the Editor:

using UnityEngine;

public class BuildStaticBatch : MonoBehaviour
{
    public GameObject[] neverMovingRocks;

    void Start()
    {
        // Combines every renderer in neverMovingRocks that shares a
        // material into as few draw calls as possible, and parents
        // the result under this GameObject's transform.
        StaticBatchingUtility.Combine(neverMovingRocks, gameObject);
    }
}

There is no console output from this call — the result shows up in the Frame Debugger as a single "Static Batch" step covering every rock that shares a material, instead of one step per rock. The cost is memory, not CPU: static batching duplicates each object's vertex data into the shared buffer, transformed into its final position ahead of time. Combine 500 copies of the same 2,000-vertex rock and you store roughly 500 x 2,000 vertices worth of data, even though visually it is still "the same rock" 500 times. Compare that to GPU instancing in Section 6, which keeps exactly one copy of the mesh in memory no matter how many instances appear on screen — the trade-off between the two techniques is almost entirely about this memory cost versus the ability to move objects at runtime.

Before static batching: After static batching (same material): Rock_01 --draw call 1 Rock_01 --. Rock_02 --draw call 2 Rock_02 ---+-- 1 combined draw call Rock_03 --draw call 3 Rock_03 ---+ (one static batch) Rock_04 --draw call 4 Rock_04 --' Memory: 4 separate meshes Memory: still ~4 rocks worth of vertex data, now stored together in one buffer, already transformed into final position.
Common mistake Marking something Static and then moving it anyway from a script. Unity does not stop you, and the object visibly stays frozen at its original position while the rest of your code thinks it moved — the combined buffer was built once, using the position at bake time, and nothing about static batching watches for later changes.

5. Dynamic Batching

Dynamic batching is static batching's opposite in one specific way: it works on objects that do move, by rebuilding a combined vertex buffer fresh every single frame instead of once. Unity's built-in render pipeline can do this automatically for renderers that meet a fairly strict set of conditions:

Because it costs CPU time every frame to rebuild that shared buffer, dynamic batching is a net win mainly for large numbers of very small, very simple objects — think scattered debris, bullet casings, or simple foliage cards — and matters far less than it used to now that GPU instancing (Section 6) and the SRP Batcher (Section 7) exist. It is still worth understanding its limits, if only to recognize when a mesh is quietly disqualifying itself:

using UnityEngine;

public class DynamicBatchEligibility : MonoBehaviour
{
    const int dynamicBatchVertexLimit = 300;

    void Start()
    {
        Mesh mesh = GetComponent<MeshFilter>().sharedMesh;

        if (mesh.vertexCount > dynamicBatchVertexLimit)
        {
            Debug.LogWarning(name + " has " + mesh.vertexCount +
                " vertices -- too heavy for dynamic batching (limit ~" +
                dynamicBatchVertexLimit + ").");
        }
        else
        {
            Debug.Log(name + " has " + mesh.vertexCount +
                " vertices -- eligible for dynamic batching.");
        }
    }
}

Attach this to a simple debris chunk with 180 vertices and a detailed prop with 4,500 vertices, and the console shows the two verdicts side by side:

Debris_Chunk has 180 vertices -- eligible for dynamic batching.
DetailedCrate has 4500 vertices -- too heavy for dynamic batching (limit ~300).
Tip Dynamic batching is a setting under Built-in Render Pipeline projects specifically (Player Settings, Other Settings). If your project uses URP or HDRP, this whole section is largely historical — the SRP Batcher in Section 7 replaces the role dynamic batching used to play, and does it without the per-frame rebuild cost.

6. GPU Instancing

GPU instancing takes a different approach entirely: instead of combining meshes into one buffer (static batching) or rebuilding a buffer every frame (dynamic batching), it sends the mesh to the GPU exactly once, then sends a list of per-instance data — most commonly a transform matrix each — and lets the GPU stamp out that one mesh at every position in a single draw call. It is the natural fit for many copies of the exact same mesh that still need to move, spawn, or despawn independently: grass, trees, rocks, arrows, bullet casings.

one mesh many transforms one draw call ------------- ---------------------------- -------------------- [ mesh data, + [ matrix 1 ][ matrix 2 ] ... --> GPU stamps the same sent once ] [ matrix N ] mesh N times, once per matrix, inside a single call.

A minimal instanced draw, scattering foliage clumps across a field:

using UnityEngine;

public class InstancedFoliage : MonoBehaviour
{
    public Mesh clumpMesh;
    public Material clumpMaterial; // must have "Enable GPU Instancing" checked
    public int clumpCount = 6000;

    Matrix4x4[][] batches; // pre-split into chunks of at most 1023

    void Start()
    {
        const int maxPerBatch = 1023; // hard limit of Graphics.DrawMeshInstanced
        int batchCount = Mathf.CeilToInt(clumpCount / (float)maxPerBatch);
        batches = new Matrix4x4[batchCount][];

        int placed = 0;
        for (int b = 0; b < batchCount; b++)
        {
            int sizeThisBatch = Mathf.Min(maxPerBatch, clumpCount - placed);
            batches[b] = new Matrix4x4[sizeThisBatch];

            for (int i = 0; i < sizeThisBatch; i++)
            {
                Vector3 pos = new Vector3(Random.Range(-60f, 60f), 0f, Random.Range(-60f, 60f));
                Quaternion rot = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f);
                float scale = Random.Range(0.8f, 1.3f);
                batches[b][i] = Matrix4x4.TRS(pos, rot, Vector3.one * scale);
                placed++;
            }
        }
    }

    void Update()
    {
        // Pre-split once in Start, so Update just submits the chunks --
        // no per-frame array allocation.
        for (int b = 0; b < batches.Length; b++)
        {
            Graphics.DrawMeshInstanced(clumpMesh, 0, clumpMaterial, batches[b]);
        }
    }
}

With clumpCount = 6000, that is 6 chunks of at most 1023 each, so 6 draw calls put 6000 foliage clumps on screen every frame — instead of 6000 separate draw calls. Splitting the chunks once in Start() rather than every frame in Update() matters: allocating a new array 6 times a frame, 60 times a second, is exactly the kind of hidden garbage-collector pressure that shows up as stutter later, even though the draw call count itself looks fine.

Tip Plain instancing draws every instance with the identical material, so per-instance visual variation (a slightly different tint per clump, for instance) needs a MaterialPropertyBlock — a small bag of per-draw-call override values that does not create a new material and does not break the batch. A shader that reads _Color per-instance and a call like Graphics.DrawMeshInstanced(clumpMesh, 0, clumpMaterial, batches[b], batches[b].Length, block) lets each chunk carry its own color array without ever touching sharedMaterial.
Common mistake Forgetting to tick "Enable GPU Instancing" on the material (in the material Inspector, next to the shader dropdown). Without it, Graphics.DrawMeshInstanced silently falls back to issuing one regular draw call per instance — the code runs without errors, but the Frame Debugger shows exactly the draw call count you were trying to avoid.

7. The SRP Batcher

If your project uses a Scriptable Render Pipeline (URP or HDRP), there is a fourth technique that works completely differently from the first three. The SRP Batcher does not merge multiple objects into fewer draw calls at all — a scene with 500 separate objects using SRP-Batcher-compatible shaders still issues 500 draw calls. What it does instead is make each individual draw call dramatically cheaper to prepare on the CPU, by caching each material's shader property data on the GPU between frames, so switching from one object to the next — even a completely different mesh, even a different material, as long as both use SRP-Batcher-compatible shaders — mostly just means pointing at already-uploaded data instead of re-uploading it.

This matters because it removes the usual reason material variety was expensive: normally, switching materials costs a SetPass call (Section 3) plus fresh property uploads. With the SRP Batcher active, that cost shrinks so much that scenes with lots of unique materials — the exact case batching and atlasing are built to avoid — stop being nearly as painful.

The requirement is on the shader side: its per-material properties need to sit inside a constant buffer block Unity recognizes, using the name UnityPerMaterial. Every built-in URP and HDRP shader, and every default Shader Graph output, already follows this convention. A hand-written shader has to opt in explicitly:

// Simplified excerpt from a URP-style shader's HLSL code.
// Properties declared inside this exact CBUFFER are what makes
// the shader SRP-Batcher compatible.

CBUFFER_START(UnityPerMaterial)
    float4 _BaseColor;
    float _Metallic;
    float _Smoothness;
CBUFFER_END

There is no code to write on the C# side to turn this on — it is automatic once every shader in play qualifies. What you can check is whether it is actually happening: the Frame Debugger (Section 13) labels a qualifying draw call as an "SRP Batch" instead of a plain draw call, and the Frame Debugger's own statistics panel reports an "SRP Batches" count for the frame. A single shader in the scene that does not qualify — a legacy Built-in Render Pipeline shader pulled into a URP project, most commonly — is enough to knock every object using it back out to ordinary, more expensive draw calls, while everything else keeps batching normally.

Tip Select any material in the Inspector while using URP or HDRP and look near the bottom: Unity shows a small "SRP Batcher compatible" line directly in the material Inspector, so you do not need to open the Frame Debugger just to check one material.

8. Texture Atlases and Material Sharing

Every technique so far has the same unspoken requirement: the objects involved must already share one material. Static batching, dynamic batching, and GPU instancing all stop working the instant two objects need different textures, because different textures normally mean different materials. The technical-art chapter covered texture atlases (several smaller textures packed into one larger sheet) as a way to save texture memory and avoid duplicate files; the same technique is also what turns a pile of visually different objects into objects that can batch together, because after atlasing, they all point at the same shared material and just sample a different region of the same shared texture.

The technical-art chapter's approach shifted and scaled a material's texture-sampling window (mainTextureOffset and mainTextureScale) to pick one tile out of a grid. The other common approach — useful when different meshes have completely arbitrary UV layouts, not a neat grid of tiles — is to remap the mesh's own UV coordinates once, so every vertex already points at the correct region of the shared atlas, and the material itself never needs to change per-object at all:

using UnityEngine;

public static class AtlasUVRemap
{
    // Remaps UVs that are currently in the 0-1 range of a standalone
    // texture into one cell of a gridSize x gridSize atlas, e.g.
    // cellX = 2, cellY = 1 inside a 4x4 atlas.
    public static Vector2[] RemapToAtlasCell(Vector2[] originalUVs, int cellX, int cellY, int gridSize)
    {
        Vector2[] remapped = new Vector2[originalUVs.Length];
        float cellSize = 1f / gridSize;

        for (int i = 0; i < originalUVs.Length; i++)
        {
            float u = (cellX + originalUVs[i].x) * cellSize;
            float v = (cellY + originalUVs[i].y) * cellSize;
            remapped[i] = new Vector2(u, v);
        }

        return remapped;
    }
}

A worked trace: an original UV coordinate of (0.5, 0.5) — dead center of its own texture — remapped into cell (2, 1) of a 4x4 atlas:

cellSize = 1 / 4 = 0.25 u = (2 + 0.5) * 0.25 = 2.5 * 0.25 = 0.625 v = (1 + 0.5) * 0.25 = 1.5 * 0.25 = 0.375 Original UV (0.5, 0.5) -> Atlas UV (0.625, 0.375) That point now samples the center of cell (2,1) inside the 4x4 atlas sheet, instead of the center of a standalone texture.

The payoff shows up directly in the material count from Section 3's BatchAudit. Take a cluster of 40 background props that used to need 40 separate single-texture materials, repack their textures into one shared atlas, remap their UVs, and re-run the same audit:

Before atlasing:
Renderers: 40
Unique materials: 40
Best case SetPass calls (if perfectly sorted): 40

After atlasing:
Renderers: 40
Unique materials: 1
Best case SetPass calls (if perfectly sorted): 1

Going from 40 unique materials to 1 does not just help sorting — it is what makes static batching, dynamic batching, and mesh combining possible for this cluster in the first place, since all three require a shared material as a precondition. Atlasing is frequently the step that has to happen before any of Sections 4 through 6 can do anything at all.

9. LOD — Level of Detail

The technical-art chapter built an LODGroup using fixed screen-relative-height thresholds — 50%, 15%, 2% — chosen as reasonable defaults. This section covers where numbers like that actually come from, so you can pick your own instead of guessing.

LODGroup switches meshes based on screen-relative height: the fraction of the screen's vertical height the object's bounding box currently covers, from 0 (invisible) to 1 (fills the screen top to bottom). Using a screen fraction instead of a raw world-space distance is deliberate — it automatically accounts for both the object's own size and the camera's field of view, two things a fixed "switch at 20 meters" rule would get wrong the moment you changed either one.

camera | |<-- LOD0: full mesh (screen height > 50%) -->| |<-- LOD1: medium mesh (20% - 50%) -->| |<-- LOD2: low mesh (5% - 20%) -->| |<-- culled (below 5%) -->| 0 m 5 m 15 m 40 m 100 m+ |-----------|-----------|-----------|-----------|----> distance LOD0 LOD1 LOD2 culled (example thresholds for a roughly 2 m tall object at 60 degree FOV -- the exact meters shift if the object's size or the FOV changes)

The relationship between screen-relative height, object size, distance, and field of view comes from basic trigonometry: an object of world-space height h, at distance d from the camera, viewed through a vertical field of view fov, fills roughly this fraction of the screen's height:

screenHeight =~ h / (2 * d * tan(fov / 2))

Rearranged to solve for distance, given a target screen-relative height threshold, this tells you exactly where a LOD switch will happen in meters, instead of picking a threshold by eye and hoping it feels right:

using UnityEngine;

public static class LODDistanceCalculator
{
    // Returns the camera distance, in meters, at which an object of
    // the given world-space height will cross the given screen-relative
    // height threshold (the same 0-1 number an LODGroup's LOD uses).
    public static float DistanceForScreenHeight(float objectWorldHeight, float screenRelativeHeight, float verticalFovDegrees)
    {
        float fovRad = verticalFovDegrees * Mathf.Deg2Rad;
        return objectWorldHeight / (2f * screenRelativeHeight * Mathf.Tan(fovRad / 2f));
    }
}

For a 2-meter-tall character, a 60-degree vertical FOV camera, and the 50% / 15% / 2% thresholds from the technical-art chapter:

DistanceForScreenHeight(2f, 0.50f, 60f)  =~  3.46 m   (switches to LOD1)
DistanceForScreenHeight(2f, 0.15f, 60f)  =~ 11.55 m   (switches to LOD2)
DistanceForScreenHeight(2f, 0.02f, 60f)  =~ 86.60 m   (culled)

Those numbers say the same 50%/15%/2% thresholds that felt reasonable for a 2-meter character would put the LOD0-to-LOD1 switch inside 3.5 meters — uncomfortably close, likely to be seen as a visible pop during normal play — while the same thresholds on a much bigger object, say a 20-meter building, would push that same switch out to 34.6 meters. This is exactly why a single set of "good" percentages does not transfer between assets of very different sizes: the percentages need to be picked with the actual object size and camera FOV in mind, using the formula above, not copied from a different asset that merely looked fine.

using UnityEngine;

public class SetupLODGroup : MonoBehaviour
{
    public Mesh highDetail;
    public Mesh mediumDetail;
    public Mesh lowDetail;
    public Material sharedMaterial;

    void Start()
    {
        LODGroup group = gameObject.AddComponent<LODGroup>();

        LOD[] lods = new LOD[3];
        lods[0] = BuildLOD(highDetail,   0.35f); // switch below 35% of screen height
        lods[1] = BuildLOD(mediumDetail, 0.12f); // switch below 12%
        lods[2] = BuildLOD(lowDetail,    0.03f); // switch below 3%, culled after that

        group.SetLODs(lods);
        group.RecalculateBounds();
    }

    LOD BuildLOD(Mesh mesh, float screenRelativeHeight)
    {
        GameObject child = new GameObject(mesh.name);
        child.transform.SetParent(transform, false);

        child.AddComponent<MeshFilter>().mesh = mesh;
        Renderer renderer = child.AddComponent<MeshRenderer>();
        renderer.sharedMaterial = sharedMaterial;

        return new LOD(screenRelativeHeight, new Renderer[] { renderer });
    }
}
Common mistake Treating the formula's output as exact and never checking it in the Scene view. It gives you a starting point based on geometry alone; it says nothing about whether the LOD1 mesh actually still reads correctly as "the same thing" at that distance, or whether the switch is visually jarring. Always walk the camera through the thresholds in Play mode afterward — the Scene view's colored LOD strip (top-right corner) shows exactly which level is active — and nudge the numbers if the pop is noticeable.

10. Frustum Culling

A camera does not need to consider the entire scene every frame — only the region of space it can actually see. That region is called the view frustum (a truncated, four-sided pyramid: a near plane close to the camera, a far plane at the draw distance, and four side planes connecting them, angled outward according to the field of view). Frustum culling means testing every object's bounds against those six planes and skipping any object entirely outside all of them — no draw call, no vertex processing, no pixel shading, because the GPU is never told about it in the first place.

camera * |____ | |____ | |____ | |__________________________| <-- far plane | | INSIDE FRUSTUM | (visible zone) | |__________________________| | |____| | |____| |____| near plane Objects entirely outside this shape (behind the camera, past the far plane, or off to either side) are skipped before the GPU ever sees them. Objects straddling the edge still get drawn -- the test is "does any part touch the frustum," not "is the whole object inside it."

Unity performs this automatically for every Camera, every frame, using each renderer's bounding box, so ordinary gameplay code never has to think about it. It becomes worth writing by hand in two situations: custom systems that decide visibility themselves before Unity's normal renderer path even runs (a custom instancing system deciding which instances to include in this frame's batch, for example), or simply to understand what is happening under the hood:

using UnityEngine;

public class ManualFrustumCheck : MonoBehaviour
{
    public Camera cam;
    public Renderer target;

    void Update()
    {
        Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(cam);
        bool visible = GeometryUtility.TestPlanesAABB(frustumPlanes, target.bounds);
        Debug.Log(target.name + " inside frustum: " + visible);
    }
}

With the target rock sitting in front of the camera, the console reads:

Rock_12 inside frustum: True

Walk the camera past the rock so it is now behind it, and the very same line of code prints the opposite result on the next frame:

Rock_12 inside frustum: False

Frustum culling is essentially free to benefit from — it needs no baking, no setup, and no per-object opt-in — but it only helps with objects that are outside the view cone entirely. An object can be squarely inside the frustum and still be something the player cannot possibly see, if something else is standing directly in front of it. That gap is what Section 11 closes.

11. Occlusion Culling

Occlusion culling skips objects that pass the frustum test — they are inside the view cone — but are still completely hidden behind something closer to the camera: a crate sitting directly behind a solid wall, a room full of props behind a closed door. Unlike frustum culling, this cannot be computed cheaply on the fly for a complex scene (testing every object against every possible occluder, every frame, is itself far too slow), so Unity's built-in system is a baked one: visibility data is precomputed once, in the Editor, and the camera reads that precomputed data at runtime instead of testing raw geometry.

camera wall (occluder) crate (occludee, hidden) * --view cone--> ##### ( X ) | ##### | ##### | | crate B (occludee, NOT hidden -- still drawn) |----------------------------------------------> ( O ) Both crates are inside the view cone -- frustum culling alone keeps both. Occlusion culling additionally removes crate A, because the baked data already knows the wall blocks that angle completely. Crate B stays, because nothing blocks it.

Setting this up means opening Window > Rendering > Occlusion Culling, marking large solid objects as Occluder Static (things that can hide other objects), marking objects that should be considered for culling as Occludee Static, and running a bake. A big scene can have hundreds of candidate occluders, and not all of them are worth the bake time: a thin fence post technically blocks a sliver of view but almost never hides anything meaningful, while a building wall or a terrain ridge blocks a huge amount. A size-based filter is a practical way to flag only the objects likely to be worth it, rather than marking everything solid in the scene by hand:

using UnityEditor;
using UnityEngine;

public class AutoMarkOccluders
{
    const float minOccluderSize = 3f; // meters, along the largest bounds axis

    [MenuItem("Tools/Auto-Mark Large Objects As Occluders")]
    static void MarkLargeObjects()
    {
        int marked = 0;

        foreach (Renderer renderer in Object.FindObjectsOfType<Renderer>())
        {
            Vector3 size = renderer.bounds.size;
            float largestAxis = Mathf.Max(size.x, size.y, size.z);

            if (largestAxis >= minOccluderSize)
            {
                GameObject go = renderer.gameObject;
                StaticEditorFlags flags = GameObjectUtility.GetStaticEditorFlags(go);
                flags |= StaticEditorFlags.OccluderStatic;
                flags |= StaticEditorFlags.OccludeeStatic;
                GameObjectUtility.SetStaticEditorFlags(go, flags);
                marked++;
            }
        }

        Debug.Log("Marked " + marked + " objects as occluders (largest axis >= " + minOccluderSize + " m).");
    }
}

Running this over a street scene with 340 total renderers, where only the buildings and boundary walls are large enough to matter, prints something like:

Marked 27 objects as occluders (largest axis >= 3 m).

Only after marking do you actually bake, from the same Occlusion Culling window's Bake tab. Once baked, walking the camera through the scene and watching the Frame Debugger shows draw calls disappearing entirely for objects around a corner or behind a wall the camera cannot see past — those objects never reach the GPU at all for that frame.

Tip Occlusion culling earns its cost in dense, indoor, or urban scenes packed with walls and buildings. It earns almost nothing in open outdoor terrain, where there is rarely anything solid enough to hide much of anything — an open field relies far more on LOD (Section 9) and a hard draw-distance cutoff (Section 12) than on occlusion data.

12. Distance Culling Per Layer

LOD reduces an object's detail as it gets farther away; frustum and occlusion culling remove objects the camera cannot see at all. There is a third, much blunter tool that sits alongside both: simply refusing to draw entire categories of object past a fixed distance, regardless of screen size or visibility. A camera's layerCullDistances sets exactly that — a separate draw-distance cutoff per layer (the same layer system used for physics collision matrices and raycasting), so small props can vanish at 30 meters while trees stay visible out to 100, without touching LOD settings on either one.

camera *----------------------------------------------------------------> distance | | | | 0 - 30 m | 30 - 100 m | 100 m+ | small props | small props culled, | trees also culled, | + trees | trees still drawn | only terrain remains | visible | |
using UnityEngine;

public class SetupLayerCullDistances : MonoBehaviour
{
    public Camera cam;

    void Start()
    {
        float[] distances = new float[32]; // one slot per Unity layer, 0-31

        distances[LayerMask.NameToLayer("SmallProps")] = 30f;
        distances[LayerMask.NameToLayer("Trees")] = 100f;
        // any layer left at 0 falls back to cam.farClipPlane instead

        cam.layerCullDistances = distances;
        cam.layerCullSpherical = true; // distance from camera position, not view-plane depth
    }
}

The difference between layerCullSpherical = true and the default false matters near the edges of the screen: the default measures distance along the camera's forward axis only (view-plane depth), so an object far to the side but close in that forward-axis sense can be treated as "near" even though its actual distance from the camera is much larger. Spherical culling measures true straight-line distance instead, which is more accurate but costs a little more CPU per object to compute — worth it for a cutoff that needs to look consistent across the whole screen, not just dead ahead.

This tool is intentionally crude compared to LOD — there is no fade, no simplified mesh, just present or absent — which is exactly why it fits categories where a hard cutoff is acceptable or even desirable: small clutter that the player was never going to focus on anyway, distant background trees a fog effect will hide regardless, or debug-only gizmos that should never appear in a build at all.

Common mistake Setting a layer's cull distance shorter than objects on that layer actually need to stay visible for gameplay reasons — a pickup item on the "Interactable" layer culled at 20 meters will simply disappear before a player sprinting toward it ever sees it, which reads as a bug, not an optimization.

13. Reading the Frame Debugger: Finding Why a Batch Broke

Every technique in this chapter can silently stop working the moment one small detail changes — a script touches .material instead of .sharedMaterial, a prop gets baked lighting that differs from its neighbors, someone bumps its scale negative on one axis. The scene still renders correctly, so nothing looks wrong — it just quietly costs far more draw calls than it should. The Frame Debugger (Window > Analysis > Frame Debugger) is the tool for catching that.

A practical workflow for tracking down a broken batch:

That right-hand panel's wording maps directly onto the sections in this chapter:

Frame Debugger message What it means, and where to look "Renderers have different Two objects that look identical to materials" you use two separate Material assets, or one of them touched .material and got a private copy. -> Section 3, Section 6 warning "The material doesn't support A custom shader is missing the instancing" "Enable GPU Instancing" checkbox, or lacks instancing support in its shader code. -> Section 6 "Different lightmap index" The two objects were baked into different lightmap pages, even though everything else matches. -> usually fixed by rebaking lighting after grouping objects "Static batching is not enabled One object in a group is missing for this renderer" its Static flag, or was disabled when static batching last ran. -> Section 4 "Not batching because of a An object with a mixed positive negative scale on one axis" and negative scale broke dynamic batching's winding-order assumption. -> Section 5

A worked trace of the process: stepping through a street scene, draw calls 12 through 27 all show "Static Batch (16 renderers)" — good, that whole cluster of rocks merged as expected. Draw call 28 is a plain, unbatched draw call for a single rock, and the panel reads "Renderers have different materials." Selecting that one rock in the Hierarchy and checking its Inspector shows its Material slot pointing at "Rock_Mat (Instance)" instead of the shared "Rock_Mat" every other rock uses — a script had called renderer.material.color = tint; on it at some point, silently forking off a private copy exactly as the warning in Section 3 describes. Swapping that line for a MaterialPropertyBlock call, as shown in Section 6, fixes it without losing the per-rock tint.

Tip Check the Frame Debugger's own statistics panel for an "SRP Batches" count, separately from plain "Batches." If a URP or HDRP project shows a healthy SRP Batches number but a low plain batch count, that is expected and fine — the SRP Batcher (Section 7) is doing its job without ever needing to merge draw calls in the first place.

The single most common root cause behind a broken batch, across a real project, is the same one Sections 3 and 6 both warn about separately: something, somewhere, read .material instead of .sharedMaterial. It is worth searching a codebase for .material (excluding .sharedMaterial matches) as a first move whenever a scene's draw call count looks higher than it should.

14. Glossary

15. Exercises

Exercise 1 — Rocks: Static Batching or GPU Instancing? A scene has 500 identical rock GameObjects scattered across an open field. They never move, and they all use the exact same mesh and the exact same material. Right now each one is its own GameObject with its own MeshRenderer, so the Frame Debugger shows 500 draw calls. Which technique fits better here, static batching or GPU instancing? Write the C# that sets your chosen technique up, and give one sentence explaining why you picked it over the other option.
Show answer

GPU instancing fits better. Static batching would also bring the draw call count down to roughly 1, but it duplicates the rock's full vertex data into the combined buffer once per instance — 500 copies of the identical mesh, wastefully, since static batching has no way to know all 500 copies are exactly the same mesh. GPU instancing keeps exactly one copy of the mesh in memory and only stores a small transform matrix per rock, which is both far lighter on memory and does not require the rocks to be permanently frozen in place the way static batching effectively does.

using UnityEngine;

public class InstancedRockField : MonoBehaviour
{
    public Mesh rockMesh;
    public Material rockMaterial; // "Enable GPU Instancing" checked
    public int rockCount = 500;

    Matrix4x4[] matrices;

    void Start()
    {
        matrices = new Matrix4x4[rockCount]; // 500 fits under the 1023 limit, one call is enough

        for (int i = 0; i < rockCount; i++)
        {
            Vector3 pos = new Vector3(Random.Range(-40f, 40f), 0f, Random.Range(-40f, 40f));
            Quaternion rot = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f);
            matrices[i] = Matrix4x4.TRS(pos, rot, Vector3.one);
        }
    }

    void Update()
    {
        Graphics.DrawMeshInstanced(rockMesh, 0, rockMaterial, matrices);
    }
}
Exercise 2 — Find the Bug: 200 Draw Calls Instead of 1 A scene spawns 200 identical crates that all start out sharing one material. Every crate should batch into a single draw call, but the Frame Debugger shows 200 separate draw calls instead, each with the panel reading "Renderers have different materials." Here is the script attached to every crate:
using UnityEngine;

public class TintedCrate : MonoBehaviour
{
    void Start()
    {
        Renderer r = GetComponent<Renderer>();
        r.material.color = new Color(Random.value, Random.value, Random.value);
    }
}
Explain exactly why this breaks batching, and rewrite it to keep the random per-crate tint while staying eligible for batching.
Show answer

The bug is r.material. The first time any code reads .material (not .sharedMaterial), Unity creates a private copy of that renderer's material just for it, so it can be modified without affecting anyone else. That happens for all 200 crates, so all 200 end up with 200 separate material instances — which is exactly why the Frame Debugger correctly reports "different materials" for every one of them; after this line runs, they genuinely are different materials.

The fix is MaterialPropertyBlock: it lets you override a per-draw-call value like color without ever creating a new material or touching sharedMaterial.

using UnityEngine;

public class TintedCrate : MonoBehaviour
{
    static MaterialPropertyBlock block;

    void Start()
    {
        if (block == null) block = new MaterialPropertyBlock();

        Renderer r = GetComponent<Renderer>();
        Color randomTint = new Color(Random.value, Random.value, Random.value);

        block.SetColor("_Color", randomTint);
        r.SetPropertyBlock(block);
        // r.sharedMaterial is never touched, so all 200 crates still
        // point at the exact same Material asset and stay batchable.
    }
}

One extra detail worth noting: reusing a single static MaterialPropertyBlock instance across all 200 Start() calls is safe here because SetPropertyBlock copies its contents into the renderer immediately — the same block object can be refilled and reapplied 200 times in a row without the crates interfering with each other.

Exercise 3 — Picking LOD Thresholds With the Formula A tree prop is 8 meters tall. Using a camera with a 50-degree vertical field of view, you want LOD1 to kick in once the tree's screen-relative height drops below 25%, and full culling once it drops below 3%. Using the DistanceForScreenHeight formula from Section 9, calculate the two distances in meters, then describe one way you would double-check the numbers feel right once the game is actually running.
Show answer
fovRad = 50 * (pi / 180) =~ 0.8727 rad
tan(fovRad / 2) = tan(0.4363) =~ 0.4663

distance = objectHeight / (2 * screenRelativeHeight * tan(fov/2))

LOD1 distance    =  8 / (2 * 0.25 * 0.4663)  =  8 / 0.23315   =~  34.31 m
culled distance  =  8 / (2 * 0.03 * 0.4663)  =  8 / 0.027978  =~ 285.90 m

So this tree should switch to its lower-detail mesh at roughly 34 meters and disappear entirely at roughly 286 meters, given this specific height and FOV.

To double check: enter Play mode, walk or fly the camera straight away from a single instance of the tree, and watch the Scene view's colored LOD strip in the top-right corner while glancing at the world-space distance (visible in a debug overlay, or just checking Vector3.Distance in a temporary script). If the LOD1 mesh is noticeably simpler than the full mesh and the switch happens close to the calculated 34 meters, the math and the asset agree. If the pop is still visually obvious even at the right distance, that is not a math problem — it means LOD1 itself needs another detail pass, or the transition needs Unity's LOD cross-fade option enabled to blend instead of snap.

← Back to all chapters