LOD0 optimization in Unity
Written by Jesper Tingvall, Product Expert, Simplygon
Disclaimer: The code in this post is written using version 10.3.2100.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
Today we are going to look at how to optimize LOD0 models in Unity. This is useful in two cases, if we have lots of assets of varying quality from asset packs or if we want to port a game to a weaker platform.
Prerequisites
This example will use the Simplygon integration in Unity, but the same concepts can be applied to all other integrations of the Simplygon API.
Problem to solve
We are creating a Unity game and are using assets from lots of different sources, mainly asset packs. Some are of higher quality than other, sometimes to high for our target platform. We want to create a tool to optimize assets that has a little bit to high density before using them in our game, in other words LOD0 optimization. We also want to create a LOD chain for the assets.
We will use some medieval weapons and enviroment props to test our pipeline.
In our model import settings we have set Scale Factor to what we expect it to be in game and are importing the assets with correct materials, or using remapping of materials into the correct ones.
Solution
We will create a scripted tool for our Unity editor that optimizes the model, creates a LOD chain then puts all of that into a prefab with a LODGroup
component setup. We'll make the tool easy to use for the users as well by adding it as a right click menu item in Unity's Project window.
LOD0 optimization
In this example we are using assets from various sources. Some of them being a little bit to high density for our game's target platform. Thus, we want to use Simplygon to do triangle reduction on our LOD0. As reduction target we are going to use max deviation. This allows us to specify how much LOD0 can differ from the original asset. In essence this allows us to specify what accuracy the models should have in our game, no matter how large it is or the original triangle count. For max deviation to use correct scale we need to have the correct Scale factor set up in the model's import settings.
/// <summary>
/// Create a pipeline for LOD 0. For LOD0 we use max deviation as target metric.
/// </summary>
public static spPipeline CreateLOD0Pipeline(ISimplygon simplygon, float maxDeviation)
{
var pipeline = simplygon.CreateReductionPipeline();
var reductionSettings = pipeline.GetReductionSettings();
reductionSettings.SetReductionTargets(EStopCondition.All, false, false, true, false);
reductionSettings.SetReductionTargetMaxDeviation(maxDeviation);
return pipeline;
}
LODGroup and Simplygon's OnScreenSize
Readers of Using Simplygon with Unity's LODGroups will recognize this part. The settings used to generate a LOD level is deeply connected to when the game engine perform LOD switches. In Unity's case LOD transitions are handled by the LODGroup
component. It determines which LOD level to showcase by relative screen height. There is also a quality setting parameter, LOD Bias. LOD Bias scales when LOD transition happens. To make our reasoning easier we will assume a LOD Bias of 1 going forward.
So how to we map this to Simplygon's optimization targets? From my personal experience the most important thing is to not use triangle ratio and I've written an entire blog about why this is the case. What I suggest is to use screen size as it can calculated from relative screen height.
First, we introduce two constants, one is the resolution we expect the game to be rendered at, and a minimum screen size constant what is Simplygon specific.
static uint GAME_RESOLUTION_HEIGHT = 1080; // Resolution used for calculating relative screen height -> screen size.
static uint MIN_SCREEN_SIZE = 20; // Simplygon's minimum screen size.
We will then create a function which calculates screen size from relative screen height. It also introduces a maxPixelDifference
parameter. When using screen size Simplygon gives around one pixel of visual error. This is very conservative, specially if we have a cross fading LOD transition. Thus, we'll allow the user to specify the number of pixels our visual error can have. We can use this as a quality metric for different assets, allowing larger error for less important assets.
/// <summary>
/// Convert from relative screen height to screen size.
/// </summary>
public static uint GetScreenSizeForRelativeScreenHeight(float lodLevel, float maxPixelDifference)
{
lodLevel = Mathf.Clamp01(lodLevel);
var ScreenSize = (GAME_RESOLUTION_HEIGHT * lodLevel / maxPixelDifference);
if (ScreenSize < MIN_SCREEN_SIZE)
{
return MIN_SCREEN_SIZE;
}
else
{
return (uint)Mathf.CeilToInt(ScreenSize);
}
}
Cascaded reduction pipelines
It is now time to create the rest of the LOD levels. We will do this as cascaded pipelines. This means that the previous LOD level will be used as input for the next one. By doing this we are decreasing processing time and minimize visual difference between the LOD levels.
/// <summary>
/// Create cascaded pipelines to create LOD levels for given screen size.
/// </summary>
public static List<spPipeline> CreateLODPipelines(ISimplygon simplygon, uint[] screenSizes)
{
var pipelines = new List<spPipeline>();
// Create pipelines for each screen size.
for (int i = 0; i < screenSizes.Length; i++)
{
var pipeline = simplygon.CreateReductionPipeline();
var reductionSettings = pipeline.GetReductionSettings();
reductionSettings.SetReductionTargets(EStopCondition.All, false, false, false, true);
reductionSettings.SetReductionTargetOnScreenSize(screenSizes[i]);
pipelines.Add(pipeline);
}
// Add each pipeline as cascaded to last one.
for (int i = 0; i < pipelines.Count - 1; i++)
{
pipelines[i].AddCascadedPipeline(pipelines[i + 1]);
}
return pipelines;
}
Create prefab with LODGroup
Now let's create a Unity prefab from our optimized assets. First we create a helper function which creates a LOD
object that contains all renderers from a GameObject
and specified transition height.
/// <summary>
/// Create LOD from renderers on game objects.
/// </summary>
public static LOD CreateLODFromChildRenderers(GameObject gameObject, float relativeScreenSize)
{
return new LOD(relativeScreenSize, gameObject.GetComponentsInChildren<Renderer>());
}
We can now create a function which takes a list of models, the models we just optimized including the optimized LOD0, along with transition distances. We'll create a new GameObject
, add a LODGroup
to it. Then load in all models and places them as childs to our new GameObject
. Using the function, we created above we create LOD
objects and assign them to the LODGroup
. Lastly it saves the GameObject
as a prefab.
/// <summary>
/// Create and save prefab from input models and set up LODComponent.
/// </summary>
public static void CreatePrefab(List<GameObject> createdLods, float[] lodLevels, string prefabName, string prefabPath, float hideDistance)
{
// Creates empty prefab root.
GameObject prefab = new GameObject(prefabName);
// Add a LODGroup
// Instanciates all created LODs as childs to prefab root and set them as LOD levels.
var lodGroup = prefab.AddComponent<LODGroup>();
LOD[] lods = new LOD[createdLods.Count];
for (int i = 0; i < createdLods.Count; i++)
{
var instantiatedLOD = createdLods[i];
instantiatedLOD.transform.parent = prefab.transform;
// If we are the last LOD level we should use hideDistance to determine when we should fade away completely.
float transition = i == createdLods.Count - 1 ? hideDistance : lodLevels[i];
lods[i] = CreateLODFromChildRenderers(instantiatedLOD, transition);
}
lodGroup.SetLODs(lods);
// Save prefab and clean scene.
PrefabUtility.SaveAsPrefabAsset(prefab, prefabPath);
GameObject.DestroyImmediate(prefab);
}
Here is how our prefab looks after it's been created. We have a common root and underneath it all LOD levels are imported. A LODGroup
switches between the renderers at the correct transition distances.
Putting it all together
Now it is time for our large optimization function. First, we import the specified asset and calculate what screen sizes the LOD chain should use.
/// <summary>
/// Create a LOD0 optimization of baseAsset.
/// Create LOD chain.
/// Save all as prefab with LODGroup set up.
/// </summary>
public static void CreateLODPrefab(GameObject gameObject, string baseAsset, ISimplygon simplygon, float maxDeviation, float[] transitionDistances, float hideDistance, float maxPixelDifference)
{
var baseFilePath = baseAsset.Replace(".fbx", "");
var baseFileName = baseFilePath.Substring(baseFilePath.LastIndexOf('/') + 1);
// Calculate screen sizes used for LOD1+
uint[] screenSizes = new uint[transitionDistances.Length];
for (int i = 0; i < screenSizes.Length; i++)
{
screenSizes[i] = GetScreenSizeForRelativeScreenHeight(transitionDistances[i], maxPixelDifference);
}
We then create the reduction pipeline which we'll use for LOD0 optimization as well as the cascaded pipelines which will build our LOD chain. We'll add the first cascaded pipeline to our LOD0 pipeline. We also create a SimplygonProcessing object which handles all export, pipeline execution and import of result. All pipelines that are cascaded to lod0pipeline
will be executed as well when it runs. Once the assets are imported we create a prefab from them.
// Create pipelines
var lod0pipeline = CreateLOD0Pipeline(simplygon, maxDeviation);
var pipelines = CreateLODPipelines(simplygon, screenSizes);
lod0pipeline.AddCascadedPipeline(pipelines[0]);
var gameObjectsToProcess = new List<GameObject>() { gameObject };
var simplygonProcessing = new SimplygonProcessing();
simplygonProcessing.Run(simplygon, lod0pipeline, gameObjectsToProcess, EPipelineRunMode.RunInThisProcess, true);
CreatePrefab(exportedAssets, transitionDistances, baseFileName, baseFilePath + ".prefab", hideDistance);
Making tool accessible from editor
The entry point to our tool will be via the right click menu in Project window. We want the use to be able to right click on a model file and it should generate an optimized prefab.
First, we add a function which determines if an asset is a model file. In our example project we have *.fbx
files so we only check for that. An improvement would be to expand this to all model formats Simplygon's API supports.
/// <summary>
/// Returns if Game object is a model file.
/// </summary>
private static bool IsModelFile(GameObject gameObject)
{
// Eventually this needs to be expanded into more file formats.
return AssetDatabase.GetAssetPath(gameObject).ToLowerInvariant().EndsWith(".fbx");
}
Now we can create a function which detect if any model files are selected and use it as validation function for MenuItem
. By setting isValidateFunction = true
we tell Unity that this is the function that will determine if a menu option should be enabled or disabled.
/// <summary>
/// Returns true if any model file is selected.
/// </summary>
[MenuItem("Assets/Simplygon/Optimize and create prefab/Small prop", isValidateFunction: true)]
[MenuItem("Assets/Simplygon/Optimize and create prefab/Large prop", isValidateFunction: true)]
private static bool HasSelectedAnyModelFiles()
{
if (Selection.gameObjects.Length == 0)
return false;
for (int i = 0; i < Selection.gameObjects.Length; i++)
if (IsModelFile(Selection.gameObjects[i]))
return true;
return false;
}
Now we introduce another helper function which performs optimization on the selected assets using specified parameters.
/// <summary>
/// Optimize all selected assets using specified settings.
/// </summary>
private static void OptimizeSelectedAssets(float lod0MaxDeviation, float[] transitionDistances, float hideDistance, float maxPixelDifference)
{
for (int i = 0; i < Selection.gameObjects.Length; i++)
{
if (IsModelFile(Selection.gameObjects[i]))
{
var path = AssetDatabase.GetAssetPath(Selection.gameObjects[i]);
Debug.Log(path);
InitializeAndRunSimplygon(Selection.gameObjects[i], path, lod0MaxDeviation, transitionDistances, hideDistance, maxPixelDifference);
}
else
{
Debug.LogWarning($"{Selection.gameObjects[i]} is not a model file.");
}
}
}
This function initializes Simplygon and performs optimization. This means that for each processing we'll initialize Simplygon again. This is a more memory safe way of running Simplygon then initializing it once and keeping it around for a large amount of assets. In most cases it will work fine, but to be extra safe it is always good to start fresh.
/// <summary>
/// Initialize Simplygon and run optimization on asset.
/// </summary>
private static void InitializeAndRunSimplygon(GameObject gameObject, string assetPath, float lod0MaxDeviation, float[] transitionDistances, float hideDistance, float maxPixelDifference)
{
using (ISimplygon simplygon = global::Simplygon.Loader.InitSimplygon
(out EErrorCodes simplygonErrorCode, out string simplygonErrorMessage))
{
if (simplygonErrorCode == EErrorCodes.NoError)
{
CreateLODPrefab(gameObject, assetPath, simplygon, lod0MaxDeviation, transitionDistances, hideDistance, maxPixelDifference);
}
else
{
Debug.LogError("Simplygon Initializing failed: " + simplygonErrorCode);
Debug.LogError("Simplygon Initializing failed: " + simplygonErrorMessage);
}
}
}
Optimization menu options
Now let's add two optimization menu options, one for small props and one for large props. It is important to highlight that lod0MaxDeviation
is not in Unity's coordinate system, but the coordinate system of the original asset. This is the parameter you are most likely to change if your assets come out looking odd.
For small props we'll use a 2 step LOD chain; on which we'll switch to at 50% and one at 10%. We'll also cull away the asset if it is smaller then 0.2% of screen size. Since small props are not that important, we'll allow for 2 pixels of errors by setting maxPixelDifference
to 2.
/// <summary>
/// Optimize assets.
/// </summary>
[MenuItem("Assets/Simplygon/Optimize and create prefab/Small prop", isValidateFunction: false)]
private static void OptimizeSmallPropAssets()
{
OptimizeSelectedAssets(
lod0MaxDeviation: 0.002f,
transitionDistances: new float[] { 0.5f, 0.1f },
hideDistance: 0.002f,
maxPixelDifference: 2);
}
Large props which we expect to be visible for longer distances have longer LOD chain. We'll go for 4 steps.
/// <summary>
/// Optimize assets.
/// </summary>
[MenuItem("Assets/Simplygon/Optimize and create prefab/Large prop", isValidateFunction: false)]
private static void OptimizeLargePropAssets()
{
OptimizeSelectedAssets(
lod0MaxDeviation: 0.01f,
transitionDistances: new float[] { 0.8f, 0.5f, 0.2f, 0.06f },
hideDistance: 0.001f,
maxPixelDifference: 2);
}
Result
Now let's see our tool in action. We will use it on the assets we specified above and see how well it optimizes the assets.
Small props: Weapons
Our script allows us to perform batch optimization of several assets at once. Let's optimize our weapons and inspect the result.
So we did not have that much of a visual difference. But the wireframe tells a different story.
Depending on how overtessellated the original asset where we get
SM_Axe
Asset | Triangle count | Transition height % |
---|---|---|
Original | 26 k | - |
LOD0 | 6 k | - |
LOD1 | 3 k | 50% |
LOD2 | 538 | 10% |
SM_Mace
Asset | Triangle count | Transition height % |
---|---|---|
Original | 12 k | - |
LOD0 | 6 k | - |
LOD1 | 4 k | 50% |
LOD2 | 380 | 10% |
SM_Scythe
Asset | Triangle count | Transition height % |
---|---|---|
Original | 13 k | - |
LOD0 | 7 k | - |
LOD1 | 5 k | 50% |
LOD2 | 1 k | 10% |
SM_Shield_2
Asset | Triangle count | Transition height % |
---|---|---|
Original | 7 k | - |
LOD0 | 4 k | - |
LOD1 | 3 k | 50% |
LOD2 | 832 | 10% |
SM_Sword_1
Asset | Triangle count | Transition height % |
---|---|---|
Original | 18 k | - |
LOD0 | 3 k | - |
LOD1 | 2 k | 50% |
LOD2 | 399 | 10% |
Large props: Enviroment
Let's see how it works on enviroment props. We can see that the Large sword prop was really overtesselated. But after optimization we should get a model much more suitable to use in game.
Large sword
Asset | Triangle count | Transition height % |
---|---|---|
Original | 10.5 k | - |
LOD0 | 3.1 k | - |
LOD1 | 2.6 k | 80% |
LOD2 | 1.7 k | 50% |
LOD3 | 760 | 20% |
LOD4 | 212 | 6% |
SM_Ruin_01
Asset | Triangle count | Transition height % |
---|---|---|
Original | 32 k | - |
LOD0 | 30 k | - |
LOD1 | 25 k | 80% |
LOD2 | 19 k | 50% |
LOD3 | 9 k | 20% |
LOD4 | 2 k | 6% |
Future work
We now have a nice tool to help us optimize all kinds of models we want to use in our game. This makes it easier for us to use assets from different sources, or enforce a certain level of quality on our assets. Triangles are not the only cause of issues for game performance and what we could do in future developments is look into those as well.
- Using aggregation our LOD0 can use geometry culling to remove hidden triangles on the inside, reducing overdraw, and merge materials to adress draw calls.
- For the last LOD level we can use remeshing to create a really low poly proxy.
Complete script
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using Simplygon.Unity.EditorPlugin;
namespace Simplygon.Examples.LOD0Optimizer
{
public class LOD0Optimizer
{
static uint GAME_RESOLUTION_HEIGHT = 1080; // Resolution used for calculating relative screen height -> screen size.
static uint MIN_SCREEN_SIZE = 20; // Simplygon's minimum screen size.
/// <summary>
/// Returns if Game object is a model file.
/// </summary>
private static bool IsModelFile(GameObject gameObject)
{
// Eventually this needs to be expanded into more file formats.
return AssetDatabase.GetAssetPath(gameObject).ToLowerInvariant().EndsWith(".fbx");
}
/// <summary>
/// Returns true if any model file is selected.
/// </summary>
[MenuItem("Assets/Simplygon/Optimize and create prefab/Small prop", isValidateFunction: true)]
[MenuItem("Assets/Simplygon/Optimize and create prefab/Large prop", isValidateFunction: true)]
private static bool HasSelectedAnyModelFiles()
{
if (Selection.gameObjects.Length == 0)
return false;
for (int i = 0; i < Selection.gameObjects.Length; i++)
if (IsModelFile(Selection.gameObjects[i]))
return true;
return false;
}
/// <summary>
/// Optimize assets.
/// </summary>
[MenuItem("Assets/Simplygon/Optimize and create prefab/Small prop", isValidateFunction: false)]
private static void OptimizeSmallPropAssets()
{
OptimizeSelectedAssets(
lod0MaxDeviation: 0.002f,
transitionDistances: new float[] { 0.5f, 0.1f },
hideDistance: 0.002f,
maxPixelDifference: 2);
}
/// <summary>
/// Optimize assets.
/// </summary>
[MenuItem("Assets/Simplygon/Optimize and create prefab/Large prop", isValidateFunction: false)]
private static void OptimizeLargePropAssets()
{
OptimizeSelectedAssets(
lod0MaxDeviation: 0.01f,
transitionDistances: new float[] { 0.8f, 0.5f, 0.2f, 0.06f },
hideDistance: 0.001f,
maxPixelDifference: 2);
}
/// <summary>
/// Optimize all selected assets using specified settings.
/// </summary>
private static void OptimizeSelectedAssets(float lod0MaxDeviation, float[] transitionDistances, float hideDistance, float maxPixelDifference)
{
for (int i = 0; i < Selection.gameObjects.Length; i++)
{
if (IsModelFile(Selection.gameObjects[i]))
{
var path = AssetDatabase.GetAssetPath(Selection.gameObjects[i]);
Debug.Log(path);
InitializeAndRunSimplygon(Selection.gameObjects[i], path, lod0MaxDeviation, transitionDistances, hideDistance, maxPixelDifference);
}
else
{
Debug.LogWarning($"{Selection.gameObjects[i]} is not a model file.");
}
}
}
/// <summary>
/// Convert from relative screen height to screen size.
/// </summary>
public static uint GetScreenSizeForRelativeScreenHeight(float lodLevel, float maxPixelDifference)
{
lodLevel = Mathf.Clamp01(lodLevel);
var ScreenSize = (GAME_RESOLUTION_HEIGHT * lodLevel / maxPixelDifference);
if (ScreenSize < MIN_SCREEN_SIZE)
{
return MIN_SCREEN_SIZE;
}
else
{
return (uint)Mathf.CeilToInt(ScreenSize);
}
}
/// <summary>
/// Initialize Simplygon and run optimization on asset.
/// </summary>
private static void InitializeAndRunSimplygon(GameObject gameObject, string assetPath, float lod0MaxDeviation, float[] transitionDistances, float hideDistance, float maxPixelDifference)
{
using (ISimplygon simplygon = global::Simplygon.Loader.InitSimplygon
(out EErrorCodes simplygonErrorCode, out string simplygonErrorMessage))
{
if (simplygonErrorCode == EErrorCodes.NoError)
{
CreateLODPrefab(gameObject, assetPath, simplygon, lod0MaxDeviation, transitionDistances, hideDistance, maxPixelDifference);
}
else
{
Debug.LogError("Simplygon Initializing failed: " + simplygonErrorCode);
Debug.LogError("Simplygon Initializing failed: " + simplygonErrorMessage);
}
}
}
/// <summary>
/// Create a pipeline for LOD 0. For LOD0 we use max deviation as target metric.
/// </summary>
public static spPipeline CreateLOD0Pipeline(ISimplygon simplygon, float maxDeviation)
{
var pipeline = simplygon.CreateReductionPipeline();
var reductionSettings = pipeline.GetReductionSettings();
reductionSettings.SetReductionTargets(EStopCondition.All, false, false, true, false);
reductionSettings.SetReductionTargetMaxDeviation(maxDeviation);
return pipeline;
}
/// <summary>
/// Create cascaded pipelines to create LOD levels for given screen size.
/// </summary>
public static List<spPipeline> CreateLODPipelines(ISimplygon simplygon, uint[] screenSizes)
{
var pipelines = new List<spPipeline>();
// Create pipelines for each screen size.
for (int i = 0; i < screenSizes.Length; i++)
{
var pipeline = simplygon.CreateReductionPipeline();
var reductionSettings = pipeline.GetReductionSettings();
reductionSettings.SetReductionTargets(EStopCondition.All, false, false, false, true);
reductionSettings.SetReductionTargetOnScreenSize(screenSizes[i]);
pipelines.Add(pipeline);
}
// Add each pipeline as cascaded to last one.
for (int i = 0; i < pipelines.Count - 1; i++)
{
pipelines[i].AddCascadedPipeline(pipelines[i + 1]);
}
return pipelines;
}
/// <summary>
/// Create LOD from renderers on game objects.
/// </summary>
public static LOD CreateLODFromChildRenderers(GameObject gameObject, float relativeScreenSize)
{
return new LOD(relativeScreenSize, gameObject.GetComponentsInChildren<Renderer>());
}
/// <summary>
/// Create and save prefab from input models and set up LODComponent.
/// </summary>
public static void CreatePrefab(List<GameObject> createdLods, float[] lodLevels, string prefabName, string prefabPath, float hideDistance)
{
// Creates empty prefab root.
GameObject prefab = new GameObject(prefabName);
// Add a LODGroup
// Instanciates all created LODs as childs to prefab root and set them as LOD levels.
var lodGroup = prefab.AddComponent<LODGroup>();
LOD[] lods = new LOD[createdLods.Count];
for (int i = 0; i < createdLods.Count; i++)
{
var instantiatedLOD = createdLods[i];
instantiatedLOD.transform.parent = prefab.transform;
// If we are the last LOD level we should use hideDistance to determine when we should fade away completely.
float transition = i == createdLods.Count - 1 ? hideDistance : lodLevels[i];
lods[i] = CreateLODFromChildRenderers(instantiatedLOD, transition);
}
lodGroup.SetLODs(lods);
// Save prefab and clean scene.
PrefabUtility.SaveAsPrefabAsset(prefab, prefabPath);
GameObject.DestroyImmediate(prefab);
}
/// <summary>
/// Create a LOD0 optimization of baseAsset.
/// Create LOD chain.
/// Save all as prefab with LODGroup set up.
/// </summary>
public static void CreateLODPrefab(GameObject gameObject, string baseAsset, ISimplygon simplygon, float maxDeviation, float[] transitionDistances, float hideDistance, float maxPixelDifference)
{
var baseFilePath = baseAsset.Replace(".fbx", "");
var baseFileName = baseFilePath.Substring(baseFilePath.LastIndexOf('/') + 1);
// Calculate screen sizes used for LOD1+
uint[] screenSizes = new uint[transitionDistances.Length];
for (int i = 0; i < screenSizes.Length; i++)
{
screenSizes[i] = GetScreenSizeForRelativeScreenHeight(transitionDistances[i], maxPixelDifference);
}
// Create pipelines
var lod0pipeline = CreateLOD0Pipeline(simplygon, maxDeviation);
var pipelines = CreateLODPipelines(simplygon, screenSizes);
lod0pipeline.AddCascadedPipeline(pipelines[0]);
var gameObjectsToProcess = new List<GameObject>() { gameObject };
var simplygonProcessing = new SimplygonProcessing();
simplygonProcessing.Run(simplygon, lod0pipeline, gameObjectsToProcess, EPipelineRunMode.RunInThisProcess, true);
CreatePrefab(simplygonProcessing.LODGameObjects, transitionDistances, baseFileName, baseFilePath + ".prefab", hideDistance);
}
}
}