[Maya API] Vertex tangent space

I’m looking for an efficient and clean way to get a consistent tangent/binormal for a vertex that helps me build a consistent/logical tangent space matrix.
With consistent I mean it moves in a logical way with the mesh when you deform the mesh.

The MFnMesh class has: getNormals(), getTangents(), getBinormals().
The getNormals method is per vertex and returns a list with length of amount of points, yet the other two are Per-face-per-vertex because it’s dependent on UVs (per face).
This means that the latter contain more data (thus more points.)

Basically my brain has been killing me here!

What would be the best way to get a tangent and binormal for a vertex that I can rely on?
Do I get all the tangents and binormals corresponding to a vertex, sum these up (and normalize it?); after that perform the Gram-Schmidt process?
Do I use getTangents? Or is it easier/faster in this scenario to go over the mesh with MItMeshPolygon (or anything like that)?

Any thoughts on this subject?
Optimization is second on the list, so if you already have some pointers for that in this scenario it’s really welcome.

So I’ve been testing/prototyping some more in Python.
Currently I’m averaging the face-vertex-tangents so I end with a single tangent (and binormal) for a vertex, but this isn’t a bulletproof solution.
For example the top and bottom vertex on a sphere will not work correctly. Also L-shape corners will give problems.

Here’s my last snippet:

"""
    Snippet testing Vertex Tangent Space in Maya (Python API)
    
    @author: Roy Nieterau
    @website: www.colorbleed.nl
    @email: roy@colorbleed.nl
    
    ==================================
        Averaging the Face Tangents
    ==================================
    Currently I am getting the vertex-tangents by averaging
    the vertex-face-tangents per vertex.
    
    Note:
        That this is not a good solution working with L-shapes:
            - i.e. the 90 degree corners of a Cube
   
    Note2:      
        Without the normalization the pinching on a sphere (top/bottom) can
        result in a matrix with 0, 0, 0 scale because the normals of the
        different faces summed up together will become zero (thus zero average) 

"""
import maya.OpenMaya as om
import maya.cmds as mc


def getPosNormalTangents(mesh, normalize=True):
    """ Prototype/Snippet to get vertex tangents from a mesh """
    selList = om.MSelectionList()
    selList.add(mesh)
    path = om.MDagPath()
    selList.getDagPath(0, path)
    path.extendToShape()
    fnMesh = om.MFnMesh(path)
    tangents = om.MFloatVectorArray()
    binormals = om.MFloatVectorArray()
    
    fnMesh.getTangents(tangents)
    fnMesh.getBinormals(binormals)
    itMeshVertex = om.MItMeshVertex(path)
    
    vertBinormals = om.MFloatVectorArray()
    vertBinormals.setLength(fnMesh.numVertices())
    
    vertTangents = om.MFloatVectorArray()
    vertTangents.setLength(fnMesh.numVertices())
    
    vertNormals = om.MFloatVectorArray()
    vertNormals.setLength(fnMesh.numVertices())
    
    vertId = 0
    connectedFaceIds = om.MIntArray()
    
    # For each vertex get the connected faces
    # For each of those faces get the 'tangentId' to get the tangent and binormal stored above
    # Use that to calculate the normal
    while(not itMeshVertex.isDone()):
        
        itMeshVertex.getConnectedFaces(connectedFaceIds)
        tangent = om.MFloatVector()
        binormal = om.MFloatVector()
        for x in xrange(connectedFaceIds.length()):
            faceId = connectedFaceIds[x]
            tangentId = fnMesh.getTangentId(faceId, vertId)
            binormal += binormals[tangentId]
            tangent += tangents[tangentId]
            
        binormal /= connectedFaceIds.length()
        tangent /= connectedFaceIds.length()
        
        if normalize:
            binormal.normalize()
            tangent.normalize()
        
        normal = tangent ^ binormal
        if normalize:
            normal.normalize()
        
        # Put the data in the vertArrays
        vertTangents.set(tangent, vertId)
        vertBinormals.set(binormal, vertId)
        vertNormals.set(normal, vertId)
          
        vertId += 1
        itMeshVertex.next()
        
    vertPoints = om.MPointArray()
    fnMesh.getPoints(vertPoints)
        
    return vertBinormals, vertTangents, vertNormals, vertPoints


