Skip to content
On this page

LOD-Switch example

This is the complete LOD-Switch example written in Python that utilized Maya's LOD-groups.

In the image below we can see the UI at the right. The first part is the settings-path to the Pipeline-file that will be used when creating the LODs. The "Show LOD chain"-checkbox will enable automatic LOD-switching once the LODs has been created. "Display LOD" will force a LOD-switch at the current camera distance. "LOD Aggressiveness" is more or less a distance-multiplier in case the computed distance needs to be adjusted in any direction. At last we have "Create LODs" which will start the process, and once completed will create LOD groups ready for switching. "Clear LODs" can be used to delete all newly created LODs / objects.

To the left we can see the generated LOD group, grey font means that the object is currently hidden, and white that is is visible.

LOD-Switch UI

python
import maya.cmds as cmds
import maya.OpenMayaUI as OpenMayaUI
import maya.OpenMaya as OpenMaya
import math
from pymel.core.nodetypes import Camera
from functools import partial

class SimplygonWrapper:
    """
    Class that contains all the Simplygon specificities in order to make it easy to update as Simplygon changes.
    """

    # Retrieves the deviation of the incoming object
    @staticmethod
    def getMaxDeviation(object):
        maxDeviation = 0.0
        try:
            maxDeviation = cmds.getAttr(object+".MaxDeviation")
        except:
            print("Warning: MaxDeviation attribute does not exist on this object.")
        return maxDeviation

    # Retrieves the scene meshes radius of the incoming object
    @staticmethod
    def getSceneMeshesRadius(object):
        sceneMeshesRadius = 0.0
        try:
            sceneMeshesRadius = cmds.getAttr(object+".SceneMeshesRadius")
        except:
            print("Warning: SceneMeshesRadius attribute does not exist on this object.")
        return sceneMeshesRadius

    # Processes the current selection using the specified settings file.
    @staticmethod
    def simplygonLODs(settingsFile):
        cmds.Simplygon(ri=True, sf=settingsFile,
                       fmn='{MeshName}_LOD{LODIndex}')
        return cmds.SimplygonQuery(gpm=True)

    # Calculates the recommended distance to start showing the incoming object
    @staticmethod
    def calculateLODSwitchDistance(object):
        # Object is the mesh transform
        # Reads the .SceneMeshesRadius attribute
        # This radius is calculated using only the geometry in the scene, ignoring cameras
        radius = SimplygonWrapper.getSceneMeshesRadius(object)

        # Reads the MaxDeviation attribute
        deviation = SimplygonWrapper.getMaxDeviation(object)

        # Calculates the pixel size (OnScreenSize)
        pixelsize = (radius * 2) / deviation

        # Screen resolution and FOV
        curView = OpenMayaUI.M3dView.active3dView()
        screenheight = curView.portHeight()
        screenwidth = curView.portWidth()
        curCamPath = OpenMaya.MDagPath()
        curView.getCamera(curCamPath)
        curCam = Camera(curCamPath.node())
        [fov_x, fov_y] = curCam.getPortFieldOfView(screenwidth, screenheight)

        # Calculates screen ratio
        screen_ratio = float(pixelsize) / float(screenheight)
        normalized_distance = 1.0 / (math.tan(fov_y / 2))

        # The view-angle of the bounding sphere rendered onscreen.
        bsphere_angle = math.atan(screen_ratio / normalized_distance)

        # The distance in real world units from the camera to the center of the
        # bounding sphere. Not to be confused with normalized distance
        distance = radius / math.sin(bsphere_angle)
        return distance

    # Returns the LOD index of the incoming object (based on name)
    @staticmethod
    def getLODIndex(object):
        lodStrIndex = object.rfind("_LOD")
        if lodStrIndex != -1:
            return int(object[lodStrIndex+len("_LOD"):])
        else:
            print("Could not get the LOD index for "+object)
            return None

