Optimizing Graphics and Rendering in Unity: Key aspects and practical solutions
Tags: unity3d, tutorial, howto, gamedev
Published: 2024-04-29 10:59:31

**Rendering plays** a critical role in creating visually appealing and interactive game scenes. However, inefficient utilization of rendering resources can lead to poor performance and limitations on target devices. Unity, one of the most popular game engines, offers various methods and tools to optimize rendering.

Last time we considered optimizing **C# code** from the viewpoint of [memory](https://dev.to/devsdaddy/memory-optimization-in-c-effective-practices-and-strategies-1m0l) and [CPU](https://dev.to/devsdaddy/optimizing-cpu-load-in-c-key-approaches-and-strategies-3fk3). In this article, we will review the basic principles of rendering optimization in Unity, provide code examples, and discuss practical strategies for improving game performance.

> This article has examples of how you can optimize a particular aspect of rendering, but these examples are written only for understanding the basics, not for use in production

## Fundamentals of rendering in Unity
Before we move on to optimization, let's briefly recap the basics of rendering in Unity. You can read more about the rendering process in [my past article](https://dev.to/devsdaddy/how-it-works-3d-games-a-bit-about-shaders-and-how-the-graphics-pipeline-works-in-unity-4ajg).


![Unity Rendering Optimization Tutorial](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n1kwhivnncik7j17mem3.jpg)

### Graphics pipeline
Unity uses a graphics pipeline to convert three-dimensional models and scenes into two-dimensional images. **The main stages of the pipeline include:**
- **Geometric transformation:** Convert three-dimensional coordinates to two-dimensional screen coordinates.
- **Rendering:** Defining visible objects and displaying them on the screen.
- **Shading:** Calculating lighting and applying textures to create the final image.
- **Post-processing:** Applying effects after rendering is complete, such as blurring or color correction.

### Rendering components
**The main components of rendering in Unity include:**
- **Meshes:** Geometric shapes of objects.
- **Materials:** Parameters that determine the appearance of an object, including color, textures, and lighting properties.
- **Shaders:** Programs that determine how objects are rendered on the screen.

## Optimization of rendering
Optimizing rendering in Unity aims to improve performance by efficiently using CPU and graphics card resources. Below we'll look at a few key optimization strategies:
- General Rendering Optimizations;
- Reducing the number of triangles and LODs;
- Culling (Frustrum, Occlusion);
- Materials and Shaders Optimization;
- Resources Packing;
- Lighting Optimization;
- Async Operations;
- Entities Graphics;
- Other Optimizations;

**Let's get started!**

## General Rendering Optimizations
Depending on which rendering engine you have chosen and the goals you are pursuing - you should make some adjustments to that engine. Below we will look in detail at the most necessary options using HDRP as an example (but some of them are valid for URP and Built-In as well).

**Graphics Setup (Project Settings -> Graphics)**
![Graphics Optimization in Unity](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n3csme5g9p91dwqx1go2.png)

**Optimal Settings for Graphics Setup:**
- **Default Render Pipeline** - uses for HDRP / URP / Custom SRP Default Asset Setup;
- **Lightmap Modes** - use only important for you mode. If you don't use mixed or realtime lights - disable modes here;
- **Fog Modes** - use only important for you fog settings. Disable unused features.
- **Disable Log Shader Compilation** to increase building time;
- **Enable Camera-Relative Lights** and Camera Culling;
- **Setup Rendering Tires** for Built-In (especially shader quality and rendering path);

> Depending on how you use shaders, you may need to configure Forward or Deferred Rendering. The default setting in Unity is mostly Forward Rendering, but you can change it to Forward and in some cases it will speed up the rendering process by several times.

**Quality Settings (Project Settings -> Quality)**
![Graphics Optimization in Unity](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kq4v0nuomgzqh1uqcukd.png)

**Optimal Settings for Quality Setup:**
- **Disable V-Sync** at low-end and mobile devices;
- Change **Textures Global MipMap Limit** for low-end devices to half-resolution or lower;
- **Reduce particles raycast budget** for low-end devices to 64-128 pts;
- **Disable LOD cross-fade** for low-end devices;
- Reduce **Skinned Mesh Weights** for low-end devices;

