Introduction to Clustered Meshlet Optimization

Several heads optimized

Written by Jesper Tingvall, Product Expert, Simplygon

Disclaimer: The code in this post is written using version 10.4.232.0 of Simplygon and Unity 6000.0.59f2. 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 post we will look at Simplygon's new clustered meshlet feature. We will cover how to generate them and a way to visualize them which serves as the base for implementing a clustered meshlet rendering pipeline in your engine.

What are clustered meshlets?

Clustered meshlets in Simplygon is a way of dividing up a model into smaller parts where each part can be culled and pick their detail level individually based on camera position and desired quality without causing cracks, resulting in continous LOD.

The Clustered meshlet optimizer divides up the meshlet into small clusters, and performs iterative merging and reduction of these. See algorithm details for more details.

Meshlets are intended for modern rendering pipelines and hardware that use mesh shader–based render pipelines. If your game is using or considering using this technology for rendering then Simplygon's clustered meshlet optimizer can help you generate optimized clustered meshlet models for your game.

Cluster streaming levels are not traditional LOD levels

A common misunderstanding with clustered meshlet is that each meshlet streaming level is a traditional LOD level. This is not the case. Each clustered meshlet level only contains partial data. You need to combine data from all levels below it as well to get the full detail required to render the model. For example, consider the drill model below that has been optimized into clustered meshlets with 5 levels.

5 drill machines, some with parts missing.

There are some important points to note about the streaming levels:

  • We can see that not every level contains a full model, it only contains parts of the model that are required to increase the detail from the previous level.
  • There is no guarantee of the amount of clusters in each level. Some levels can contain many clusters while other levels can contain only a few clusters.
  • Level 0 might in some simple models contain only one cluster, while in other models it might contain many clusters.

The purpose of the clustered meshlet streaming levels is to allow us to stream in more detail as we get closer to the object. This allows us to render very dense meshes with high performance. This is outside the scope of current blog post, but can be found in the documentation

Generating clustered meshlets

To generate clustered meshlets we use the Clustered meshlet Optimizer.

The Clustered meshlet Optimizer is a quite new tool, so it does not have that many options yet. We set following settings in the example below to ensure it can generate clusters with as much freedom as possible. If your engine has specific limitations you might need to adjust these settings.

  • SplitMeshletsByMaterial is set to false to allow meshlets to span multiple materials.
  • UseNonContiguousGrouping is set to true which allows meshlet clusters to be non-contiguous. This is useful if our model contain lots of small disconnected parts.
def RunClusteredMeshletOptimizer(sg : Simplygon.ISimplygon, geometry_data : Simplygon.spPackedGeometryData):
    """Run clustered meshlet optimizer on geometry data."""
    
    clustered_meshlet_optimizer = sg.CreateClusteredMeshletOptimizer()
    
    clustered_meshlet_optimizer.SetMaxVerticesPerMeshlet(256)
    clustered_meshlet_optimizer.SetMaxTrianglesPerMeshlet(128)
    clustered_meshlet_optimizer.SetSplitMeshletsByMaterial(False)
    clustered_meshlet_optimizer.SetUseNonContiguousGrouping(True)
    clustered_meshlet_optimizer.SetGeometry(geometry_data)
    
    clustered_meshlet_optimizer.Run()

Packed geometry data

Clustered meshlet optimizer works on a specific kind of geometry data that you might not have encountered before. The input and output are packed geometry data. The difference between packed geometry data and traditional geometry data is that all geometry fields are stored per vertex rather then per corner. We can convert to and from packed geometry data using NewPackedCopy and NewUnpackedCopy. We suggest that you ensure that your import and export from Simplygon path can handle packed geometry data.

The output from the optimizer is packed geometry data containing clustered meshlets in one model. If we import this mesh into a DCC tool it would look quite strange. In order to render our clustered meshlet models correctly we need to use a meshlet rendering pipeline, which is outside the scope of this blog post. Hence we will look at how to visualize the clustered meshlet data for debug purposes in the next section.

Extracting metadata for clustered meshlets

Simplygon also generates the metadata required to know when each meshlet cluster should be rendered. This metadata is stored as custom fields on the packed geometry data output from the clustered meshlet optimizer. We can extract this metadata and store it in a data structure which we later export to a json file.

