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.
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())