**Additional Rendering Settings (Project Settings -> Player)**
![Graphics Optimization in Unity](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oawfk4zn6ynwq6buipr6.png)

**Optimal Settings for Quality Setup:**
- **Set default fullscreen mode** as Exclusive Fullscreen;
- **Set Capture Single Screen** as enabled (disable rendering for multi-monitors);
- Disable **Player Log**;
- Set Color Space to **Gamma** (**Linear for HDRP**);
- Set **MSAA fallback to Downgrade**;
- Set **DirectX 12 API as default for Rendering** (especially if you need to use Ray Tracing);
- Enable **GPU Skinning** and **Graphics Jobs**;
- Enable **Lightmap Streaming**;
- Switch **Scripting backend to IL2CPP**;
- Use **Incremental GC**;

**Render Pipeline Setup (HDRP Asset)**
![Graphics Optimization in Unity](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iqwhga589gs8k3dd2uou.png)

**Now let's look at Settings in HDRP Asset:**
- Use **lower Color Buffer Format**;
- Disable **Motion Vectors** at low-end devices;
- Setup **LOD Bias** for different Quality Modes;
- Play with **different rendering distance and quality levels** for decals, shadows etc.;
- Enable **Dynamic Resolution for low-end Devices** (like FRS, DLSS etc);
- Enable **Screen Space Reflections** or use Baked Reflections for low-end devices;

**Camera Optimization**
![Graphics Optimization in Unity](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lvj4rh6llibx4c8morrj.png)

**Now let's look at Camera Setup:**
- Use **lower Clipping Planes** for low-end devices;
- Allow **Dynamic Resolution with Performance Setup** at low-end devices;
- Use **Culling masks and Occlusion Culling**;

### Reducing the number of triangles and LODs
The fewer triangles in a scene, the faster Unity can render it. Use simple shapes where possible and avoid excessive detail. Use tools like **LOD** (levels of detail) and **Impostors** to automatically reduce the detail of objects at a distance.

**LOD (level of detail)** is a system that allows you to use less detailed objects at different distances.

![Graphics Optimization in Unity](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m74pg9ig9epsz1zssp1q.png)

**Impostors** is a system that bakes a highly polygonal object to display as sprites, which can also be useful on the course. Unlike regular Billboards, Impostors look different from different angles, just like a regular 3D model should.

![Graphics Optimization in Unity](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ycnvl7ccffiqias8j7cx.png)