def extract_meshlet_metadata(scene_mesh: Simplygon.spSceneMesh, meshlets_geometry_data: Simplygon.spPackedGeometryData):
    """Give us metadata about the meshlets in the geometry data."""

    custom_fields = meshlets_geometry_data.GetCustomFields()
    meshlet_triangles = Simplygon.spUnsignedIntArray.SafeCast(custom_fields.FindValueArray('cmo:MeshletTriangles'))
    meshletBounds = Simplygon.spRealArray.SafeCast(custom_fields.FindValueArray("cmo:MeshletBounds"))
    meshletErrors = Simplygon.spRealArray.SafeCast(custom_fields.FindValueArray("cmo:MeshletErrors"))
    meshletParentBounds = Simplygon.spRealArray.SafeCast(custom_fields.FindValueArray("cmo:MeshletParentBounds"))
    meshletParentErrors = Simplygon.spRealArray.SafeCast(custom_fields.FindValueArray("cmo:MeshletParentErrors"))

    meshlets = []

    for i in range(meshlet_triangles.GetTupleCount()):
        meshlet_name = scene_mesh.GetName() + f"_meshlet_{get_meshlet_level(meshlets_geometry_data, i)}_{i}"
        meshlet = {
            "name" : meshlet_name,
            "bounds_center" : (meshletBounds.GetItem(i*4+0), meshletBounds.GetItem(i*4+1), meshletBounds.GetItem(i*4+2)),
            "bounds_radius" : meshletBounds.GetItem(i*4+3),
            "error" : meshletErrors.GetItem(i),

            "level" : get_meshlet_level(meshlets_geometry_data, i),

            "parent_bounds_center" : (meshletParentBounds.GetItem(i*4+0), meshletParentBounds.GetItem(i*4+1), meshletParentBounds.GetItem(i*4+2)),
            "parent_bounds_radius" : meshletParentBounds.GetItem(i*4+3),
            "parent_error" : meshletParentErrors.GetItem(i),
        }
        meshlets.append(meshlet)

    return meshlets

This helper function finds the streaming level for a given meshlet id.

def get_meshlet_level(meshlets_geometry_data: Simplygon.spPackedGeometryData, meshlet_id: int) -> int:
    """Get the streaming level for a given meshlet id."""

    sgLevelMeshletsRaw = meshlets_geometry_data.GetCustomFields().FindValueArray('cmo:LevelMeshlets')
    sgLevelMeshlets = Simplygon.spUnsignedIntArray.SafeCast(sgLevelMeshletsRaw)

    for i in range(sgLevelMeshlets.GetTupleCount()):
        if meshlet_id < sgLevelMeshlets.GetItem(i):
            return i - 1
    return sgLevelMeshlets.GetTupleCount() - 1

Visualize clustered meshlet data for debug purposes

As we pointed out earlier, it is quite hard to visualize clustered meshlet data directly in a DCC tool. To help us debug and verify that our clustered meshlet generation is working as expected we will extract each meshlet into its own mesh and export it to fbx format. This is not the suggested way of handling clustered meshlets in a production engine, but it is useful for visualization purposes.

Split and export clustered meshlets

This function splits the clustered meshlet geometry into individual meshlet meshes. Each mesh contains lots of replicated data, so this is not an efficient way of handling the geometry. If you try to run this on a very dense model you might run out of memory.