def vectorsToMatrix(binormal=(1, 0, 0), tangent=(0, 1, 0), normal=(0, 0, 1), pos=(0, 0, 0), asApi=False):
    """ Function to convert an orthogonal basis defined from seperate vectors + position to a matrix """
    def _parseAPI(vec):
        if isinstance(vec, (om.MVector, om.MFloatVector, om.MPoint, om.MFloatPoint)):
            vec = [vec(x) for x in xrange(3)]
        return vec
    
    binormal = _parseAPI(binormal)    
    tangent = _parseAPI(tangent)    
    normal = _parseAPI(normal)    
    pos = _parseAPI(pos)
    
    if asApi:
        matrix = om.MMatrix()
        for x in xrange(3):
            om.MScriptUtil.setDoubleArray(matrix[0], x, binormal[x])
            om.MScriptUtil.setDoubleArray(matrix[1], x, tangent[x])
            om.MScriptUtil.setDoubleArray(matrix[2], x, normal[x])
            om.MScriptUtil.setDoubleArray(matrix[3], x, pos[x])
        return matrix

    else:
        return [binormal[0], binormal[1], binormal[2], 0,
                tangent[0],  tangent[1],  tangent[2],  0,
                normal[0],   normal[1],   normal[2],   0,
                pos[0],      pos[1],      pos[2],      1]


#
#	Test it on two objects (select two objects)
#	1. Used for calculating the tangent spaces
#	2. Duplicated to every vertex with the matrix of the tangent space
#
if __name__ == "__main__":
    sel = mc.ls(sl=1)
    if not len(sel) > 1:
        raise RuntimeError("Select two objects. The second object will be duplicated and moved to every vertex of the first object.")
        
    vertBinormals, vertTangents, vertNormals, vertPoints = getPosNormalTangents(sel[0])
    for x in xrange(vertPoints.length()):
        dup = mc.duplicate(sel[1])
        mat = vectorsToMatrix(vertBinormals[x], vertTangents[x], vertNormals[x], vertPoints[x])
        mc.xform(dup, m=mat)

Hope somebody can point me in the right direction! :wink:

Maybe a solution is to weight the face-vertex-tangents based on the UV triangle size, I think that’s what they mention as a solution here:
http://www.crytek.com/download/Triangle_mesh_tangent_space_calculation.pdf

EDIT:
Ok. So I implemented the functionality to average by UV area size. It does give other results, but it’s hard to check whether this is ‘perfect’.
It’s hard to change the mesh and update it directly with this script, so i’ll probably have to implement this in the C++ node to see if it works consistently.

Sharing the code here:

"""
    Snippet testing Vertex Tangent Space in Maya (Python API)
    
    @author: Roy Nieterau
    @website: www.colorbleed.nl
    @email: roy@colorbleed.nl
    
    ==================================
        Averaging the Face Tangents
    ==================================
    Currently I am getting the vertex-tangents by averaging
    the vertex-face-tangents per vertex.
    
    Note:
        That this is not a good solution working with L-shapes:
            - i.e. the 90 degree corners of a Cube
   
    Note2:      
        Without the normalization the pinching on a sphere (top/bottom) can
        result in a matrix with 0, 0, 0 scale because the normals of the
        different faces summed up together will become zero (thus zero average)
        
    Release Notes:
        - Added weightByUVTriangleSize parameter to weight averaging by UV area size.

"""
import maya.OpenMaya as om
import maya.cmds as mc


