Using Simplygon with Unity LODGroups
Written by Jesper Tingvall, Product Expert, Simplygon
Disclaimer: The code in this post is written using version 10.0.4600.0 of Simplygon and Unity 2021.1.25f1. 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
This post will showcase how we can use Simplygon to generate LOD levels to populate Unity's LODGroup
component. We are going to use cascaded reduction pipelines with screen size as stop criteria. This means we can think of LOD transitions in terms of visual difference instead of polygon count and distance to camera.
This is the asset we will be processing.
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
Unity has a build in LODGroup
component which we want to generate LOD meshes for. The transition criteria for it is relative screen height. We want to create LOD meshes using this metric to guaranteed a max visual difference.
Solution
The solution is a number of scripts which automates LOD creation using Simplygon from within Unity's UI. If the GameObject
already has a LODGroup
we are going to populate that one. If it has no LODGroup one will be created with predefined default values.
To create LODs which suits the LODGroup
we need to figure out what reduction settings that corresponds to the different LODs. In Unity each LOD is represented by a collection of renderer components and relative screen hight, a value between 0 and 1. This is the ratio of the GameObject
’s screen space height to the total screen height. A ratio of 0.5 would represent that the GameObject
's height fills half of the view.
Some smaller code pieces are omitted and can be found in the Complete Script selection.
Relative height to screen Size
Instead of guessing what number of triangles, ratio of triangles to keep, that would look good we are going to use screen size as stop condition for Simplygon.
Unitys LODGroup
component uses relative screen height to figure out what LOD level to use. Thus we need to map this into the screen size Simplygon use as a stop condition.
To calculate screen size we need to know what resolution we are going to render our game at. We also need to take into account that the smallest screen size Simplygon utilizes is 20 pixels in diameter. We are also introducing the LOD_MAX_PIXEL_DIFFERENCE
quality metric which allow us to specify the number of pixels our LOD model can differ from the previous one. This allow us to trade optimization difference in pixels between LODs, which can cause LOD pop.
static uint GAME_RESOLUTION_HEIGHT = 1080;
static float LOD_MAX_PIXEL_DIFFERENCE = 1;
static uint MIN_SCREEN_SIZE = 20;
public static uint GetScreenSizeForRelativeScreenHeight(float lodLevel)
{
lodLevel = Mathf.Clamp01(lodLevel);
var ScreenSize = (GAME_RESOLUTION_HEIGHT * lodLevel / LOD_MAX_PIXEL_DIFFERENCE);
if (ScreenSize < MIN_SCREEN_SIZE)
{
return MIN_SCREEN_SIZE;
}
else
{
return (uint)Mathf.CeilToInt(ScreenSize);
}
}
One limitation with this approach is that it assumes that the object's height and width is roughly the same. If this is not the case we risk switching LOD to early or to late.
Once we can calculate screen size required for one LOD level we can create a list of all screen sizes we need to process for a given LODGroup
.
public static uint[] GetScreenHeightsFromLODGroup(LODGroup lodGroup)
{
var lods = lodGroup.GetLODs();
// Ignore last value, it determines when object should be culled away and we do not need to process a LOD model for it.
uint[] screenHeights = new uint[lodGroup.lodCount - 1];
for (int i = 0; i < screenHeights.Length; i++)
{
screenHeights[i] = GetScreenSizeForRelativeScreenHeight(lods[i].screenRelativeTransitionHeight);
}
return screenHeights;
}
Create pipelines
For each screen size we calculated above we can create a reduction pipeline with the desired screen size as a stopping condition. We will leave all other options as default for now.
private static spPipeline CreatePipelineForScreenHeight(ISimplygon simplygon, uint screenHeight)
{
var pipeline = simplygon.CreateReductionPipeline();
spReductionSettings reductionSettings = pipeline.GetReductionSettings();
reductionSettings.SetReductionTargets(EStopCondition.All, false, false, false, true);
reductionSettings.SetReductionTargetOnScreenSize(screenHeight);
return pipeline;
}
Run cascaded pipelines
Now we are ready to do some processing with Simplygon. First we are going to initialize Simplygon and export the object we want to optimize. We are also checking if we need to process any LODs at all.
public static List<GameObject> SimplygonProcess(GameObject selectedGameObject, IEnumerable<uint> screenHeights)
{
if (screenHeights.Count() < 1)
return new List<GameObject>();
using (ISimplygon simplygon = Loader.InitSimplygon(out EErrorCodes simplygonErrorCode, out string simplygonErrorMessage))
{
if (simplygonErrorCode == EErrorCodes.NoError)
{
var exportTempDirectory = SimplygonUtils.GetNewTempDirectory();
var selectedGameObjects = new List<GameObject>() { selectedGameObject };
List<spPipeline> pipelines = new List<spPipeline>();
List<GameObject> lods = new List<GameObject>();
using spScene sgScene = SimplygonExporter.Export(simplygon, exportTempDirectory, selectedGameObjects);
For each LOD level we are going to create a reduction pipeline using the function we created above.
try
{
for (int i = 0; i < screenHeights.Count(); i++)
{
var pipeline = CreateReductionPipelineForScreenHeight(simplygon, screenHeights.ElementAt(i));
pipelines.Add(pipeline);
}
We are then going to add them as cascaded pipelines to each other. Cascaded pipelines works via that the previous pipelines optimized mesh is used as input for the next pipeline.
for (int i = 0; i < pipelines.Count - 1; i++)
{
pipelines[i].AddCascadedPipeline(pipelines[i + 1]);
}
We run the scene and import the result. We only need to run the first pipeline as all pipelines in a cascade will be processed when we call RunScene
on the top-level pipeline. After running the pipelines we import the result and then dispose of all created pipelines.
pipelines[0].RunScene(sgScene, EPipelineRunMode.RunInThisProcess);
string assetFolderPath = GetAssetFolder(selectedGameObject);
int startingLodIndex = 1;
SimplygonImporter.Import(simplygon, pipelines[0], ref startingLodIndex,
assetFolderPath, selectedGameObject.name, lods);
}
finally
{
foreach (var pipeline in pipelines)
pipeline?.Dispose();
}
return lods;
}
else
{
Debug.LogError($"Simplygon initializing failed ({simplygonErrorCode}): " + simplygonErrorMessage);
return null;
}
}
Resulting output. LOD1 uses LOD0 as input, LOD2 uses LOD1 as input and so on.
Clean up USD
The Simplygon package uses USD Unity SDK to import and export from Unity. Upon import it adds several components and game objects representing the USD file structure. We are not interested in keeping these and they can be removed with following script.
public static void CleanUpUSDComponents(GameObject objectToClean)
{
// Remove UsdPrimSources components
foreach (var primSource in objectToClean.GetComponentsInChildren<UsdPrimSource>())
{
GameObject.DestroyImmediate(primSource);
}
// Remove Materials GameObject
var materialsChild = objectToClean.transform.Find("Materials");
if (materialsChild)
{
GameObject.DestroyImmediate(materialsChild.gameObject);
}
// Remove UsdAsset
var usdAsset = objectToClean.GetComponent<UsdAsset>();
if (usdAsset)
GameObject.DestroyImmediate(usdAsset);
}
Create LOD Groups
Once we have the optimized models created and imported we need to assign them to the LODGroup
. First we add a helper functions for creating Unity LOD
objects from relative screen size and a GameObject
containing all renderers for the LOD level.
public static LOD CreateLODFromChildRenderers(GameObject gameObject, float relativeScreenSize)
{
return new LOD(relativeScreenSize, gameObject.GetComponentsInChildren<Renderer>());
}
Then we have a function which takes a list of imported optimized LOD models and assign them to a LODGroup
.
public static void AttachLODsToLODGroup(LODGroup lodGroup, List<GameObject> lodGameObjects)
{
if (lodGameObjects.Count != lodGroup.lodCount)
{
Debug.LogError("Incorrect number of LODS for Lod group", lodGroup);
return;
}
var lods = lodGroup.GetLODs();
for (int i = 0; i < lods.Length; i++)
{
var fadeWidth = lods[i].fadeTransitionWidth;
lods[i] = CreateLODFromChildRenderers(lodGameObjects[i], lods[i].screenRelativeTransitionHeight);
lods[i].fadeTransitionWidth = fadeWidth;
}
lodGroup.SetLODs(lods);
}
Putting it all together
When we have all pieces in place we can make a function which takes a game object and creates Simplygon optimized LOD levels for it. First we are going to find the LODGroup
component or, if it is not present, create one.
private static void ProcessObject(GameObject selectedGameObject)
{
Debug.Log($"Processing {selectedGameObject.name}", selectedGameObject);
var lodGroup = selectedGameObject.GetComponent<LODGroup>();
if (!lodGroup)
lodGroup = SimplygonLODGroupHelper.CreateLODGroup(selectedGameObject, DEFAULT_LOD_LEVELS);
We are calculating all screen sizes we need to create LODs for using data in the LODGroup
. Afterwards we start the optimization process.
var screenHeights = SimplygonLODProcessor.GetScreenHeightsFromLODGroup(lodGroup);
if (screenHeights.Count() < 1)
{
Debug.LogWarning($"No LODS to process for {selectedGameObject.name}", selectedGameObject);
}
var Lods = SimplygonLODProcessor.SimplygonProcess(selectedGameObject, screenHeights);
When the result is imported we attach the optimized LOD models to our selected object and assign their renderers to our LODGroup
.
if (Lods != null)
{
// Assign renderers to LOD group (including LOD0)
Lods.Insert(0, selectedGameObject);
SimplygonLODGroupHelper.AttachLODsToLODGroup(lodGroup, Lods);
Lods.Remove(selectedGameObject);
// Set LODs as childs to processed game object
foreach (var lod in Lods)
{
lod.transform.parent = selectedGameObject.transform;
SimplygonUSDHelper.CleanUpUSDComponents(lod);
}
}
else
{
Debug.LogError($"Failed creating LODs for {selectedGameObject.name}", selectedGameObject);
}
}
Result
After adding some additional boiler plate code we get a nice menu in Unity which allows us to select and object and either add a LODGroup for it with pre-defined levels, or generate LODs for an existing LODGroup.
The benefit of such system is that we can lift up our focus from exact vertex count for LODs, what vertex ratios each LOD should be of original mesh and at what distances transition should happen. We can instead focus on what matters; if it looks okay in the game.
Complete scripts
The scripts are depending on editor only functions and should be placed inside a folder named Editor or an editor only Assembly Definition.
SimplygonLODGroupHelper
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
using System.Collections.Generic;
using UnityEngine;
namespace Simplygon.Examples.UnityLODGroup
{
public class SimplygonLODGroupHelper
{
public static LODGroup CreateLODGroup(GameObject gameObject, float[] lodLevels)
{
var lodGroup = gameObject.AddComponent<LODGroup>();
LOD[] lodGroupLods = new LOD[lodLevels.Length];
for (int i = 0; i < lodLevels.Length; i++)
{
lodGroupLods[i] = new LOD(lodLevels[i], new Renderer[] { });
}
lodGroup.SetLODs(lodGroupLods);
return lodGroup;
}
public static LOD CreateLODFromChildRenderers(GameObject gameObject, float relativeScreenSize)
{
return new LOD(relativeScreenSize, gameObject.GetComponentsInChildren<Renderer>());
}
public static void AttachLODsToLODGroup(LODGroup lodGroup, List<GameObject> lodGameObjects)
{
if (lodGameObjects.Count != lodGroup.lodCount)
{
Debug.LogError("Incorrect number of LODS for Lod group", lodGroup);
return;
}
var lods = lodGroup.GetLODs();
for (int i = 0; i < lods.Length; i++)
{
var fadeWidth = lods[i].fadeTransitionWidth;
lods[i] = CreateLODFromChildRenderers(lodGameObjects[i], lods[i].screenRelativeTransitionHeight);
lods[i].fadeTransitionWidth = fadeWidth;
}
lodGroup.SetLODs(lods);
}
}
}
SimplygonLODGroupMenu
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
using UnityEditor;
using UnityEngine;
using System.Linq;
namespace Simplygon.Examples.UnityLODGroup
{
public class SimplygonLODGroupMenu
{
static float[] DEFAULT_LOD_LEVELS = new float[] { 0.5f, 0.2f, 0.05f, 0 };
[MenuItem("Simplygon/LODGroup/Create LODS Prefabs")]
static void CreateLODSForPrefabs()
{
if (Selection.objects.Length > 0)
{
foreach (var selectedObject in Selection.objects)
{
var selectedGameObject = selectedObject as GameObject;
if (selectedGameObject)
{
ProcessObject(selectedGameObject);
}
else
{
Debug.LogWarning($"{selectedObject.name} is not a GameObject and will be ignored,");
}
}
}
else
{
Debug.LogWarning("No objects selected");
}
}
private static void ProcessObject(GameObject selectedGameObject)
{
Debug.Log($"Processing {selectedGameObject.name}", selectedGameObject);
var lodGroup = selectedGameObject.GetComponent<LODGroup>();
if (!lodGroup)
lodGroup = SimplygonLODGroupHelper.CreateLODGroup(selectedGameObject, DEFAULT_LOD_LEVELS);
var screenHeights = SimplygonLODProcessor.GetScreenHeightsFromLODGroup(lodGroup);
if (screenHeights.Count() < 1)
{
Debug.LogWarning($"No LODS to process for {selectedGameObject.name}", selectedGameObject);
}
var Lods = SimplygonLODProcessor.SimplygonProcess(selectedGameObject, screenHeights);
if (Lods != null)
{
// Assign renderers to LOD group (including LOD0)
Lods.Insert(0, selectedGameObject);
SimplygonLODGroupHelper.AttachLODsToLODGroup(lodGroup, Lods);
Lods.Remove(selectedGameObject);
// Set LODs as childs to processed game object
foreach (var lod in Lods)
{
lod.transform.parent = selectedGameObject.transform;
SimplygonUSDHelper.CleanUpUSDComponents(lod);
}
}
else
{
Debug.LogError($"Failed creating LODs for {selectedGameObject.name}", selectedGameObject);
}
}
}
}
SimplygonLODProcessor
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
using Simplygon.Unity.EditorPlugin;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace Simplygon.Examples.UnityLODGroup
{
public class SimplygonLODProcessor
{
static uint GAME_RESOLUTION_HEIGHT = 1080;
static float LOD_MAX_PIXEL_DIFFERENCE = 1;
static uint MIN_SCREEN_SIZE = 20;
public static uint GetScreenSizeForRelativeScreenHeight(float lodLevel)
{
lodLevel = Mathf.Clamp01(lodLevel);
var ScreenSize = (GAME_RESOLUTION_HEIGHT * lodLevel / LOD_MAX_PIXEL_DIFFERENCE);
if (ScreenSize < LOD_MAX_PIXEL_DIFFERENCE)
{
Debug.Log($"LOD to tiny: {ScreenSize}");
}
if (ScreenSize < MIN_SCREEN_SIZE)
{
return MIN_SCREEN_SIZE;
}
else
{
return (uint)Mathf.CeilToInt(ScreenSize);
}
}
public static uint[] GetScreenHeightsFromLODGroup(LODGroup lodGroup)
{
var lods = lodGroup.GetLODs();
uint[] screenHeights = new uint[lodGroup.lodCount - 1]; // Ignore last value, it is always 0.
for (int i = 0; i < screenHeights.Length; i++)
{
screenHeights[i] = GetScreenSizeForRelativeScreenHeight(lods[i].screenRelativeTransitionHeight);
}
return screenHeights;
}
private static spPipeline CreateReductionPipelineForScreenHeight(ISimplygon simplygon, uint screenHeight)
{
var pipeline = simplygon.CreateReductionPipeline();
spReductionSettings reductionSettings = pipeline.GetReductionSettings();
reductionSettings.SetReductionTargets(EStopCondition.All, false, false, false, true);
reductionSettings.SetReductionTargetOnScreenSize(screenHeight);
return pipeline;
}
private static string CreateLODFolder()
{
string baseFolder = "Assets/LODs";
if (!AssetDatabase.IsValidFolder(baseFolder))
{
AssetDatabase.CreateFolder("Assets", "LODs");
}
return baseFolder;
}
private static string GetAssetFolder(GameObject selectedGameObject)
{
string baseFolder = CreateLODFolder();
string assetFolderGuid = AssetDatabase.CreateFolder(baseFolder, selectedGameObject.name);
string assetFolderPath = AssetDatabase.GUIDToAssetPath(assetFolderGuid);
return assetFolderPath;
}
public static List<GameObject> SimplygonProcess(GameObject selectedGameObject, IEnumerable<uint> screenHeights)
{
if (screenHeights.Count() < 1)
return new List<GameObject>();
using (ISimplygon simplygon = Loader.InitSimplygon(out EErrorCodes simplygonErrorCode, out string simplygonErrorMessage))
{
if (simplygonErrorCode == EErrorCodes.NoError)
{
var exportTempDirectory = SimplygonUtils.GetNewTempDirectory();
var selectedGameObjects = new List<GameObject>() { selectedGameObject };
List<spPipeline> pipelines = new List<spPipeline>();
List<GameObject> lods = new List<GameObject>();
using spScene sgScene = SimplygonExporter.Export(simplygon, exportTempDirectory, selectedGameObjects);
try
{
for (int i = 0; i < screenHeights.Count(); i++)
{
var pipeline = CreateReductionPipelineForScreenHeight(simplygon, screenHeights.ElementAt(i));
pipelines.Add(pipeline);
}
for (int i = 0; i < pipelines.Count - 1; i++)
{
pipelines[i].AddCascadedPipeline(pipelines[i + 1]);
}
pipelines[0].RunScene(sgScene, EPipelineRunMode.RunInThisProcess);
string assetFolderPath = GetAssetFolder(selectedGameObject);
int startingLodIndex = 1;
SimplygonImporter.Import(simplygon, pipelines[0], ref startingLodIndex,
assetFolderPath, selectedGameObject.name, lods);
}
finally
{
foreach (var pipeline in pipelines)
pipeline?.Dispose();
}
return lods;
}
else
{
Debug.LogError($"Simplygon initializing failed ({simplygonErrorCode}): " + simplygonErrorMessage);
return null;
}
}
}
}
}
SimplygonUSDHelper
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
using UnityEngine;
using Unity.Formats.USD;
namespace Simplygon.Examples.UnityLODGroup
{
public class SimplygonUSDHelper
{
public static void CleanUpUSDComponents(GameObject objectToClean)
{
// Remove UsdPrimSources components
foreach (var primSource in objectToClean.GetComponentsInChildren<UsdPrimSource>())
{
GameObject.DestroyImmediate(primSource);
}
// Remove Materials GameObject
var materialsChild = objectToClean.transform.Find("Materials");
if (materialsChild)
{
GameObject.DestroyImmediate(materialsChild.gameObject);
}
// Remove UsdAsset
var usdAsset = objectToClean.GetComponent<UsdAsset>();
if (usdAsset)
GameObject.DestroyImmediate(usdAsset);
}
}
}