def split_into_meshlets(scene_mesh: Simplygon.spSceneMesh, meshlets_geometry_data: Simplygon.spPackedGeometryData):
    """Split a clustered meshlet geometry into individual meshlet meshes. This is a very not optimized way of doing this, and a very wasteful way of handling the geometry. This is just for demonstration purposes and should not be used in production."""

    print(f"Split {scene_mesh.GetName()} into meshlets")

    meshlet_triangles = Simplygon.spUnsignedIntArray.SafeCast( meshlets_geometry_data.GetCustomFields().FindValueArray('cmo:MeshletTriangles'))

    for i in range(meshlet_triangles.GetTupleCount()):
        # Calculate number of triangles in this meshlet
        triangles = 0
        if (i < meshlet_triangles.GetTupleCount()-1):
            triangles = meshlet_triangles.GetItem(i+1) - meshlet_triangles.GetItem(i)
        else:
            triangles = meshlets_geometry_data.GetTriangleCount() - meshlet_triangles.GetItem(i)

        print (f"Creating meshlet {i} for level {get_meshlet_level(meshlets_geometry_data, i)} containing {triangles} triangles")
        
        # Create a clone of our old scene mesh to hold the meshlet geometry. This way we keep all materials, transforms, etc.
        meshlet_scene_mesh = Simplygon.spSceneMesh.SafeCast(scene_mesh.NewCopy())

        # Give it a name based on the original mesh name, meshlet level and id.
        meshlet_name = scene_mesh.GetName() + f"_meshlet_{get_meshlet_level(meshlets_geometry_data, i)}_{i}"
        meshlet_scene_mesh.SetName(meshlet_name)

        # Here is where the magic happens. 
        # We copy the geometry data, set specific triangle count of the meshlet, then copy the relevant triangle and vertex data into it.
        # We get some waste here, but we can live with it as this is just for demo.
        packed_meshlet_geometry = meshlets_geometry_data.NewCopy(True)
        packed_meshlet_geometry.SetTriangleCount(triangles)
        packed_meshlet_geometry.GetTriangles().CopyRange(meshlets_geometry_data.GetTriangles(), 0, meshlet_triangles.GetItem(i), triangles)
        packed_meshlet_geometry.GetVertexIds().CopyRange(meshlets_geometry_data.GetVertexIds(), 0, meshlet_triangles.GetItem(i)*3, triangles*3)

        # Geometry nodes only work with unpacked geometry, so we convert it back into unpacked form.
        meshlet_scene_mesh.SetGeometry(packed_meshlet_geometry.NewUnpackedCopy())

        # Add the new meshlet mesh to the scene.
        parent = scene_mesh.GetParent()
        parent.AddChild(meshlet_scene_mesh)

Visualizing clustered meshlets

To render the clusters we will implement a 'software clustered meshlet' renderer in Unity. It is important to notice that this is only for visualization purposes. This will result in no performance gain. In fact this can have quite bad performance if you load in a huge model.

The rendering is handled by the Meshlet class. Where the most important function is ShouldRender. It uses the metadata we extracted earlier to calculate if this meshlet should be rendered based on camera position and error threshold. Each meshlet also knows the parent meshlet bounds and error which is required for the calculation to be able to run in parallel.

Error Threshold is a global value that controls how aggressive we want to be when culling clusters. A higher value will pick clusters with higher error, resulting in less triangles being rendered. It is connected to pixel error which we have used in previous blog posts via following formula.

ErrorThreshold = ErrorInPixels / ScreenHeightInPixels

public bool ShouldRender()
{
    float currentError = CalculateBoundsError(
        BoundsCenter,
        BoundsRadius,
        Error,
        renderCamera);

    float parentError = CalculateBoundsError(
        ParentBoundsCenter,
        ParentBoundsRadius,
        ParentError,
        renderCamera);

    // Formula from https://documentation.simplygon.com/SimplygonSDK_10.4.232.0/api/tools/clusteredmeshletoptimizer.html#runtime-meshlet-selection
    return currentError <= ErrorThreshold && parentError > ErrorThreshold;
}

To calculate the error for our meshlet in screen percentage we need to transform the meshlet's bounds center and radius into Unity's world coordinate system.

float CalculateBoundsError(Vector3 boundsCenter, float boundsRadius, float error, Camera camera)
{
    // Formula from https://documentation.simplygon.com/SimplygonSDK_10.4.232.0/api/tools/clusteredmeshletoptimizer.html#runtime-meshlet-selection
    var delta = TransformToUnityWorldCoordinateSystem(boundsCenter) - camera.transform.position;
    float d = delta.magnitude - TransformToUnityWorldScale(boundsRadius);
    return error / (d > camera.nearClipPlane ? d : camera.nearClipPlane) * (CalculateCameraProjectionFactor(camera) * 0.5f);
}

Result

