Optimizing Graphics and Rendering in Unity: Key aspects and practical solutions

Optimizing Graphics and Rendering in Unity: Key aspects and practical solutions

Create high-performance Unity games on any platform

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 and CPU. 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.

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)w

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;

  • For 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)

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)

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)

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 for low-end devices;

Camera Optimization

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.

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.

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 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.

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.

For an improved Frustrum Culling experience, I suggest taking a library that handles it using Jobs.

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 Variant

  • 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:

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:

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;

// 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:

// 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:

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.

// 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:

// 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:

// 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.

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:

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:

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;

  • 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 - 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 | My Blog | My GitHub | Buy me a Beer

BTC: bc1qef2d34r4xkrm48zknjdjt7c0ea92ay9m2a7q55

ETH: 0x1112a2Ef850711DF4dE9c432376F255f416ef5d0