Example: Voxelizer Node

 
 
 

Example: Voxelizer Node

Filename: voxelizerNode.py

Rendered Output: The following rendered image was obtained by voxelizing the mesh of a sphere. We applied a blue lambertian material to the original sphere, and a red surface shader to the voxelized mesh. The voxelized mesh was also assigned a Toon outline with a line width value of 0.223.

Program Summary: The plug-in code below creates a new MPxNode which accepts a mesh as input, as well as user-defined attributes to determine the voxel width and inter-voxel distance. This voxelizer node transforms the input mesh into a new mesh consisting of cubes, which fill the volume occupied by the original mesh. The voxelization process makes use of MFnMesh.allIntersections() to cast rays from inside the bounding box of the original mesh towards the camera. Assuming that the mesh is closed, if the ray intersects with an odd number points along the mesh's surface, then the origin of the ray is contained within the mesh.

NoteThis voxelizer was implemented as an MPxNode plug-in. It would not be possible to create such a voxelized mesh by deriving from MPxDeformerNode because a deformer cannot change the topology of the mesh. That is to say, the edge connections between the vertices cannot be modified within a deformer; a deformer can only modify the vertex positions.
# voxelizerNode.py

import sys
import maya.OpenMayaMPx as OpenMayaMPx
import maya.OpenMaya as OpenMaya

# Plug-in information:
kPluginNodeName = 'voxelizerNode'           # The name of the node.
kPluginNodeId = OpenMaya.MTypeId( 0xBEEF6 ) # A unique ID associated to this node type.

# Default input values.
defaultVoxelWidth = 0.9       # The width of a cubic voxel.
defaultVoxelDistance = 1.0    # The distance which separates the center of two adjacent voxels.