So with all that in place we can finally generate some clustered meshlets and visualize them. The clustered meshlet file is imported into a Unity scene, and meshlet data is loaded via Simplygon->Meshlet->Load Meshlets From JSON menu. As we mentioned before, this is just for visualization so the performance is quite bad. The result looks like this:

We can see that we swap clusters as we move the camera around in the scene, but we cannot spot any popping artifacts.

If we select a meshlet in the scene we can see its bounds visualized as wire spheres. We visualize both the cluster's radius and the parent cluster's radius.

Meshlet rendering two radiuses, a smaller yellow radius and a larger grey radius

A cluster's bounding sphere must contain all its child cluster's bounding spheres. So we can sometimes see bounding spheres that are quite large compared to the actual cluster size.

Meshlet rendering large radius

Comparing clustered meshlets to traditional lod chain

How does clustered meshlets compare to traditional lod chains? This is a little bit of comparing apples to oranges, but we can still make some general observations.

A rendering pipeline based on clustered meshlets allows for more fine grained control of the rendered detail. Each meshlet can be culled individually based on its screen size, allowing us to only render the parts of the model that are actually visible and at the desired quality. This is in contrast to traditional lod chains where we switch between different levels of detail for the entire model at once.

If we look at it from a purely mesh quality per triangle perspective then in most cases traditional lod chains will give us slightly better quality for the same amount of triangles. Meshlet creation and optimization introduces constraints on the optimization process; for example, certain borders are frozen and we share vertex data (normals, positions, UVs) across multiple meshlets. This means that we cannot optimize each meshlet individually to the same degree as we can with traditional lod chains.

So the takeaway from this is that if your game use meshlets on some platforms, but not on others, you want to create two different optimization paths; one for clustered meshlets and one for traditional lod chains. Basing the LOD chain on the clustered meshlet optimized model will not give you the best possible quality.

Other tools to handle dense meshes

If you are evaluating different technologies to handle very dense meshes in your game project, there are some other tools you might want to investigate as well.

  • Another option to store dense meshes is to use Micro-Meshes. These excel when it comes to mesh compression and can with hardware acceleration render very dense meshes.
  • In some cases you want to optimize dense meshes before generating clustered meshlets. In these cases you can use Simplygon's High-density Reducer.
  • To generate proxy models, for example distant HLODs, from dense meshes you can use Simplygon's High-density Remesher.

Complete scripts

clustered_meshlets.py

This script outputs a fbx file containing all clustered meshlets as individual meshes for visualization purposes, and a json file containing metadata about the clustered meshlets that we can use for rendering.

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

import gc
import json
from simplygon10 import simplygon_loader
from simplygon10 import Simplygon


def import_scene(sg: Simplygon.ISimplygon, file_path: str) -> Simplygon.spScene:
    """Import file into Simplygon scene."""
    
    scene_importer = sg.CreateSceneImporter()
    scene_importer.SetImportFilePath(file_path)
    scene_importer.Run()
    scene = scene_importer.GetScene()
    print(f"Imported {file_path}")
    return scene

def export_scene(sg: Simplygon.ISimplygon, scene: Simplygon.spScene, file_path: str):
    """Export Simplygon scene to file."""
    
    scene_exporter = sg.CreateSceneExporter()
    scene_exporter.SetExportFilePath(file_path)
    scene_exporter.SetScene(scene)
    scene_exporter.Run()
    print(f"exported {file_path}")
    