You can also reduce the number of triangles on the fly if you want to create your own clipping conditions. For example you can use [this component](https://github.com/Whinarn/UnityMeshSimplifier/tree/master) for runtime mesh processing.

### Culling (Frustrum, Occlusion)
Culling objects involves making objects invisible. This is an effective way to reduce both the CPU and GPU load.

In many games, a quick and effective way to do this without compromising the player experience is to cull small objects more aggressively than large ones. For example, small rocks and debris could be made invisible at long distances, while large buildings would still be visible.

**Occlusion culling** is a process which prevents Unity from performing rendering calculations for GameObjects that are completely hidden from view (occluded) by other GameObjects. When rendering rather large polygonal objects (for example, in-door or out-door scenes) not all vertices are actually visible on the screen. By not sending these vertices for rendering, you can save a lot on rendering speed with **Frustrum Culling**.

![Graphics Optimization in Unity](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ag8e2nwn68j85lu646i9.png)

**In Unity has its own system for Occlusion Culling, it works based on cutoff areas.**

**To determine whether occlusion culling is likely to improve the runtime performance of your Project, consider the following:**
- Preventing wasted rendering operations can save on both CPU and GPU time. Unity’s built-in occlusion culling performs runtime calculations on the CPU, which can offset the CPU time that it saves. Occlusion culling is therefore most likely to result in performance improvements when a Project is GPU-bound due to overdraw.
- Unity loads occlusion culling data into memory at runtime. You must ensure that you have sufficient memory to load this data.
- Occlusion culling works best in Scenes where small, well-defined areas are clearly separated from one another by solid GameObjects. A common example is rooms connected by corridors.
- You can use occlusion culling to occlude Dynamic GameObjects, but Dynamic GameObjects cannot occlude other GameObjects. If your Project generates Scene geometry at runtime, then Unity’s built-in occlusion culling is not suitable for your Project.

![Graphics Optimization in Unity](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/isap501rlipiz50h7j54.png)

For an improved Frustrum Culling experience, I suggest taking a [library that handles it using Jobs](https://github.com/MPozek/Unity-Jobs-View-Frustum-Culling).

### Materials and Shaders optimization
**Materials and Shaders** can have a significant impact on performance. The following things should be considered when working with materials:
- Use as **few textures as possible**, where possible bake your sub textures such as **Ambient into Diffuse**. Also keep an eye on texture sizes.
- Where possible, use **GPU Instancing** and **Material Variants**
- Use the **simplest shaders with the minimum number of passes**.
- Use **shader LOD** to control simplicity of your material in runtime.
- Use **simple instructions** in **shaders** and avoid complex mathematical operations.

**Write LOD-based shaders for your project:**
```c
Shader "Examples/ExampleLOD"
{
    SubShader
    {
        LOD 200

        Pass
        {                
              // The rest of the code that defines the Pass goes here.
        }
    }

    SubShader
    {
        LOD 100

        Pass
        {                
              // The rest of the code that defines the Pass goes here.
        }
    }
}
```

**Switching Shader LOD at Runtime:**
```csharp
Material material = GetComponent<Renderer>().material;
material.shader.maximumLOD = 100;
```

**Complex mathematical operations**
Transcendental mathematical functions (such as **pow**, **exp**, **log**, **cos**, **sin**, **tan**) are quite resource-intensive, so avoid using them where possible. Consider using lookup textures as an alternative to complex math calculations if applicable.

Avoid writing your own operations (such as **normalize**, **dot**, **inversesqrt**). Unity’s built-in options ensure that the driver can generate much better code. Remember that the Alpha Test (**discard**) operation often makes your fragment shader slower.

**Floating point precision**
While the precision (**float** vs **half** vs **fixed**) of floating point variables is largely ignored on desktop GPUs, it is quite important to get a good performance on mobile GPUs.

### Resources Packing
Bundling textures and models reduces the number of calls to the disk and reduces resource utilization. There are several options for packaging resources in the way that is right for you:
- Using **Sprite Packer** for **2D Sprites** and **UI** Elements;
- Using **Baked Texture atlases** in 3D Meshes (baked in 3D Editors);
- **Compress Textures using Crunched Compression** with disabling unused mipmaps;
- Using **Runtime Texture Baking**;

```csharp
// Runtime Texture Packing Example
Texture2D[] textures = Resources.LoadAll<Texture2D>("Textures");
Texture2DArray textureArray = new Texture2DArray(512, 512, textures.Length, TextureFormat.RGBA32, true);
for (int i = 0; i < textures.Length; i++)
{
    Graphics.CopyTexture(textures[i], 0, textureArray, i);
}

Resources.UnloadUnusedAssets();
```

Also, don't forget about choosing the right texture compression. If possible, also use Crunched compression. And of course disable unnecessary MipMaps levels to save space.

### Disable invisible renders
Disabling rendering of objects behind the camera or behind other objects can significantly improve performance. **You can use culling or runtime disabling:**

```csharp
// Runtime invisible renderers disabling example
Renderer renderer = GetComponent<Renderer>();
if (renderer != null && !renderer.isVisible)
{
    renderer.enabled = false;
}
```

### Lighting and Shadow Optimization
**All Lights can be rendered using either of two methods:**
- **Vertex lighting** calculates the illumination only at the vertices of meshes and interpolates the vertex values over the rest of the surface. Some lighting effects are not supported by vertex lighting but it is the cheaper of the two methods in terms of processing overhead. Also, this may be the only method available on older graphics cards.
- **Pixel lighting** is calculated separately at every screen pixel. While slower to render, pixel lighting does allow some effects that are not possible with vertex lighting. Normal-mapping, light cookies and realtime shadows are only rendered for pixel lights. Additionally, spotlight shapes and point light highlights look much better when rendered in pixel mode.

Lights have a big impact on rendering speed, so lighting quality must be traded off against frame rate. Since pixel lights have a much higher rendering overhead than vertex lights, Unity will only render the brightest lights at per-pixel quality and render the rest as vertex lights.

Realtime shadows have quite a high rendering overhead, so you should use them sparingly. Any objects that might cast shadows must first be rendered into the shadow map and then that map will be used to render objects that might receive shadows. Enabling shadows has an even bigger impact on performance than the pixel/vertex trade-off mentioned above.

**So, let's look at general tips for lighting performance:**
- Disable lights when it not visible;
- Do not use realtime lightings everywhere;
- Play with shadow distance and quality;
- Disable Receive Shadows and Cast Shadows where it not used. For example - disable Cast Shadowing for roads and shadow casting at landed objects;
- Use vertex lights for low-end devices;

**Simple example of realtime lights disabling at runtime:**
```csharp
Light[] lights = FindObjectsOfType<Light>();
foreach (Light light in lights)
{
    if (!light.gameObject.isStatic)
    {
        light.enabled = false;
    }
}
```

### Async Operations
Try to use asynchronous functions and coroutines for heavy in-frame operations. Also try to take calculations out of **Update()** method, because they will block the main rendering thread and increase micro-frizz between frames, reducing your FPS.

```csharp
// Bad Example
void Update() {
    // Heavy calculations here
}

// Good Example
void LateUpdate(){
    if(!runnedOperationWorker){
        RunHeavyOperationHere();
    }
}

void RunHeavyOperationHere() {
    // Create Async Calculations Here
}
```

**Bad Example of Heavy Operations:**
```csharp
// Our Upscaling Method
public void Upscale() {
    if(isUpscaled) return;

    // Heavy Method Execution
    UpscaleTextures(() => {
        Resources.UnloadUnusedAssets();
        OnUpscaled?.Invoke();
        Debug.Log($"Complete Upscale for {gameObject.name} (Materials Pool): {materialPool.Count} textures upscaled.");
    });

    isUpscaled = true;
}

private void UpscaleTextures(){
    if(!isUpscaled) Upscale();
}
```

**Good Example of Heavy Operation:**
```csharp
// Our Upscaling Method
public void Upscale() {
    if(isUpscaled) return;

    // Run Heavy method on Coroutine (can be used async instead)
    StopCoroutine(UpscaleTextures());
    StartCoroutine(UpscaleTextures(() => {
        Resources.UnloadUnusedAssets();
        OnUpscaled?.Invoke();
        Debug.Log($"Complete Upscale for {gameObject.name} (Materials Pool): {materialPool.Count} textures upscaled.");
    }));

    isUpscaled = true;
}

private void UpscaleTextures(){
    if(!isUpscaled) Upscale();
}
```

### Entities Graphics
If you using **ECS** for your games - you can speed-up your entities rendering process using **Entities Graphics**. This package provides systems and components for rendering ECS Entities. **Entities Graphics is not a render pipeline**: it is a system that collects the data necessary for rendering ECS entities, and sends this data to Unity's existing rendering architecture.

![Unity Graphics Optimization](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/drs5bxzmjs9xogr2isba.png)

**The Universal Render Pipeline (URP) and High Definition Render Pipeline (HDRP)** are responsible for authoring the content and defining the rendering passes.

https://docs.unity3d.com/Packages/com.unity.entities.graphics@1.0/manual/index.html

**Simple Usage Example:**
```csharp
public class AddComponentsExample : MonoBehaviour
{
    public Mesh Mesh;
    public Material Material;
    public int EntityCount;

    // Example Burst job that creates many entities
    [GenerateTestsForBurstCompatibility]
    public struct SpawnJob : IJobParallelFor
    {
        public Entity Prototype;
        public int EntityCount;
        public EntityCommandBuffer.ParallelWriter Ecb;

        public void Execute(int index)
        {
            // Clone the Prototype entity to create a new entity.
            var e = Ecb.Instantiate(index, Prototype);
            // Prototype has all correct components up front, can use SetComponent to
            // set values unique to the newly created entity, such as the transform.
            Ecb.SetComponent(index, e, new LocalToWorld {Value = ComputeTransform(index)});
        }

        public float4x4 ComputeTransform(int index)
        {
            return float4x4.Translate(new float3(index, 0, 0));
        }
    }

    void Start()
    {
        var world = World.DefaultGameObjectInjectionWorld;
        var entityManager = world.EntityManager;

        EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

        // Create a RenderMeshDescription using the convenience constructor
        // with named parameters.
        var desc = new RenderMeshDescription(
            shadowCastingMode: ShadowCastingMode.Off,
            receiveShadows: false);

        // Create an array of mesh and material required for runtime rendering.
        var renderMeshArray = new RenderMeshArray(new Material[] { Material }, new Mesh[] { Mesh });

        // Create empty base entity
        var prototype = entityManager.CreateEntity();

        // Call AddComponents to populate base entity with the components required
        // by Entities Graphics
        RenderMeshUtility.AddComponents(
            prototype,
            entityManager,
            desc,
            renderMeshArray,
            MaterialMeshInfo.FromRenderMeshArrayIndices(0, 0));
        entityManager.AddComponentData(prototype, new LocalToWorld());

        // Spawn most of the entities in a Burst job by cloning a pre-created prototype entity,
        // which can be either a Prefab or an entity created at run time like in this sample.
        // This is the fastest and most efficient way to create entities at run time.
        var spawnJob = new SpawnJob
        {
            Prototype = prototype,
            Ecb = ecb.AsParallelWriter(),
            EntityCount = EntityCount,
        };

        var spawnHandle = spawnJob.Schedule(EntityCount, 128);
        spawnHandle.Complete();

        ecb.Playback(entityManager);
        ecb.Dispose();
        entityManager.DestroyEntity(prototype);
    }
}
```

### Profiling
And of course, don't optimize graphics blindly. **Use Unity profiling tools like Profiler to identify rendering bottlenecks and optimize performance.**

**For example - create your profiler metrics for heavy calculations:**
```csharp
Profiler.BeginSample("MyUpdate");
// Calculations here
Profiler.EndSample();
```

## Additional Optimization Tips
**So, let's take a look at an additional checklist for optimizing your graphics after you've learned the basic techniques above:**
- Keep the vertex count below **200K and 3M per frame** when building for PC (depending on the target GPU);
- If you’re using built-in shaders, **pick ones from the Mobile or Unlit categories**. They work on non-mobile platforms as well, but are simplified and approximated versions of the more complex shaders;
- Keep **the number of different materials per scene low**, and share as many materials between different objects as possible;
- Set **the Static property on a non-moving object** to allow internal optimizations like **Static Batching**. **Or use GPU Instancing**;
- Only have a single **(preferably directional) pixel light affecting your geometry**, rather than multiples;
- Bake **lighting rather** than using dynamic lighting. You can also **bake normal maps and lightmaps directly into your diffuse textures**;
- Use **compressed texture formats when possible**, and use 16-bit textures over 32-bit textures. **Use Crunch Compression**;
- **Avoid using fog** where possible;
- Use **Occlusion Culling**, **LODs** and **Impostors** to reduce the amount of visible geometry and draw-calls in cases of complex static scenes with lots of occlusion. Design your levels with occlusion culling in mind;
- Use **skyboxes** or **planes with sprite** to “fake” distant geometry;
- Use **pixel shaders or texture combiners** to mix several textures instead of a multi-pass approach;
- **Avoid Heavy calculations in Update()** method;
- Use **half precision variables** where possible;
- **Minimize use of complex mathematical operations** such as **pow**, **sin** and **cos** in pixel shaders;
- Use fewer **textures per fragment**;

## Let's summarize
**Optimizing rendering is a rather painstaking process**. Some basic things - such as lighting settings, texture and model compression, preparing objects for **Culling** and **Batching**, or [UI optimization](https://dev.to/devsdaddy/unity-ui-optimization-workflow-step-by-step-full-guide-for-everyone-44da) - should be done already during the first work on your project to form your optimization-focused work pipeline. However, you can optimize most other things on demand by profiling.

**And of course thank you for reading the article, I would be happy to discuss various aspects of optimization with you.**

---

**You can also support writing tutorials, articles and see ready-made solutions for your projects:**

[**My Discord**](https://discord.gg/a7cHDtfYbv) | [**My Blog**](https://devsdaddy.hashnode.dev/) | [**My GitHub**](https://github.com/DevsDaddy) | [**Buy me a Beer**](https://boosty.to/devsdaddy)

**BTC:** bc1qef2d34r4xkrm48zknjdjt7c0ea92ay9m2a7q55
**ETH:** 0x1112a2Ef850711DF4dE9c432376F255f416ef5d0