Optimizing a modular character asset

Written by Samuel Rantaeskola, Product Expert, Simplygon

Disclaimer: This post is written using version 10.2.11500.0 of Simplygon. 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 3D modeling, ensuring smooth transitions between different asset pieces when they change levels of detail (LODs), is crucial. This blog dives into the modular seams feature, a tool that helps maintain visual continuity and performance for characters made of multiple parts. We’ll explore how scripting this feature can enhance the quality and efficiency of your 3D projects, making the transitions between different LODs seamless and visually consistent. Join us as we look at practical examples and scripting tips to get the most out of modular seams in your work.

For a complete technical overview of the feature you can read about at Modular Seams api concepts overview in the documentation.

Problem to solve

For our example, we will use a character that is composed of several pieces, which can be combined to create various character variations. Here it is:

Modular character

Our example relies on the parts being split out into separate FBX files, but it could easily be modified to handle a scene containing all the parts, for example.

The body parts of the asset are stored in separate FBX files within a folder. To ensure that the asset seams are correct, we will use a naming convention that can be utilized in the validation step. In our asset folder, we have the following assets:

  • head[1-6].fbx
  • torso.fbx
  • left_hand[1-2].fbx
  • right_hand[1-2].fbx
  • legs.fbx
  • right_foot.fbx
  • left_foot.fbx

Solution

We will create a script that takes in the body parts, identifies the shared seams, validates the connections, and finally reduces each part separately by 50%.

Listing the body parts

For our example, we will set up a simple helper class to keep track of the different body parts. This approach will make the rest of the code cleaner. Here it is:

# Simple structure to keep the information about the different
# body parts in our example and help with connection validation.
class BodyPart:

    def __init__(self, name: str, path: str):
        self._name = name
        self._path = path
        self._expected_connections = []
        self._type = get_part_type(name)
        self._scene = None
    
        
    @property 
    def scene(self) -> Simplygon.spScene:
        return self._scene

    @scene.setter 
    def scene(self, scene: Simplygon.spScene) -> None:
        self._scene = scene

    @property 
    def name(self) -> str:
        return self._name
    
    @property 
    def path(self) -> str:
        return self._path

    @property 
    def type(self) -> str:
        return self._type

    @property 
    def expected_connections(self) -> list:
        return self._expected_connections
    
    # Adds a part to the expected connections
    def add_expected_connection(self, connected_part_name: str) -> None:
        self._expected_connections.append(connected_part_name)
    
    # Reports a connection to this part and checks if it is an expected connection.
    # If so, it will be cleared from the expected connection list.      
    def found_connection(self, connected_part_name: str) -> None:
        # We don't need to check connections between parts of the same type.     
        if get_part_type(connected_part_name) == self.type:
            return
        # If this is not an expected connection, something is most likely 
        # wrong with the setup. Report it.
        if connected_part_name not in self._expected_connections:
            print(f'Warning: Found an unexpected connection between {self.name} and {connected_part_name}.')
        # This is an expected connection that was made. 
        # We can clear it from the connection list.    
        else: 
            self._expected_connections.remove(connected_part_name)

As mentioned earlier, a file naming convention will help us validate the connections. Here is a map that allows us to ensure the geometry is correctly set up:

part_connections = {}
part_connections["torso"] = ["head", "hand", "leg"]
part_connections["leg"] = ["torso", "foot"]
part_connections["hand"] = ["torso"]
part_connections["foot"] = ["leg"]
part_connections["head"] = ["torso"]    

With this structure in place, we can add three helper functions that allow us to load all body parts from the asset directory, categorize them and validate the connections.

# Looks up the type for the body part by a simple 
# substring lookup in the name of the part.
def get_part_type(part_name: str) -> str:
    for part_type in part_connections:
        if part_type in part_name:
            return part_type
    return None