##########################################################
# Plug-in 
##########################################################
class VoxelizerNode(OpenMayaMPx.MPxNode):
    # Static variables which will later be replaced by the node's attributes.
    voxelWidthAttribute = OpenMaya.MObject()
    voxelDistanceAttribute = OpenMaya.MObject()
    inputMeshAttribute = OpenMaya.MObject()
    outputMeshAttribute = OpenMaya.MObject()
    
    def __init__(self):
        ''' Constructor. '''
        # (!) Make sure you call the base class's constructor.
        OpenMayaMPx.MPxNode.__init__(self)
        
    
    def compute(self, pPlug, pDataBlock):
        ''' Here, we will create a voxelized version of the input mesh. '''
        
        if( pPlug == VoxelizerNode.outputMeshAttribute ):
            
            # Get our custom input node attributes and values.
            voxelWidthHandle = pDataBlock.inputValue( VoxelizerNode.voxelWidthAttribute )
            voxelWidth = voxelWidthHandle.asFloat()
            
            voxelDistanceHandle = pDataBlock.inputValue( VoxelizerNode.voxelDistanceAttribute )
            voxelDistance = voxelDistanceHandle.asFloat()
            
            inputMeshHandle = pDataBlock.inputValue( VoxelizerNode.inputMeshAttribute )
            inputMeshObj = inputMeshHandle.asMesh()
            
            
            # Compute the bounding box around the mesh's vertices.
            boundingBox = self.getBoundingBox( inputMeshObj )    
            
            # Determine which voxel centerpoints are contained within the mesh.
            voxels = self.getVoxels( voxelDistance, inputMeshObj, boundingBox )
            
            # Create a mesh data container, which will store our new voxelized mesh.
            meshDataFn = OpenMaya.MFnMeshData()
            newOutputMeshData = meshDataFn.create()
            
            # Create a cubic polygon for each voxel and populate the 'newOutputMeshData' MeshData object.
            self.createVoxelMesh( voxels, voxelWidth, newOutputMeshData)


            # Set the output data.            
            outputMeshHandle = pDataBlock.outputValue( VoxelizerNode.outputMeshAttribute )
            outputMeshHandle.setMObject( newOutputMeshData )
            
        else:
            return OpenMaya.kUnknownParameter
    
    
    def getBoundingBox(self, pMeshObj):
        ''' Calculate a bounding box around the mesh's vertices. '''
        
        # Create the bounding box object we will populate with the points of the mesh.
        boundingBox = OpenMaya.MBoundingBox()
        
        meshFn = OpenMaya.MFnMesh( pMeshObj )
        
        pointArray = OpenMaya.MPointArray()
        
        # Get the points of the mesh in its local coordinate space.
        meshFn.getPoints( pointArray, OpenMaya.MSpace.kTransform ) 

        for i in range( 0, pointArray.length() ):
            point = pointArray[i]
            boundingBox.expand( point )
         
        return boundingBox
    
    
    def getVoxels(self, pVoxelDistance, pMeshObj, pBoundingBox):
        ''' 
        Obtain a list of voxels as a set of (x,y,z) coordinates in the mesh's local space. 
        
        We obtain these voxels by casting rays from points separated pVoxelDistance apart within the
        mesh's bounding box, and test whether or not these points are contained within the mesh.
        
        A point is contained within a closed mesh if the ray shot from the point intersects an odd
        number of times with the surface of the mesh.
        '''
        # Initialize a list of voxels contained within the mesh.
        voxels = []
        
        # Get a reference to the MFnMesh function set, and use it on the given mesh object.
        meshFn = OpenMaya.MFnMesh( pMeshObj )
        
        # Compute an offset which we will apply to the min and max corners of the bounding box.
        halfVoxelDist = 0.5 * pVoxelDistance
        
        # Offset the position of the minimum point to account for the inter-voxel distance.
        minPoint = pBoundingBox.min()
        minPoint.x += halfVoxelDist
        minPoint.y += halfVoxelDist
        minPoint.z += halfVoxelDist
        
        # Offset the position of the maximum point to account for the inter-voxel distance.
        maxPoint = pBoundingBox.max()
        maxPoint.x += halfVoxelDist
        maxPoint.y += halfVoxelDist
        maxPoint.z += halfVoxelDist
        
        # Define an iterator which will allow us to step through the pVoxelDistance
        # point intervals contained within our bounding box. We use this iterator
        # in the for loops that follow to visit each voxel center in the bounding box.
        def floatIterator(start, stop, step):
            r = start
            while r < stop:
                yield r
                r += step
        
        # Iterate over every point in the bounding box, stepping by pVoxelDistance...
        for xCoord in floatIterator( minPoint.x, maxPoint.x, pVoxelDistance ):
            for yCoord in floatIterator( minPoint.y, maxPoint.y, pVoxelDistance ):
                for zCoord in floatIterator( minPoint.z, maxPoint.z, pVoxelDistance ):

                    # 2D representation of a ray cast from the point within the bounding box:
                    #
                    #  (+) ^-----------------
                    #      |                |
                    #  y   |                |  - We are shooting the ray from the point: [*]
                    # axis | <======[*]     |  - The direction of the ray is parallel to the -Z axis.
                    #      |                |
                    #      |                |
                    #  (-) ------------------>
                    #     (-)    z axis     (+)
                    #
                    # If the ray intersects with an odd number of points along the surface of the mesh, the
                    # point is contained within the mesh (assuming a closed mesh). 
                    raySource = OpenMaya.MFloatPoint( xCoord, yCoord, zCoord )
                    rayDirection = OpenMaya.MFloatVector( 0, 0, -1 )
                    intersectionPoints = OpenMaya.MFloatPointArray()
                    tolerance = 0.0001
                    
                    meshFn.allIntersections( raySource,                  # raySource - where we are shooting the ray from.
                                             rayDirection,               # rayDirection - the direction in which we are shooting the ray.
                                             None,                       # faceIds - here, we do not care if specific faces are intersected)
                                             None,                       # triIds - here, we do not care if specific tri's are intersected)
                                             False,                      # idsSorted - here, we do not need to sort the faceId's or triId's indices.
                                             OpenMaya.MSpace.kTransform, # coordinate space - the mesh's local coordinate space.
                                             float(9999),                # the range of the ray.
                                             False,                      # testBothDirections - we are not checking both directions from the raySource
                                             None,                       # accelParams - this object is not applicable here.
                                             False,                      # sortHits - we do not need to sort the intersection points along the ray.
                                             intersectionPoints,         # hitPoints - the array of points which have been intersected.
                                             None,                       # hitRayParams - we do not need any parametric distances of the points along the ray.
                                             None,                       # hitFaces - we do not need the id's of the faces intersected.
                                             None,                       # hitTriangles - we do not need the id's of the triangles intersected.
                                             None,                       # hitBary1s - we do not need the barycentric coordinates of the points within the triangles.
                                             None,                       # hitBary2s - we do not need the barycentric coordinates of the points within the triangles.
                                             tolerance                   # tolerance - a numeric tolerance threshold which allow intersections to occur just outside the mesh.
                                             )
                    
                    # If there is an odd number of intersection points, then the point lies within the mesh. Otherwise,
                    # the point lies outside the mesh. We are only concerned with voxels whose centerpoint lies within the mesh
                    if( intersectionPoints.length() % 2 == 1 ):
                        voxels.append( raySource )
        
        # Return the list of voxel coordinates which lie within the mesh.
        return voxels
    
    
    def createVoxelMesh(self, pVoxelPositions, pVoxelWidth, pOutMeshData):
        ''' Create a mesh containing one cubic polygon for each voxel in the pVoxelPositions list. '''
        
        numVoxels = len( pVoxelPositions )
        
        numVerticesPerVoxel = 8 # a cube has eight vertices.
        numPolygonsPerVoxel = 6 # a cube has six faces.
        numVerticesPerPolygon = 4  # four vertices are required to define a face of a cube.
        numPolygonConnectsPerVoxel = numPolygonsPerVoxel * numVerticesPerPolygon # 24
        
        # Initialize the required arrays used to create the mesh in MFnMesh.create()
        totalVertices = numVoxels * numVerticesPerVoxel
        vertexArray = OpenMaya.MFloatPointArray()
        vertexArray.setLength( totalVertices )
        vertexIndexOffset = 0
        
        totalPolygons = numVoxels * numPolygonsPerVoxel
        polygonCounts = OpenMaya.MIntArray()
        polygonCounts.setLength( totalPolygons )
        polygonCountsIndexOffset = 0
        
        totalPolygonConnects = numVoxels * numPolygonConnectsPerVoxel
        polygonConnects = OpenMaya.MIntArray()
        polygonConnects.setLength( totalPolygonConnects )
        polygonConnectsIndexOffset = 0
        
        # Populate the required arrays used in MFnMesh.create()
        for i in range( 0, numVoxels ):
            voxelPosition = pVoxelPositions[i]
            
            # Add a new cube to the arrays.
            self.createCube( voxelPosition, pVoxelWidth,
                             vertexArray, vertexIndexOffset, numVerticesPerVoxel,
                             polygonCounts, polygonCountsIndexOffset, numPolygonsPerVoxel, numVerticesPerPolygon,
                             polygonConnects, polygonConnectsIndexOffset )
            
            # Increment the respective index offsets.
            vertexIndexOffset += numVerticesPerVoxel
            polygonCountsIndexOffset += numPolygonsPerVoxel
            polygonConnectsIndexOffset += numPolygonConnectsPerVoxel
        
        
        # Create the mesh now that the arrays have been populated. The mesh is stored in pOutMeshData
        meshFn = OpenMaya.MFnMesh()
        meshFn.create( totalVertices, totalPolygons, vertexArray, polygonCounts, polygonConnects, pOutMeshData )
    
    
    def createCube(self, pVoxelPosition, pWidth, 
                         pVertexArray, pVertexIndexOffset, pNumVerticesPerVoxel,
                         pPolygonCountArray, pPolygonCountIndexOffset, pNumPolygonsPerVoxel, pNumVerticesPerPolygon,
                         pPolygonConnectsArray, pPolygonConnectsIndexOffset ):
        ''' Add a cubic polygon to the specified arrays. '''
        
        # We are using half the given width to compute the vertices of the cube. 
        halfWidth = float( pWidth / 2.0 )
        
        # Declare the eight corners of the cube. The cube is centered at pVoxelPosition.
        
        vertices = [OpenMaya.MFloatPoint(-halfWidth + pVoxelPosition.x, -halfWidth + pVoxelPosition.y, -halfWidth + pVoxelPosition.z), # 0
                    OpenMaya.MFloatPoint( halfWidth + pVoxelPosition.x, -halfWidth + pVoxelPosition.y, -halfWidth + pVoxelPosition.z), # 1
                    OpenMaya.MFloatPoint( halfWidth + pVoxelPosition.x, -halfWidth + pVoxelPosition.y,  halfWidth + pVoxelPosition.z), # 2
                    OpenMaya.MFloatPoint(-halfWidth + pVoxelPosition.x, -halfWidth + pVoxelPosition.y,  halfWidth + pVoxelPosition.z), # 3
                    OpenMaya.MFloatPoint(-halfWidth + pVoxelPosition.x,  halfWidth + pVoxelPosition.y, -halfWidth + pVoxelPosition.z), # 4
                    OpenMaya.MFloatPoint(-halfWidth + pVoxelPosition.x,  halfWidth + pVoxelPosition.y,  halfWidth + pVoxelPosition.z), # 5
                    OpenMaya.MFloatPoint( halfWidth + pVoxelPosition.x,  halfWidth + pVoxelPosition.y,  halfWidth + pVoxelPosition.z), # 6
                    OpenMaya.MFloatPoint( halfWidth + pVoxelPosition.x,  halfWidth + pVoxelPosition.y, -halfWidth + pVoxelPosition.z)] # 7
        
        # Declare the data structure which binds each vertex to a polygon corner 
        polygonConnections = [ (0, 12, 16), # the vertex indexed at 0 corresponds to the polygon corners whose indexes are (0, 12, 16) in pPolygonConnectsArray. 
                               (1, 19, 20),
                               (2,  9, 23),
                               (3,  8, 13),
                               (4, 15, 17),
                               (5, 11, 14),
                               (6, 10, 22),
                               (7, 18, 21) ]

        # Store the eight corners of the cube in the vertex array.
        for i in range( 0, pNumVerticesPerVoxel ):
            # Store the vertex in the passed vertex array.
            pVertexArray.set( vertices[i], pVertexIndexOffset + i )
            
            # Assign the vertex in the pVertexArray to the relevant polygons.
            for polygonConnectionIndex in polygonConnections[i]:
                pPolygonConnectsArray.set( pVertexIndexOffset + i, pPolygonConnectsIndexOffset + polygonConnectionIndex )
        
        # Declare the number of vertices for each face.
        for i in range( 0, pNumPolygonsPerVoxel ):
             # Set the number of vertices for the polygon at the given index.
             pPolygonCountArray.set( pNumVerticesPerPolygon, pPolygonCountIndexOffset + i )
             

