DEV Community

Cover image for Unity Foundational Architecture: Profiling & Optimization

Unity Foundational Architecture: Profiling & Optimization

Table of Contents:

Introduction

Building a clean, scalable architecture is the first step to a successful project, but even the most elegant C# codebase will eventually hit a performance wall. Whether you're wrangling a complex web of NavMeshAgents, pushing the limits of the Universal Render Pipeline (URP), or managing intense multiplayer network states, optimization is an unavoidable phase of development.

The problem? Optimization is often treated as a dark art—a desperate scramble of turning off shadows, baking lights, and refactoring Update() loops until the frame rate magically improves.

In this post, we are replacing guesswork with a systematic approach. We'll look at the golden rules of performance, how to track down bottlenecks, and practical C# techniques—like zero-allocation patterns—to keep your game running smooth.

But before we touch a single line of code, we need to talk about the most intimidating window in the engine: The Profiler.

The Profiler: Reading the Story of a Frame

When you first open Window > Analysis > Profiler, it’s easy to feel overwhelmed. There are dozens of modules, nested hierarchies, and endless streams of data.

Here is the secret: you can ignore 90% of it. You don't need to understand every single function call in the hierarchy view to fix a performance issue. You just need to know how to read the story of a frame which should tell you where to look in your codebase. After identifying the problem the general loop is Profile->Indentify->Fix->Repeat.

Identifying A Problematic Frame in the CPU Timeline

After capturing some data in the profiler and hitting pause, you can see the CPU Usage timeline and now you can click anywhere in the timeline to inspect a problematic frame. What makes a frame problematic? Quite simply there are only two things you really have to look at:

  1. Hunt the Spikes Run your game and watch the timeline graph. A healthy game looks like a relatively flat horizon. A performance problem looks like a spike. When you see a massive mountain break your target fps threshold, pause the Profiler and click on that exact frame.
  2. Read the Colors In the CPU Usage timeline, Unity color-codes the workload. The color of your spike tells you exactly which part of your game is causing the bottleneck. You can also toggle them on/off by clicking on them:
    • Blue (Scripts): Your C# code is the culprit. You might have a heavy calculation in an Update() loop, inefficient component fetching, or a massive pathfinding request.
    • Green (Rendering): The engine is struggling to draw the scene. This usually means too many draw calls, unbatched materials in URP, or heavy UI geometry updates.
    • Orange (Physics): The physics engine is choking. You might have too many complex mesh colliders or excessive FixedUpdate queries.
    • Swampy Brown 💩 (Garbage Collector): You are allocating and throwing away too much temporary memory, and the GC is taking a lot of the frame time to clean it up.

By simply finding the spike and checking its color, you immediately narrow down your optimization efforts from "something is slowing down my game" to "my C# scripts are taking 12ms." You haven't even looked at the hierarchy yet, but you already know exactly where to start digging.

Reading the Profiler Hierarchy View

Once you've identified a spike in the CPU Timeline, it's time to find the exact line of code responsible. When you select a frame in the Timeline, the lower panel updates to show the Hierarchy view (B) for that specific slice of time. If it doesn't show the Hierarchy View simply switch to it using the dropdown (A) show in the image below.

The Hierarchy view is a massive tree of every function Unity executed during that frame. To keep from getting lost, follow this workflow:

  1. Test on Target Hardware Test on Target Hardware when possible. A high-end development rig will hide performance sins that a mobile device or a console will immediately expose.
  2. Think in terms of Milliseconds
    Frames Per Second (FPS) is a terrible metric for optimization because it is not linear. The jump from 30 to 60 FPS is about the same amount of time as the jump from 60 to infinite FPS.

    Instead, look at the CPU Usage Timeline. Your target frame rate dictates your time budget:

    • 60 FPS = (1000 ms/sec) / (60 frames/sec) = 16.66 milliseconds per frame.
    • 30 FPS = (1000 ms/sec) / (30 frames/sec) = 33.33 milliseconds per frame.

    If a specific frame takes 20ms to process, you've missed your 60 FPS budget on that frame. The Profiler's job is simply to tell you where those 20ms went.

    The simple fact is that you should be using frame time instead of FPS to measure performance.

    Read the original article, “Robert Dunlop’s fps versus frame time”, for more information.

  3. Sort by Time ms
    In the hierarchy view, ignore Calls,GC Alloc and other column headers for a moment. Click the Time ms column header to sort descending if not already sorted that way. The process taking the most time will jump to the top. Usually, the top item will just be PlayerLoop. This is Unity's main engine loop and doesn't tell you much on its own, but it serves as your entry point.

    Also do keep in mind that sometimes the Time ms column might show a low number but the Calls column might be something high like 500, for example. The true ms value is always Calls * Time ms.

  4. Follow the Breadcrumbs
    Expand the arrows next to the most expensive calls to drill down into the tree. You are usually looking for the transition from engine code to your code. A typical path to a script bottleneck looks like this:

    • PlayerLoop -> Update.ScriptRunBehaviourUpdate -> BehaviourUpdate -> MyCustomMonoBehaviour.Update() If you see your script name and Calls * Time ms is something high like 8ms (50% of your 16.66ms frame budget for target 60fps framerate), you've found a culprit and a starting point for analysis outside the profiler.

