-
Notifications
You must be signed in to change notification settings - Fork 114
Pixar USD Python API
Here we will collect knowledge related to the Pixar USD file format.
The ultimate goal is to create a "Procedural Modeling Language" (let's call it PROMO) similar to Houdini VEX, but much more high-level and powerful and output scene data to the USD. For example, we should easily create a procedural city with PROMO:
include PROMO
parameters = {'name': "Barcelona", 'size': [2, 3], 'style': "post-apocalypses"}
city = PROMO.createCity(parameters)
PROMO.saveToUSD(city)This PROMO can be a middle layer in the text-to-3d Large Language Model.
You will need Python 3 (I have Python 3.10) and USD Python API. Install Python from the internet. Fortunately, the compiled USD Python API already exists and can be installed with pip install usd-core.
Pixar USD Hello World tutorial is amazing. Once you have USD Python API installed it is easy to build your first USD scene.
We go a bit further and export scene geometry from Maya 2023. Create any polygons in the current scene and run this script:
"""
Export geometry from Maya scene to USD file
"""
import random
from pxr import Usd, UsdGeom
import pymel.core as pm
def get_geometry_data(mesh):
"""
Get points data for each face for USD file record
"""
points = [] # World position coordinates (tuples) for each geometry point (point3f[] points)
face_vertex_counts = [] # Number of vertices in each geometry face (int[] faceVertexCounts)
face_vertex_indices = [] # List of geometry vertex indexes (int[] faceVertexIndices)
# Get vertex data for each face
vertex_index = 0
for face in mesh.faces:
vertex_indexes = []
for vertex in face.getVertices():
position = tuple(mesh.vtx[vertex].getPosition(space='world'))
points.append(position)
vertex_indexes.append(vertex_index)
vertex_index += 1
face_vertex_counts.append(len(vertex_indexes))
face_vertex_indices.extend(vertex_indexes)
return points, face_vertex_counts, face_vertex_indices
def process_geometry(stage, root_xform):
"""
Iterate over all scene meshes and record them to the USD stage
"""
for mesh in pm.ls(type='mesh'):
# Create a USD Mesh primitive for the mesh object
usd_mesh = UsdGeom.Mesh.Define(stage, root_xform.GetPath().AppendChild(mesh.getParent().name()))
# Get geometry data
points, face_vertex_counts, face_vertex_indices = get_geometry_data(mesh)
# Set the collected attributes for the USD Mesh
usd_mesh.GetPointsAttr().Set(points)
usd_mesh.GetFaceVertexCountsAttr().Set(face_vertex_counts)
usd_mesh.GetFaceVertexIndicesAttr().Set(face_vertex_indices)
def export_geometry():
"""
Create USD file and record geometry data
"""
# Create USD stage
usd_file_path = f'D:/maya_geometry_{random.randint(1, 1000)}.usda'
# Create USD stage and root object
stage = Usd.Stage.CreateNew(usd_file_path)
root_xform = UsdGeom.Xform.Define(stage, '/')
process_geometry(stage, root_xform)
# Save the USD stage to the file
stage.GetRootLayer().Save()
print(f'>> {usd_file_path}')
export_geometry()We add a random suffix to the file name because if you try to export USD a second time with the same file path, it will throw an error, even if you delete the file. It can be fixed by cleaning the USD cache... later.
If we need to export geometry from the Houdini geometry context it would be even simpler. The snippet below is a basic solution, you can examine a more detailed USD exporter which includes normals, materials, and potentially other stuff.
"""
Export geometry from the Houdini geometry context.
Create a Python node in a Geometry context and connect your geometry to the first input.
"""
import random
from pxr import Usd, UsdGeom, Gf
import hou
def get_geometry_data(geometry):
"""
Get mesh geometry data
"""
points = [] # List of point positions (point3f[] points)
face_vertex_counts = [] # List of vertex count per face (int[] faceVertexCounts)
face_vertex_indices = [] # List of vertex indices (int[] faceVertexIndices)
# Collect points
for point in geometry.points():
position = point.position()
points.append(Gf.Vec3f(position[0], position[1], position[2]))
# Collect face data
for primitive in geometry.prims():
vertices = primitive.vertices()
face_vertex_counts.append(len(vertices))
face_vertex_indices.extend([vertex.point().number() for vertex in vertices])
return points, face_vertex_counts, face_vertex_indices
def export_geometry():
"""
Create and save a USD file with geometry from the first input.
"""
# Create a new USD stage
usd_file_path = f'E:/houdini_export_{random.randint(1, 100)}.usda'
stage = Usd.Stage.CreateNew(usd_file_path)
# Access the input geometry
node = hou.pwd()
geometry = node.geometry()
input_node = node.inputs()[0]
input_node_name = input_node.name()
points, face_vertex_counts, face_vertex_indices = get_geometry_data(geometry)
# Create a USD Mesh primitive
mesh = UsdGeom.Mesh.Define(stage, f'/Root/{input_node_name}')
mesh.GetPointsAttr().Set(points)
mesh.GetFaceVertexCountsAttr().Set(face_vertex_counts)
mesh.GetFaceVertexIndicesAttr().Set(face_vertex_indices)
# Save the stage
stage.GetRootLayer().Save()
export_geometry()Ok, we now can export existing geometry from Maya or Houdini. But what about creating a geometry? How we can manage to build meshes with Python and save them to a USD file?
We already have the exporting part done, we retrieve "points", "face_vertex_counts", and "face_vertex_indices" attributes from mesh and feed them to our USD file to store geometry. Those are the key attributes we need to manipulate to define geometry.
Let's start with the very basic shape, a 4-point polygon. If you create a plane in Houdini and export it to the USDA with a native USD Export node:
The USDA file of a single quad looks like this:
#usda 1.0
def Xform "Root"
{
def Mesh "super_plane"
{
int[] faceVertexCounts = [4]
int[] faceVertexIndices = [0, 1, 3, 2]
uniform token orientation = "leftHanded"
point3f[] points = [(-1, 0, -1), (1, 0, -1), (-1, 0, 1), (1, 0, 1)]
uniform token subdivisionScheme = "none"
}
}By examining this simple case, we can understand how geometry is represented in USDA format.
We have one face, the faceVertexCounts array holds the list of faces represented as an integer number, the number of vertices in each face.
We have four points, the faceVertexIndices array contains the list of point numbers. The order of points is important: as you can see in Houdini starting from 0 and going clockwise the face is defined by 0, 1, 3, 2 points.
The last thing we need is the coordinates of each point, that are stored in the points array. The order of point coordinates is relevant to the faceVertexIndices array, e.g. point 1 has (1, 0, -1) position.
We also have "orientation" and "subdivisionScheme" attributes required to display geometry correctly. We can set them with Python for generated mesh easily.
Here we create a USDA file, add a mesh object to the Root of USD stage, and define mesh parameters by calling the plane() function from the procedurals module. To run this script in standalone Python Editor (like PyCharm) you need to install USD Python API, or you can run the code from Houdini "Python Source Editor", it has USD library installed.
from pxr import Usd, UsdGeom, Sdf
import procedurals
stage = Usd.Stage.CreateNew('D:/super_plane.usda')
# Build mesh object
root_xform = UsdGeom.Xform.Define(stage, '/Root')
mesh_path = Sdf.Path(root_xform.GetPath()).AppendChild('super_plane')
mesh = UsdGeom.Mesh.Define(stage, mesh_path)
# Build mesh geometry
plane_data = procedurals.plane(2, 2)
mesh.GetPointsAttr().Set(plane_data['points'])
mesh.GetFaceVertexCountsAttr().Set(plane_data['face_vertex_counts'])
mesh.GetFaceVertexIndicesAttr().Set(plane_data['face_vertex_indices'])
# Set orientation and subdivisionScheme
mesh.CreateOrientationAttr().Set(UsdGeom.Tokens.leftHanded)
mesh.CreateSubdivisionSchemeAttr().Set("none")
stage.GetRootLayer().Save()All mesh generation magic happens in the procedurals.plane() function. It returns a dictionary of mesh parameters, that we sat in the same way we did when we read those parameters from existing mesh in exporter. You can find USD scripts here
Here is the plane() function in procedurals file:
def plane(row_points, col_points):
"""
Create a procedural plane with a custom number of rows and columns and a size of 2.
"""
points = [] # List of point positions
face_vertex_counts = [] # List of vertex count per face
face_vertex_indices = [] # List of vertex indices
# Spacing between points
width = 2
height = 2
row_spacing = height / (row_points - 1)
col_spacing = width / (col_points - 1)
# Generate points for the grid
for row_point in range(row_points):
for column_point in range(col_points):
x = column_point * col_spacing - width / 2
z = row_point * row_spacing - height / 2
points.append((x, 0, z))
# Define faces using the indices of the grid points
for row_point in range(row_points - 1):
for column_point in range(col_points - 1):
# Calculate the indices of the corners of the cell
top_left = row_point * col_points + column_point
top_right = top_left + 1
bottom_left = top_left + col_points
bottom_right = bottom_left + 1
# Define the face using the indices of the 4 corners
face_vertex_indices.extend([top_left, top_right, bottom_right, bottom_left])
face_vertex_counts.append(4)
plane_data = {'points': points,
'face_vertex_counts': face_vertex_counts,
'face_vertex_indices': face_vertex_indices}
return plane_dataHere we define a polygonal plane by generating "points", "face_vertex_counts", and "face_vertex_indices" attributes data. We do it in two stages (same as we retrieved this data from existing mesh for the exporter): create points and create faces.
The function takes a number of horizontal and vertical points as input, e.g. single quad will be created with a plane(2, 2) call.
We are building a points array with cartesian coordinates of each point. Within a nested loop iterating over rows and columns, we create a grid: first (in the "col_points" loop) one row of columns is created (points 0 and 1), then the "row_points" loop multiplies columns to get a proper number of rows (points 2 and 3). That is why we have this weird point order: 0, 1, 3, 2.
We calculate X and Z coordinates (Y will be always 0):
x = column_point * col_spacing - width / 2
z = row_point * row_spacing - height / 2Row and column spacing are the sizes of each edge. For the horizontal (X) coordinate of each point, it will be the point number multiplied by edge length. Subtracting width / 2 from each horizontal point coordinate moves the resulting grid to the origin.
In the second part, we are getting the face_vertex_indices array that represents point order for our mesh as well as face_vertex_counts, the number of points for each face, which will be an array 4 (because each face will always have 4 points). We are using the same nested loop over rows and columns, calculate the point number for each point, and record those numbers into the face_vertex_indices array:
top_left = row_point * col_points + column_point
top_right = top_left + 1
bottom_left = top_left + col_points
bottom_right = bottom_left + 1Each face in the grid is defined by four points: top-left, top-right, bottom-left, and bottom-right.
top_left = row * col_points + col This calculates the index of the top-left vertex of the cell. The index is determined based on the position in the grid. For example, in a 3x3 grid, the top-left point of the second cell (first row, second column) would be at index 1.
top_right = top_left + 1 The top-right vertex is simply the next one in the same row, so it's the index of the top-left vertex plus 1.
bottom_left = top_left + col_points The bottom-left vertex is directly below the top-left vertex, which means it's col_points indices away.
bottom_right = bottom_left + 1 Similarly, the bottom-right vertex is next to the bottom-left, so its index is bottom_left + 1.
Creating a procedural sphere is also a two-stage process: generating the points and then defining the faces.
First, we focus on point creation. We will involve a bit of trigonometry and utilize polar coordinates for sphere creation. You can examine detailed description of a circle creation to get a better understanding of the math behind the code. In the case of a sphere, the difference will be in polar to cartesian coordinates conversion (we will use two angles to calculate X, Y, and Z world coordinates) and we will use 2 nested loops for point definition. Let's dive in!
Understanding Spherical Coordinates:
- Spherical coordinates are typically defined by two angles: the azimuth (θ, theta, horizontal angle) and the inclination (φ, phi, vertical angle), along with a radius (r). In our case, we disregard the radius for simplicity (assuming it is equal to 1 unit).
- The azimuth angle (θ) sweeps around the equator of the sphere, usually from 0 to 360 degrees (or 0 to 2π radians).
- The inclination angle (φ) sweeps from the top (north pole) to the bottom (south pole) of the sphere, typically from 0 to 180 degrees (or 0 to π radians).
Converting Spherical to Cartesian Coordinates:
- To place points on the sphere, you'll convert spherical coordinates to Cartesian coordinates (x, y, z) using the following formulas:
x = sin(φ) * cos(θ)y = sin(φ) * sin(θ)z = cos(φ)
Creating Points in Nested Loops:
- Use two nested loops: one for v_points (varying φ from top to bottom) and another for h_points (varying θ around the equator).
- The range of φ should be from 0 to π and the range of θ should be from 0 to 2π.
- The step size for each angle can be calculated based on the number of points (h_points and v_points).
Implement polar to cartesian coordinates conversion is simple:
def get_cartesian_position(h_angle, v_angle):
"""
Convert polar to cartesian coordinates
"""
position = (math.sin(v_angle) * math.cos(h_angle), math.sin(v_angle) * math.sin(h_angle), math.cos(v_angle))
return positionNow let's build a sphere points:
def sphere(h_points, v_points):
"""
Create polygonal sphere
"""
# Crate sphere points
points.append((0, 0, 1)) # Top pole
for v_point in range(1, v_points - 1): # Range excludes poles
v_angle = v_point * 3.14 / (v_points - 1)
for h_point in range(h_points):
h_angle = 2 * h_point * 3.14 / h_points
position = get_cartesian_position(h_angle, v_angle)
points.append(position)
points.append((0, 0, -1)) # Bottom poleUntil we build the entire geometry (points, and faces), we will not be able to examine intermediate results because the USD file will be incorrect and loading it into Houdini will provide no information. So for the intermediate steps, like point creation you can create a Python node in the Geometry context and write the code there, so you will access all created points in Houdini viewport. Here is the same code adapted for Python Node in the Geometry context:
import hou
import math
geo = hou.pwd().geometry()
h_points = 4
v_points = 6
def get_cartesian_position(h_angle, v_angle):
position = (math.sin(v_angle) * math.cos(h_angle),
math.sin(v_angle) * math.sin(h_angle),
math.cos(v_angle))
return position
# Top pole
top_pole = geo.createPoint()
top_pole.setPosition(hou.Vector3(0, 0, 1))
for v_point in range(1, v_points - 1): # Range excludes poles
v_angle = v_point * 3.14 / (v_points -1)
for h_point in range(h_points):
h_angle = 2 * h_point * 3.14 / h_points
position = get_cartesian_position(h_angle, v_angle)
point = geo.createPoint()
point.setPosition(hou.Vector3(position[0], position[1], position[2]))
# Bottom Pole
top_pole = geo.createPoint()
top_pole.setPosition(hou.Vector3(0, 0, -1)) 