# Sets up the expected conections for each body part so that 
# we can validate that the parts are connected correctly.
def setup_expected_connections(parts: map) -> None:
    for part1_name in parts:
        for part2_name in parts:
            part1_type = parts[part1_name].type
            part2_type = parts[part2_name].type
            # Connections between objects of the same type are not checked.
            if part1_type != part2_type:
                # If part 1 is supposed to be connected to part 2, 
                # we add it to the expected connection list.
                if part2_type in part_connections[part1_type]:
                    parts[part1_name].add_expected_connection(part2_name)

# Lists all the parts in the asset directory.
def list_parts(asset_directory: str) -> list:
   parts = {}
   for filename in os.listdir(asset_directory):
        if filename.lower().endswith('.fbx'):
            name = filename.split(".")[0]
            parts[name] = BodyPart(name, os.path.join(asset_directory, filename))
   return parts                    

Finding the seams

Now that we have all the body parts in place, we can begin identifying the various seams between them. Before we proceed, however, we need to configure the reduction settings.

The modular seams tool will employ a deterministic optimization heuristic for the shared seams. This process will consider different mesh properties when determining the order in which to remove vertices. By modifying the reduction importance settings, you can influence how the seams are reduced. We are going to use the default importance values in our example, but you can adjust these to meet your specific requirements.

def setup_reduction_settings(reduction_settings: Simplygon.spReductionSettings, triangle_ratio: float) -> None:
    reduction_settings.SetKeepSymmetry( True )
    reduction_settings.SetUseAutomaticSymmetryDetection( True )
    reduction_settings.SetUseHighQualityNormalCalculation( True )
    reduction_settings.SetReductionHeuristics( Simplygon.EReductionHeuristics_Consistent )
    
    # The importances can be changed here to allow the features to
    # be weighed differently both during regular reduction and during
    # the analyzing of modular seam 
    reduction_settings.SetEdgeSetImportance( 1.0 )
    reduction_settings.SetGeometryImportance( 1.0 )
    reduction_settings.SetGroupImportance( 1.0 )
    reduction_settings.SetMaterialImportance( 1.0 )
    reduction_settings.SetShadingImportance( 1.0 )
    reduction_settings.SetSkinningImportance( 1.0 )
    reduction_settings.SetTextureImportance( 1.0 )
    reduction_settings.SetVertexColorImportance( 1.0 )
    
    # The reduction targets below are only used for the regular 
    # reduction, not the modular seam analyzer 
    reduction_settings.SetReductionTargetTriangleRatio( triangle_ratio )
    reduction_settings.SetReductionTargets(Simplygon.EStopCondition_All, True, False, False, False)

It's time to dig into the heart of this blog: the modular seams process. The process is quite straightforward. First, we need to gather all the geometries from our body parts and feed them into the modular seams analyzer. Since the parts need to be perfectly connected, sharing vertices in exactly the same positions, we also must provide an error tolerance. With the geometry data and error tolerance in place, the analyzer can begin to identify the shared seams.

def generate_modular_seams(sg: Simplygon.ISimplygon, parts: list, output_directory: str) -> Simplygon.spModularSeams:
    # Check if the directory exists
    if not os.path.exists(asset_directory):
        print("The specified directory does not exist.")
        return

    # Create a geometry data collection to store geometries in.
    geometry_collection = sg.CreateGeometryDataCollection()

    smallest_radius = math.inf
    # Loop through all the files in the directory
    for part_name in parts:
        modular_part_scene = load_scene(sg, parts[part_name])
        # Store the smallest scene radius for the calculation of a good epsilon
        modular_part_scene.CalculateExtents()            
        radius = modular_part_scene.GetRadius()
        smallest_radius = min(smallest_radius, radius)
        # Extract all the geometries from the scene 
        add_geometries_to_collection(parts[part_name], geometry_collection)
    
    # Figure out a small value in relation to the scene that will be the 
    # tolerance for the modular seams if a coordinate is moved a distance
    # smaller than the tolerance, then it is regarded as the same coordinate
    # so two vertices are the at the same place if the distance between them
    # is smaller than radius * smallValue 
    smallValue = 0.0001
    tolerance = smallest_radius * smallValue
    
    
    # We need to create a reduction setting structure so that the modular
    # seams analyzer knows which mesh properties are the most important in
    # the reduction. The actual reduction ratios won't be used though. 
    reduction_settings = sg.CreateReductionSettings()
    setup_reduction_settings(reduction_settings, 0.0)
    
    # Create the modular seam analyzer. 
    modular_seam_analyzer = sg.CreateModularSeamAnalyzer()
    modular_seam_analyzer.SetTolerance(tolerance)
    modular_seam_analyzer.SetIsTranslationIndependent( False )
    
    # Add the geometries to the analyzer 
    for geom_id in range(geometry_collection.GetItemCount()):
        geometry = Simplygon.spGeometryData.SafeCast(geometry_collection.GetItemAsObject(geom_id))
        modular_seam_analyzer.AddGeometry(geometry)
    
    # The analyzer needs to know the different reduction settings 
    # importances and such because it runs the reduction as far as 
    # possible for all the seams and stores the order and max deviations
    # for future reductions of assets with the same seams 
    modular_seam_analyzer.Analyze(reduction_settings)
    
    # Fetch the modular seams. These can be stored to file and used later 
    modular_seams = modular_seam_analyzer.GetModularSeams()
    modular_seams.SaveToFile(os.path.join(output_directory, 'modular_seams.modseam'))
    return modular_seams