def getPosNormalTangents(mesh, normalize=True, weightByUVTriangleSize=True):
    """ Prototype/Snippet to get vertex tangents from a mesh """
    selList = om.MSelectionList()
    selList.add(mesh)
    path = om.MDagPath()
    selList.getDagPath(0, path)
    path.extendToShape()
    fnMesh = om.MFnMesh(path)
    tangents = om.MFloatVectorArray()
    binormals = om.MFloatVectorArray()
    
    fnMesh.getTangents(tangents)
    fnMesh.getBinormals(binormals)
    itMeshVertex = om.MItMeshVertex(path)
    
    vertBinormals = om.MFloatVectorArray()
    vertBinormals.setLength(fnMesh.numVertices())
    
    vertTangents = om.MFloatVectorArray()
    vertTangents.setLength(fnMesh.numVertices())
    
    vertNormals = om.MFloatVectorArray()
    vertNormals.setLength(fnMesh.numVertices())
    
    vertId = 0
    connectedFaceIds = om.MIntArray()
    
    intScriptUtil = om.MScriptUtil()
    intPtr = intScriptUtil.asIntPtr()
    doubleScriptUtil = om.MScriptUtil()
    doublePtr = doubleScriptUtil.asDoublePtr()
    
    
    if weightByUVTriangleSize:
        itMeshPoly = om.MItMeshPolygon(path)
    
    # For each vertex get the connected faces
    # For each of those faces get the 'tangentId' to get the tangent and binormal stored above
    # Use that to calculate the normal
    while(not itMeshVertex.isDone()):
        
        itMeshVertex.getConnectedFaces(connectedFaceIds)
        tangent = om.MFloatVector()
        binormal = om.MFloatVector()
        
        if weightByUVTriangleSize:
            # We will multiply the vectors with the UVTriangleSize
            # Then to get the weighted average we divide by the sum of the UVTriangleSizes
            divisor = 0 
        else:
            divisor = connectedFaceIds.length()
        for x in xrange(connectedFaceIds.length()):
            faceId = connectedFaceIds[x]
            tangentId = fnMesh.getTangentId(faceId, vertId)
            
            if weightByUVTriangleSize:
                
                itMeshPoly.setIndex(faceId, intPtr)
                itMeshPoly.getUVArea(doublePtr)
                area = om.MScriptUtil.getDouble(doublePtr)
                
                binormal += binormals[tangentId] * area
                tangent += tangents[tangentId] * area
                divisor += area
                
            else:
                binormal += binormals[tangentId]
                tangent += tangents[tangentId]
            
        binormal /= divisor
        tangent /= divisor
        
        if normalize:
            binormal.normalize()
            tangent.normalize()
        
        normal = tangent ^ binormal
        if normalize:
            normal.normalize()
        
        # Put the data in the vertArrays
        vertTangents.set(tangent, vertId)
        vertBinormals.set(binormal, vertId)
        vertNormals.set(normal, vertId)
          
        vertId += 1
        itMeshVertex.next()
        
    vertPoints = om.MPointArray()
    fnMesh.getPoints(vertPoints)
        
    return vertBinormals, vertTangents, vertNormals, vertPoints


def vectorsToMatrix(binormal=(1, 0, 0), tangent=(0, 1, 0), normal=(0, 0, 1), pos=(0, 0, 0), asApi=False):
    """ Function to convert an orthogonal basis defined from seperate vectors + position to a matrix """
    def _parseAPI(vec):
        if isinstance(vec, (om.MVector, om.MFloatVector, om.MPoint, om.MFloatPoint)):
            vec = [vec(x) for x in xrange(3)]
        return vec
    
    binormal = _parseAPI(binormal)    
    tangent = _parseAPI(tangent)    
    normal = _parseAPI(normal)    
    pos = _parseAPI(pos)
    
    if asApi:
        matrix = om.MMatrix()
        for x in xrange(3):
            om.MScriptUtil.setDoubleArray(matrix[0], x, binormal[x])
            om.MScriptUtil.setDoubleArray(matrix[1], x, tangent[x])
            om.MScriptUtil.setDoubleArray(matrix[2], x, normal[x])
            om.MScriptUtil.setDoubleArray(matrix[3], x, pos[x])
        return matrix

    else:
        return [binormal[0], binormal[1], binormal[2], 0,
                tangent[0],  tangent[1],  tangent[2],  0,
                normal[0],   normal[1],   normal[2],   0,
                pos[0],      pos[1],      pos[2],      1]


#
#    Test it on two objects (select two objects)
#    1. Used for calculating the tangent spaces
#    2. Duplicated to every vertex with the matrix of the tangent space
#
if __name__ == "__main__":
    sel = mc.ls(sl=1)
    if not len(sel) > 1:
        raise RuntimeError("Select two objects. The second object will be duplicated and moved to every vertex of the first object.")
        
    vertBinormals, vertTangents, vertNormals, vertPoints = getPosNormalTangents(sel[0])
    for x in xrange(vertPoints.length()):
        dup = mc.duplicate(sel[1])
        mat = vectorsToMatrix(vertBinormals[x], vertTangents[x], vertNormals[x], vertPoints[x])
        mc.xform(dup, m=mat)

Still I’m very interested in hearing ideas from others! So pointers are welcome.