Deeper Profiling in the Hierarchy View

When you find MyCustomMonoBehaviour.Update(), you might notice you can't expand it any further. By default, Unity only profiles native engine messages (Update, FixedUpdate, Start, etc.). It doesn't look inside your custom methods to see whether CalculatePath() or CheckSightlines() is causing the slowdown.

You have two options here:

  1. Deep Profiling: You can click the Deep Profile button at the top of the profiler window. This forces Unity to look at every single method in your codebase. This will give you the exact line of code, but it adds massive overhead. It will tank your framerate so heavily that the frame times will be skewed.
  2. Architectural Profiling (The Better Way): Instead of deep profiling, wrap your suspicious methods in Profiler.BeginSample("MyCustomLabelOrMethodName") and Profiler.EndSample(). This will cause your custom profilier labels will appear directly in the Hierarchy tree. It's very important that you don't miss any Profiler.EndSample() calls (every BeginSample(...) should have an EndSample()). You should also remove (or comment out) these calls when you are done fixing your issue.

That's about the gist of it when it comes to the profiler window without getting to in-depth. There's a really nice article by Unity about Performance Profiling that covers a bit of what we talked about and goes more in depth.

Taming the Garbage Collector (Memory Allocation)

The Garbage Collector (GC) is the enemy of a smooth framerate. When you create new temporary data on the heap, the GC eventually has to clean it up, causing a CPU spike that players feel as a "stutter". The goal is to reduce recurring allocation during gameplay. The most critical place where you want to optimize is your hot paths (hot paths = sections of code that execute frequently like every frame in Update). Here are some tips and techniques you can employ to help keep the GC happy.

Banish LINQ from the Game Loop

LINQ (.Where, .Select, .ToList) is fantastic for enterprise software, but terrible for hot paths in games.

Behind the scenes, LINQ creates enumerator objects, delegates, and closures, all of which are allocated on the heap which the GC will have to clean up later. Use standard for or foreach loops with pre-allocated cached lists instead.

Instead of this:

void Update()
{
    // Allocates temporary memory every single frame!
    var activeEnemies = allEnemies.Where(e => e.isActive).ToList();
}
Enter fullscreen mode Exit fullscreen mode

Do this:

// Pre-allocate a reusable list
private List<Enemy> _activeEnemies = new List<Enemy>(50);