This function also saves the modular seams structure into a file, which can be quite useful if you are building a game where you are continuously adding new body parts after the game's release. Rather than rebuilding all previously made character components, you can simply use this structure to optimize the newly created assets. If results start deteriorating, it might be worth rebuilding the structure. However, you will then need to reprocess the older assets again, as the seam reduction could change, and newly created parts might not match the older ones.

The function described above depends on add_geometries_to_collection, which simply gathers all the geometries from each body part scene. It is included in the script at the end of the post.

Validating Connections and Outputting Debug Data

The seams are in place, but are they the correct ones? We can utilize the structures and helper functions shown earlier in this post to validate the output.

The first step is simply to confirm that the expected connections have been established and that there are no unexpected connections.

The modular seams analyzer will identify all the open borders in the input geometries; some borders are shared, while others are not. We are primarily interested in the ones that connect our different parts. The function below will review all the connections made, ensure that all parts are connected as expected, and report any discrepancies.

def validate_modular_scene(sg: Simplygon.ISimplygon, modular_seams: Simplygon.spModularSeams, parts: map, debug_directory: str, output_geometry: bool) -> None:
    # Loop through all the identified seams in the modular seams structure.
    for seam_index in range(modular_seams.GetModularSeamCount()):
        geometry_names = modular_seams.NewModularSeamGeometryStringArray(seam_index)
        geom_count = geometry_names.GetItemCount()
        # Any open border in the geometries will be a modular seam, 
        # but we can just disregard the ones that only is connected to one geometry.    
        if geom_count > 1:
            connections = []
            for geom_index in range(0, geom_count):
                connections.append(geometry_names.GetItem(geom_index))
            connections = list(set(connections))
            # In our example we are only interested in seams that are across parts, 
            # not within a part. If there only is one connection remaining it is an
            # internal connection we can disregard.
            if len(connections) > 1:
                for i in range(0, len(connections)):
                    part1 = parts[connections[i]]
                    for j in range(0, len(connections)):
                        part1.found_connection(connections[j])
                if output_geometry:
                    # Output the geometry for this scene
                    output_debug_geom(sg, modular_seams, seam_index)
                        
    # Now that we have checked all connections we need to see if there are any expected connections that
    # haven't been found.                    
    for part_name in parts:
        if len(parts[part_name].expected_connections) > 0:
            print(f'Warning: {part_name} was not connected to these expected parts {parts[part_name].expected_connections}')

In the function mentioned above, you have the option to output the shared seams as geometry. This allows you to visualize the data, making it easier to understand why a particular seam may not be connecting two parts, for example. The output function will be included as part of the complete script at the end of the post.

Validating our example content

At this point, we can start examining our example asset. Is it constructed correctly? Running the process up to this point yields the following output:

Seam 0 consists of 27 vertices and is shared among 7 geometries:
 geom 0:  head1
 geom 1:  head2
 geom 2:  head3
 geom 3:  head4
 geom 4:  head5
 geom 5:  head6
 geom 6:  torso
Seam 18 consists of 14 vertices and is shared among 3 geometries:
 geom 0:  left_hand1
 geom 1:  left_hand2
 geom 2:  torso
Seam 27 consists of 31 vertices and is shared among 2 geometries:
 geom 0:  legs
 geom 1:  torso
Seam 34 consists of 14 vertices and is shared among 3 geometries:
 geom 0:  right_hand1
 geom 1:  right_hand2
 geom 2:  torso
Warning: left_foot was not connected to these expected parts ['legs']
Warning: legs was not connected to these expected parts ['left_foot', 'right_foot']
Warning: right_foot was not connected to these expected parts ['legs']

It seems like our character has an issue with the feet; neither of them is connected to the legs. This will cause a problem if we proceed with processing. Let's investigate why this is happening.

Disconnected feet

In the picture above, it is clear what's happening. Both feet have a vertex that is far from being connected with the corresponding vertex in the leg. The modular seams analyzer will identify the two seams, but it will not recognize them as being shared between the two parts. Consequently, the connection isn't made. Snapping the vertices to the corresponding vertex should fix the problem. Let's do that and run the validation again.

Seam 0 consists of 27 vertices and is shared among 7 geometries:
 geom 0:  head1
 geom 1:  head2
 geom 2:  head3
 geom 3:  head4
 geom 4:  head5
 geom 5:  head6
 geom 6:  torso
Seam 17 consists of 11 vertices and is shared among 2 geometries:
 geom 0:  left_foot_fixed
 geom 1:  legs
Seam 18 consists of 14 vertices and is shared among 3 geometries:
 geom 0:  left_hand1
 geom 1:  left_hand2
 geom 2:  torso
Seam 25 consists of 11 vertices and is shared among 2 geometries:
 geom 0:  legs
 geom 1:  right_foot_fixed
Seam 26 consists of 31 vertices and is shared among 2 geometries:
 geom 0:  legs
 geom 1:  torso
Seam 32 consists of 14 vertices and is shared among 3 geometries:
 geom 0:  right_hand1
 geom 1:  right_hand2
 geom 2:  torso

That took care of the problem! All our body parts are connected. We can go to work and create the LODs for them.

Creating the LODs

We have built the connections and validated the content. Now, it's time to start creating the LODs for the different body parts. These two functions will do the trick:

# Creates LODs for all body parts with the incoming reduction processor 
# and stores the results in the output directory
def create_lods(sg: Simplygon.ISimplygon, parts: map, reduction_processor: Simplygon.spReductionProcessor, lod_num: int, output_directory: str) -> None:    
    # Process all the parts with the current reduction settings
    for part_name in parts:
        print(f'Creating LOD {lod_num} for {part_name}')
        scene_copy = parts[part_name].scene.NewCopy()        
        reduction_processor.SetScene(scene_copy)
        reduction_processor.RunProcessing()
        output_file = os.path.join(output_directory, f'{part_name}_LOD{lod_num}.fbx')
        save_scene(sg, scene_copy, output_file)

# Reduces all the different parts utilizing the modular seams structure.
# The reduced assets will be stored in the output directory.        
def run_reduction_with_modular_seams(sg: Simplygon.ISimplygon, parts: map, modular_seams: Simplygon.spModularSeams, output_directory: str) -> None:
    reduction_processor = sg.CreateReductionProcessor()
    reduction_settings = reduction_processor.GetReductionSettings()
    modular_seam_settings = reduction_processor.GetModularSeamSettings()
        
    # Set the same reduction (importance) settings as the modular seam
    # analyzer for consistent quality 
    setup_reduction_settings(reduction_settings, 0.5)
    modular_seam_settings.SetReductionRatio(0.5)
    modular_seam_settings.SetMaxDeviation(0.0)
    modular_seam_settings.SetStopCondition(Simplygon.EStopCondition_All)
    modular_seam_settings.SetModularSeams(modular_seams)
    create_lods(sg, parts, reduction_processor, 1, output_directory)
    
    # Check log for any warnings or errors.     
    print("Check log for any warnings or errors.")
    check_log(sg)

