Skip to article frontmatterSkip to article content

9. Exploring Point Features in OpenUSD

This chapter covers

Complex 3D scenes with thousands or millions of elements, such as particles, forests, or crowded street scenes, can quickly become inefficient, leading to large file sizes and slow performance. Points offer a solution by providing a lightweight, structured way to represent and manipulate large datasets. In OpenUSD, they can streamline scene construction, optimize rendering, and make it easier to work with complex data while maintaining flexibility and performance.

We’ll start by showing you how to create basic point prims, and convert point cloud data from external sources into USD formats. Next, we explain the Point Instancer—a method that efficiently represents large numbers of instances using a single prim, making it ideal for applications such as particle systems, vegetation, or crowd simulations. We’ll also introduce curves and explain their role in defining smooth, continuous shapes or motion paths that complement point-based data.

We’ll provide some practical demonstrations, showing how to minimize file size and reduce computation overhead during rendering. The goal is to help you work confidently with points, creating scalable, flexible, and efficient USD scenes.

9.1 Understanding Points in 3D

Points are the most basic elements in a 3D space, representing specific locations with x, y, and z coordinates. We’ve already used them as vertices (the corners of polygonal meshes), point lights (tiny light sources), and joints (connection points that constrain movement between objects).

In visual effects, points are often used to create particle systems for effects like smoke, fire, and rain, allowing for highly customizable and dynamic visuals. They can also be used to represent raw data from 3D scans or simulations, providing a basis for creating detailed digital twins of real-world objects and environments. Additionally, points can be used in procedural modeling or in scientific visualization to represent data points in a 3D scatter plot.

Individual points can contain rich data beyond simple x, y, and z coordinates. Common point attributes include:

Points aren’t just for vertices, lights, or joints. Because they can hold so many kinds of data, they’re incredibly versatile. The UsdGeom.Points schema is specifically designed to support these common uses:

Exploring every possible use case for points is beyond the scope of this chapter, so here we will introduce the basics of creating Point Clouds, Point Instancing, and Curves which will provide a good foundation from which to explore the many possible applications for points in OpenUSD.

Let’s look at the fundamental method of adding points to a stage by creating and populating a Points Prim.

OpenUSD provides us with the UsdGeom.Points schema which is used to represent collections of points in 3D space. We can use it to create a point-based geometry primitive (prim) called a Points Prim which acts as a container for unordered point data, including attributes like position, color, and width. We call this ‘unordered’ point data because the schema does not impose any specific structure or connectivity between the points, it simply stores them as a collection of independent positions in space. Unlike meshes (which have defined topology connecting vertices into faces) or curves (where point order determines the shape), UsdGeom.Points treats each point as an isolated entity.

This section will guide you through the process of defining point data, setting up attributes like positions, sizes, and colors, demonstrating how to use UsdGeom.Points to begin using points on your stages.

Our first demonstration won’t require any referenced assets, but we’ll start by setting the correct working directory for this chapter. This will ensure a smooth workflow when we begin referencing assets later on.

You will find all the assets for this chapter on our GitHub repository here: https://github.com/learn-usd/learn-usd.github.io/tree/main/code_and_assets/Ch09.

You should have the following directory structure for this chapter:

/Ch09
├── Assets
│   ├── bun_zipper_res2.ply
│   ├── bunny_1.0.usda
│   ├── Pine1.usd
│   └── Pine2.usd
└── create your .usds here

Remember to set Ch09 as your working directory using:

In most cases points are used in large numbers to create their desired effect, however, we’re going to start off by adding just three points to a stage which we’ll call ‘points_example.usda’ (see Figure 1). This will be sufficient to demonstrate the principle of adding points to a Points Prim, and how to set the positions, sizes, and colors of the points by defining attributes on the Points Prim. When you create a Points prim the points initially appear as spheres (or diamonds in Blender) to aid visibility, because the points do not inherently have a defined shape.

Points on the stage of points_example.usda

Figure 1:Points on the stage of points_example.usda. The positions, sizes and color of the points can be defined using the UsdGeom.Points schema.

Let’s go ahead and create a new stage and define a Points Prim. We’re going to set the up axis to ‘z’ on this stage and remembering best practice, we’ll make our Points Prim the default prim as this makes it easier for other tools and applications to load the stage correctly:

from pxr import Usd, UsdGeom, Gf, Sdf

# Create a new stage
stage = Usd.Stage.CreateNew("points_example.usda")    

# Set the up-axis to Z 
UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z)    

# Add a points prim
points = UsdGeom.Points.Define(stage, "/Points")    

# Set the default prim to the Points prim
stage.SetDefaultPrim(points.GetPrim())

The Points Prim is currently an empty container. It only exists meaningfully when it is populated with position data for each point. UsdGeom.Points expects a sequence-like data structure for its points attribute, so a list is a natural fit.

Let’s create a list using Gf.Vec3f to define the point’s positions, then set them using the points.GetPointsAttr().Set method. This is what brings the points into existence:

# Define the positions of the points
positions = [
    Gf.Vec3f(1.0, 1.0, 1.0), 
    Gf.Vec3f(-1.0, -1.0, -1.0), 
    Gf.Vec3f(2.0, 0.5, 0.0)
]    