class LODStage:
    """
    Contains all the meshes and information about a LOD stage.
    """

    def __init__(self, index):
        self.__index = index
        self.__objects = []
        self.__distance = None

    # The index of this LOD stage
    @property
    def index(self):
        return self.__index

    # The name of the objects in this LOD stage
    @property
    def objects(self):
        return self.__objects

    # The recommended distance to use this LOD stage at
    @property
    def distance(self):
        return self.__distance

    # Adds an object to this LOD stage. If it's the distance hasn't been set yet the
    # recommended switch distance will be calculated using this object
    def addObject(self, object):
        if self.__distance == None:
            self.__distance = SimplygonWrapper.calculateLODSwitchDistance(
                object)
        self.__objects.append(object)

    # Adds a collection of object to this LOD stage. Distance is not calculated when
    # using this method.
    def addObjects(self, objects):
        self.__objects.extend(objects)

    # Shows the objects in this LOD stage
    def show(self):
        for object in self.objects:
            cmds.showHidden(object)

    # Hides the objects in this LOD stage
    def hide(self):
        cmds.hide(self.objects)

    # Uses the incoming selection to determine which objects to select in this LOD stage.
    def select(self, selected):
        newSelection = []
        for sel in selected:
            index = sel.find("_LOD")
            if(index > 0):
                sel = sel[:index]
            for obj in self.objects:
                if sel in obj:
                    newSelection.append(obj)
        cmds.select(newSelection)

class LODManager:
    """
    Manages all the LOD stages in the scene.
    """
    instance = None
    LOD_CHAIN_NAME = "SIMPLYGON_LOD_CHAIN"

    def __init__(self):
        self.reset()

    # Returns all the LOD stages. An indexed collection.
    @property
    def LODStages(self):
        return self.__LODStages

    # Returns the number of LOD stages
    @property
    def numLODs(self):
        return self.__numLODs

    # Resets the LODManager by clearing out the LOD stages.
    def reset(self):
        self.__LODStages = {}
        self.__lodObjects = []
        self.__numLODs = 0

    # Adds the source meshes that was used to create the LOD stages with.
    # They from LODStage0 and will be set to distance 0.
    # It will make sure to only add the transforms and clears out parent structures.
    def addSourceMeshes(self, sourceObjects):
        objects = []

        # Loop the hierarchy of the source objects and only add the transform objects.
        for object in sourceObjects:
            children = cmds.listRelatives(object, c=True, type="transform")

            # Sometimes the selection contains both parent and child. Need to make sure
            # not to add an object twice.
            if children != None:
                objects.extend(children)
            elif not object in objects:
                objects.append(object)
        self.__LODStages[0] = LODStage(0)
        self.__LODStages[0].addObjects(objects)
        self.__LODStages[0].distance = 0

    # Creates all the LOD stages from the incoming collection of LOD objects.
    def createLODStages(self, lodObjects):
        self.__lodObjects = lodObjects

        # Extract the LOD index from the name of the object
        for lod in lodObjects:
            lodIndex = SimplygonWrapper.getLODIndex(lod)
            if lodIndex == None:
                raise Exception(
                    "Found a LOD object for which the LOD index could not be obtained.")
            if lodIndex > self.__numLODs:
                self.__numLODs = lodIndex+1

            # Add a LOD stage if one doesn't exist
            if lodIndex not in self.__LODStages:
                self.__LODStages[lodIndex] = LODStage(lodIndex)
            self.__LODStages[lodIndex].addObject(lod)

    # Deletes the Maya LOD group object.
    @staticmethod
    def deleteMayaLODChain():

        # Remove existing LOD chain if there is one
        if cmds.objExists(LODManager.LOD_CHAIN_NAME):
            cmds.delete(LODManager.LOD_CHAIN_NAME)

    # Deletes all LODs in the scene including the Maya LOD chian object.
    def deleteLODs(self):
        LODManager.deleteMayaLODChain()

        # Delete all LODS
        for obj in self.__lodObjects:
            if cmds.objExists(obj):
                cmds.delete(obj)

    # Generates a Maya LOD chain for all the LOD objects by instancing them
    # under a new object.
    def generateMayaLODChain(self):

        # Clear the old Maya LOD group
        LODManager.deleteMayaLODChain()

        # Create a new LOD group object
        self.__lodGrp = cmds.createNode(
            "lodGroup", n=LODManager.LOD_CHAIN_NAME)
        try:
            # Connect the LOD stage object to the camera.
            cmds.connectAttr('perspShape.worldMatrix',
                             self.__lodGrp + '.cameraMatrix', f=True)
        except:
            print("Failed to connect the LOD stage object to the camera.")

        # Loop through all LOD stages and add them to the LOD group.
        for index in self.LODStages:
            objects = self.LODStages[index].objects
            instanceObjects = []

            # Create an instance for each object
            for obj in objects:
                inst = cmds.instance(obj, leaf=True, n="instance_" + obj)
                instanceObjects.extend(inst)

            # Group the LOD and add it to the to LOD group object
            group = cmds.group(instanceObjects, name="LOD"+str(index))
            cmds.parent(group, self.__lodGrp)
        self.setLODAggressiveness(1)

    # Sets the visibility of the Maya LOD object or the original objects
    def setLODChainVisibility(self, vis):
        if not cmds.objExists(LODManager.LOD_CHAIN_NAME):
            return
        if vis:
            for index in self.LODStages:
                self.LODStages[index].hide()
            cmds.showHidden(LODManager.LOD_CHAIN_NAME)
        else:
            for index in self.LODStages:
                self.LODStages[index].show()
            cmds.hide(LODManager.LOD_CHAIN_NAME)

    # Show only the objects for the specified LOD
    def showLOD(self, index):
        selected = cmds.ls(sl=True, long=False) or []
        self.setLODChainVisibility(False)
        for i in self.LODStages:
            if i == index:
                self.LODStages[i].show()
                self.LODStages[i].select(selected)
            else:
                self.LODStages[i].hide()

    # Scales the LOD switch distance according the aggressiveness
    def setLODAggressiveness(self, aggressiveness):
        for index in self.LODStages:
            if index > 0:
                cmds.setAttr(self.__lodGrp + '.threshold[%s]' % (
                    index-1), self.LODStages[index].distance/aggressiveness)

    # For debug purposes, prints all the LOD stage objects.
    def printLODStages(self):
        for i in self.LODStages:
            print("LOD"+str(i)+": "+str(self.LODStages[i].objects))

