8.5 Optimizing Art Assets

Phase 8 · Technical Art · Study time: 20–35 h

Keeping the art fast: polygon budgets, texture atlases, LODs and draw-call reduction — vital on mobile hardware.

This section is about the actual content you put in front of the GPU (Graphics Processing Unit, the chip that turns triangles and textures into pixels on screen): meshes and textures. On a PC or console, a slow frame is often the CPU's fault — too much gameplay logic, too many physics bodies. On a phone, it is usually the opposite: the art is what makes a frame slow. A phone's GPU is tiny compared to a desktop graphics card, it shares memory and power with the CPU, and it slows itself down on purpose when it gets hot. One character model, one set of textures, and a handful of transparent effects can single-handedly decide whether a game holds 60 frames per second (fps) or drops to 20. This section covers how to measure that cost, and the specific techniques — LODs, batching, atlases, compression, channel packing, and culling — that studios shipping mobile games like HoYoverse's use to keep art fast.

1. Why Art Is Usually the Bottleneck on Mobile

Every frame, two different pieces of hardware take turns doing work. The CPU runs your gameplay code, figures out what needs to be drawn this frame, and hands the GPU a list of "draw this mesh, with this material" instructions called draw calls. The GPU then does two jobs in sequence: it transforms every triangle's corners (vertex processing — a vertex is one corner point of a triangle), then it colors in every pixel those triangles cover (fragment or pixel shading). If either side runs out of time inside the frame budget, the frame is late and the fps drops.

16.6 ms frame budget at 60 fps (1000 ms / 60 = 16.6 ms per frame) CPU work: [ game logic ][ physics ][ prepare draw calls ] GPU work: [ vertex processing ][ pixel shading + overdraw ] On desktop and console, the CPU side is often the tight one. On phones, the GPU side (art-driven) is usually the tight one.

On a phone, the GPU is small on purpose — it has to fit in a device that runs on a battery and fits in your pocket. Three things make it especially sensitive to art content:

Because of this, art assets need a bigger safety margin on mobile than on PC. The rest of this section is about specific, measurable knobs that keep meshes and textures inside that margin: how many triangles a model has, how many draw calls it costs, how big its textures are, and how much overdraw its effects cause.

Tip "Runs fine on my phone" is not a finished job. Test on a mid-range or low-end device, not just your own newest phone, and watch the frame rate a few minutes into play, not just at launch, so thermal throttling shows up in your testing too.

2. How to Measure: Draw Calls, Triangles, Texture Memory, Overdraw

You cannot optimize what you have not measured. Unity gives you three main places to look:

You can also read the same numbers from an Editor script, which is handy for a quick custom readout instead of hunting through windows:

#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;

public class FrameStatsWindow : EditorWindow
{
    [MenuItem("Tools/Frame Stats")]
    static void Open()
    {
        GetWindow<FrameStatsWindow>("Frame Stats");
    }

    void OnGUI()
    {
        // UnityStats is the same internal data source that feeds the
        // Game view's Stats overlay. It is undocumented but widely
        // used for quick custom tooling like this.
        GUILayout.Label("Draw calls: " + UnityStats.drawCalls);
        GUILayout.Label("Batches: " + UnityStats.batches);
        GUILayout.Label("Triangles: " + UnityStats.triangles);
        GUILayout.Label("Vertices: " + UnityStats.vertices);
        Repaint();
    }
}
#endif

Expected result: opening Tools > Frame Stats and pressing Play shows a small window with four numbers that update live, in step with the Game view's own Stats overlay. Walking the camera toward a detailed character raises the triangle count; looking at a wall of separate props raises the draw call count.

Each of the four numbers points at a different kind of problem:

Common mistake Only testing performance in the Unity Editor. The Editor adds its own overhead and hides real device behavior (thermal throttling, real memory bandwidth, real driver overhead). Numbers from the Editor are a rough guide; numbers from a Profiler session connected to an actual mid-range phone are the ones that matter.

3. Polygon Budgets