# Set the points attribute on the Points prim
points.GetPointsAttr().Set(positions)    

stage.Save()

Saving the stage and viewing it should show the three points we just created. They will probably appear small on the stage, so you may need to select the Points Prim in the hierarchy and use the hotkey (‘f’ in USDComposer, ‘./Del’ on the numkey pad in Blender) to focus on the points,[f][g] then adjust your viewing position to see them from a similar angle to that in Figure 1.

Note The positions attribute uses default float precision, balancing accuracy and memory efficiency for most 3D applications. For high accuracy (e.g., in scientific simulations), you can use Gf.Vec3d (double precision), but note that using double precision for large numbers of points can significantly increase computation costs.

Next, let’s use the widths attribute to vary the size of each point. This attribute is typically listed as a float array that contains the widths for each point:

# Define sizes using a float array
sizes = [0.5, 0.4, 0.2]    

# Set the points width attribute based on the sizes
points.GetWidthsAttr().Set(sizes)    

Let’s adjust the points’ appearance using the primvars:displayColor attribute to define the color of all the points uniformly.

Note for Blender users Currently, Blender does not interpret primvars:displayColor when visualizing points, so the following three code snippets will not make any perceptible change to your scene. However, we recommend you read and understand these examples, as the final snippet which demonstrates procedural attribute control is applicable to other properties that Blender can interpret, such as width or rotation. Don’t worry, Blender has full support for the point-based demonstrations in the rest of the chapter.

The primvars:displayColor attribute is often used to apply simple color overrides directly to a primitive without the need for complex shading networks or materials. It is particularly useful in scenarios where you want to quickly visualize an object with a solid color. The color is typically defined as RGB values using Gf.Vec3f.

# Get the points prim
points_prim = points.GetPrim()    

# Define the color as green using Gf.Vec3f
uniform_color = [Gf.Vec3f(0.0, 1.0, 0.0)]    

# Get the primvars:displayColor attribute and set it to the defined uniform_color
points_prim.GetAttribute("primvars:displayColor").Set(uniform_color)    

stage.Save()

Changing the display color of a Points Prim applies a single color to all points by default. To assign a unique color to each point, we need to create a new primvar specifically for per-point colors. Remember, a primvar (primitive variable) is a type of attribute that stores data, like colors, where each element (in this case, points) can have a unique value.

In the code above we used the primvars:displayColor attribute to apply a uniform color to all points. Next, let’s create a new primvar named displayColor (distinct from the default one) that allows for per-point color assignment. This new primvar stores an array of Gf.Vec3f values, which represent RGB colors, indicated by Sdf.ValueTypeNames.Color3fArray.

Then let’s set the primvar’s interpolation mode to “varying”, ensuring each point can have a distinct color. Finally, we’ll assign the array of colors to the primvar using primvar.Set(), ensuring each point gets its unique color based on its position in the list.

The following snippet will make the first point red, the second green, and third point blue:

# Red for the first point
# Green for the second point
# Blue for the third point
colors = [
    Gf.Vec3f(1.0, 0.0, 0.0),    
    Gf.Vec3f(0.0, 1.0, 0.0),    
    Gf.Vec3f(0.0, 0.0, 1.0)     
]

# Creates a primvar for per-point colors, storing an array of Color3f values with 'varying' interpolation
primvar = UsdGeom.PrimvarsAPI(points_prim).CreatePrimvar(
    "displayColor", Sdf.ValueTypeNames.Color3fArray, UsdGeom.Tokens.varying
)    

# Assigns the per-point color array to the primvar
primvar.Set(colors)    

stage.Save()

In the examples above we have been hard coding values for position, width and color. However, it is possible to program dynamic variations to any points attribute using several approaches: -Mathematical functions: Adjust values based on formulas (e.g., vary position, width, or color using sinusoidal functions or position-based calculations). -Randomization: Introduce randomness to create variation (e.g., randomizing color, position, or width for a more natural or varied look). -Point attributes: Modify values based on point-specific attributes like velocity or age (e.g., width changes with speed, or color depends on a point’s position). -Time-based animation: Animate values over time (e.g., changing position along a path, color oscillation, or growing/shrinking widths).

These techniques enable you to dynamically modify attributes making Points Prims an extremely flexible type of geometry with many possible uses.

To give just one example of how this might be done using Python, let’s introduce an approach for modifying colors dynamically based on the position of each point. The idea is to transition from blue to red as the Z-value increases, an approach that might be used for scatter plots or terrain maps to indicate elevation.

Let’s dynamically assign colors to each point based on its z coordinate, creating a smooth gradient from blue at the lowest point to red at the highest point. We’ll start by extracting the z coordinates from the list of point positions that we defined earlier. We can extract the z coordinate using ‘pos[2]’ as this represents the zero-indexed third component of the Gf.Vec3f vector, which stores values in the order x, y, z. Then let’s determine the minimum (z_min) and maximum (z_max) z-values, which define the range over which we interpolate colors.

Next, we’ll define an interpolation function that maps each point’s z value to a color. The function normalizes the z-values to a range between 0 and 1, ensuring that the lowest point corresponds to pure blue and the highest point to pure red. The interpolation is linear, meaning the red component increases evenly as the z-value increases, while the blue component decreases accordingly. Green remains at 0.0, maintaining a smooth transition between just blue and red.