def optimize_all_geometry_into_meshlets(sg: Simplygon.ISimplygon, scene: Simplygon.spScene):
    """Optimize all geometry in the scene into clustered meshlets."""

    # Create a selection set containing all scene meshes. This is an easy way to loop through all meshes in the scene.
    scene_meshes_selection_set_id = scene.SelectNodes("SceneMesh")
    scene_meshes_selection_set = scene.GetSelectionSetTable().GetSelectionSet(scene_meshes_selection_set_id)

    # Data structure to hold meshlet info for all generated meshlets. We will save this to a json file later.
    meshlet_data = []

    for node_id in range(scene_meshes_selection_set.GetItemCount()):
        scene_mesh = Simplygon.spSceneMesh.SafeCast(scene.GetNodeByGUID( scene_meshes_selection_set.GetItem(node_id)))
        geometry = scene_mesh.GetGeometry()

        if not geometry: 
            print("Scene without geometry, skipping")
            continue

        print(f"Generating clustered meshlets for {scene_mesh.GetName()}")
        meshlets_geometry_data = scene_mesh.GetGeometry().NewPackedCopy()
        RunClusteredMeshletOptimizer(sg, meshlets_geometry_data)

        # Split the optimized geometry into individual meshlets and extract meshlet data. This step is just for demonstration purposes and should not be used in production.
        split_into_meshlets(scene_mesh, meshlets_geometry_data)
        meshlet_data.extend(extract_meshlet_metadata(scene_mesh, meshlets_geometry_data))

        # Remove original mesh
        scene_mesh.GetParent().RemoveChild(scene_mesh)

    # Bundle it into a entries class so we can later load it into Unity.
    save_data = {"entries": meshlet_data}

    return save_data


def split_into_meshlets(scene_mesh: Simplygon.spSceneMesh, meshlets_geometry_data: Simplygon.spPackedGeometryData):
    """Split a clustered meshlet geometry into individual meshlet meshes. This is a very not optimized way of doing this, and a very wasteful way of handling the geometry. This is just for demonstration purposes and should not be used in production."""

    print(f"Split {scene_mesh.GetName()} into meshlets")

    meshlet_triangles = Simplygon.spUnsignedIntArray.SafeCast( meshlets_geometry_data.GetCustomFields().FindValueArray('cmo:MeshletTriangles'))

    for i in range(meshlet_triangles.GetTupleCount()):
        # Calculate number of triangles in this meshlet
        triangles = 0
        if (i < meshlet_triangles.GetTupleCount()-1):
            triangles = meshlet_triangles.GetItem(i+1) - meshlet_triangles.GetItem(i)
        else:
            triangles = meshlets_geometry_data.GetTriangleCount() - meshlet_triangles.GetItem(i)

        print (f"Creating meshlet {i} for level {get_meshlet_level(meshlets_geometry_data, i)} containing {triangles} triangles")
        
        # Create a clone of our old scene mesh to hold the meshlet geometry. This way we keep all materials, transforms, etc.
        meshlet_scene_mesh = Simplygon.spSceneMesh.SafeCast(scene_mesh.NewCopy())

        # Give it a name based on the original mesh name, meshlet level and id.
        meshlet_name = scene_mesh.GetName() + f"_meshlet_{get_meshlet_level(meshlets_geometry_data, i)}_{i}"
        meshlet_scene_mesh.SetName(meshlet_name)

        # Here is where the magic happens. 
        # We copy the geometry data, set specific triangle count of the meshlet, then copy the relevant triangle and vertex data into it.
        # We get some waste here, but we can live with it as this is just for demo.
        packed_meshlet_geometry = meshlets_geometry_data.NewCopy(True)
        packed_meshlet_geometry.SetTriangleCount(triangles)
        packed_meshlet_geometry.GetTriangles().CopyRange(meshlets_geometry_data.GetTriangles(), 0, meshlet_triangles.GetItem(i), triangles)
        packed_meshlet_geometry.GetVertexIds().CopyRange(meshlets_geometry_data.GetVertexIds(), 0, meshlet_triangles.GetItem(i)*3, triangles*3)

        # Geometry nodes only work with unpacked geometry, so we convert it back into unpacked form.
        meshlet_scene_mesh.SetGeometry(packed_meshlet_geometry.NewUnpackedCopy())

        # Add the new meshlet mesh to the scene.
        parent = scene_mesh.GetParent()
        parent.AddChild(meshlet_scene_mesh)


