Mesh Scripting in Touchdesigner

Using ScriptSOPs with Python to generate custom meshes in Touchdesigner

Rationale

Touchdesigner already has a nice set of basic operators for building geometry like the SphereSOP or the GridSOP.

However there might be use cases where you need to work with custom and adjustable geometry, e.g. I recently needed to have wall mesh that exactly matches an LED-wall in order to have the output on the display controllers match seamless with the LED-ceiling. Experimenting with the ScriptSOP and geometric formulas lead to a series of generative organic structures afterwards.

Herinafter, the basic usage of Touchdesigners Python API in ScriptSOPs is described. We'll start by building a simple quad and will move to more complex structure like a sphere and a so-called supershape. I've put up a repository with all the examples below at github.com/guidoschmidt/Touchdesigner.GenerativeMeshes.

Quad

At the beginning, to understand the basic API, we'll build a simple quad:

  1. Put a ScriptSOP onto the Touchdesigner canvas
  2. We'll now write a simple function that will draw a quad on the XY-plane reaching from [-1, -1] to [+1, +1] on the xy-plane
  3. In the python script of the ScriptSOP, we make use of the appendMesh() function:
def build_quad(op):
    # Append a mesh to the ScriptSOP operator, that contains 2 rows and 2 colums
    rows = 2
    columns = 2
    mesh = op.appendMesh(rows, columns, closedU=False, closedV=False, addPoints=True)
    # Now set the coordinates of the meshs points
    mesh[0, 0].point.P = [+1, +1, 0]
    mesh[0, 1].point.P = [-1, +1, 0]
    mesh[1, 0].point.P = [+1, -1, 0]
    mesh[1, 1].point.P = [-1, -1, 0]
    # You can set the mesh primitives center to a 3d position
    mesh[0, 0].prim.center = tdu.Position(0, 1, 0)
    return


def onCook(scriptOp):
    # Clear data from the scriptOp first
    scriptOp.clear()
    # Call our build_quad function to generate the quad mesh
    build_quad(scriptOp)
    return

Notice that TDs appendMesh function is defined from a grid of rows and columns. Using the OpenGL topology wireframe options on any material reveals the underling triangle structure.

Ribbon

As a next step we extend the quad in one direction to generate a row of quads forming a ribbon like structure. To do so, we write a function that accepts a parameter rows. This parameter defines the count of rows in X direction. Here we also change the z value of the mesh vertices by using row index as parameter for the sine function. This bends the ribbon into a repeating wave shape.

def build_ribbon(op, rows):
    columns = 2
    mesh = op.appendMesh(rows, columns, closedU=False, closedV=False, addPoints=True)
    # rows comes in as a parameter and defines how many rows the ribbon should
    # have. We need to iterate over a range of indices from 0 to rows:
    for row in range(mesh.numRows):
        # To get a wavy look the z coordinate is calculated
        # using the sinus function
        mesh[row, 0].point.P = [row, 0, math.sin(row)]
        mesh[row, 1].point.P = [row, 1, math.sin(row)]
    # The mesh primitive is placed onto position [0, -1, 0] in 3D space:
    mesh[0, 0].prim.center = tdu.Position(0, -1, 0)
    return


def onCook(scriptOp):
    # Clear data from the scriptOp first
    scriptOp.clear()
    build_ribbon(scriptOp, 10)
    return

Grid

Now let's extend the ribbon again and write a function to generate a grid. In the following example I did modulate the y coordinate using y = math.sin(row + 1.5) * math.cos(col) in order to create a more interesting landscape-alike shape (if you like you can try to use a noise function to modulate the y coordinate):

def build_grid(op, sx, sy):
    mesh = op.appendMesh(sx, sy, closedU=False, closedV=False, addPoints=True)
    for row in range(mesh.numRows):
        for col in range(mesh.numCols):
            y = math.sin(row) * math.cos(col)
            mesh[row, col].point.P = [row, y, col]
    # Instead of writing tdu.Position(0, 0, 1) you can also
    # use pythons list syntax:
    mesh[0, 0].prim.center = [0, 2, 0]
    return


def onCook(scriptOp):
    scriptOp.clear()
    build_grid(scriptOp, 30, 30)
    return

Globe

To script a globe mesh, we will make use of the spherical coordinates. In order to avoid connections between the poles on the sphere, we only iterate up to maximum row count minus 1. To apply a texture on the mesh, we could additionally add texture coordinates as a mesh attribute or use a TextureSOP afterwards.

def build_globe(op, sx, sy):
    mesh = op.appendMesh(sx, sy, closedU=True, closedV=False, addPoints=True)
    radius = 10
    # In order to prevent connection between north and south pole
    # of the globe mesh, we need to iterate only up to numRows - 1
    row_count = mesh.numRows - 1
    col_count = mesh.numCols
    for row in range(mesh.numRows):
        lat = (row / row_count) * math.pi - (math.pi / 2)
        for col in range(mesh.numCols):
            lng = (col / col_count) * math.pi * 2 - math.pi
            x = radius * math.cos(lng) * math.cos(lat)
            y = radius * math.sin(lng) * math.cos(lat)
            z = radius * math.sin(lat)
            mesh[row, col].point.P = [x, y, z]
    mesh[0, 0].prim.center = [0, 0, 0]
    return


def onCook(scriptOp):
    scriptOp.clear()
    build_globe(scriptOp, 30, 30)
    return

Supershape

If you have seen my recent posts from my series Liveforms of Generative Geometry, the following section describes the basic technique behind these generative meshes. I've appled Paul Bourkes supershape formulas to the sphere coordinates we scripted in the preceding example. The supershape formulas lives in its own function to separate the mesh generation itself from the supershape magic:

def supershape(theta, m, n1, n2, n3):
    a = 1
    b = 1
    t1 = abs((1 / a) * math.cos(m * theta / 4))
    t1 = math.pow(t1, n2)
    t2 = abs((1 / b) * math.sin(m * theta / 4))
    t2 = math.pow(t2, n3)
    t3 = t1 + t2
    r = math.pow(t3, -1 / n1)
    return r


def build_supershape(op, sx, sy):
    m = 10.0
    n1 = 1.5
    n2 = 1.3
    n3 = 2.0
    lng_rng = math.pi / 2
    lat_rng = math.pi * 2
    mesh = op.appendMesh(sx, sy, closedU=False, closedV=False, addPoints=True)
    radius = 10
    row_count = mesh.numRows - 1
    col_count = mesh.numCols - 1
    for row in range(mesh.numRows):
        lat = (row / row_count) * lng_rng 
        r2 = supershape(lat, m, n1, n2, n3)
        for col in range(mesh.numCols):
            lng = (col / col_count) * lat_rng - (lat_rng / 2)
            r1 = supershape(lng, m, n1, n2, n3)
            x = radius * r1 * math.cos(lng) * math.cos(lat)
            y = radius * r1 * math.sin(lng) * math.cos(lat)
            z = radius * r2 * math.sin(lat)
            mesh[row, col].point.P = [x, y, z]
    mesh[0, 0].prim.center = [0, 0, 0]
    return

Debugging

Scripts that generate geometry and meshes are hard to debug and it's not always straight forward to understand what is going on. In order to better understand the topology of a scripted mesh, try to make extensive use of Display Options. Right-clicking into any SOP operator, select Display Options. Display of vertex order, normals or uv coordinates can help to get a better understanding of the topology and help to find bugs or errors in the implementation.

You can also make use of a SOP to DAT operator to get a list of all the geometryinformation from a SOP, which is super helpful to check vertices, primitives and attributes of a mesh.