Finally, we’ll generate a list of colors by calling the interpolation function and applying it to the displayColor primvar. By setting the new primvar’s interpolation mode to ‘varying’ we ensure that each point can have a distinct color. This results in a gradient where lower points appear blue, higher points appear red, and intermediate points transition smoothly between the two.

# Extract the z-coordinates of the points
z_coordinates = [pos[2] for pos in positions]    

# Find the minimum and maximum z-values
z_min = min(z_coordinates)
z_max = max(z_coordinates)    

# Define a function to interpolate between blue and red based on z-coordinate
def interpolate_color(z):    
    # Normalize z to a range of [0, 1]
    normalized_z = (z - z_min) / (z_max - z_min) if z_max != z_min else 0    

    # Red increases as z increases
    red = normalized_z    

    # Blue decreases as z increases
    blue = 1.0 - normalized_z    

    # Return the computed color as a Gf.Vec3f
    return Gf.Vec3f(red, 0.0, blue)    

# Generate per-point colors using the interpolation function
dynamic_colors = [interpolate_color(z) for z in z_coordinates]    

# Create a primvar to store the dynamic colors
primvar = UsdGeom.PrimvarsAPI(points_prim).CreatePrimvar(
    "displayColor", Sdf.ValueTypeNames.Color3fArray, UsdGeom.Tokens.varying
)    

# Apply the dynamically generated colors to the primvar
primvar.Set(dynamic_colors)    

stage.Save()

Viewing the stage now should show that the highest point, i.e., furthest along the z axis, is red, the lowest point is blue, with the one in between being a mix of both colors.

We have demonstrated just one way to create these kinds of dynamic relationships between point attributes, but there are many other ways and we encourage the reader to explore the huge potential of these methods.

By following these steps above, we have defined a UsdGeom.Points prim on our stage, defined the number of points by allocating their positions, then defined their sizes and colors, before saving the data to a .usda file. We have also touched on the flexibility provided by programmable variation of point attributes. This workflow is the foundation for adding dynamic point-based effects to your 3D scenes using OpenUSD, paving the way for the creation of particle systems, scatter plots, and many other visual effects.

9.2 Converting Point Cloud Datasets to USD

Point clouds are used for capturing geometric details as discrete points in three-dimensional space. Generated by 3D scanning technologies such as LiDAR or depth cameras, point clouds provide a lightweight and efficient alternative to traditional polygonal models, offering high precision without the complexity of meshes. These features make point clouds ideal for applications like Geographic Information Systems (GIS), digital twins, medical imaging, autonomous vehicle simulations, and virtual production. They are useful for: -Efficient Representation of Complex Shapes: Instead of storing dense polygon meshes, point clouds provide a lightweight alternative. -Data Acquisition: Directly captured from the real world using scanning technologies. -Intermediate Format: Used for converting scanned data into other types of 3D asset, such as meshes.

In OpenUSD, the UsdGeom.Points schema is used to store point cloud data, allowing for efficient processing and visualization. By converting a sample dataset, such as the publicly available Stanford Bunny scan, into a .usda file, you can explore how individual points represent the surface geometry of an object. This hands-on approach demonstrates how point clouds can be used to create detailed 3D models, providing a solid foundation for working with point-based data in OpenUSD.

Commonly stored in formats like .PLY, .XYZ, or .LAS, point cloud data can be converted into .usd, .usda, or .usdc files for use in OpenUSD, unlocking their potential in various 3D workflows. In the rest of this section, we’re going to provide a step-by-step guide to converting a point cloud dataset into a .usda.

In order to load and read point cloud data for conversion, we will need to install a new package that is not part of the standard Python library. The package is called Open3D and it is an open-source library for working with 3D data, providing tools for processing, analyzing, and visualizing 3D point clouds, meshes, and other geometric data structures.

If you’re working in a Python virtual environment, you’ll need to install Open3D within that environment to ensure it’s available for your project.

For local virtual environments, first activate your environment as you normally would, then open a terminal (outside the Python interpreter) and run the following command:

# Install Open3D package within your virtual environment
pip install open3d    

This command will initiate the download and installation process, which you can monitor in your terminal.

Note It is common to see some error messages during the installation (e.g., “pip’s dependency resolver does not currently take into account all the packages that are installed.”). These messages indicate dependency or compatibility conflicts within the existing virtual environment. However, these warnings typically do not affect the functionality of Open3D for our purposes so you can ignore them.

Once the installation is complete, verify that Open3D is installed correctly by running:

python -c "import open3d; print(open3d.__version__)"

If this prints a version number (e.g., 0.19.0), Open3D is installed and functional.

Finally, to start the Python interpreter in your terminal, type:

python

If the installation is successful, you will see the version number printed (i.e., 0.19.0). Now you might want to save this notebook with a distinctive name like ‘Install_Open3D’. This can be a template for future sessions where you intend to use Open3D.