def extract_meshlet_metadata(scene_mesh: Simplygon.spSceneMesh, meshlets_geometry_data: Simplygon.spPackedGeometryData):
    """Give us metadata about the meshlets in the geometry data."""

    custom_fields = meshlets_geometry_data.GetCustomFields()
    meshlet_triangles = Simplygon.spUnsignedIntArray.SafeCast(custom_fields.FindValueArray('cmo:MeshletTriangles'))
    meshletBounds = Simplygon.spRealArray.SafeCast(custom_fields.FindValueArray("cmo:MeshletBounds"))
    meshletErrors = Simplygon.spRealArray.SafeCast(custom_fields.FindValueArray("cmo:MeshletErrors"))
    meshletParentBounds = Simplygon.spRealArray.SafeCast(custom_fields.FindValueArray("cmo:MeshletParentBounds"))
    meshletParentErrors = Simplygon.spRealArray.SafeCast(custom_fields.FindValueArray("cmo:MeshletParentErrors"))

    meshlets = []

    for i in range(meshlet_triangles.GetTupleCount()):
        meshlet_name = scene_mesh.GetName() + f"_meshlet_{get_meshlet_level(meshlets_geometry_data, i)}_{i}"
        meshlet = {
            "name" : meshlet_name,
            "bounds_center" : (meshletBounds.GetItem(i*4+0), meshletBounds.GetItem(i*4+1), meshletBounds.GetItem(i*4+2)),
            "bounds_radius" : meshletBounds.GetItem(i*4+3),
            "error" : meshletErrors.GetItem(i),

            "level" : get_meshlet_level(meshlets_geometry_data, i),

            "parent_bounds_center" : (meshletParentBounds.GetItem(i*4+0), meshletParentBounds.GetItem(i*4+1), meshletParentBounds.GetItem(i*4+2)),
            "parent_bounds_radius" : meshletParentBounds.GetItem(i*4+3),
            "parent_error" : meshletParentErrors.GetItem(i),
        }
        meshlets.append(meshlet)

    return meshlets


def get_meshlet_level(meshlets_geometry_data: Simplygon.spPackedGeometryData, meshlet_id: int) -> int:
    """Get the streaming level for a given meshlet id."""

    sgLevelMeshletsRaw = meshlets_geometry_data.GetCustomFields().FindValueArray('cmo:LevelMeshlets')
    sgLevelMeshlets = Simplygon.spUnsignedIntArray.SafeCast(sgLevelMeshletsRaw)

    for i in range(sgLevelMeshlets.GetTupleCount()):
        if meshlet_id < sgLevelMeshlets.GetItem(i):
            return i - 1
    return sgLevelMeshlets.GetTupleCount() - 1


def RunClusteredMeshletOptimizer(sg : Simplygon.ISimplygon, geometry_data : Simplygon.spPackedGeometryData):
    """Run clustered meshlet optimizer on geometry data."""
    
    clustered_meshlet_optimizer = sg.CreateClusteredMeshletOptimizer()
    
    clustered_meshlet_optimizer.SetMaxVerticesPerMeshlet(256)
    clustered_meshlet_optimizer.SetMaxTrianglesPerMeshlet(128)
    clustered_meshlet_optimizer.SetSplitMeshletsByMaterial(False)
    clustered_meshlet_optimizer.SetUseNonContiguousGrouping(True)
    clustered_meshlet_optimizer.SetGeometry(geometry_data)
    
    clustered_meshlet_optimizer.Run()


def optimize_file(sg: Simplygon.ISimplygon, in_file: str, out_file: str, meshlet_file: str):
    """Remove and bake decals on selected meshes."""
    
    sg.SetGlobalDefaultTangentCalculatorTypeSetting(Simplygon.ETangentSpaceMethod_MikkTSpace)

    scene = import_scene(sg, in_file)
    
    meshlets = optimize_all_geometry_into_meshlets(sg, scene)
    
    export_scene(sg, scene, out_file)

    # Write meshlet data to json file
    with open(meshlet_file, 'w', encoding='utf-8') as file:
        json.dump(meshlets, file, ensure_ascii=False, indent=4)


def main():
    sg = simplygon_loader.init_simplygon()

    in_file ="wall.fbx"
    out_file ="wall_meshlets.fbx"
    meshlet_file ="wall.json"

    optimize_file(sg, in_file, out_file, meshlet_file)
    sg = None
    gc.collect()


if __name__== "__main__":
    main()

Meshlet.cs

Runtime Unity component that handles rendering of a single clustered meshlet based on camera position and error threshold. The calculations can ported to a proper mesh shader based rendering pipeline for optimal performance.

using UnityEngine;

