Implementing A Simple Maya Python Deformer
Deformers within Maya can provide users with easy-to-use and intuitive interfaces for modifying, or even animating, low-level components, such as vertices, of geometry. They can be used in many situations, such as providing a starting point for more complex models, keyed for animation, or even to help sculpt and shape geometry, to name a few. Deformers are useful because they are a node in the DAG. This means they affect the geometry at that specific point in time, and can be edited at a later date. They can also be removed like any other node, and the order that the node appears in the graph can have dramatic changes on the resulting geometry. In other programs, such as 3ds Max, they are commonly referred to as Modifiers.
Note that this is not an introduction to Deformers, and I will not be covering how they work in detail. This article will be an overview of a simple deformer written in Python to get you started.
Our Deformer
The deformer we will be writing is going to emulate something similar to the Push modifier in 3ds Max. This simply takes all vertices, and moves them along their pre-deformed normals. The result of this simple deformer can also be performed using the move tool along the normal direction. However, deformers pose many advantages over doing this process manually, such as being able to be set to a value in the attribute editor, being able to be animated, and being part of the DAG hierarchy. Our deformer will have a single attribute that lets the user control the amount the geometry inflates by.
A Word About Versions
Maya has a penchant for changing the API. From Maya 2015 onwards, a couple of major changes of which classes Python deformers use were made. These changes will not be reflected in this article. In 2016, the classes for accessing inputs and geometry within deformers changed once more, as well as the envelope attribute. A simple solution can be found below, but will not be included in our script, as it is targeting Maya 2015. The changes are trivial to implement.
# get maya's api version
maya_api_version = OpenMaya.MGlobal.apiVersion()
# get the input and geometry based on the maya version
if maya_api_version < 201600:
input_attr = OpenMayaMPx.cvar.MPxDeformerNode_input
input_geom_attr = OpenMayaMPx.cvar.MPxDeformerNode_inputGeom
else:
input_attr = OpenMayaMPx.cvar.MPxGeometryFilter_input
input_geom_attr = OpenMayaMPx.cvar.MPxGeometryFilter_inputGeom
# get the envelope based on the maya version
if maya_api_version < 201600:
envelope_attr = OpenMayaMPx.cvar.MPxDeformerNode_envelope
else:
envelope_attr = OpenMayaMPx.cvar.MPxGeometryFilter_envelope
Implementation
We will need to import both maya.OpenMaya and maya.OpenMayaMPx for this deformer. The class we create must inherit from OpenMayaMPx.MPxDeformerNode, allowing us access to input and output geometry, and the envelope. An envelope simply defines how much of an effect the deformer has on the underlying geometry. We must also define a name and unique ID for out plugin. This name will be how Maya knows how to create our deformer.
import maya.OpenMayaMPx as OpenMayaMPx
import maya.OpenMaya as OpenMaya
# -*- Plugin information -*-
plugin_node_name = 'pushDeformer'
plugin_node_id = OpenMaya.MTypeId(0x1C3B0475)
class PushDeformerNode(OpenMayaMPx.MPxDeformerNode):
...
Since we inherited from OpenMayaMPx.MPxDeformerNode, we need to call its __init__ within ours.
def __init__(self):
OpenMayaMPx.MPxDeformerNode.__init__(self)
#END
As previously mentioned, we will have a single attribute to let the user specify how far to push the vertices. A deformer's attributes within Python are initially defined as an MObject type, and are later properly created.
inflation_attr = OpenMaya.MObject()
We should now write a helper function now to retrieve the input mesh so we can cast it to an MFnMesh to access its pre-deformed normals. The following function is partially from an official example in the Maya documentation. The arguments data and geom_idx are provided by the deform method, which we will see next.
def get_input_geom(self, data, geom_idx):
input_attr = OpenMayaMPx.cvar.MPxDeformerNode_input
input_geom_attr = OpenMayaMPx.cvar.MPxDeformerNode_inputGeom
input_handle = data.outputArrayValue(input_attr)
input_handle.jumpToElement(geom_idx)
input_geom_obj = input_handle.outputValue().child(input_geom_attr).asMesh()
return input_geom_obj
#END
The actual deformation is performed in a function called deform. As the documentation states, this function takes the arguments dataBlock, geometryIterator, localToWorldMatrix, and geometryIndex. We will retrieve input data using dataBlock and geometryIndex, and modify the output geometry using geometryIterator. Initially, we retrieve both the envelope and our custom attribute value by passing the appropriate variables into the inputValue function of the dataBlock. Next, we obtain the input mesh and store its normals after casting it to its correct type, an MFnMesh. Finally, we iterate through all vertices using the geometryIterator, and push each vertex along its corresponding normal by our inflation attribute value. We also multiply by the envelope here, so it acts as expected.
def deform(self, data, geom_it, local_to_world_mat, geom_idx):
envelope_attr = OpenMayaMPx.cvar.MPxDeformerNode_envelope
envelope = data.inputValue(envelope_attr).asFloat()
inflation_handle = data.inputValue(PushDeformerNode.inflation_attr)
inflation = inflation_handle.asDouble()
input_geom_obj = self.get_input_geom(data, geom_idx)
normals = OpenMaya.MFloatVectorArray()
mesh = OpenMaya.MFnMesh(input_geom_obj)
mesh.getVertexNormals(True, normals, OpenMaya.MSpace.kTransform)
while not geom_it.isDone():
idx = geom_it.index()
nrm = OpenMaya.MVector(normals[idx])
pos = geom_it.position()
new_pos = pos + (nrm * inflation * envelope)
geom_it.setPosition(new_pos)
geom_it.next()
#END
Next, we must define four functions: creation, initialization, plugin initialization, and plugin uninitialization. Starting with creation, we simply return a casted instance of our class.
def node_creator():
return OpenMayaMPx.asMPxPtr(PushDeformerNode())
#END
The initializer is very important. This is where you actually create the attributes you stored earlier as MObjects, and where you let the plugin know when to recompute. Setting up the attribute is done by using an object of type OpenMaya.MFnNumericAttribute. With this, we are able to create variables and customize how they are able to be used within Maya, such as whether they can be animated, if they display in the channel box, minimum and maximum values, and so on. After creating each attribute using this method, we call the addAttribute function on our class, passing in our MObject that represents the attribute. Afterwards, we specify which attributes modify which parts of the deformer by using the attributeAffects function on our class. In this example, our inflation attribute will modify the output geometry. Adding this line will make Maya recompute the output geometry whenever this attribute is modified.
def node_initializer():
num_attr = OpenMaya.MFnNumericAttribute()
# Setup attributes
PushDeformerNode.inflation_attr = num_attr.create('inflation', 'in', OpenMaya.MFnNumericData.kDouble, 0.0)
num_attr.setMin(0.0)
num_attr.setMax(10.0)
num_attr.setChannelBox(True)
PushDeformerNode.addAttribute(PushDeformerNode.inflation_attr)
# Link inputs that change the output of the mesh
PushDeformerNode.attributeAffects(PushDeformerNode.inflation_attr, OpenMayaMPx.cvar.MPxDeformerNode_outputGeom)
#END
Finally, the plugin's initialize and uninitialize functions are required to complete our deformer. They are very unexceptional and simply register or deregister the node itself, passing in the name and unique id we configured at the beginning of this script.
def initializePlugin(mobject):
mplugin = OpenMayaMPx.MFnPlugin(mobject)
try:
mplugin.registerNode(plugin_node_name, plugin_node_id, node_creator, node_initializer, OpenMayaMPx.MPxNode.kDeformerNode)
except:
sys.stderr.write('Failed to register node: ' + plugin_node_name)
raise
#END
def uninitializePlugin(mobject):
mplugin = OpenMayaMPx.MFnPlugin(mobject)
try:
mplugin.deregisterNode(plugin_node_id)
except:
sys.stderr.write('Failed to deregister node: ' + plugin_node_name)
raise
#END
Applying The Deformer
The deformer must be loaded for Maya to find the deformer. Place your script in a folder that matches your MAYA_PLUG_IN_PATH variable, such as the default scripts folder. Loading the plugin is done via the loadPlugin command. Applying the deformer can be done using the deformer command, with the type argument set to what you named your plugin (plugin_node_name, in the script above). Calling this function will apply the deformer to all selected objects that can receive it.
# Load the plugin
maya.cmds.loadPlugin('push_deformer.py')
# Apply the deformer
cmds.deformer(name="some_node_name", type='pushDeformer')
Complete Example
Below, you can find the complete deformer code, as well as a script for loading and applying the deformer.
import sys
import maya.OpenMayaMPx as OpenMayaMPx
import maya.OpenMaya as OpenMaya
# -*- Plugin information -*-
plugin_node_name = 'pushDeformer'
plugin_node_id = OpenMaya.MTypeId(0x1C3B0475)
class PushDeformerNode(OpenMayaMPx.MPxDeformerNode):
# Amount to push the vertices by
inflation_attr = OpenMaya.MObject()
def __init__(self):
OpenMayaMPx.MPxDeformerNode.__init__(self)
#END
def deform(self, data, geom_it, local_to_world_mat, geom_idx):
envelope_attr = OpenMayaMPx.cvar.MPxDeformerNode_envelope
envelope = data.inputValue(envelope_attr).asFloat()
inflation_handle = data.inputValue(PushDeformerNode.inflation_attr)
inflation = inflation_handle.asDouble()
input_geom_obj = self.get_input_geom(data, geom_idx)
normals = OpenMaya.MFloatVectorArray()
mesh = OpenMaya.MFnMesh(input_geom_obj)
mesh.getVertexNormals(True, normals, OpenMaya.MSpace.kTransform)
while not geom_it.isDone():
idx = geom_it.index()
nrm = OpenMaya.MVector(normals[idx])
pos = geom_it.position()
new_pos = pos + (nrm * inflation * envelope)
geom_it.setPosition(new_pos)
geom_it.next()
#END
def get_input_geom(self, data, geom_idx):
input_attr = OpenMayaMPx.cvar.MPxDeformerNode_input
input_geom_attr = OpenMayaMPx.cvar.MPxDeformerNode_inputGeom
input_handle = data.outputArrayValue(input_attr)
input_handle.jumpToElement(geom_idx)
input_geom_obj = input_handle.outputValue().child(input_geom_attr).asMesh()
return input_geom_obj
#END
#END
def node_creator():
return OpenMayaMPx.asMPxPtr(PushDeformerNode())
#END
def node_initializer():
num_attr = OpenMaya.MFnNumericAttribute()
# Setup attributes
PushDeformerNode.inflation_attr = num_attr.create('inflation', 'in', OpenMaya.MFnNumericData.kDouble, 0.0)
num_attr.setMin(0.0)
num_attr.setMax(10.0)
num_attr.setChannelBox(True)
PushDeformerNode.addAttribute(PushDeformerNode.inflation_attr)
# Link inputs that change the output of the mesh
PushDeformerNode.attributeAffects(PushDeformerNode.inflation_attr, OpenMayaMPx.cvar.MPxDeformerNode_outputGeom)
#END
def initializePlugin(mobject):
mplugin = OpenMayaMPx.MFnPlugin(mobject)
try:
mplugin.registerNode(plugin_node_name, plugin_node_id, node_creator, node_initializer, OpenMayaMPx.MPxNode.kDeformerNode)
except:
sys.stderr.write('Failed to register node: ' + plugin_node_name)
raise
#END
def uninitializePlugin(mobject):
mplugin = OpenMayaMPx.MFnPlugin(mobject)
try:
mplugin.deregisterNode(plugin_node_id)
except:
sys.stderr.write('Failed to deregister node: ' + plugin_node_name)
raise
#END
import maya.mel as mel
import maya.cmds as cmds
class ApplyPushDeformer(object):
def __init__(self):
pass
def __load_plugin(self):
if not cmds.pluginInfo('push_deformer.py', l=True, q=True):
if cmds.loadPlugin('push_deformer.py') == None:
cmds.warning('Failed to load plugin.')
return False
return True
#END
def __apply(self, mesh):
print 'selecting ' + str(mesh)
cmds.select(mesh)
name = str(mesh) + '_pushDeformer'
name = mel.eval('formValidObjectName(\"{0}\");'.format(name))
cmds.deformer(name=name, type='pushDeformer')
#END
def apply_push_deformer(self, meshlist):
if not self.__load_plugin():
return
for x in meshlist:
self.__apply(x)
#END
# Unused; keeping anyway
def apply_push_deformer_selection():
if not self.__load_plugin():
return
objs = cmds.ls(sl=True, type='transform')
if not len(objs):
cmds.warning('No objects selected.')
return
for x in objs:
cmds.select(x)
name = str(x) + '_pushDeformer'
name = mel.eval('formValidObjectName(\"{0}\");'.format(name))
cmds.deformer(name=name, type='pushDeformer')
#END
#END