class MainWindow:
    """
    The main window of the plug-in
    """

    def __init__(self, lodManager):
        self.__lodManager = lodManager

        # Listen to when a scene is opened. We probably want to reset everything then.
        cmds.scriptJob(e=('SceneOpened', partial(self.sceneOpened)))
        windowName = "Simplygon LOD"
        instance = self
        if cmds.workspaceControl(windowName, exists=True):
            cmds.deleteUI(windowName)
        window = cmds.workspaceControl(windowName, visible=True, tabToControl=[
                                       "ChannelBoxLayerEditor", 1], iw=350, ih=700)
        container = cmds.columnLayout(adjustableColumn=False, p=window)

        # Add all the components.
        settingsContainer = cmds.rowLayout(p=container, nc=2)

        # Column widths
        cw1 = 120
        cw2 = 120
        cw3 = 120
        self.__settingsPathField = cmds.textFieldGrp(
            l="LOD Settings", p=settingsContainer, en=False, tx="D:/Pipelines/9_0/reduction.json", cw2=[cw1, cw2], cl2=["left", "left"])
        cmds.button("Browse", p=settingsContainer, w=cw3,
                    c=partial(self.browseSettings))
        self.__displayLodChain = cmds.checkBoxGrp(l="Show LOD Chain", p=container, onc=partial(
            self.showLODChain), ofc=partial(self.hideLODChain), en=False, cw2=[cw1, cw2], cl2=["left", "left"])
        self.__lodSlider = cmds.intSliderGrp(l="Display LOD", step=1, p=container, dragCommand=partial(self.showLOD), changeCommand=partial(
            self.showLOD), min=0, max=1, en=False, f=True, cw3=[cw1, cw2, cw3], cl3=["left", "left", "left"])
        self.__lodAggressivenessSlider = cmds.floatSliderGrp(l="LOD Aggressiveness", step=0.1, p=container, changeCommand=partial(
            self.changeLODAggressiveness), min=0.1, max=10, value=1, en=False, f=True, cw3=[cw1, cw2, cw3], cl3=["left", "left", "left"])
        buttonContainer = cmds.rowLayout(p=container, nc=2)
        cmds.button("Create LODs", p=buttonContainer,
                    w=cw1, c=partial(self.generateLODs))
        self.__clearLODButton = cmds.button(
            "Clear LODs", p=buttonContainer, w=cw2, c=partial(self.deleteLODs), en=False)
        cmds.showWindow(window)

    # Resets the values of all the components.
    def reset(self):
        cmds.checkBoxGrp(self.__displayLodChain, e=False, en=False)
        cmds.intSliderGrp(self.__lodSlider, e=True, v=0)
        cmds.floatSliderGrp(self.__lodAggressivenessSlider, e=True, v=1.0)

    # Enables the controls for LOD switching
    def enableLODSwitching(self, en):
        cmds.checkBoxGrp(self.__displayLodChain, e=True, en=en)
        cmds.intSliderGrp(self.__lodSlider, e=True, en=en)
        cmds.floatSliderGrp(self.__lodAggressivenessSlider, e=True, en=en)
        cmds.button(self.__clearLODButton, e=True, en=en)

    # Sets the state of the LOD chain visibility check box
    def setShowLODChain(self, show):
        cmds.checkBoxGrp(self.__displayLodChain, e=True, v1=show)

    # Sets the number of LODs to set the bounds of the slider
    def setLODSliderMax(self, numLods):
        cmds.intSliderGrp(self.__lodSlider, e=True, max=numLods-1)

    # Called when the browse button is clicked
    def browseSettings(self, *args):

        # We must stop playback, otherwise things gets messy
        cmds.play(state=False)
        splFilter = "*.json"
        fileName = cmds.fileDialog2(
            fileFilter=splFilter, dialogStyle=1, fileMode=1)
        if fileName != None:
            cmds.textFieldGrp(self.__settingsPathField, e=True, tx=fileName[0])

    # Called when the LOD chain check box is set to true
    def showLODChain(self, *args):

        # We must stop playback, otherwise things gets messy
        cmds.play(state=False)
        self.__lodManager.setLODChainVisibility(True)

    # Called when the LOD chain check box is set to false
    def hideLODChain(self, *args):

        # We must stop playback, otherwise things gets messy
        cmds.play(state=False)
        self.__lodManager.setLODChainVisibility(False)

    # Called the LOD slider is changed or dragged
    def showLOD(self, *args):
        playing = cmds.play(q=True, state=True)
        if playing:
            cmds.play(state=False)
        self.setShowLODChain(False)
        self.__lodManager.showLOD(args[0])
        if playing:
            cmds.play(state=True)

    # Called the LOD aggressiveness slider is changed
    def changeLODAggressiveness(self, *args):
        self.__lodManager.setLODAggressiveness(args[0])

    # Called when the delete LODs button is clicked
    def deleteLODs(self, *args):
        self.__lodManager.showLOD(0)
        self.__lodManager.deleteLODs()
        self.__lodManager.reset()
        self.enableLODSwitching(False)

    # Called when the generate LODs button is clicked.
    def generateLODs(self, *args):
        self.deleteLODs(None)
        self.__lodManager.addSourceMeshes(cmds.ls(sl=True, long=False) or [])
        settingFile = cmds.textFieldGrp(
            self.__settingsPathField, q=True, tx=True)
        lods = SimplygonWrapper.simplygonLODs(settingFile)
        self.__lodManager.createLODStages(lods)
        self.__lodManager.generateMayaLODChain()
        self.__lodManager.setLODChainVisibility(False)
        self.__lodManager.showLOD(0)
        self.enableLODSwitching(True)
        self.setLODSliderMax(self.__lodManager.numLODs)

    # Called when a new scene is opened
    def sceneOpened(self):
        self.__lodManager.reset()
        self.reset()
        self.enableLODSwitching(False)

MainWindow(LODManager())