void Update()
{
    // Clears the list without deallocating memory
    _activeEnemies.Clear();

    for (int i = 0; i < allEnemies.Count; i++)
    {
        if (allEnemies[i].isActive)
        {
            _activeEnemies.Add(allEnemies[i]);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Boxing Trap

Value types (like int, float, or struct) live cleanly on the Stack. But if you pass a value type into a method that expects a reference type (like an object or interface), C# has to "box" that value, hence the term "boxing". It wraps the int inside a temporary object on the Heap, instantly creating garbage. Aside from method calls, another common place where this happens that people often miss is with string formatting (e.g, healthText.text = string.Format("HP: {0}", currentHealth);).

Consider this code:

public Text healthText;
private int currentHealth = 100;

void Update()
{
    // string.Format takes an (string, object) array. 
    // currentHealth (int) is implicitly boxed into an object every single frame!
    healthText.text = string.Format("HP: {0}", currentHealth);
}
Enter fullscreen mode Exit fullscreen mode

Because string.Format demands an object reference for {0}, Unity is forced to allocate memory on the heap every single frame inside Update(). Then the stack frame is popped along with the pointer pointing to the currentHealth object on the heap which is now unnecessary garbage that the GC has to clean up.

String Concatenation in Hot Paths

Strings in C# are immutable. You cannot change a string once it's created; you can only destroy it and create a new one.

If you write healthText.text = "Health: " + hp.ToString(); inside an Update loop, you are creating and abandoning a brand-new string object 60 times a second.

Avoid doing string concatenation every frame. Use an event-driven architecture. Only concatenate and update the string when the hp value actually changes. If you must build complex strings frequently, use a cached StringBuilder which, internally, will modify a buffer rather than allocating new strings.

Object Pooling

Instantiating a prefab forces the engine to allocate memory, deserialize data, and run Awake, Start, and OnEnable methods. Destroying it causes the engine to run OnDisable and Destroy methods before handing it straight to the Garbage Collector.

If you are firing a machine gun that Instantiates 10 bullets a second and then Destroys each of them on hit, your framerate will tank.

Instead, build an Object Pool. During initialization, scene startup or loading screen, instantiate 50 bullets and call SetActive(false) on each of them. When you fire, grab an inactive bullet, move it to the gun barrel, and then call SetActive(true) on it. When it hits an object, turn it back off (SetActive(false)). This way you are reusing the exact same memory blocks indefinitely and keeping the GC happy.

Debug Logs in Production

Debug.Log is surprisingly heavy. Standard logging does a lot of string concatenation, boxing, stack tracing, and heavy disk I/O behind the scenes. Leaving Debug.Log calls in your production builds is a guaranteed way to bleed performance, especially in Hot Paths.

Strip them out from production builds using compilation conditions ([Conditional("SYMBOL")]) or a custom logger.

// this line needs to be written this way because simply using System.Diagnostics
// will cause a clash between it's Debug class and UnityEngine's Debug class.
using Conditional = System.Diagnostics.ConditionalAttribute;

public static class LogUtility
{
    [HideInCallstack, Conditional("UNITY_EDITOR")]
    public static void Log(string message, UnityEngine.Object context = null)
    {
        if (Debug.unityLogger.logEnabled)
            Debug.Log(message, context);
    }

    [HideInCallstack, Conditional("UNITY_EDITOR")]
    public static void LogWarning(string message, UnityEngine.Object context = null)
    {
        if (Debug.unityLogger.logEnabled)
            Debug.LogWarning(message, context);
    }

    [HideInCallstack, Conditional("UNITY_EDITOR")]
    public static void LogError(string message, UnityEngine.Object context = null)
    {
        if (Debug.unityLogger.logEnabled)
            Debug.LogError(message, context);
    }

    // other overloaded methods here ...
}
Enter fullscreen mode Exit fullscreen mode

By using this and replacing your Debug.Log calls with it you tell the compiler to strip the function calls from builds where UNITY_EDITOR is not defined, saving you some performance in production builds.

  • [HideInCallstack]: Is an attribute that hides methods or helper functions from the Unity Console's stack trace. Without it, every time you double-click a log that was output by our LogUtility in the console, Unity opens the IDE and points to our custom logging function inside LogUtility instead of the script that actually triggered the log/error. By adding this attribute, you hide the "middleman" method from the stack trace.
  • [Conditional("UNITY_EDITOR")]: Is an attribute that uses a C# compilation feature that removes method calls from your compiled builds based on scripting define symbols. It's a cleaner, less error-prone alternative to wrapping code blocks in #if and #endif preprocessor directives but functionally the same. It does however have some restrictions:
    • void Return Type Only: The method must return void. If it returned a value, removing the call would break statements expecting that return data.
    • No out Parameters: It cannot use out parameters, though ref parameters are permitted.
    • Ignored on Unity Callbacks: You cannot use it on native Unity lifecycle methods like Start(), Update(), or Awake(). Unity calls these directly via the engine backend rather than standard C# compilation.
    • Inner Code Validation: Because the method body is still compiled (only the invocations are removed), the code inside must remain valid for all platforms. If you reference an editor-only namespace like UnityEditor, you must still use #if UNITY_EDITOR to wrap the code inside the method to prevent build errors.

Taming the CPU (Scripting & Logic)

Writing non allocating C# is only part the battle. You also have to understand how C# talks to Unity's underlying C++ engine and how your logic can be wasting unnecessary cpu cycles.

The Magic Method Interop Cost

Unity's "magic methods" (Start, Update, FixedUpdate, LateUpdate, etc) aren't standard C# overrides. Unity's C++ core has to "cross a bridge" to call your C# script functions. This transition has a cost (this is known as an interop cost). Having 1,000 scripts with an empty Start() or Update() method will actually drain performance just from the interop overhead.

You can find many of Unity's popular magic methods listed in the manual here.

What to do about it:

  • Delete empty magic methods: Never leave an empty Start() or Update() or any other magic method in your scripts.
  • Centralize your Update methods: If you have hundreds of simple entities (like bullets or floating damage text), don't give them their own Update methods. Instead, use a single UpdateManager script with one Update() method that iterates through a plain C# list of those objects and calls a custom ManagedUpdate(float deltaTime) method on them. This way you pay the C++ bridge toll only once. There is a nice blog post from Unity about using an UpdateManager vs Update() with some benchmark results here.

Time Slicing

A common mistake in Unity is assuming that because the game runs at 60 FPS, every system needs to update at 60 FPS.

Ask yourself these questions: Does your UI minimap need to update every single frame? Does the enemy AI need to recalculate its path 60 times a second? Usually, the answer is NO. Running heavy calculations too frequently is a massive waste of CPU cycles.

What to do about it: Use coroutines or simple timer variables to run heavy logic every 0.2 seconds (5 times a second) instead of every frame.

private float _pathfindingTimer = 0f;
private readonly float _pathfindingInterval = 0.2f; // 5 times a second

void Update()
{
    _pathfindingTimer += Time.deltaTime;

    if (_pathfindingTimer >= _pathfindingInterval)
    {
        CalculateNavMeshPath();
        _pathfindingTimer = 0f;
    }
}
Enter fullscreen mode Exit fullscreen mode

You can even combine this solution with the UpdateManager discussed above turning it into an UpdateScheduler and kill two birds with one stone.

By throttling non-critical systems, you flatten the CPU timeline and prevent nasty spikes.

Taming the GPU (Scene & Rendering)

While this article focuses more on the CPU side of things that a programmer would need to know, knowing about occlusion culling and batching is a good entry point into GPU performance optimization and something you should at least know a little about. Let's just scratch the surface a bit and talk about static and dynamic batching and occlusion culling.

If your CPU is fine but your game is still slow, you are likely bottlenecking the GPU with too many Draw Calls.

A draw call is simply the CPU sending an instruction to the GPU saying, "Draw this mesh using this material." If you have 1,000 distinct objects on screen, that's 1,000 draw calls. The CPU spends so much time preparing and sending these instructions that the GPU sits idle waiting for them, killing your performance.

Rendering optimization involves reducing draw calls by combining them. This is called Batching.

Batching & GPU Instancing

Every distinct material in your scene requires a separate draw call. Batching groups objects together so they can be drawn in one go.

Historically Unity handles this using Static and Dynamic batching, the latter being replaced by the SRP Batcher in URP and HDRP as the preferred method. You can still enable Dynamic batching in URP/HDRP but Unity keeps hiding or moving the option as if to persuade you to use the SRP Batcher.

In URP/HDRP, you should keep Dynamic Batching turned off. Dynamic Batching is off by default in URP/HDRP. Enabling it can interfere with the SRP Batcher's native rendering path.

Static Batching:
This is for objects that never move, like walls, floors, and buildings. By checking the "Batching Static" box in the Inspector, Unity combines these meshes into a single massive mesh under the hood during the build process.

  • Where to enable it: Enabled in Player Settings (Edit > Project Settings > Player > Other Settings).
  • Requirements:
    • GameObject: must be active in the scene, possess an enabled Mesh Filter component referencing a valid mesh, possess an enabled Mesh Renderer component, and must be marked as Batching Static in the Inspector’s static dropdown flags (see image below) or combined via the StaticBatchingUtility class at runtime.
    • Mesh: The source meshes must have Read/Write Enabled checked in their import settings. Vertex count must be greater than zero. Must not already be combined into another mesh. Must use the same vertex attributes (e.g., matching position, normals, and UV layouts). Hard vertex limit of 64,000 vertices per combined batch.
    • Material and Shader: Objects must share the same material (or compatible shaders under specific pipelines). The assigned shader must not contain the DisableBatching tag set to true.
  • The Trade-off: It drastically reduces draw calls, but increases your RAM and app size because the combined mesh is saved in memory.

Dynamic Batching: Automatically batches small, moving meshes that share the same material. It has strict vertex limits, but it's great for simple projectiles or debris.

  • Where to enable it: You can still enable Dynamic batching in URP/HDRP but Unity keeps hiding or moving the option most likely to persuade you to use the SRP Batcher instead.
    • BIRP (Built-In Render Pipeline): Enabled in Player Settings (Edit > Project Settings > Player > Other Settings).
    • URP/HDRP: The option has been moved to your specific URP Render Pipeline Asset. If the setting is missing, this is Unity hiding it and to show it you have to go to Edit > Preferences, click Core Render Pipeline in the side menu, look for Additional Properties Visibility setting and set it to Always Visible (see image below). But if you are using URP/HDRP, you should leave this turned off and rely on the SRP Batcher instead.
  • Requirements:
    • Project Settings: Must have Dynamic Batching enabled in Player Settings (Edit > Project Settings > Player > Other Settings).
    • GameObject: Objects must have identical or uniform scaling. Non-uniform scaling (e.g, Transform.localScale of (1, 2, 1)) prevents batching.
    • Mesh: Does not support Skinned Meshes (characters/cloth), complex particle systems, or objects utilizing real-time spot/point lights affecting them mid-render. Meshes must contain fewer than 300 vertices and no more than 900 vertex attributes (e.g, if your shader uses position, normals, and UVs, the vertex limit shrinks).
    • Material & Shader: GameObjects must use the exact same Material instance. Shaders cannot use multiple passes. Multi-pass shaders (like traditional per-pixel lights or legacy deferred paths) break dynamic batching. If objects are lightmapped, they must point to the exact same lightmap location and UV index.
  • The Trade-off: The main trade-off of dynamic batching is that it trades CPU performance for GPU efficiency. Unity consumes CPU cycles to analyze, transform, and merge vertices into a single buffer on the fly, which can sometimes cost more than the draw calls it saves.

SRP Batcher (URP/HDRP):
Standard batching requires objects to share the exact same Material. The SRP Batcher is much smarter—it only requires objects to share the exact same Shader Variant. It is also automatically used by URP/HDRP.

Instead of combining meshes (which is CPU-intensive), the SRP Batcher binds the shader to the GPU once, and then simply streams the different material properties (like color or smoothness) into a dedicated block of memory.

How to maximize the SRP Batcher in URP:

  • Use the standard URP/Lit or URP/Simple Lit shaders as much as you can.
  • Avoid Custom Shaders for basic props. Every unique custom shader breaks the batch.
  • Check the Profiler. In the Profiler’s Render module, look for SRP Batcher. If your draw calls are high but your SRP Batcher count is low, you have too many incompatible shaders in your scene.

How the SRP Batcher handles dynamic objects instead:
Traditional batching focuses on reducing the number of draw calls (e.g, combining 100 meshes into 1 mesh). The SRP Batcher doesn't reduce the draw call count. Instead, it makes each draw call dramatically faster. Because it keeps material data persistent in GPU memory and only streams transform data, the CPU spends virtually zero overhead time setting up the GPU for consecutive draw calls—as long as those objects share the same Shader Variant (not necessarily the same material).

GPU Instancing:
While the SRP Batcher reduces CPU setup time for objects using the same shader, GPU Instancing goes a step further: it draws hundreds or thousands of copies of the exact same mesh using the exact same material in a single physical draw call.

Instead of sending transform data to the GPU one object at a time, the engine uploads the mesh geometry once alongside an array of per-instance data (like positions, rotations, scales, or per-instance colors).

When to Use GPU Instancing: Foliage and Environment Details like Forest trees, grass patches, rocks, or fence segments. Dense Game Entities like Bullet hell projectiles, coins, or floating loot drops.

How to enable it for materials:

  • Standard Materials: Select your material in the Inspector and check the Enable GPU Instancing box at the bottom.
  • Custom Shader Graph: Open your Shader Graph, go to Graph Settings, and toggle Enable GPU Instancing.

Batching vs GPU Instancing Priority:
Unity actually has a strict, built-in order for how it prioritizes rendering optimizations. If a object is technically eligible for all four methods at the same time, Unity will not try to apply them all at once. It goes down a checklist and picks the highest priority method.

  1. Is it elligible for SRP Batching / Static Batching? (If yes, use both and stop.)
  2. Is it elligible for GPU Instancing? (If yes, do it and stop)
  3. Is it elligible for Dynamic Batching? (If yes, do it. But ideally, it never gets this far in URP/HDRP)

Summary - Static vs Dynamic vs SRP Batching Pro & Cons:

Batching Method Best Used For Pros Cons
Static Batching Non-moving environment objects (walls, terrain, buildings). Excellent performance; no runtime CPU overhead. Increases memory size significantly; objects cannot move.
Dynamic Batching Built-in Render Pipeline, when the SRP Batcher cannot be used. Your only option for dynamic objects in BIRP. Helps the GPU at the cost of using a bit more CPU. Higher CPU cost, Strict Mesh constraints (< 300 vertices), and conflicts with SRP Batcher
SRP Batcher (URP/HDRP Only) Large scenes with varied meshes but identical shaders. Reduces CPU draw call overhead almost entirely; allows moving objects. Not available in the Built-in Render Pipeline; requires compatible shaders.
GPU Instancing Identical meshes repeated many times (forest trees, grass, bullets). Handled entirely by the GPU; extremely fast. Requires the exact same mesh and material; requires shader support.

Occlusion Culling

Before Unity even considers batching your meshes or sending them to the GPU, it runs through a culling phase. If you can stop an object from being processed in the first place, you bypass the CPU setup overhead and GPU cost entirely.

There are two main types of culling in Unity:

  1. Frustum Culling (Automatic & Free): The camera has a mathematical "frustum" (a pyramid-shaped field of view). If an object is completely outside this pyramid—meaning it is behind the camera or off to the side—Unity automatically culls it. You don't have to do anything to enable this.
  2. Occlusion Culling (Manual & Costs CPU): What happens if an object is inside the camera's frustum, but completely hidden behind a solid brick wall? By default, Unity will still process it, send it to the GPU, and let the GPU figure out that it shouldn't draw those pixels (this is called Overdraw). Occlusion culling fixes this by actively disabling objects hidden behind other objects.

How Occlusion Culling Works:
Occlusion Culling in Unity is powered by a middleware system called Umbra. It works by breaking your scene into a grid of 3D cells and calculating lines of sight between them during the build process (Baking).

At runtime, the CPU queries this baked data to see which cells the camera can currently see, and instantly turns off the mesh renderers of the objects in the cells that are blocked by solid objects.

The Golden Rule of Occlusion: Occlusion Culling is not free. You are trading CPU time (querying the Umbra data) to save GPU time (drawing the meshes). If your game is a flat, open-world terrain where you can see for miles, occlusion culling will actually hurt your performance by burning CPU cycles to hide almost nothing. It is designed for dense environments with large vision-blockers like cities, corridors, or dungeon layouts.

How to Use It:
Setting up occlusion culling requires strict scene discipline.

  • Tag Your Geometry: Select the GameObjects in your scene and look at the "Static" dropdown in the top right of the Inspector.
    • Occluder Static: Tag large, solid objects that block vision (walls, buildings, mountains) with this setting.
    • Occludee Static: Tag objects that can be hidden by occluders (props, enemies, small furniture) with this setting.
    • Note: You can tag objects with both. A brick wall could be both an Occluder and an Occludee.
  • Open the Window and Bake: Go to Window > Rendering > Occlusion Culling. Switch to the Bake tab and hit the Bake button. Unity will generate the spatial data.

Recommended Settings:
The default bake settings are often too fine for most games, resulting in massive bake times, bloated file sizes, and high CPU overhead at runtime.

Here is how you should tune the parameters in the Bake tab:

  • Smallest Occluder: (Default 5). This tells Unity the smallest object size that can hide other things. Increase this. If you set this to 1 meter, Unity will try to calculate what is hidden behind every single barrel and crate in your scene. Set this to the size of your large walls or buildings (e.g, 5 to 10). Do not use small props to block vision.
  • Smallest Hole: (Default 0.25). This dictates the smallest gap the camera can see through (like a cracked door or a window). Increase this if possible. If your game doesn't have tiny peepholes, increasing this value (e.g., to 0.5 or 1) drastically reduces the complexity of the grid and speeds up both baking and CPU query times.
  • Backface Threshold: (Default 100). This optimizes the data size by removing polygons that face away from the camera. Unless you have hollow geometry that the camera can somehow clip inside of, leave this at 100.

By tuning Smallest Occluder and Smallest Hole to be as large as your level design allows, you ensure the CPU spends minimal time querying the occlusion data, leaving more of your frame ms budget for your game logic.

Conclusion

Optimization is not a band-aid you apply at the end of a project; it is a mindset you adopt from day one. By integrating zero-allocation patterns, time-sliced logic, smart rendering practices, and all that fun stuff into your architecture, you prevent performance fires before they start.

But remember:

  • Never guess, always measure your changes.
  • Premature optimization is the root of all evil.

Don't spend three days writing a complex object pool for a particle effect that only ends up firing once an hour. Use the Profiler, find the red spikes eating away at your ms frame budget, and optimize only what is actively holding your game back.

Clean architecture naturally leads to good performance. Keep your systems decoupled, respect the Garbage Collector, and let the Profiler tell you the story of your frames.

Top comments (0)