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.
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.
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.
You cannot optimize what you have not measured. Unity gives you three main places to look:
Window > Analysis > Frame Debugger) lets you step through a single frame one draw call at a time and see exactly what each one drew.Window > Analysis > Profiler) shows CPU and GPU time per frame, broken down by system, and can attach to a real device over a cable — essential, since the Editor's own numbers never quite match a real phone.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:
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:
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.
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.
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).
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.
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.
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 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.
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.
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 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.
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.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.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.
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.
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.
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.
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:
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):
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.
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.
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.
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.
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.
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.
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.
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.
Pulling every technique in this section into one checklist for a single character or prop that needs to hold 60 fps on a phone:
renderer.material where sharedMaterial would do, to avoid silently breaking batching (Section 6).(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.
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.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.
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).