A tool for optimizing distant meshes in Unity

Written by Jesper Tingvall, Product Expert, Simplygon

Disclaimer: The code in this post is written using version 10.3.5200.0 of Simplygon and Unity 2022.3.13f1. If you encounter this post at a later stage, some of the API calls might have changed. However, the core concepts should still remain valid.

Introduction

In this blog we'll create a Unity tool similar to our Unreal Engine Stand-In feature. It can be of great use to optimize objects in backgrounds of video games.

Prerequisites

This example will use the Simplygon integration in Unity with HDRP render pipeline, but the same concepts can be applied to all other Unity render pipelines.

Problem to solve

We have an Unity game based on the HDRP render pipeline. In a lot of scenes we have objects that will never be viewed up close. We wants these objects to be replaced with cheap proxies that are efficient to render. We want this to be done in a nice non-destructive way and automate the building of these proxies. The assets are using HDRP/Lit shader based materials.

In our video game we have a couple of highly detailed houses up on a hill which we want to optimize.

3 detailed houses on a hill.

Solution

We are going to create some Unity editor tools which will help us optimize scenes. We are going to base our tools on the remeshing processor. By doing so we will optimize a number of different potential bottle necks in our scene.

  • Triangle count and size of triangles
  • Draw calls
  • Overdraw

We will disable and tag the original GameObject as EditorOnly. That way we will get a nice non-destructive work flow, we can easily go back and change the original asset and then rebake the standin. As the original is marked as EditorOnly it will not be shipped with the game. That means that if the data is only referenced by the object we created a proxy of, we can save hard drive space by only shipping a cheap replacemen proxy.

Remeshing pipeline

We will now create the pipeline that will perform the optimization. As quality metric we will use screen size. Texture size is automatically determined by enabling UseAutomaticTextureSize. With AutomaticTextureSizeMultiplier the quality of textures can be fine tuned.