Nothing fancy is happening in the above functions. We are simply setting up the reduction processor to our desired reduction percentage, providing it with the modular seams structure, processing each body part, and storing the results in our output folder. Finally, there is a check in the log to see if there are any issues with our content. This function will be included in the full script.

Result

We have all the building pieces in place and just need to string them together and run the full script.

Here is a full assembly of the character using a selection of body parts.

Full assembly

If we scrutinize the picture, we can see that the seams are connected, and the mission is accomplished!

Complete script

import math
import os
from simplygon10 import simplygon_loader
from simplygon10 import Simplygon

"""
Validation code section.
This code contains validation functions that are very specific to the example asset we use. It should be adjusted to
work with your assets.
"""

# A body is created from several connected pieces. In a production environment
# this would be data driven, and desribed in a external file. For simpliicty
# we have this as global data in this example.
part_connections = {}
part_connections["torso"] = ["head", "hand", "leg"]
part_connections["leg"] = ["torso", "foot"]
part_connections["hand"] = ["torso"]
part_connections["foot"] = ["leg"]
part_connections["head"] = ["torso"]    

# Looks up the type for the body part by a simple 
# substring lookup in the name of the part.
def get_part_type(part_name: str) -> str:
    for part_type in part_connections:
        if part_type in part_name:
            return part_type
    return None

# Sets up the expected conections for each body part so that 
# we can validate that the parts are connected correctly.
def setup_expected_connections(parts: map) -> None:
    for part1_name in parts:
        for part2_name in parts:
            part1_type = parts[part1_name].type
            part2_type = parts[part2_name].type
            # Connections between objects of the same type are not checked.
            if part1_type != part2_type:
                # If part 1 is supposed to be connected to part 2, 
                # we add it to the expected connection list.
                if part2_type in part_connections[part1_type]:
                    parts[part1_name].add_expected_connection(part2_name)


# Simple structure to keep the information about the different
# body parts in our example and help with connection validation.
class BodyPart:

    def __init__(self, name: str, path: str):
        self._name = name
        self._path = path
        self._expected_connections = []
        self._type = get_part_type(name)
        self._scene = None
    
        
    @property 
    def scene(self) -> Simplygon.spScene:
        return self._scene

    @scene.setter 
    def scene(self, scene: Simplygon.spScene) -> None:
        self._scene = scene

    @property 
    def name(self) -> str:
        return self._name
    
    @property 
    def path(self) -> str:
        return self._path

    @property 
    def type(self) -> str:
        return self._type

    @property 
    def expected_connections(self) -> list:
        return self._expected_connections
    
    # Adds a part to the expected connections
    def add_expected_connection(self, connected_part_name: str) -> None:
        self._expected_connections.append(connected_part_name)
    
    # Reports a connection to this part and checks if it is an expected connection.
    # If so, it will be cleared from the expected connection list.      
    def found_connection(self, connected_part_name: str) -> None:
        # We don't need to check connections between parts of the same type.     
        if get_part_type(connected_part_name) == self.type:
            return
        # If this is not an expected connection, something is most likely wrong with the setup. Report it.
        if connected_part_name not in self._expected_connections:
            print(f'Warning: Found an unexpected connection between {self.name} and {connected_part_name}.')
        # This is an expected connection that was made. We can clear it from the connection list.    
        else: 
            self._expected_connections.remove(connected_part_name)

# Lists all the parts in the asset directory.
def list_parts(asset_directory: str) -> list:
   parts = {}
   for filename in os.listdir(asset_directory):
        if filename.lower().endswith('.fbx'):
            name = filename.split(".")[0]
            parts[name] = BodyPart(name, os.path.join(asset_directory, filename))
   return parts