Now we are ready to begin scripting in the normal way by setting our working directory and importing modules. Remember to set your working directory to <“your/file/path/to/‘Ch08’”> to ensure that your terminal can access the point cloud file we have provided on our repo. You will find it under “./Assets/bun_zipper_res2.ply”.

We’re going to walk you through the process of converting the “bun_zipper_res2.ply” file into a .usda file. We’ll begin the process by importing the modules we will need and loading the point cloud data from the .ply file. After extracting the relevant data, such as the positions (X, Y, Z) and colors (if available), we can create a new stage and define a UsdGeom.Points primitive. Next, we’ll assign the extracted positions to the point attributes and set any color information to the primvars:displayColor attribute. Finally, we’ll export the data to a .usda file, completing the conversion process.

Let’s start by importing those modules, loading the point cloud, and getting the point data into a list of Gf.Vec3f values which we’ll call ‘points’:

# Import the open3d module for reading point clouds
import open3d as o3d    
from pxr import Usd, UsdGeom, Gf, Sdf

# Load point cloud from a PLY file
pcd = o3d.io.read_point_cloud("<your_path_to_ply_file ex: './Assets/bun_zipper_res2.ply'>")    

# Get points as a list of Gf.Vec3f
points = [Gf.Vec3f(p[0], p[1], p[2]) for p in pcd.points]    

We can extract color information from the .ply data if it exists and create a list of Gf.Vec3f values from it. Although the file we’re converting doesn’t contain color data, we’ll include this step in the process to demonstrate how it’s done. To handle cases where color data is missing, we’ll use an if/else statement to apply a default white color to the points:

if pcd.has_colors():
    # Get colors (if available)
    colors = [Gf.Vec3f(c[0], c[1], c[2]) for c in pcd.colors]    
else:
    # Default to white
    colors = [Gf.Vec3f(1.0, 1.0, 1.0)] * len(points)  

Next, let’s create a .usda stage and define a Points prim to hold the point cloud data. To make transformations easier, we’ll place this Points prim under an Xform. Since we know this particular point cloud is small in scale, we can use this Xform to apply a scale factor later. We’ll name the Xform “ScaledPointCloud” and the Points prim “Bunny”:

# Create the USD stage
stage = Usd.Stage.CreateNew("point_cloud_conversion.usda")    

# Define an Xform to be the parent of the points_prim
xform = UsdGeom.Xform.Define(stage, "/ScaledPointCloud")    

# Define a UsdGeom.Points primitive under the Xform
points_prim = UsdGeom.Points.Define(stage, "/ScaledPointCloud/Bunny")    

With the points_prim created, let’s use the points and colors lists from earlier to set the point positions and colors. Next, we’ll uniformly adjust the point width and apply a 100x scaling factor to the parent Xform:

# Set points based on points list
points_prim.GetPointsAttr().Set(points)    

# Set colors based on colors list
points_prim.GetPrim().GetAttribute("primvars:displayColor").Set(colors)    

# Set width attribute of the points_prim
width = 0.005
points_prim.GetWidthsAttr().Set([width] * len(points))    

# Apply scaling of 100x to the Xform (this scales the whole point cloud)
xform_xform = UsdGeom.XformCommonAPI(xform)
xform_xform.SetScale((100.0, 100.0, 100.0))    

stage.Save()

Now let’s view the stage. To see the bunny easily, select the bunny prim in the hierarchy and use the ‘f’ hotkey to focus on it. You’ll notice that we’ve made the points small to help visualize the points as a cloud. Let’s increase the width of the points so that we can see the form of the bunny better (Figure 2 shows a before/after comparison):

# Adjust the width value
width = 0.01    

# Set width attribute of the points_prim
points_prim.GetWidthsAttr().Set([width] * len(points))    

stage.Save()
Point Cloud representation of a bunny

Figure 2:A Point Cloud representation of a bunny. The model is composed by the point cloud using numerous points to represent the contour of the bunny. Here we’ve shown it with small points to help visualize the point cloud, and with larger points to give the model form.

Now consider a high-resolution model containing millions of points, each with its own unique color. This illustrates how point clouds enable the creation of incredibly complex and detailed models in a highly efficient and scalable manner. They excel at capturing intricate details while maintaining a lightweight and flexible structure, making them ideal for both visualization and processing.

The beauty of points is that they are essentially just a set of coordinates, which makes them lightweight and easily manageable within complex scenes. When we couple that efficiency with the concept of object instancing, we unlock a very effective method for handling large amounts of data. OpenUSD provides the PointInstancer for precisely this, so let’s explore that next.

9.3 Utilizing PointInstancer

Utilizing PointInstancer in OpenUSD is a powerful technique for creating highly efficient and dynamic visual effects by instancing geometry at multiple points in 3D space. A PointInstancer is a specialized USD prim that allows you to define a set of prototype geometries and instance them at locations determined by a collection of points. These can be any geometric primitives or complex assets that you want to distribute over the scene. This method is highly efficient because it reduces memory usage and rendering time by reusing the same geometry multiple times instead of duplicating it. It is particularly useful for creating effects like particle systems, forests with thousands of trees, crowds, or any scenario where numerous similar objects need to be rendered or manipulated simultaneously.