private static spPipeline CreateRemeshingPipeline(ISimplygon simplygon, uint screenHeight, float textureSizeMultiplier)
{
    var pipeline = simplygon.CreateRemeshingPipeline();
    var settings = pipeline.GetRemeshingSettings();
    settings.SetOnScreenSize(screenHeight);

    using var simplygonMappingImageSettings = pipeline.GetMappingImageSettings();
    simplygonMappingImageSettings.SetGenerateMappingImage(true);
    simplygonMappingImageSettings.SetGenerateTexCoords(true);
    simplygonMappingImageSettings.SetGenerateTangents(true);
    simplygonMappingImageSettings.SetUseFullRetexturing(true);
    simplygonMappingImageSettings.SetApplyNewMaterialIds(true);
    simplygonMappingImageSettings.SetUseAutomaticTextureSize(true);
    simplygonMappingImageSettings.SetAutomaticTextureSizeMultiplier(textureSizeMultiplier);

Setup material caster for Unity HDRP shader

It is very simple to set up compute casters for Unity's HDRP shaders. All we have to do is create a compute caster and specify the target channel and color space.

private static spComputeCaster CreateComputeCaster(ISimplygon simplygon, string channel, EImageColorSpace colorSpace)
{
    var caster = simplygon.CreateComputeCaster();
    var casterSettings = caster.GetComputeCasterSettings();
    casterSettings.SetOutputColorSpace(EImageColorSpace.Linear);
    casterSettings.SetMaterialChannel(channel);
    return caster;
}

All we have to do now is look in our HDRP to Simplygon channel mapping table and add the channels we want to cast as well as corresponding color space. In our case we want to cast base map, mask map and normal map. We add this to the CreateRemeshingPipeline function.

// Add a caster for the albedo.
pipeline.AddMaterialCaster(CreateComputeCaster(simplygon, "BaseMap", EImageColorSpace.sRGB), 0);

// Add a caster for the map channels.
pipeline.AddMaterialCaster(CreateComputeCaster(simplygon, "MaskMap", EImageColorSpace.Linear), 0);

// Add a caster for normals.
pipeline.AddMaterialCaster(CreateComputeCaster(simplygon, "NormalMap", EImageColorSpace.Linear), 0);

Bake standin

Once we have our remeshing pipeline we can create a script which performs the optimization. It starts with loading Simplygon, then it creates a remeshing pipeline and runs it. Lastly it setups the created standin and hides the original object.

public static void BakeStandin(StandIn originalObject)
{
    DestroyStandin(originalObject);

    Debug.Log("Building standin " + originalObject.name);
    using (ISimplygon simplygon = global::Simplygon.Loader.InitSimplygon
    (out EErrorCodes simplygonErrorCode, out string simplygonErrorMessage))
    {
        if (simplygonErrorCode == EErrorCodes.NoError)
        {
            var pipelines = CreateRemeshingPipeline(simplygon, (uint)originalObject.ScreenSize, originalObject.AutomaticTextureSizeMultiplier);
            var simplygonProcessing = new SimplygonProcessing(simplygon);
            simplygonProcessing.Run(pipelines, new List<GameObject>() { originalObject.gameObject }, EPipelineRunMode.RunInThisProcess, true);
            ApplyProcessedStandin(originalObject, simplygonProcessing);
        } 
        else
        {
            Debug.LogError("Simplygon initializing failed!");
        }
    }
}

Let us take a look on how the asset looks like before and after optimization. We can see that we now have a watertight model without inside, and a lot of geometry details is now preserved in the normal map.

Before
After

We can also inspect the wireframe. Here we can see that it has significantly lower triangle density, as well as inside has been removed.

Before
After (Shaded Wireframe)

This helper function setups our created standin. We change the name and removes the StandIn component so we do not create a standin of a standin. We also disable the original game object and tags it as EditorOnly. We save references to the created data so we can clean it up.

private static void ApplyProcessedStandin(StandIn originalObject, SimplygonProcessing simplygonProcessing)
{
    // Fetch and cleanup standin generated by processing
    var createdStandin = simplygonProcessing.LODGameObjects[0]; 
    createdStandin.name = originalObject.name + " (Standin)";
    GameObject.DestroyImmediate(createdStandin.GetComponent<StandIn>());
		
    // update StandIn component
    originalObject.StandinGameObject = createdStandin
    originalObject.LODFolder = simplygonProcessing.PrefabAssetFolder;

    // Hide the original GameObject in favor of generated standin
    originalObject.gameObject.SetActive(false);
    originalObject.gameObject.tag = "EditorOnly";
}

Destroy standin

To make an easy non destructive workflow we also introduce a function to clean up all created data. This removes the created standin from the scene, set our original GameObject active and lastly remove the created LOD data from our project.

static public void DestroyStandin(StandIn standin)
{
    Debug.Log("Destroying standin " + standin.name);
    if (standin.StandinGameObject)
    {
        GameObject.DestroyImmediate(standin.StandinGameObject);
    }

    standin.gameObject.SetActive(true);
    standin.gameObject.tag = "Untagged";

    if (standin.LODFolder != null && standin.LODFolder.Length > 0)
    {
        AssetDatabase.MoveAssetToTrash(standin.LODFolder);
        standin.LODFolder = "";
    }
}

Standin component

The StandIn component will hold quality settings for our standin. We will use screen size to determine quality along with a texture quality scalar which we will use with AutomaticTextureSizeMultiplier.

public class StandIn : MonoBehaviour
{
    public uint ScreenSize = 300;
    public float TextureQuality = 8;
    public GameObject StandinGameObject;
    public string LODFolder;

    public bool HasBuildStandin
    {
        get
        {
            if (StandinGameObject)
                return true;
            if (LODFolder != null)
                return LODFolder.Length > 0;
            return false;
        }
    }
}

We will add this component to add GameObjects we want to replace with a standin.

Standin component user interface

In order to add a nice user experience we are going to create a custom user interface script for our StandIn component. We can create a custom editor with CustomEditor and overriding the OnInspectorGUI function.

[CustomEditor(typeof(StandIn))]
public class StandinEditor : Editor
{
    override public void OnInspectorGUI()
    {
        var standin = target as StandIn;
        EditorGUILayout.LabelField("On Screen Size:");
        standin.ScreenSize = (uint)EditorGUILayout.IntSlider((int)standin.ScreenSize, 20, 2000);

        EditorGUILayout.LabelField("Automatic Texture Size Multiplier:");
        standin.TextureQuality = EditorGUILayout.Slider(standin.TextureQuality, 1, 32);

        if (GUILayout.Button("Build standin"))
        {
            StandinBaker.BakeStandin(standin);
        }

        if (standin.HasBuildStandin)
        {
            if (GUILayout.Button("Destroy standin"))
            {
                StandinBaker.DestroyStandin(standin);
            }
        }
    }
}

After adding this script our StandIn component's UI looks like this.

To make it easy to process an entire scene we will create two menu options. First we introduce an option to bake all standins in our scene.

[MenuItem("Simplygon/Standin/Bake all")]
static public void BakeAllStandins()
{
    foreach (var standin in GameObject.FindObjectsOfType<StandIn>())
    {
        BakeStandin(standin);
    }
}

We are also adding an option to destroy all standins. Notice that we include inactive GameObjects in FindObjectsOfType. This is neccesary as we disable the original GameObjects upon building the standins.

[MenuItem("Simplygon/Standin/Destroy all")]
static public void DestroyAllStandins()
{
    foreach (var standin in GameObject.FindObjectsOfType<StandIn>(includeInactive: true))
    {
        DestroyStandin(standin);
    }
}

After adding these functions we get a nice menu to bake or destroy all standins in our scene.

Result

Let us first add a StandIn component to each of the distant object we want to optimize. Let's use the default settings for now.

Unity UI. Asset with added StandIn component.

We can process the entire scene with help of the Bake all menu option we added. After optimization our distant meshes are replaced with standins. If we inspect the assets from the camera direction we expect the player to have in game it is very hard to tell a difference. Our optimized assets are the three in the middle on top of the rocks.

Before
After

So what did we gain? We now have significantly lower triangle count for our impostors with way less small triangles in the models. We also require less draw calls for the proxies, only one draw call per proxy.

Object Original triangle count Original material count Standin triangle count Standin material count
ModulerHouse3 115 k 17 5 k 1
Multi_modulerHouse 703 k 25 7 k 1
ModulerHouse1 538 k 20 8 k 1

With this tool in place we can easily ensure that rendering performance are spend on objects that are close to the player and have an impact on gameplay.

Next steps

In order to optimize our standins even futher we could also use visibility culling to remove any parts of them not visible from where the player could be.

In order to mimic the Unreal Engine plugin's standin feature a 'near' aggregation based pipeline could also be useful. It could remove internal geometry using geometry culling as well as baking materials to reduce draw calls.

Complete scripts

StandinEditor.cs

This script should be placed in an Editor folder.

// Copyright (c) Microsoft Corporation. 
// Licensed under the MIT license. 

using UnityEditor;
using UnityEngine;

namespace Simplygon.Examples.Standin
{
    [CustomEditor(typeof(StandIn))]
    public class StandinEditor : Editor
    {
        override public void OnInspectorGUI()
        {
            var standin = target as StandIn;
            EditorGUILayout.LabelField("On Screen Size:");
            standin.ScreenSize = (uint)EditorGUILayout.IntSlider((int)standin.ScreenSize, 20, 2000);

            EditorGUILayout.LabelField("Automatic Texture Size Multiplier:");
            standin.AutomaticTextureSizeMultiplier = EditorGUILayout.Slider(standin.AutomaticTextureSizeMultiplier, 1, 32);

            if (GUILayout.Button("Build standin"))
            {
                StandinBaker.BakeStandin(standin);
            }

            if (standin.HasBuildStandin)
            {
                if (GUILayout.Button("Destroy standin"))
                {
                    StandinBaker.DestroyStandin(standin);
                }
            }
        }
    }
}

StandinBaker.cs

This script should be placed in an Editor folder.

// Copyright (c) Microsoft Corporation. 
// Licensed under the MIT license. 

using Simplygon.Unity.EditorPlugin;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

namespace Simplygon.Examples.Standin
{
    public class StandinBaker
    {

        [MenuItem("Simplygon/Standin/Bake all")]
        static public void BakeAllStandins()
        {
            foreach (var standin in GameObject.FindObjectsOfType<StandIn>())
            {
                BakeStandin(standin);
            }
        }

        [MenuItem("Simplygon/Standin/Destroy all")]
        static public void DestroyAllStandins()
        {
            foreach (var standin in GameObject.FindObjectsOfType<StandIn>(includeInactive: true))
            {
                DestroyStandin(standin);
            }
        }

        static public void DestroyStandin(StandIn standin)
        {
            Debug.Log("Destroying standin " + standin.name);
            if (standin.StandinGameObject)
            {
                GameObject.DestroyImmediate(standin.StandinGameObject);
            }

            standin.gameObject.SetActive(true);
            standin.gameObject.tag = "Untagged";

            if (standin.LODFolder != null && standin.LODFolder.Length > 0)
            {
                AssetDatabase.MoveAssetToTrash(standin.LODFolder);
                standin.LODFolder = "";
            }
        }

        public static void BakeStandin(StandIn originalObject)
        {
            DestroyStandin(originalObject);

            Debug.Log("Building standin " + originalObject.name);
            using (ISimplygon simplygon = global::Simplygon.Loader.InitSimplygon
            (out EErrorCodes simplygonErrorCode, out string simplygonErrorMessage))
            {
                if (simplygonErrorCode == EErrorCodes.NoError)
                {
                    var pipelines = CreateRemeshingPipeline(simplygon, (uint)originalObject.ScreenSize, originalObject.AutomaticTextureSizeMultiplier);
                    var simplygonProcessing = new SimplygonProcessing(simplygon);
                    simplygonProcessing.Run(pipelines, new List<GameObject>() { originalObject.gameObject }, EPipelineRunMode.RunInThisProcess, true);
                    ApplyProcessedStandin(originalObject, simplygonProcessing);
                } 
                else
                {
                    Debug.LogError("Simplygon initializing failed!");
                }
            }
        }


        private static void ApplyProcessedStandin(StandIn originalObject, SimplygonProcessing simplygonProcessing)
        {
            // Fetch and cleanup standin generated by processing
            var createdStandin = simplygonProcessing.LODGameObjects[0];
            createdStandin.name = originalObject.name + " (Standin)";
            GameObject.DestroyImmediate(createdStandin.GetComponent<StandIn>());

            // update StandIn component
            originalObject.StandinGameObject = createdStandin;
            originalObject.LODFolder = simplygonProcessing.PrefabAssetFolder;

            // Hide the original GameObject in favor of generated standin
            originalObject.gameObject.SetActive(false);
            originalObject.gameObject.tag = "EditorOnly";
        }


        private static spPipeline CreateRemeshingPipeline(ISimplygon simplygon, uint screenHeight, float textureSizeMultiplier)
        {
            var pipeline = simplygon.CreateRemeshingPipeline();
            var settings = pipeline.GetRemeshingSettings();
            settings.SetOnScreenSize(screenHeight);

            using var simplygonMappingImageSettings = pipeline.GetMappingImageSettings();
            simplygonMappingImageSettings.SetGenerateMappingImage(true);
            simplygonMappingImageSettings.SetGenerateTexCoords(true);
            simplygonMappingImageSettings.SetGenerateTangents(true);
            simplygonMappingImageSettings.SetUseFullRetexturing(true);
            simplygonMappingImageSettings.SetApplyNewMaterialIds(true);
            simplygonMappingImageSettings.SetUseAutomaticTextureSize(true);
            simplygonMappingImageSettings.SetAutomaticTextureSizeMultiplier(textureSizeMultiplier);

            // Add a caster for the albedo.
            pipeline.AddMaterialCaster(CreateComputeCaster(simplygon, "BaseMap", EImageColorSpace.sRGB), 0);

            // Add a caster for the map channels.
            pipeline.AddMaterialCaster(CreateComputeCaster(simplygon, "MaskMap", EImageColorSpace.Linear), 0);

            // Add a caster for normals.
            pipeline.AddMaterialCaster(CreateComputeCaster(simplygon, "NormalMap", EImageColorSpace.Linear), 0);

            return pipeline;
        }

        private static spComputeCaster CreateComputeCaster(ISimplygon simplygon, string channel, EImageColorSpace colorSpace)
        {
            var caster = simplygon.CreateComputeCaster();
            var casterSettings = caster.GetComputeCasterSettings();
            casterSettings.SetOutputColorSpace(EImageColorSpace.Linear);
            casterSettings.SetMaterialChannel(channel);
            return caster;
        }
    }
}

Standin.cs

// Copyright (c) Microsoft Corporation. 
// Licensed under the MIT license. 

using UnityEngine;

namespace Simplygon.Examples.Standin
{
    public class StandIn : MonoBehaviour
    {
        // Quality settings
        public uint ScreenSize = 300;
        public float AutomaticTextureSizeMultiplier = 8;
        
        // Standin object and data folder so we can clean up
        public GameObject StandinGameObject;
        public string LODFolder;

        public bool HasBuildStandin
        {
            get
            {
                if (StandinGameObject)
                    return true;
                if (LODFolder != null)
                    return LODFolder.Length > 0;
                return false;
            }
        }
    }
}
⇐ Back to all posts

Request 30-days free evaluation license

*
*
*
*
Industry
*

Request 30-days free evaluation license

*
*
*
*
Industry
*