# Optional but helpful to be able to see what the analyzer found. 
# Each unique modular seam can be extracted as a geometry. If the analyzer ran with 
# IsTranslationIndependent=false then the seam geometry should be exactly located at the same 
# place as the modular seams in the original scene. 
# Each modular seam also has a string array with all the names of the geometries that have that 
# specific modular seam. 
def output_debug_geom(sg: Simplygon.ISimplygon, modular_seams: Simplygon.spModularSeams, seam_index: int) -> None:
    debug_geom = modular_seams.NewDebugModularSeamGeometry(seam_index)
    debug_scene = sg.CreateScene()
    debug_scene.GetRootNode().CreateChildMesh(debug_geom)
    sgSceneExporter = sg.CreateSceneExporter()
    sgSceneExporter.SetExportFilePath(os.path.join(debug_directory, f'{seam_index}.obj' ))
    sgSceneExporter.SetScene( debug_scene )
    sgSceneExporter.Run()
    geometry_names = modular_seams.NewModularSeamGeometryStringArray(seam_index)
    geom_count = geometry_names.GetItemCount()           
    print(f'Seam {seam_index} consists of {debug_geom.GetVertexCount()} vertices and is shared among {geom_count} geometries:')
    for geom_index in range(geom_count):
        print(f' geom {geom_index}:  {geometry_names.GetItem(geom_index)}')
        
# This function can be used to validate that the correct connections are made.
# In our example we will just assume that all hands in the asset folder should be
# connected to the torso, feet to legs etc.                
def validate_modular_scene(sg: Simplygon.ISimplygon, modular_seams: Simplygon.spModularSeams, parts: map, debug_directory: str, output_geometry: bool) -> None:
    # Loop through all the identified seams in the modular seams structure.
    for seam_index in range(modular_seams.GetModularSeamCount()):
        geometry_names = modular_seams.NewModularSeamGeometryStringArray(seam_index)
        geom_count = geometry_names.GetItemCount()
        # Any open border in the geometries will be a modular seam, 
        # but we can just disregard the ones that only is connected to one geometry.    
        if geom_count > 1:
            connections = []
            for geom_index in range(0, geom_count):
                connections.append(geometry_names.GetItem(geom_index))
            connections = list(set(connections))
            # In our example we are only interested in seams that are across parts, 
            # not within a part. If there only is one connection remaining it is an
            # internal connection we can disregard.
            if len(connections) > 1:
                for i in range(0, len(connections)):
                    part1 = parts[connections[i]]
                    for j in range(0, len(connections)):
                        part1.found_connection(connections[j])
                if output_geometry:
                    # Output the geometry for this scene
                    output_debug_geom(sg, modular_seams, seam_index)
                        
    # Now that we have checked all connections we need to see if there are any expected connections that
    # haven't been found.                    
    for part_name in parts:
        if len(parts[part_name].expected_connections) > 0:
            print(f'Warning: {part_name} was not connected to these expected parts {parts[part_name].expected_connections}')
        
"""
End of the validation code
"""

"""
Helper functions for checking the log and saving and loading.
"""

def check_log(sg: Simplygon.ISimplygon):
    # Check if any errors occurred. 
    has_errors = sg.ErrorOccurred()
    if has_errors:
        errors = sg.CreateStringArray()
        sg.GetErrorMessages(errors)
        error_count = errors.GetItemCount()
        if error_count > 0:
            print('Errors:')
            for error_index in range(error_count):
                print(errors.GetItem(error_index))
            sg.ClearErrorMessages()
    else:
        print('No errors.')
    
    # Check if any warnings occurred. 
    has_warnings = sg.WarningOccurred()
    if has_warnings:
        warnings = sg.CreateStringArray()
        sg.GetWarningMessages(warnings)
        warning_count = warnings.GetItemCount()
        if warning_count > 0:
            print('Warnings:')
            for warning_index in range(warning_count):
                print(warnings.GetItem(warning_index))
            sg.ClearWarningMessages()
    else:
        print('No warnings.')
        

