Mesh I/O and manipulation¶
Reading a mesh from disk¶
Mitsuba provides an abstract Shape class to handle all geometric shapes. For triangle meshes, it has a concrete class Mesh that is further extended by 3 plugins which can load meshes directly from a file:
OBJ: handles meshes containing triangles and quadrilaterals from Wavefront OBJ files
PLY: handles Stanford PLY format meshes (both the ASCII and binary format)
Serialized: Mitsuba 0.6 serialized mesh format.
As any other Mitsuba object, we can use the load_dict function to instantiate one of these three plugins. They each have their own specific input parameters which you’ll find in their respective documentation, but here are the input parameters they all share:
filename: filename of the mesh file that should be loaded
face_normals: when set to true, any existing or computed vertex normals are discarded and face normals will instead be used during rendering. This gives the rendered object a faceted appearance.
to_world: specifies an linear object-to-world transformation.
Let’s now load a mesh and start playing with it.
[2]:
bunny = mi.load_dict({
"type": "ply",
"filename": "../scenes/meshes/bunny.ply",
"face_normals": False,
"to_world": mi.ScalarTransform4f().rotate([0, 0, 1], angle=10),
})
print(bunny)
PLYMesh[
name = "bunny.ply",
bbox = BoundingBox3f[
min = [-0.0779344, -0.0611472, -0.0738726],
max = [0.0874787, 0.0992267, 0.0468007]
],
vertex_count = 35947,
vertices = [843 KiB of vertex data],
face_count = 69451,
faces = [814 KiB of face data],
face_normals = 0
]
The string representation of a Mesh object gives an overview of its size. If you wish to access some of these values, they are available through the following methods Shape.bbox(), Mesh.vertex_count(), Mesh.face_count().
Procedural mesh¶
By directly using the Mesh class, it is also possible to procedurally create a mesh. To illustrate this, we will build a spanning triangle disk and give it a wavy fringe. The exact details of how the vertex positions and face indices are generated are not important for the purposes of this guide. However, we do leave them as comments in the code.
[3]:
# Wavy disk construction
#
# Let N define the total number of vertices, the first N-1 vertices will compose
# the fringe of the disk, while the last vertex should be placed at the center.
# The first N-1 vertices must have their height modified such that they oscillate
# with some given frequency and amplitude. To compute the face indices, we define
# the first vertex of every face to be the vertex at the center (idx=N-1) and the
# other two can be assigned sequentially (modulo N-2).
# Disk with a wavy fringe parameters
N = 100
frequency = 12.0
amplitude = 0.4
# Generate the vertex positions. dr.column_stack() interleaves the
# component arrays into a tensor with one vertex per row.
theta = dr.linspace(mi.Float, 0.0, dr.two_pi, N)
x, y = dr.sincos(theta)
z = amplitude * dr.sin(theta * frequency)
vertex_pos = dr.column_stack([x, y, z])
# Move the last vertex to the center
vertex_pos[N - 1, :] = 0.0
# Generate the face indices, likewise one face per row
idx = dr.arange(mi.UInt32, N - 1)
center = dr.full(mi.UInt32, N - 1, shape=N - 1)
face_indices = dr.column_stack([center, (idx + 1) % (N - 2), idx % (N - 2)])
A Mesh is created in a single call that takes the face indices as an (F, 3) integer tensor and the vertex positions as a (P, 3) tensor, optionally followed by normals, texture coordinates, and index maps. Construction is one-shot; all later edits go through the parameter interface shown below, which can also resize the mesh (e.g. write new faces and positions tensors and call update() to remesh a shape).
These tensors store one vertex (or face) per row, which is also how the data was laid out above. If yours instead lives in a Dr.Jit vector type such as Point3f, which keeps every component in a separate array, pass flip_axes=True to the tensor constructor to reorder it.
[ ]:
# Create the mesh, providing its data as field tensors
mesh = mi.Mesh("wavydisk", faces=face_indices, positions=vertex_pos)
The mesh state is exposed through the traverse() mechanism, and all later edits go through it. Each entry is a row-major tensor: positions is (P, 3), normals is (N, 3), texcoords is (V, 2), and the connectivity is available as an (F, 3) tensor under faces. Once a batch of writes is complete, a call to SceneParameters.update() validates it and refreshes dependent state. For instance, modifying the vertex positions recomputes both the bounding box and the
vertex normals. (For affine transformations of the whole mesh, the Mesh.transform() method is a convenient alternative that also maps the shading normals correctly.)
[5]:
mesh_params = mi.traverse(mesh)
mesh_params["positions"] = 1.1 * mesh_params["positions"]
print(mesh_params.update())
[(Mesh[
name = "wavydisk",
bbox = BoundingBox3f[
min = [-0.999874, -0.999497, -0.399547],
max = [0.999874, 1, 0.399547]
],
vertex_count = 100,
vertices = [1.17 KiB of vertex data],
face_count = 99,
faces = [1.16 KiB of face data],
face_normals = 0
], {'vertex_positions', 'faces'})]
Structural edits work the same way: a batch of writes may change the number of faces or vertices, as long as the batch as a whole describes a consistent mesh. Let’s cut slits into the disk by keeping only every second face. Since this write changes the topology, the shading normals regenerate automatically.
[ ]:
mesh_params["faces"] = mesh_params["faces"][::2]
mesh_params.update();
And now let’s take a look at our new mesh!
[6]:
scene = mi.load_dict({
"type": "scene",
"integrator": {"type": "path"},
"light": {"type": "constant"},
"sensor": {
"type": "perspective",
"to_world": mi.ScalarTransform4f().look_at(
origin=[0, -5, 5], target=[0, 0, 0], up=[0, 0, 1]
),
},
"wavydisk": mesh,
})
img = mi.render(scene)
from matplotlib import pyplot as plt
plt.axis("off")
plt.imshow(mi.util.convert_to_bitmap(img));
Writing a mesh to disk¶
No matter how a Mesh object was loaded or built, it can always be exported to a PLY file format using the Mesh.write_ply() method. No other file formats are currently supported.
🗒 Note
Any mesh attribute (see below) that is attached to the object at the time when Mesh.write_ply() is called will be written to output file as a property. Mitsuba therefore allows you to create complex procedural properties for your meshes and export them to be used in some other context entirely.
[7]:
mesh.write_ply("wavydisk.ply")
Adding and editing attributes¶
Meshes in Mitsuba can have additional attributes per face or per vertex. Each attribute is either one or several floating point numbers, no other types are supported.
The Mesh.add_attribute() method lets you define new attributes by giving them a name and a (rows, channels) tensor of initial values. The attribute name must be prefixed with either vertex_ or face_, as this defines whether the attribute is defined for each face or for each vertex. For this example, we will be adding a RGB color to each vertex.
Moreover, Mitsuba 3 has a mesh attribute texture plugin that conviently allows you to visualize attributes.
[8]:
mesh = mi.load_dict({
"type": "ply",
"filename": "wavydisk.ply",
"bsdf": {
"type": "diffuse",
"reflectance": {
"type": "mesh_attribute",
"name": "vertex_color", # This will be used to visualize our attribute
},
},
})
# Needs to start with vertex_ or face_
mesh.add_attribute(
"vertex_color", dr.zeros(mi.TensorXf, (mesh.vertex_count(), 3))
) # Add 3 floats per vertex (initialized at 0)
Once an attribute is created it can still be modified using the traverse() mechanism. As shown below, the attribute is exposed as a (vertex_count, channels) tensor under a key corresponding to the attribute’s name.
[9]:
mesh_params = mi.traverse(mesh)
mesh_params
[9]:
SceneParameters[
---------------------------------------------------------------------------------
Name Flags Type Parent
---------------------------------------------------------------------------------
bsdf.reflectance.scale float MeshAttribute
silhouette_sampling_weight float PLYMesh
faces UInt PLYMesh
vertex_positions ∂, D Float PLYMesh
vertex_normals ∂, D Float PLYMesh
vertex_texcoords ∂ Float PLYMesh
vertex_color ∂ Float PLYMesh
]
We can now easily change the values of the attribute using some simple Dr.Jit arithmetic.
[10]:
N = mesh.vertex_count()
vertex_colors = dr.zeros(mi.TensorXf, (N, 3))
vertex_colors[:N - 1, 0] = 1 # Fringe is red
vertex_colors[N - 1, 2] = 1 # Center is blue
mesh_params["vertex_color"] = vertex_colors
mesh_params.update()
[10]:
[(PLYMesh[
name = "wavydisk.ply",
bbox = BoundingBox3f[
min = [-0.999874, -0.999497, -0.399547],
max = [0.999874, 1, 0.399547]
],
vertex_count = 100,
vertices = [3.52 KiB of vertex data],
face_count = 99,
faces = [1.16 KiB of face data],
face_normals = 0,
mesh attributes = [
vertex_color: 3 floats
]
],
{'vertex_color'})]
And visualize the result!
[11]:
scene = mi.load_dict(
{
"type": "scene",
"integrator": {"type": "path"},
"light": {"type": "constant"},
"sensor": {
"type": "perspective",
"to_world": mi.ScalarTransform4f().look_at(
origin=[0, -5, 5], target=[0, 0, 0], up=[0, 0, 1]
),
},
"wavydisk": mesh,
}
)
img = mi.render(scene)
plt.axis("off")
plt.imshow(mi.util.convert_to_bitmap(img));