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.
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.
We can also inspect the wireframe. Here we can see that it has significantly lower triangle density, as well as inside has been removed.
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.
Menu options
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.
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.
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;
}
}
}
}