def load_scene(sg: Simplygon.ISimplygon, part: BodyPart) -> Simplygon.spScene:
    # Create scene importer 
    scene_importer = sg.CreateSceneImporter()
    scene_importer.SetImportFilePath(part.path)
    
    # Run scene importer. 
    import_result = scene_importer.Run()
    if Simplygon.Failed(import_result):
        raise Exception('Failed to load scene.')
    scene = scene_importer.GetScene()
    welder = sg.CreateWelder()
    welder.SetScene(scene)
    welder.RunProcessing()
    part.scene = scene
    return scene

def save_scene(sg: Simplygon.ISimplygon, scene: Simplygon.spScene, path: str) -> None:
    # Create scene exporter. 
    scene_exporter = sg.CreateSceneExporter()
    scene_exporter.SetExportFilePath(path)
    scene_exporter.SetScene(scene)
    scene_exporter.SetReferenceExportMode(Simplygon.EReferenceExportMode_Embed)
    # Run scene exporter. 
    exportResult = scene_exporter.Run()
    if Simplygon.Failed(exportResult):
        raise Exception('Failed to save scene.')

"""
End helper functions
"""

def setup_reduction_settings(reduction_settings: Simplygon.spReductionSettings, triangle_ratio: float) -> None:
    reduction_settings.SetKeepSymmetry( True )
    reduction_settings.SetUseAutomaticSymmetryDetection( True )
    reduction_settings.SetUseHighQualityNormalCalculation( True )
    reduction_settings.SetReductionHeuristics( Simplygon.EReductionHeuristics_Consistent )
    
    # The importances can be changed here to allow the features to be weighed differently both during 
    # regular reduction and during the analyzing of modular seam 
    reduction_settings.SetEdgeSetImportance( 1.0 )
    reduction_settings.SetGeometryImportance( 1.0 )
    reduction_settings.SetGroupImportance( 1.0 )
    reduction_settings.SetMaterialImportance( 1.0 )
    reduction_settings.SetShadingImportance( 1.0 )
    reduction_settings.SetSkinningImportance( 1.0 )
    reduction_settings.SetTextureImportance( 1.0 )
    reduction_settings.SetVertexColorImportance( 1.0 )
    
    # The reduction targets below are only used for the regular reduction, not the modular seam 
    # analyzer 
    reduction_settings.SetReductionTargetTriangleRatio( triangle_ratio )
    reduction_settings.SetReductionTargets(Simplygon.EStopCondition_All, True, False, False, False)

# Adds all geometries from the scene in the body part to the geometry data collection. 
def add_geometries_to_collection(part: BodyPart, geometry_collection: Simplygon.spGeometryDataCollection) -> None:
    # Extract all geometries in the scene into individual geometries 
    set_id = part.scene.SelectNodes("ISceneMesh")
    selection_set = part.scene.GetSelectionSetTable().GetSelectionSet(set_id)

    for geom_index in range(selection_set.GetItemCount()):
        guid = selection_set.GetItem(geom_index)
        node = part.scene.GetNodeByGUID(guid)
        mesh = Simplygon.spSceneMesh.SafeCast(node)
        geom = mesh.GetGeometry()
        geom.SetName(part.name)
        geometry_collection.AddGeometryData(geom)