A polygon budget (also called a triangle budget) is a target maximum triangle count for a piece of content, agreed on before an artist starts modeling, so the game stays inside its total frame budget once everything is combined. Without a budget, individual assets tend to grow — "just a bit more detail" — until the whole scene is over budget and nobody can point at which single asset caused it.

There is no universal number; budgets depend on the target device, how many objects are on screen at once, and how much of the frame is left after other systems (UI, effects, lighting) take their share. Reasonable starting points for a modern mobile game in the same class as an open-world game like HoYoverse's titles look roughly like this:

A worked trace: imagine a street scene with 1 hero character on screen at 28,000 triangles, 6 NPCs averaging 8,000 triangles, and 40 background props averaging 400 triangles, all currently at their highest LOD:

1 hero x 28,000 tris = 28,000 6 NPCs x 8,000 tris = 48,000 40 props x 400 tris = 16,000 -------- total = 92,000 triangles

92,000 triangles for this street is comfortably inside a 1-3 million total budget — but that math only works because most of the NPCs and props on screen are not actually at their highest LOD; they are farther from the camera and have already switched to a cheaper mesh. That is exactly what Section 4 covers.

Tip Vertex count often matters more to the GPU than triangle count, because the vertex shader runs once per vertex, and a model with hard edges (armor plates, sharp corners) needs extra duplicated vertices at each seam. Two meshes with the same triangle count can have very different vertex counts. Triangle count is still the number artists usually track day to day because it is the easier one to reason about while modeling.

4. Level of Detail (LOD)

Level of Detail (LOD) means keeping several versions of the same mesh — one highly detailed, one or more simplified — and switching between them based on how much screen space the object actually occupies. A character standing right in front of the camera needs every detail; the same character 50 meters away covers a handful of pixels, and spending 28,000 triangles on something a player cannot see the detail of is wasted GPU time.

Screen height occupied by the object: >50% 15-50% 2-15% under 2% | | | | Mesh used: LOD0 LOD1 LOD2 culled Triangle count (example character): 18,000 6,000 1,200 0 As the camera moves away, Unity swaps the renderer for a cheaper mesh automatically -- the player is rarely close enough to notice.

Unity's LODGroup component manages this switching. It does not use raw camera distance directly — it uses screen-relative transition height (what fraction of the screen's height the object's bounding box currently takes up), which naturally accounts for field of view and the object's own size, unlike a fixed meters-based cutoff that would need retuning per asset.

using UnityEngine;

public class SetupCharacterLOD : MonoBehaviour
{
    public Mesh highDetailMesh; // ~18,000 triangles
    public Mesh midDetailMesh;  // ~6,000 triangles
    public Mesh lowDetailMesh;  // ~1,200 triangles
    public Material material;

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

        LOD[] lods = new LOD[3];
        lods[0] = MakeLOD(highDetailMesh, 0.5f);  // switch below 50% of screen height
        lods[1] = MakeLOD(midDetailMesh, 0.15f);  // switch below 15%
        lods[2] = MakeLOD(lowDetailMesh, 0.02f);  // switch below 2%, culled after that

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

    LOD MakeLOD(Mesh mesh, float screenRelativeHeight)
    {
        GameObject lodObject = new GameObject("LOD_" + mesh.name);
        lodObject.transform.SetParent(transform, false);

        MeshFilter meshFilter = lodObject.AddComponent<MeshFilter>();
        MeshRenderer meshRenderer = lodObject.AddComponent<MeshRenderer>();
        meshFilter.mesh = mesh;
        meshRenderer.material = material;

        return new LOD(screenRelativeHeight, new Renderer[] { meshRenderer });
    }
}

Expected result: right after Start() runs, the character has three child objects, each holding one LOD mesh. As you move the Scene view camera away from the character, Unity automatically switches which child renderer is active — you can watch it happen live using the Scene view's LOD visualization (a colored strip in the top-right of the Scene view, green for LOD0 through red for the lowest LOD).

