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.

Sci fi turret in Unity

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.

Unity LOD Group component

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.

4 LODs of a weapon turret; LOD0 to LOD3.

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.

Sci fi turret with different LOD levels

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);
        }
    }
}
⇐ Back to all posts

Request 30-days free evaluation license

*
*
*
*
Industry
*

Request 30-days free evaluation license

*
*
*
*
Industry
*