def generate_modular_seams(sg: Simplygon.ISimplygon, parts: list, output_directory: str) -> Simplygon.spModularSeams:
    # Check if the directory exists
    if not os.path.exists(asset_directory):
        print("The specified directory does not exist.")
        return

    # Create a geometry data collection to store geometries in.
    geometry_collection = sg.CreateGeometryDataCollection()

    smallest_radius = math.inf
    # Loop through all the files in the directory
    for part_name in parts:
        modular_part_scene = load_scene(sg, parts[part_name])
        # Store the smallest scene radius for the calculation of a good epsilon
        modular_part_scene.CalculateExtents()            
        radius = modular_part_scene.GetRadius()
        smallest_radius = min(smallest_radius, radius)
        # Extract all the geometries from the scene 
        add_geometries_to_collection(parts[part_name], geometry_collection)
    
    # Figure out a small value in relation to the scene that will be the 
    # tolerance for the modular seams if a coordinate is moved a distance
    # smaller than the tolerance, then it is regarded as the same coordinate
    # so two vertices are the at the same place if the distance between them
    # is smaller than radius * smallValue 
    smallValue = 0.0001
    tolerance = smallest_radius * smallValue
    
    
    # We need to create a reduction setting structure so that the modular
    # seams analyzer knows which mesh properties are the most important in
    # the reduction. The actual reduction ratios won't be used though. 
    reduction_settings = sg.CreateReductionSettings()
    setup_reduction_settings(reduction_settings, 0.0)
    
    # Create the modular seam analyzer. 
    modular_seam_analyzer = sg.CreateModularSeamAnalyzer()
    modular_seam_analyzer.SetTolerance(tolerance)
    modular_seam_analyzer.SetIsTranslationIndependent( False )
    
    # Add the geometries to the analyzer 
    for geom_id in range(geometry_collection.GetItemCount()):
        geometry = Simplygon.spGeometryData.SafeCast(geometry_collection.GetItemAsObject(geom_id))
        modular_seam_analyzer.AddGeometry(geometry)
    
    # The analyzer needs to know the different reduction settings 
    # importances and such because it runs the reduction as far as 
    # possible for all the seams and stores the order and max deviations
    # for future reductions of assets with the same seams 
    modular_seam_analyzer.Analyze(reduction_settings)
    
    # Fetch the modular seams. These can be stored to file and used later 
    modular_seams = modular_seam_analyzer.GetModularSeams()
    modular_seams.SaveToFile(os.path.join(output_directory, 'modular_seams.modseam'))
    return modular_seams



# Creates LODs for all body parts with the incoming reduction processor 
# and stores the results in the output directory
def create_lods(sg: Simplygon.ISimplygon, parts: map, reduction_processor: Simplygon.spReductionProcessor, lod_num: int, output_directory: str) -> None:    
    # Process all the parts with the current reduction settings
    for part_name in parts:
        print(f'Creating LOD {lod_num} for {part_name}')
        scene_copy = parts[part_name].scene.NewCopy()        
        reduction_processor.SetScene(scene_copy)
        reduction_processor.RunProcessing()
        output_file = os.path.join(output_directory, f'{part_name}_LOD{lod_num}.fbx')
        save_scene(sg, scene_copy, output_file)

# Reduces all the different parts utilizing the modular seams structure.
# The reduced assets will be stored in the output directory.        
def run_reduction_with_modular_seams(sg: Simplygon.ISimplygon, parts: map, modular_seams: Simplygon.spModularSeams, output_directory: str) -> None:
    reduction_processor = sg.CreateReductionProcessor()
    reduction_settings = reduction_processor.GetReductionSettings()
    modular_seam_settings = reduction_processor.GetModularSeamSettings()
        
    # Set the same reduction (importance) settings as the modular seam
    # analyzer for consistent quality 
    setup_reduction_settings(reduction_settings, 0.5)
    modular_seam_settings.SetReductionRatio(0.5)
    modular_seam_settings.SetMaxDeviation(0.0)
    modular_seam_settings.SetStopCondition(Simplygon.EStopCondition_All)
    modular_seam_settings.SetModularSeams(modular_seams)
    create_lods(sg, parts, reduction_processor, 1, output_directory)
    
    # Check log for any warnings or errors.     
    print("Check log for any warnings or errors.")
    check_log(sg)
    
if __name__ == '__main__':
        sg = simplygon_loader.init_simplygon()
        if sg is None:
            exit(Simplygon.GetLastInitializationError())

        asset_directory = 'assets'
        output_directory = 'output'
        debug_directory = 'debug'
        parts = list_parts(asset_directory)
        setup_expected_connections(parts)

        # Generate the modular seam structure.
        modular_seams = generate_modular_seams(sg, parts, output_directory)
        
        # Validate that the expected connections have been made.
        validate_modular_scene(sg, modular_seams, parts, debug_directory, True)

        # Create the LODs
        run_reduction_with_modular_seams(sg, parts, modular_seams, output_directory)

        del sg
⇐ Back to all posts

Request 30-days free evaluation license

*
*
*
*
Industry
*

Request 30-days free evaluation license

*
*
*
*
Industry
*