##########################################################
# Plug-in initialization.
##########################################################
def nodeCreator():
    ''' Creates an instance of our node class and delivers it to Maya as a pointer. '''
    return OpenMayaMPx.asMPxPtr( VoxelizerNode() )

def nodeInitializer():
    ''' Defines the input and output attributes as static variables in our plug-in class. '''
    # The following MFnNumericAttribute function set will allow us to create our attributes.
    numericAttributeFn = OpenMaya.MFnNumericAttribute()
    
    # This one allows us to create our input and output mesh attributes.
    typedAttributeFn = OpenMaya.MFnTypedAttribute()
    
    #==================================
    # INPUT NODE ATTRIBUTE(S)
    #==================================
    # We will need a voxel width.
    global defaultVoxelWidth
    VoxelizerNode.voxelWidthAttribute = numericAttributeFn.create( 'voxelWidth', 'vw',
                                                                    OpenMaya.MFnNumericData.kFloat, defaultVoxelWidth )
    numericAttributeFn.setWritable( True )
    numericAttributeFn.setReadable( False )
    numericAttributeFn.setStorable( True )
    numericAttributeFn.setHidden( False )
    numericAttributeFn.setMin( 0.1 )
    VoxelizerNode.addAttribute( VoxelizerNode.voxelWidthAttribute )

    # We will need a voxel distance value (higher values means we can see more voxels within the volume of the mesh).
    global defaultVoxelDistance
    VoxelizerNode.voxelDistanceAttribute = numericAttributeFn.create( 'voxelDistance', 'vd', 
                                                                       OpenMaya.MFnNumericData.kFloat, defaultVoxelDistance )
    numericAttributeFn.setWritable( True )
    numericAttributeFn.setReadable( False )
    numericAttributeFn.setStorable( True )
    numericAttributeFn.setHidden( False )
    numericAttributeFn.setMin( 0.1 )
    VoxelizerNode.addAttribute( VoxelizerNode.voxelDistanceAttribute )
    
    # We will need an input mesh attribute.
    VoxelizerNode.inputMeshAttribute = typedAttributeFn.create( 'inputMesh', 'im',
                                                                OpenMaya.MFnData.kMesh )
    typedAttributeFn.setWritable( True )
    typedAttributeFn.setReadable( False )
    typedAttributeFn.setStorable( False )
    typedAttributeFn.setHidden( False )
    VoxelizerNode.addAttribute( VoxelizerNode.inputMeshAttribute )

    #==================================
    # OUTPUT NODE ATTRIBUTE(S)
    #==================================
    VoxelizerNode.outputMeshAttribute = typedAttributeFn.create( 'outputMesh', 'om',
                                                                 OpenMaya.MFnData.kMesh )
    typedAttributeFn.setWritable( False )
    typedAttributeFn.setReadable( True )
    typedAttributeFn.setStorable( False )
    typedAttributeFn.setHidden( False )
    VoxelizerNode.addAttribute( VoxelizerNode.outputMeshAttribute )
    
    #==================================
    # NODE ATTRIBUTE DEPENDENCIES
    #==================================
    # If any of the inputs change, the output mesh will be recomputed.
    VoxelizerNode.attributeAffects( VoxelizerNode.voxelWidthAttribute, VoxelizerNode.outputMeshAttribute )
    VoxelizerNode.attributeAffects( VoxelizerNode.voxelDistanceAttribute, VoxelizerNode.outputMeshAttribute )
    VoxelizerNode.attributeAffects( VoxelizerNode.inputMeshAttribute, VoxelizerNode.outputMeshAttribute )
    
    