namespace Simplygon.Examples.MeshletVisualization
{
    public class Meshlet : MonoBehaviour
    {
        public Vector3 BoundsCenter;
        public float BoundsRadius;
        public float Error;

        public Vector3 ParentBoundsCenter;
        public float ParentBoundsRadius;
        public float ParentError;

        public int Level;

        public Renderer renderer;
        public Camera renderCamera;
        public MeshletVisualizer visualizer;

        public float ErrorThreshold
        {
            get
            {
                return visualizer.ErrorThreshold;
            }
        }

        void Start()
        {
            renderer = GetComponent<Renderer>();
            renderCamera = Camera.main;
            visualizer = FindAnyObjectByType<MeshletVisualizer>();
        }

        public Vector3 TransformToUnityWorldCoordinateSystem(Vector3 vector)
        {
            // Converting from fbx file coordinate system to unity coordinate system.
            // Our model is converted by Unity automatically, but we need to do the same transform on the metadata.
            var point = 0.01f * new Vector3(-vector.x, vector.y, vector.z);

            // The meshlet only knows its position relative to its origin, so we need to transform it into world position by our transform.
            return transform.TransformPoint(point);
        }

        public float TransformToUnityWorldScale(float radius)
        {
            // Scaling radius of clustered meshlets. Currently this only supports uniform scaling.
            return 0.01f * radius * Mathf.Max(transform.lossyScale.x, transform.lossyScale.y, transform.lossyScale.z);
        }

        public void OnDrawGizmosSelected()
        {
            // Our meshlet has same radius as it's parent. We are as large as our parent, but have less error.
            if (ParentBoundsRadius == BoundsRadius)
            {
                Gizmos.color = new Color(1, 0.65f, 0);
                Gizmos.DrawWireSphere(TransformToUnityWorldCoordinateSystem(BoundsCenter), TransformToUnityWorldScale(BoundsRadius));
            }
            // Our meshlet is smaller than it's parent, draw both radiuses.
            else if (ParentBoundsRadius > 0)
            {
                Gizmos.color = Color.yellow;
                Gizmos.DrawWireSphere(TransformToUnityWorldCoordinateSystem(BoundsCenter), TransformToUnityWorldScale(BoundsRadius));

                Gizmos.color = Color.gray;
                Gizmos.DrawWireSphere(TransformToUnityWorldCoordinateSystem(ParentBoundsCenter), TransformToUnityWorldScale(ParentBoundsRadius));
            }
            // Our meshlet has no parent. We are the root meshlet.
            else
            {
                Gizmos.color = Color.green;
                Gizmos.DrawWireSphere(TransformToUnityWorldCoordinateSystem(BoundsCenter), TransformToUnityWorldScale(BoundsRadius));
            }
        }

        public bool ShouldRender()
        {
            float currentError = CalculateBoundsError(
                    BoundsCenter,
                    BoundsRadius,
                    Error,
                    renderCamera);

            float parentError = CalculateBoundsError(
                        ParentBoundsCenter,
                        ParentBoundsRadius,
                        ParentError,
                        renderCamera);

            // Formula from https://documentation.simplygon.com/SimplygonSDK_10.4.232.0/api/tools/clusteredmeshletoptimizer.html#runtime-meshlet-selection
            return currentError <= ErrorThreshold && parentError > ErrorThreshold;
        }

        float CalculateCameraProjectionFactor(Camera camera)
        {
            // Formula from https://documentation.simplygon.com/SimplygonSDK_10.4.232.0/api/tools/clusteredmeshletoptimizer.html#runtime-meshlet-selection
            return 1.0f / Mathf.Tan(camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
        }

        /** Returns the error for our meshlet in screen %.*/
        float CalculateBoundsError(Vector3 boundsCenter, float boundsRadius, float error, Camera camera)
        {
            // Formula from https://documentation.simplygon.com/SimplygonSDK_10.4.232.0/api/tools/clusteredmeshletoptimizer.html#runtime-meshlet-selection
            var delta = TransformToUnityWorldCoordinateSystem(boundsCenter) - camera.transform.position;
            float d = delta.magnitude - TransformToUnityWorldScale(boundsRadius);
            return error / (d > camera.nearClipPlane ? d : camera.nearClipPlane) * (CalculateCameraProjectionFactor(camera) * 0.5f);
        }

        void Update()
        {
            renderer.enabled = ShouldRender();
        }
    }
}

MeshletVisualizer.cs

Class which holds the global error threshold value that controls how agressive we want to be when picking clusters to render.

using UnityEngine;

namespace Simplygon.Examples.MeshletVisualization
{
    public class MeshletVisualizer : MonoBehaviour
    {
        public float ErrorThreshold = 0.02f;