Common mistake Only building two LOD levels with a big triangle-count gap between them (say, 18,000 straight down to 1,200). The switch becomes visible as a sudden "pop" the player notices. A middle LOD1 step, and enabling Unity's LOD cross-fade option (which briefly blends between the two meshes instead of snapping), both make the transition much less noticeable.

5. Draw Calls and Why They Are Expensive

A draw call is one instruction from the CPU telling the GPU "render this mesh, using this material." Before most draw calls, the GPU also needs a SetPass call (a state change — switching to a different shader, texture, or set of render settings). Both cost CPU time to prepare and hand off, and that cost exists whether the mesh has 10 triangles or 10,000 — a draw call for a tiny background pebble costs almost the same CPU overhead as one for the hero character.

On a desktop PC, a modern CPU and graphics driver can handle several thousand draw calls a frame without trouble. On a phone, weaker CPU cores and thinner, less optimized mobile graphics drivers make each draw call proportionally far more expensive. A reasonable rough budget for a mid-range phone holding 60 fps is somewhere around 100 to 300 draw calls per frame for the whole scene — not per object.

Same 40 background props, two different setups: Setup A: 40 separate materials --> up to 40 draw calls Setup B: 1 shared material --> as low as 1 draw call (if combined) Same visual result on screen. Very different cost to the CPU.

This is why "how many draw calls does this scene cost" matters as much as "how many triangles does this model have" — they are two separate budgets, and a model can be light on triangles but still expensive because it uses five different materials.

6. Reducing Draw Calls: Batching, Atlases, Combining Meshes, and GPU Instancing

Every technique in this section works by the same underlying trick: get the GPU to draw more triangles per draw call, instead of issuing one draw call per object.

Static and Dynamic Batching, and the SRP Batcher