Let’s use PointInstancer to create a small forest. The following snippet will create the stage with the z axis as its up axis, add a ‘World’ xform as its Default Prim, and create a distant light to illuminate the scene. Then it will define the point instancer using UsdGeom.PointInstancer.Define:

from pxr import Usd, UsdGeom, Gf, UsdLux

stage = Usd.Stage.CreateNew("forest.usda")

UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z)

# Define the World prim
world = UsdGeom.Xform.Define(stage, "/World")    

# Set the World prim as the default prim
stage.SetDefaultPrim(world.GetPrim())    

# Define a distant light
distant_light = UsdLux.DistantLight.Define(stage, "/World/DistantLight")    

# Set the rotation of the light
distant_light.AddRotateXYZOp().Set(Gf.Vec3d(60, -25, -35))    
distant_light.CreateIntensityAttr(5000.0)

# Define the point_instancer prim
point_instancer = UsdGeom.PointInstancer.Define(stage, "/World/Instancer")    

The standard term used in OpenUSD for objects that will be instanced by a PointInstancer is prototypes. The convention is to place them under a dedicated scope to keep the stage hierarchy simple and easy to read.

To build our forest scene, let’s define the prototype geometry that the Point Instancer will replicate. We’ll achieve this by referencing two distinct tree models as our prototype geometries. Figure 3 shows the two trees you will find in the directory in our GitHub repo. (https://github.com/learn-usd/learn-usd.github.io/tree/main/code_and_assets/Ch09)

Two types of pine tree prototypes

Figure 3:Two types of pine trees we’ll use as prototypes. These two pine tree models will be instanced throughout the scene to create a realistic forest using the PointInstancer in OpenUSD.

The following snippet will establish a ‘prototypes’ scope to hold our tree assets. Within this scope, we’ll define two XForms, each referencing a tree model via its file path. To prevent visual clutter, as these prototypes would overlap at the World Origin, we can make the ‘prototypes’ scope invisible. This invisibility will not impact the instances we create from these prototypes later on.

Let’s also use this example to demonstrate an efficient way to import multiple assets and assign xforms to each of them. Rather than adding each xform and reference one at a time, we will establish a list of file paths to each asset, then define the pine trees in a loop, reducing redundancy. This approach also makes it easier to extend with more tree models while keeping the code concise:

# Create a scope to group prototype objects  
prototypes = UsdGeom.Scope.Define(stage, "/World/Prototypes")    

# Define paths for prototype assets
pine_paths = {
    "Pine1": './Assets/Pine1.usd',  # Replace with your actual path
    "Pine2": './Assets/Pine2.usd'   # Replace with your actual path
}    

# Iterate over prototype asset paths
for name, path in pine_paths.items():    
    # Create an Xform for each prototype under '/World/Prototypes'
    xform = UsdGeom.Xform.Define(stage, f"/World/Prototypes/{name}")    
    # Add a reference to the external USD file
    xform.GetPrim().GetReferences().AddReference(path)    

# Set prototypes to be invisible
prototypes.GetPrim().GetAttribute("visibility").Set("invisible")    

stage.Save()

To populate our OpenUSD PointInstancer, we’ll need a list of randomly generated 3D positions. Each position will be represented by a Gf.Vec3f object, where the X and Y coordinates are random integers between 0 and 1000, and the Z coordinate is fixed at 0. To achieve this random generation, we’ll use Python’s random module. This module offers a variety of functions for generating random numbers and performing random selections. Specifically, we’ll use it to produce the random integer values for the X and Y coordinates of our point instance positions, where random.randint(0, 1000) generates a random integer between 0 and 1000 (inclusive). In total, we want 20 trees so we will generate that many positions.

Once we have our list of random positions, we’ll assign it to the positions attribute of our Point Instancer by utilizing the GetPositionsAttr() method. This instructs the Point Instancer to create instances of our objects at the newly defined, random locations:

import random

# Generate random positions for point instances, starting with a random X-coordinate
positions = [Gf.Vec3f(
    random.randint(0, 1000),    # Generate a random X-coordinate
    random.randint(0, 1000),    # Generate a random Y-coordinate
    0                          # Fixed Z-coordinate (0 for flat plane)
) for i in range(10 * 2)]       # Create 20 positions

# Set the generated positions to the PointInstancer
point_instancer.GetPositionsAttr().Set(positions)

Finally, instead of hard coding the distribution of the trees, we can enable the PointInstancer to dynamically distribute the two types of tree across the scene based on the defined indices. Let’s create an index list to determine which prototype is used for each instance: each prototype’s index (i) is repeated 10 times, so the first 10 instances are assigned to index 0 (Pine1), and the next 10 to index 1 (Pine2). This ensures that the first 10 instances use Pine1, and the next 10 use Pine2. The GetProtoIndicesAttr().Set(indices) method then applies these indices to the PointInstancer, dictating which prototype is assigned to each instance. Finally, the CreatePrototypesRel().SetTargets(prototype_paths) method establishes a relationship between the PointInstancer and the prototype geometry paths (Pine1 and Pine2):

# Extract names of prototypes (e.g., "Pine1", "Pine2") from the dictionary
prototype_names = list(pine_paths.keys())    

# Create a list of paths for each prototype (e.g., "/World/Prototypes/Pine1")
prototype_paths = [f"/World/Prototypes/{name}" for name in prototype_names]    

# Set the number of instances per prototype
num_instances_per_prototype = 10    

# Create a list of indices, 10 per prototype
indices = [i for i in range(len(prototype_names)) for _ in range(num_instances_per_prototype)]    

# Set the prototype indices to the Point Instancer
point_instancer.GetProtoIndicesAttr().Set(indices)    

# Set the prototype asset references (targets) to the Point Instancer
point_instancer.CreatePrototypesRel().SetTargets(prototype_paths)    

stage.Save()

Viewing the stage now will show the trees positioned randomly within the area determined by the random integer values for the X and Y coordinates. Currently, the instanced trees share a uniform size. However, varied scales are often desirable, such as when instancing pebble models for a beach or human models for a crowd. To introduce this variation, we can randomize the instantiated object’s transforms. Taking scale as an example, we’ll add a few lines of code to create a range of sizes for our instanced trees.

First, we’ll import Python’s math module, and OpenUSD’s Vt module, which provides a set of data types and utilities for handling typed arrays, vectors. Next, we’ll access the PointInstancer at ‘/World/Instancer’ and determine the instance count using its ProtoIndicesAttr. Then, we’ll iterate through each instance, generating a random scaling factor between 0.75 and 1.25. This factor is applied uniformly across the x, y, and z axes.

To conform to OpenUSD’s data type requirements, these scale values are structured as a typed Vec3f array. Each instance’s scale is represented as a Vec3f, and these are collected into a Vt.Vec3fArray—OpenUSD’s container for arrays of Vec3f values. This ensures the data matches the expected format for the PointInstancer’s ScalesAttr() attribute.

Finally, we apply the generated scale variations to the PointInstancer by setting its ScalesAttr() with the constructed Vt.Vec3fArray, effectively transforming each instanced tree with a unique scale:

from pxr import Vt
import math

# Access the PointInstancer
point_instancer = UsdGeom.PointInstancer(stage.GetPrimAtPath("/World/Instancer"))    

# Get the number of instances
num_instances = len(point_instancer.GetProtoIndicesAttr().Get())    

# Generate random scales between 0.75 and 1.25 (uniform across all axes)
scales = [Gf.Vec3f(s, s, s)
          for s in [random.uniform(0.75, 1.25) for _ in range(num_instances)]]    

# Wrap in a typed array for Vec3f
scales_array = Vt.Vec3fArray(scales)    

# Apply the transformations
point_instancer.GetScalesAttr().Set(scales_array)    

stage.Save()

Viewing the stage now will show a forest of 20 randomly positioned and scaled trees created from just two prototypes, as shown in Figure 4. (You may need to reload the stage and zoom out a bit to see the trees. Blender users may need to follow the additional instructions below.)

Forest generated by PointInstancer using two types of trees

Figure 4:The forest generated by a PointInstancer using two types of trees.

Blender Geometry Nodes setting for forest visualization

Figure 5:To view the forest correctly, Blender users will need to select Geometry Nodes from the hierarchy, then open a geometry nodes window to change the Collection Info node setting to ‘Relative’.

With PointInstancer, we’ve seen how to efficiently populate large scenes by instancing geometry across a set of points in 3D space. This approach minimizes memory overhead by using prototype geometries and replicating them at various positions, making it ideal for complex environments.

While we’ve focused on solid objects like trees, points can do more than create geometry. Next, we’ll explore how points can create Curves, and give an example of how to utilize them in a scene.

9.4 Understanding Curves in 3D

In OpenUSD, curves are geometric primitives that represent smooth, continuous shapes defined by control points. They are versatile, used for a range of applications from motion guides and simple arcs to complex forms like hair, grass, ropes, and curved surfaces. Curves efficiently represent complex geometries with fewer data points than polygons, reducing memory usage and improving rendering speed. This makes them ideal for workflows requiring precise control over shape and motion, such as character rigging (e.g., facial expressions, muscle deformation), procedural modeling (e.g., roads, rivers), and special effects (e.g., hair and fur dynamics).

The UsdGeom.BasisCurves schema (which enhances the functionality of UsdGeom.Curves) provides a framework for creating and manipulating curves, supporting types like linear and Bézier with control over resolution, interpolation, and vertex data.

For every UsdGeom.BasisCurves prim, the following attributes are essential:

  1. Points (GetPointsAttr): This is the most fundamental attribute, as it defines the control points that determine the shape of the curve. Without this attribute, the curve has no geometry.

  2. Curve Vertex Counts (GetCurveVertexCountsAttr): This attribute is necessary to specify how many vertices (control points) are in each curve segment. It ensures that the system knows how to process the curve correctly.

  3. Type (GetTypeAttr): The type attribute specifies the degree of the curve (e.g., linear, cubic), which is essential to define how the curve is constructed and interpolated.

  4. Basis (GetBasisAttr): The basis attribute determines the interpolation method (e.g., Bézier, B-spline) and is required to define how the curve will be interpolated between control points.

These attributes are necessary to define a basic curve, as they control the curve’s geometry, how it’s segmented, its degree, and the interpolation method used. Other attributes like normals, tangents, and widths are optional and add additional details or control, but they are not essential for every curve.

Let’s demonstrate how to create and use a basic curve to control camera movement (See Figure 6). First, we’ll reference the forest.usda file we created earlier and animate a camera to sweep sideways past the trees. Before starting, make sure your working directory is set to ‘Ch08’. Then, we’ll begin by importing the necessary modules (including Python’s math module), defining the animation settings, and setting up the default prim for the stage.

Sweeping curve determining camera's motion

Figure 6:Showing the sweeping curve that will determine the camera’s motion

In this example we’ll define the number of frames in the animation in a more dynamic way than we did in earlier chapters. Here we will set the end timecode by computing it from the desired animation duration and fps. We’ll set the fps at 24 and the duration at 5 seconds, however, this method would allow us to alter the duration and have the end time code adjust automatically:

from pxr import Usd, UsdGeom, Gf
import math

stage = Usd.Stage.CreateNew("curves_example.usda")

# Set the frames per second for the animation
fps = 24.0    

# Sets the total duration of the animation in seconds
duration_in_seconds = 5    

# Calculates the total number of frames (samples) based on fps and duration
num_samples = int(fps * duration_in_seconds)    

# Sets the time code rate (fps) for the stage
stage.SetTimeCodesPerSecond(fps)    
stage.SetStartTimeCode(0)

# Sets the end time code of the animation to the last frame
stage.SetEndTimeCode(num_samples - 1)    

# Define the World Xform and set it as default prim
world = UsdGeom.Xform.Define(stage, "/World")
stage.SetDefaultPrim(world.GetPrim())    

With the stage prepared, let’s get our curve set up. The following snippet will use all four attributes necessary to define a basic curve. We will use the UsdGeom.BasisCurves schema to define a new curve at the /World/CurvesExample location. A list of control points is provided to define the shape of the curve, with each point specified as a Gf.Vec3f location. These control points are then applied to the BasisCurves prim using the GetPointsAttr().Set() method. The GetCurveVertexCountsAttr().Set() method sets the number of control points for the curve, as derived from the length of the control points list. The curve is defined as a cubic curve by setting the GetTypeAttr().Set() method to “cubic”, and its interpolation method is set to Bézier using the GetBasisAttr().Set() method with UsdGeom.Tokens.bezier. This configuration ensures that the curve will be a smooth, cubic Bézier curve, making it suitable for applications like animation paths or smooth geometric transitions.

# Defines a BasisCurves prim at '/World/CurvesExample'
basis_curves = UsdGeom.BasisCurves.Define(stage, "/World/CurvesExample")    

# Defines the list of control points for the curve
control_points = [    
    Gf.Vec3f(0, 100, 700),
    Gf.Vec3f(250, 130, 550),
    Gf.Vec3f(500, 160, 525),
    Gf.Vec3f(700, 190, 575),
    Gf.Vec3f(900, 150, 700)
]

# Sets the control points for the BasisCurves prim
basis_curves.GetPointsAttr().Set(control_points)    

# Sets the number of vertices (control points) for the curve
basis_curves.GetCurveVertexCountsAttr().Set([len(control_points)])    

# Specifies that the curve is cubic
basis_curves.GetTypeAttr().Set("cubic")    

# Sets the curve basis to Bézier for interpolation
basis_curves.GetBasisAttr().Set(UsdGeom.Tokens.bezier)    

stage.Save()

Next, let’s create a camera, and give it something to look at by referencing the forest.usda that we created earlier. You’ll remember that when we created the forest, we set the up axis to z, however, the stage we are working on now has y as its up axis. Therefore, we will apply a -90° rotation around the x axis, so ensure that the trees are upright:

# Create a new Xform for the 'Forest' prim under '/World'
forest = UsdGeom.Xform.Define(stage, '/World/Forest')    

# Access the references of the 'Forest' prim
forest_refs = forest.GetPrim().GetReferences()    

# Adds a reference to the 'forest.usda' file
forest_refs.AddReference("./forest.usda")  # Replace with your actual path

# Adds a rotation operation on the X-axis to the 'Forest' prim
forest_rotate_op = forest.AddRotateXOp()    

# Sets the rotation to -90 degrees on the X-axis
forest_rotate_op.Set(-90)    

# Defines a new Camera prim under '/World'
camera = UsdGeom.Camera.Define(stage, "/World/Camera")    

# Makes the camera prim xformable to allow transformations
cam_xform = UsdGeom.Xformable(camera)    

# Sets the camera's focal length to 20
camera.GetFocalLengthAttr().Set(20)    

stage.Save()

Let’s prepare the computation of the camera’s position by defining some functions. The following snippet will define three functions that work together to compute interpolated positions along a Bézier curve.

The lerp function is a basic linear interpolation function, which computes a weighted average between two values (a and b) based on a parameter t. It is the building block for the interpolation process, enabling smooth transitions between control points.

The interpolate_bezier function implements the De Casteljau algorithm, an iterative method for computing Bézier curve points. It progressively reduces the list of control points by linearly interpolating between adjacent points at each iteration. After performing this process multiple times (depending on the number of control points), the function returns the final point on the curve for the given interpolation parameter t.

The interpolate_positions function generates a list of interpolated positions along the curve by calling interpolate_bezier for each point in the desired range of samples. It divides the parameter t by the total number of samples to evenly space the interpolations, creating a smooth sequence of positions along the Bézier curve.

# Define a function that linearly interpolates between a and b based on the parameter t
def lerp(a, b, t):
    return a * (1 - t) + b * t    

# Define a function to iteratively compute Bézier curve points
def interpolate_bezier(points, t):    
    # Copy the list of control points to avoid modifying the original
    pts = points[:]    
    # Number of control points
    n = len(pts)    
    # Iterate over the number of refinement steps
    for r in range(1, n):    
        # Perform linear interpolation on adjacent control points
        for i in range(n - r):    
            pts[i] = Gf.Vec3f(
                # Interpolate the x-component
                lerp(pts[i][0], pts[i+1][0], t),    
                # Interpolate the y-component
                lerp(pts[i][1], pts[i+1][1], t),    
                # Interpolate the z-component
                lerp(pts[i][2], pts[i+1][2], t)    
            )
    # Return the final interpolated point
    return pts[0]    

# Define the interpolate_positions function to generate a list of interpolated positions by calling interpolate_bezier
def interpolate_positions(points, num_samples):    
    return [interpolate_bezier(points, t / (num_samples - 1)) for t in range(num_samples)]

# Sample positions based on control points
sampled_positions = interpolate_positions(control_points, num_samples)    

Next, let’s prepare the camera’s transform operations that will make use of the interpolated positions calculated earlier.

The following snippet sets up the camera’s transformation operations, ensuring that it has both a translation (TranslateOp()) and a rotation (RotateXYZOp()). It first checks if these operations already exist in the camera’s transform stack. If they do, it retrieves them; if not, it creates and adds them. This approach ensures that transformations are applied correctly without duplicating operations.

# Initialize variable to store the camera's translation operation
translate_op = None    

# Initialize variable to store the camera's rotation operation
rotate_op_cam = None    

# Iterate through the existing transformation operations
for op in cam_xform.GetOrderedXformOps():    
    # Store the translation operation if found
    if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
        translate_op = op    
    # Store the rotation operation if found
    elif op.GetOpType() == UsdGeom.XformOp.TypeRotateXYZ:
        rotate_op_cam = op    

# Ensure a translation operation exists, otherwise add one
if translate_op is None:    
    translate_op = cam_xform.AddTranslateOp()

# Ensure a rotation operation exists, otherwise add one
if rotate_op_cam is None:    
    rotate_op_cam = cam_xform.AddRotateXYZOp()

Now we’re ready to animate the camera’s movement along the curve. To give the shot a more professional feel, we’re going to ensure that the camera is always looking at one point in the forest. This requires calculating additional rotations as the camera progresses along the curve. The following snippet first defines a fixed ‘look-at’ target, then calculates the necessary Euler angles (pitch, yaw, and roll) so that the camera stays oriented toward this point. (Note that a +180° value is added to the yaw. This accounts for the fact that OpenUSD cameras look opposite to our computed direction, so it correctly flips the orientation so that the camera’s -Z axis points toward the target). Finally, it applies both position and rotation updates for each frame of the animation, ensuring smooth motion:

# Defines the fixed point in the forest that the camera will always face
look_at_target = Gf.Vec3f(500, 300, -500)    

# Define a function to compute Euler angles (pitch, yaw, roll) so the camera looks at the target
def compute_euler_angles(eye, target):    
    # Compute the unit vector pointing from the camera to the target
    direction = (target - eye).GetNormalized()    
    # Yaw: rotation around Y-axis
    yaw = math.degrees(math.atan2(direction[0], direction[2])) + 180  
    # Pitch: tilt up/down
    pitch = math.degrees(math.asin(direction[1]))  
    # No roll needed
    roll = 0.0  
    return (pitch, yaw, roll)

# Animate the camera along the curve
for frame in range(num_samples):    
    # Retrieve the camera's position at the current frame
    pos = sampled_positions[frame]    
    # Apply the translation to move the camera along the curve
    translate_op.Set(value=pos, time=frame)    
    # Compute the required rotation to keep the camera facing the target
    euler = compute_euler_angles(pos, look_at_target)    
    # Apply the computed rotation for smooth orientation changes
    rotate_op_cam.Set(value=Gf.Vec3f(euler[0], euler[1], euler[2]), time=frame)   

stage.Save()

Now if we view the stage, we will see a smooth camera animation following the Bézier curve we defined. The camera will move along the path while continuously looking at the fixed point in the forest. This setup creates a cinematic sweeping motion, similar to a dolly shot, providing a professional and intentional framing of the scene.

This exercise demonstrates how using a Bézier curve allows for smooth, natural-looking movement with precise control over key positions along the path, and how curves can be used to set up procedural animation for camera motion, making it an excellent tool for virtual cinematography and scene exploration.

It’s also worth noting that the ‘curves_example.usda’ file we just created also highlights OpenUSD’s referencing system in action. This stage references the ‘forest.usda’, which itself references the two pine tree USD files. This layered approach to referencing is a core feature of OpenUSD, allowing for modular and efficient scene construction.

Summary