        void Update()
        {
            if (Input.GetKeyDown(KeyCode.E))
            {
                ErrorThreshold += 0.00025f;
            }
            else if (Input.GetKeyDown(KeyCode.Q))
            {
                ErrorThreshold = Mathf.Max(0.0f, ErrorThreshold - 0.00025f);
            }
        }
    }
}

MeshletLoaderEditor.cs

This script loads meshlet metadata from a json file and applies it to the corresponding meshlet GameObjects in the scene.

using UnityEditor;
using UnityEngine;
using System.IO;

namespace Simplygon.Examples.MeshletVisualization
{
    public static class MeshletLoaderEditor
    {
        [MenuItem("Simplygon/Meshlet/Load Meshlets From JSON")]
        public static void LoadMeshletsMenuItem()
        {
            string path = EditorUtility.OpenFilePanel("Select Meshlet JSON File", Application.dataPath, "json");
            if (string.IsNullOrEmpty(path))
                return;

            LoadMeshletsFromJson(path);
            Debug.Log("Meshlet data loaded from JSON.");
        }

        public static void LoadMeshletsFromJson(string jsonFilePath)
        {
            if (!File.Exists(jsonFilePath))
            {
                Debug.LogError($"Meshlet JSON file not found: {jsonFilePath}");
                return;
            }

            string jsonText = File.ReadAllText(jsonFilePath);
            MeshletEntryArray meshletArray = JsonUtility.FromJson<MeshletEntryArray>(jsonText);

            if (meshletArray == null || meshletArray.entries == null)
            {
                Debug.LogError("Failed to parse meshlet data.");
                return;
            }

            foreach (var entry in meshletArray.entries)
            {
                GameObject go = GameObject.Find(entry.name);
                if (go == null)
                {
                    Debug.LogWarning($"GameObject '{entry.name}' not found in scene.");
                    continue;
                }

                Meshlet meshlet = go.GetComponent<Meshlet>();
                if (meshlet == null)
                {
                    meshlet = go.AddComponent<Meshlet>();
                }

                meshlet.BoundsCenter = ToVector3(entry.bounds_center);
                meshlet.BoundsRadius = entry.bounds_radius;
                meshlet.Error = entry.error;
                meshlet.Level = entry.level;
                meshlet.ParentBoundsCenter = ToVector3(entry.parent_bounds_center);
                meshlet.ParentBoundsRadius = entry.parent_bounds_radius;
                meshlet.ParentError = entry.parent_error;
            }
        }

        private static Vector3 ToVector3(float[] arr)
        {
            if (arr == null || arr.Length != 3)
                return Vector3.zero;
            return new Vector3(arr[0], arr[1], arr[2]);
        }
    }
}

MeshletEntry.cs

Serialized meshlet metadata entry used to load meshlet data from json file.

using System;

namespace Simplygon.Examples.MeshletVisualization
{
    [Serializable]
    public class MeshletEntry
    {
        public string name;
        public float[] bounds_center;
        public float bounds_radius;
        public float error;
        public int level;
        public float[] parent_bounds_center;
        public float parent_bounds_radius;
        public float parent_error;
    }
}

MeshletEntryArray.cs

Serialized array of meshlet entries used to load meshlet data from json file. Since Unity read json files as classes we need this wrapper class.

using System;

namespace Simplygon.Examples.MeshletVisualization
{
    [Serializable]
    public class MeshletEntryArray
    {
        public MeshletEntry[] entries;
    }
}
⇐ Back to all posts

Request 30-days free evaluation license

*
*
*
*
Industry
*

Request 30-days free evaluation license

*
*
*
*
Industry
*