Static batching combines the meshes of objects marked "Static" in the Editor, at build time, as long as they share the same material. It works well for props that never move (buildings, rocks, level geometry) but uses extra memory, since each combined instance duplicates its mesh data. Dynamic batching automatically merges small meshes (a low vertex-count limit) that share a material, at runtime — useful, but it adds its own per-frame CPU cost and only helps for small meshes, so it matters less than it used to. The SRP Batcher (available in Unity's Universal and High Definition Render Pipelines) is different: it does not reduce the draw call count itself, but it drastically cuts the CPU cost of preparing each one by keeping per-material data cached on the GPU between draw calls. On a modern URP mobile project, making sure the SRP Batcher is actually active (shown in the Frame Debugger) is often a bigger win than chasing batching manually.

Texture Atlases Enable Batching

Static and dynamic batching, and manual mesh combining, all share one requirement: the objects being combined must use the same material (same shader, same texture). Two rocks with two different textures cannot be batched together, no matter how simple their meshes are. A texture atlas (several separate textures packed into one larger image, with each object's UV coordinates — the 2D texture-mapping coordinates — remapped into a smaller region of that image) turns many different-looking objects into objects that all share one material, which is what unlocks batching for them. Section 7 covers atlases in full.

Combining Meshes by Script

For a cluster of static props that already share one material, you can also merge their meshes directly at load time, which guarantees a single draw call regardless of the batching settings above:

using UnityEngine;

public class CombinePropMeshes : MonoBehaviour
{
    void Start()
    {
        MeshFilter[] meshFilters = GetComponentsInChildren<MeshFilter>();
        CombineInstance[] combine = new CombineInstance[meshFilters.Length];

        for (int i = 0; i < meshFilters.Length; i++)
        {
            combine[i].mesh = meshFilters[i].sharedMesh;
            combine[i].transform = meshFilters[i].transform.localToWorldMatrix;
            meshFilters[i].gameObject.SetActive(false); // hide the originals
        }

        Mesh combinedMesh = new Mesh();
        combinedMesh.CombineMeshes(combine);

        GetComponent<MeshFilter>().mesh = combinedMesh;
        gameObject.SetActive(true);
    }
}

Expected result: a cluster of, say, 40 separate rock GameObjects that each cost their own draw call becomes one combined mesh with 40 times the vertex count of a single rock, drawn in exactly 1 draw call. The trade-off: the 40 rocks can no longer move, be culled, or be destroyed individually, since they are now one mesh — this technique is for static, never-changing clusters only.

GPU Instancing for Repeated Props

GPU instancing handles the opposite case: many copies of the exact same mesh (grass, rocks, trees, arrows) at different positions, where you cannot pre-combine them because they need to move, spawn, or despawn independently. Instead of one draw call per copy, the CPU sends the mesh once, plus a list of transform matrices, and the GPU draws every copy in that single call.

using UnityEngine;

public class InstancedRocks : MonoBehaviour
{
    public Mesh rockMesh;
    public Material instancedMaterial; // must have "Enable GPU Instancing" checked
    public int rockCount = 500;

    Matrix4x4[] matrices;

    void Start()
    {
        matrices = new Matrix4x4[rockCount];
        for (int i = 0; i < rockCount; i++)
        {
            Vector3 pos = Random.insideUnitSphere * 50f;
            matrices[i] = Matrix4x4.TRS(pos, Quaternion.identity, Vector3.one);
        }
    }

    void Update()
    {
        // One draw call submits all 500 rocks to the GPU at once.
        Graphics.DrawMeshInstanced(rockMesh, 0, instancedMaterial, matrices, matrices.Length);
    }
}

Expected result: 500 rocks appear on screen, but the Frame Debugger shows only 1 draw call for all of them, instead of 500. The rocks can still be repositioned every frame by rewriting the matrices array, which plain batching cannot do.

Tip Graphics.DrawMeshInstanced has a hard limit of 1023 instances per call (a fixed shader constant-buffer size). Above that count, split the matrices array into chunks of 1023 or fewer and call it once per chunk.
Common mistake Reading renderer.material (not sharedMaterial) anywhere in code, even just to check a property. Accessing .material silently creates a private copy of that material the first time it is touched, which breaks batching for that object from that point on, since it no longer shares a material with anything else. Use sharedMaterial for read-only access.

7. Texture Atlases

A texture atlas packs several smaller textures into one larger image. Instead of a head texture, a body texture, a weapon texture, and a hair texture as four separate files (and four separate materials), all four are packed into one image, and each mesh's UV coordinates are shifted to point at its own region of that shared image.

Before: 4 separate textures, 4 materials -> up to 4 draw calls [ Head.png ] [ Body.png ] [ Weapon.png ] [ Hair.png ] After: 1 atlas texture, 1 material -> 1 draw call +-------------------+-------------------+ | Head | Body | | UV (0, 0.5)-(0.5, 1) UV (0.5, 0.5)-(1, 1) | +-------------------+-------------------+ | Weapon | Hair | | UV (0, 0)-(0.5, 0.5) UV (0.5, 0)-(1, 0.5) | +-------------------+-------------------+

Atlases are not free. Packing wastes some space (padding is needed between regions so that mip-mapping — see Section 9 — does not blend one region's edge into its neighbor's, an effect called "bleeding"). Atlases also have a size ceiling (2048x2048 or 4096x4096 are common maximums on mobile), so there is a limit to how much you can pack into one before you need a second atlas. And once several parts share one atlas, swapping just one part at runtime (a different weapon skin, for example) usually means either repacking or accepting that this part breaks out of the shared atlas and costs its own draw call again.

That last trade-off matters directly for a game with swappable character costumes or skins, the way many mobile games sell them: a common real-world compromise is a handful of atlases per character — one for the base body and face (which never changes), and a separate one per swappable slot (outfit, weapon) — rather than one giant atlas for everything, trading a few extra draw calls for the ability to swap one part without touching the rest.

8. Texture Compression: ASTC and ETC2

An uncompressed 32-bit RGBA texture stores 8 bits per color channel, 32 bits (4 bytes) per pixel. Texture compression formats store the same image using far fewer bits per pixel by encoding small blocks of pixels together instead of storing every pixel independently — at some cost to image quality, chosen so the loss is hard to notice at normal viewing distance.

ASTC (Adaptive Scalable Texture Compression) is the modern standard on both Android and iOS. Its defining feature is a selectable block size: every block always encodes to a fixed 128 bits, but you choose how many pixels each block covers, which directly sets the bits-per-pixel (bpp) rate:

ETC2 is the older Android baseline format (guaranteed support on OpenGL ES 3.0 devices): roughly 4 bpp for RGB, roughly 8 bpp once alpha is added. PVRTC is an older iOS-only format (4 bpp or 2 bpp fixed modes) mostly replaced by ASTC on current devices. In a modern project targeting both platforms, ASTC alone usually covers everything; ETC2 stays around as a fallback for the small slice of very old Android hardware that does not support ASTC.

You can set the compression format per platform in code, so it applies automatically on import instead of manually configuring every texture in the Inspector:

using UnityEditor;
using UnityEngine;

public class SetMobileTextureFormat : AssetPostprocessor
{
    void OnPreprocessTexture()
    {
        // In a real project, filter by folder so this only touches
        // the textures you actually mean to, e.g.:
        // if (!assetPath.Contains("Characters")) return;

        TextureImporter importer = (TextureImporter)assetImporter;

        TextureImporterPlatformSettings androidSettings =
            importer.GetPlatformTextureSettings("Android");
        androidSettings.overridden = true;
        androidSettings.format = TextureImporterFormat.ASTC_6x6;
        androidSettings.compressionQuality = 50;
        importer.SetPlatformTextureSettings(androidSettings);

        TextureImporterPlatformSettings iosSettings =
            importer.GetPlatformTextureSettings("iPhone");
        iosSettings.overridden = true;
        iosSettings.format = TextureImporterFormat.ASTC_6x6;
        importer.SetPlatformTextureSettings(iosSettings);
    }
}

Expected result: from now on, any texture imported or reimported into the project automatically gets an Android override and an iOS override set to ASTC 6x6, visible in the texture's Inspector under the Android and iOS platform tabs — no more manually clicking through every texture's import settings by hand.

Common mistake Using the same aggressive compression block size for every texture type. Normal maps (which encode surface direction, not color) show visible banding artifacts at low bit rates because they are more sensitive to precision loss than a color texture is. A common practice is ASTC 4x4 or 6x6 for normal maps and base color, and a coarser block size like 8x8 or 10x10 for less sensitive data like ambient occlusion or roughness masks.

9. Resolution, Mip-maps, and Texture Memory

A mip-map (short for the Latin "multum in parvo," much in a small space) is a pre-shrunk copy of a texture at half the width and half the height of the level above it, continuing down to 1x1. Unity generates the full chain automatically. When an object is far from the camera and only covers a few pixels, the GPU samples a small mip level instead of the full-resolution texture.

Base Mip 1 Mip 2 Mip 3 ...down to 1x1 1024x1024 -> 512x512 -> 256x256 -> 128x128 -> ... -> 1x1 A distant object samples a small mip instead of the 1024x1024 base texture: fewer bytes read per pixel, and no shimmering from a high-frequency texture being squeezed into a few pixels.

Mip-maps are not just a quality feature — they are a performance one too. Sampling a small, tightly packed mip level fits inside the GPU's fast texture cache far better than sampling scattered pixels out of a huge base texture, the same cache-locality idea from earlier chapters applied to texture memory instead of arrays.

The full mip chain costs extra memory, though: roughly one third more than the base level alone, since each mip level has one quarter the pixels of the level above it, and the sum of an infinite series 1 + 1/4 + 1/16 + 1/64 + ... converges to 4/3. A worked trace for a 1024x1024 base texture:

Base 1024x1024 = 1,048,576 texels Mip 1 512x512 = 262,144 texels (1/4 of base) Mip 2 256x256 = 65,536 texels Mip 3 128x128 = 16,384 texels Mip 4 64x64 = 4,096 texels ...continuing down to 1x1... ---------------- Sum, all levels ~ 1,398,101 texels =~ 1.334 x base Matches the 4/3 rule: (base texel count) x 4 / 3

Combining the bits-per-pixel numbers from Section 8 with the 4/3 mip-chain rule gives a real memory estimate. A 2048x2048 base-color texture at ASTC 6x6 (3.56 bpp):

texels = 2048 x 2048 = 4,194,304 base bits = 4,194,304 x 3.56 = 14,931,722 bits base bytes = 14,931,722 / 8 =~ 1,866,465 bytes =~ 1.78 MB with mip chain: 1.78 MB x 4/3 =~ 2.37 MB

For resolution guidance on a mobile project: a hero character's base color texture is often 1024x1024 or 2048x2048; a mid-size prop is often 512x512 or 1024x1024; a small background prop or a tiling ground texture is often 512x512 or smaller. Going bigger than the object's actual screen presence ever needs is one of the most common sources of wasted texture memory.

Tip Unity's texture streaming system (Mip Streaming, enabled per-texture in the Inspector) keeps only the mip levels currently needed in memory, based on how close the camera actually is, and loads higher mips in as the camera approaches. This matters most for open-world games with far more total texture data than could ever fit in memory at full resolution all at once.

10. Channel Packing

A physically based material commonly needs several grayscale maps beyond its base color: a metallic map (how metal-like each pixel is), a roughness map (how sharp or blurry reflections are), and an ambient occlusion (AO) map (how much a crevice is naturally shadowed by nearby geometry). Storing each as its own full RGBA texture wastes space, since a grayscale value only needs one channel, not four. Channel packing stores three (or four) unrelated grayscale maps in the separate red, green, and blue channels of a single texture instead.

Three grayscale maps: One packed RGB texture: [ Metallic map, grayscale ] +---------------------------+ [ Roughness map, grayscale ] -> | Red channel = Metallic | [ AO map, grayscale ] | Green channel = Roughness| | Blue channel = AO | +---------------------------+ Shader reads 1 texture instead of 3 separate ones.

The shader unpacks the channels by simply reading each color component on its own:

// HLSL fragment shader snippet
fixed4 mask = tex2D(_MaskMap, uv);

float metallic  = mask.r; // red channel   = metallic
float roughness = mask.g; // green channel = roughness
float ao        = mask.b; // blue channel  = ambient occlusion

Expected result: the shader now needs one texture sample to get all three values instead of three separate samples. This matters on mobile specifically because mobile GPUs are frequently bandwidth-bound (limited by how many bytes they can move per second, not by how much raw math they can do) — cutting texture reads from 3 to 1 per pixel is a direct, measurable win, and it also means storing and streaming one texture asset instead of three.

There is no single industry-wide standard for which map goes in which channel (a "mask map" in one engine's convention might be metallic/AO/detail-mask/smoothness in the alpha channel; another team might use a different order entirely). What matters is that your team picks one convention, documents it once, and every shader and every artist follows it consistently.

11. Overdraw and Transparency

Overdraw is when the GPU shades the same pixel more than once in a single frame. A small amount is unavoidable — opaque objects sitting in front of each other still cost some overdraw before the GPU figures out what is actually visible. The real damage comes from transparency: particle effects, hair cards, foliage, and UI panels that use alpha blending.

Opaque rendering can use early depth testing (also called an early-Z or depth pre-pass): the GPU checks whether a pixel is hidden behind something closer before running the full pixel shader on it, and skips the shading work entirely if so. Alpha-blended transparency cannot use this shortcut in the same way — a transparent pixel has to blend with whatever is already behind it, so it must actually run its shader and combine colors, and transparent objects must be drawn back-to-front for that blending to look correct, in an order the depth buffer cannot simply skip past.

One pixel column, 5 overlapping transparent particles stacked on top of each other, all covering the same screen pixel: Particle 5 ############ <- drawn 5th (still has to shade, on top of 4 layers already drawn) Particle 4 ############ Particle 3 ############ Particle 2 ############ Particle 1 ############ Background ############ That one pixel gets shaded 6 times total before the player sees the final blended color -- 6x overdraw on a single pixel.

A worked example of the cost: a phone screen at 1080x2400 has 2,592,000 pixels. At 60 fps and a nominal overdraw factor of 1x (each pixel shaded once), the GPU shades roughly 155.5 million pixels per second just for the opaque scene. A burst of particle effects and a couple of stacked transparent UI panels can easily push the average overdraw factor to 3x or more in the affected screen area — tripling the pixel-shading work in that region, with nothing extra to show for it on screen beyond what a well-optimized version of the same effect would look like.

Common mistake Using alpha-blended transparency for effects that could be alpha-cutout (fully opaque or fully invisible per pixel, decided by a threshold test with no blending) or fully opaque instead — leaves, chain-link fences, and simple particle sprites are common cases where cutout looks almost identical and avoids the overdraw and sorting cost of true blending entirely.

Practical ways to keep overdraw under control: keep individual particles small on screen and their lifetime short, avoid stacking multiple full-screen transparent panels in the UI at once, prefer alpha-cutout over alpha-blend where the visual difference is small, and use the Scene view's Overdraw shading mode regularly during development to spot hotspots (areas that render almost solid white from many stacked layers) before they reach a player's phone.

12. Occlusion Culling

You likely already know frustum culling (skipping anything outside the camera's view cone, so the GPU never even considers it). Occlusion culling goes a step further: it skips objects that are inside the view cone but are still completely hidden behind something else closer to the camera, like a statue standing directly behind a solid wall.

Camera Wall (occluder) Statue (occludee) C --view cone--> [##########] ( X ) Frustum culling alone: the statue is inside the view cone, so it would normally be sent to the GPU and drawn. Occlusion culling: Unity has pre-computed that the wall fully blocks this view angle, so the statue is skipped entirely -- no draw call, no vertex processing, no pixel shading for it.

Unity's built-in occlusion culling is a baked system: you open Window > Rendering > Occlusion Culling, mark which objects can block visibility (Occluder Static) and which objects should be considered for culling (Occludee Static), and bake the data once. At runtime, the camera reads that precomputed data to decide what to skip, which is far cheaper than testing actual geometry against the camera every frame.

using UnityEditor;
using UnityEngine;

public class MarkAsOccluder
{
    [MenuItem("Tools/Mark Selection As Occluder And Occludee")]
    static void Mark()
    {
        foreach (GameObject go in Selection.gameObjects)
        {
            StaticEditorFlags flags = GameObjectUtility.GetStaticEditorFlags(go);
            flags |= StaticEditorFlags.OccluderStatic;
            flags |= StaticEditorFlags.OccludeeStatic;
            GameObjectUtility.SetStaticEditorFlags(go, flags);
        }
    }
}

Expected result: selecting a set of wall and building GameObjects and running this menu item flags them as both occluders and occludees in the Inspector's Static dropdown, so the next occlusion bake takes them into account. After baking, walking the camera down a street and looking at the Frame Debugger shows draw calls disappearing for buildings around a corner the camera cannot actually see behind.

Occlusion culling earns its keep the most in dense, indoor, or urban scenes, where walls and buildings block a lot of what is technically still inside the view cone. It earns its keep the least in open outdoor terrain, where there is rarely anything solid enough to hide much of anything — an open plain relies far more on LOD (Section 4) and a hard draw-distance cutoff than on occlusion culling.

13. Practical Checklist for a 60fps Mobile Character or Prop

Pulling every technique in this section into one checklist for a single character or prop that needs to hold 60 fps on a phone:

Geometry

Textures

Materials and Draw Calls

Transparency and Culling

Testing

The full pipeline, start to finish: model (Section 3) -> LOD levels (Section 4) -> texture atlas + channel-packed masks (Sections 7, 10) -> compressed + mip-mapped textures (Sections 8, 9) -> batched / instanced draw calls (Section 6) -> transparency kept in check (Section 11) -> occlusion-culled when off-screen behind something (Section 12) -> measured on a real phone (Section 2)

14. Glossary

15. Exercises

Exercise 1 — Count the Draw Calls A scene has 40 rock props scattered around a cliff. Today, each rock uses its own 512x512 texture and its own material (no atlas, no combining, no instancing). Answer: (a) roughly how many draw calls do the 40 rocks cost today, and why? (b) If all 40 rocks were repacked to share one texture atlas and one material, and then combined into a single mesh at load time using the technique from Section 6, how many draw calls would they cost, and what would the trade-off be?
Show answer

(a) Roughly 40 draw calls — one per rock, since each has a different material and cannot be batched or combined with the others. Even though the rocks are simple and low-poly, each one still costs its own CPU overhead to submit.

(b) Once every rock's texture lives in one shared atlas and every rock uses the same material, Mesh.CombineMeshes can merge all 40 rock meshes into a single mesh, bringing the cost down to exactly 1 draw call for the whole cluster. The trade-off: the combined cluster can no longer be moved, culled, or destroyed rock-by-rock — it behaves as one object from that point on, so this only makes sense for rocks that are meant to be permanent, static level geometry.

Exercise 2 — Design an LOD Chain A new enemy prop has a highest-detail mesh (LOD0) of 24,000 triangles. Using the rough rule of thumb from Section 4 that each LOD level cuts the triangle count by roughly a third of the previous level, and reusing the screen-height thresholds from the SetupCharacterLOD example (50%, 15%, 2%), fill in reasonable triangle counts for LOD1 and LOD2, and write out the lods[] setup lines that would configure it.
Show answer

Applying roughly a third of the previous level each step: LOD0 = 24,000, LOD1 =~ 8,000, LOD2 =~ 2,600 (24,000 / 3 =~ 8,000; 8,000 / 3 =~ 2,600).

LOD[] lods = new LOD[3];
lods[0] = MakeLOD(highDetailMesh, 0.5f);  // ~24,000 tris, switch below 50% of screen height
lods[1] = MakeLOD(midDetailMesh, 0.15f);  // ~8,000 tris, switch below 15%
lods[2] = MakeLOD(lowDetailMesh, 0.02f);  // ~2,600 tris, switch below 2%, culled after that

lodGroup.SetLODs(lods);
lodGroup.RecalculateBounds();

The exact ratio (a third) is a rule of thumb, not a law — an artist would actually build LOD1 and LOD2 by hand and check that they still read correctly on screen at their intended distance, but this gives a reasonable first target to hand to the modeler.

Exercise 3 — Texture Memory Budget A mobile game gives each character a 6 MB texture budget, compressed, including mip-maps. A character uses: a 2048x2048 base color texture, a 1024x1024 channel-packed mask texture (metallic/roughness/AO), and a 1024x1024 normal map — all compressed at ASTC 6x6 (3.56 bpp). Using the formulas from Section 9 (texels x bpp / 8 for base bytes, then x 4/3 for the full mip chain), does this character fit inside the 6 MB budget? Show the math for each texture.
Show answer

Base color, 2048x2048:

texels     = 2048 x 2048 = 4,194,304
base bytes = 4,194,304 x 3.56 / 8 =~ 1,866,465 bytes =~ 1.78 MB
with mips  = 1.78 MB x 4/3 =~ 2.37 MB

Mask map, 1024x1024:

texels     = 1024 x 1024 = 1,048,576
base bytes = 1,048,576 x 3.56 / 8 =~ 466,616 bytes =~ 0.445 MB
with mips  = 0.445 MB x 4/3 =~ 0.593 MB

Normal map, 1024x1024 (same size and format as the mask map):

with mips  =~ 0.593 MB

Total: 2.37 + 0.593 + 0.593 =~ 3.56 MB, comfortably inside the 6 MB budget, with roughly 2.44 MB of headroom left for anything else the character might need (an emission mask, a second material's textures, and so on).

← Back to all chapters