def initializePlugin( mobject ):
    ''' Initialize the plug-in '''
    mplugin = OpenMayaMPx.MFnPlugin( mobject )
    try:
        mplugin.registerNode( kPluginNodeName, kPluginNodeId, nodeCreator, nodeInitializer )
    except:
        sys.stderr.write( 'Failed to register node: ' + kPluginNodeName )
        raise
    
def uninitializePlugin( mobject ):
    ''' Uninitializes the plug-in '''
    mplugin = OpenMayaMPx.MFnPlugin( mobject )
    try:
        mplugin.deregisterNode( kPluginNodeId )
    except:
        sys.stderr.write( 'Failed to deregister node: ' + kPluginNodeName )
        raise

##########################################################
# Sample usage.
##########################################################
''' 
# Copy the following lines and run them in Maya's Python Script Editor:

import maya.cmds as cmds

cmds.file( newFile=True, force=True )

cmds.unloadPlugin( 'voxelizerNode.py' )
cmds.loadPlugin( 'voxelizerNode.py' )

# Create a sphere which will act as our input shape.
cmds.polySphere( r=5.0, sx=20, sy=20, name='sphere1' )
cmds.move( -20, 0, 0, 'sphere1' ) # move it over to the side.

# Create the voxelization node.
cmds.createNode( 'voxelizerNode', name='voxelizerNode1' )

# Create a target shape.
cmds.createNode( 'transform', name='target1' )
cmds.createNode( 'mesh', name='target1Shape', parent='target1' )
cmds.sets( 'target1Shape', add='initialShadingGroup' )

# Connect the attributes.
cmds.connectAttr( 'sphere1Shape.outMesh', 'voxelizerNode1.inputMesh' )
cmds.connectAttr( 'voxelizerNode1.outputMesh', 'target1Shape.inMesh' )

'''