Fall 2024
Quick facts:
In this assignment you will work with about the most widely used way to represent surfaces for graphics: triangle meshes. A triangle mesh is just a collection of triangles in 3D, but the key thing that makes it a mesh rather than just a bag of triangles is that the triangles are connected to one another to form a seamless surface. The textbook and lecture notes discuss the data structures used for storing and manipulating triangle meshes, and in this assignment we will work with the simplest kind of structure: an indexed triangle mesh.
You will complete this project in groups of 2. Groups members must be in the same section (i.e., 480 or 580). Pair programming is strongly encouraged. Instructions for group setup are given below under Getting Started.
Your job in this assignment is to write a simple mesh generation and processing utility that is capable of building triangle meshes to approximate some simple curved surfaces, and also can add some information to existing meshes. It reads and writes meshes stored in the popular OBJ file format, which we’ve already worked with some in class.
Suppose we wish to generate a mesh for a loaded die as shown below:
4 different views of our loaded die, represented as a triangle mesh.
The cube extends from -1 to 1 in each of the x, y, and z directions. Each face has the following texture applied:
The texture to apply to each face of the cube. In uv-space, the lower left corner of the image is the origin, and the top right corner of the image is the point (1,1). The u direction extends horizontally across the image, the v direction extends vertically.
We could represent this mesh by listing out each triangle. Each triangle would be specified by 3 points in 3D space (i.e., the locations of each of its vertices), 3 points in 2D space (i.e., the uv-space texture coordinates of each of its vertices), and 3 unit-length 3D vectors (i.e., the normals of the surface at each vertex). This is sufficient to represent this mesh, but you may notice that we are repeating some information; for instance, the position of each corner of the cube is shared by at least 3 different triangles. Additionally, each triangle represents a flat surface, and thus the normals at each of its vertices are all equivalent. (Later, we will use triangles to approximate curved surfaces where this is not the case.) We can reduce this repetition by introducing an indexing scheme.
The OBJ file format is one such indexing scheme, and is the one we’ll be using in this assignment. In this format, the positions of all vertices are listed first, and then triangles are specified by providing 3 integers that index into this list of positions. Texture coordinates and normals are abstracted similarly; thus, each triangle is specified by 3 position indices, 3 texture coordinate indices, and 3 normal indices.
Vertex Position: A vertex position is specified on a single line by the letter “v” followed by 3 floating point numbers that represent the x, y, and z coordinates of the point.
Texture Coordinate: A texture coordinate is specified by the letters “vt” followed by 2 floating point numbers that represent the u and v coordinates respectively.
Vertex Normal: A vertex normal is specified by the letters “vn” followed by 3 floating point numbers that represent the normal vector. (The OBJ file format does not require that these be unit length, but we will require it for this assignment.)
Triangle Faces: Triangles are specified with the letter “f” followed by 3 groups of indices. Groups of indices can be a single integer (indexing the vertex position), two integers separated by a “/” (indexing the vertex position and texture coordinate respectively), two integers separated by “//” (indexing the vertex position and vertex normal respectively), and three integers each separated with a “/” (indexing the position, texture coordinates, and normals). Note that indices in the OBJ file format are 1-based! Vertices should be specified in counter-clockwise order, assuming you are looking down at the outer surface of the triangle (i.e., the triangle’s normal is pointed towards you).
Given all of this information, we can specify the loaded die above with the following OBJ file:
v 1.0 -1.0 -1.0
v 1.0 -1.0 1.0
v -1.0 -1.0 1.0
v -1.0 -1.0 -1.0
v 1.0 1.0 -1.0
v 1.0 1.0 1.0
v -1.0 1.0 1.0
v -1.0 1.0 -1.0
vt 1.0 1.0
vt 0.0 1.0
vt 0.0 0.0
vt 1.0 0.0
vn 1.0 0.0 0.0
vn -1.0 0.0 0.0
vn 0.0 1.0 0.0
vn 0.0 -1.0 0.0
vn 0.0 0.0 1.0
vn 0.0 0.0 -1.0
f 1/1/4 2/2/4 3/3/4
f 1/1/4 3/3/4 4/4/4
f 1/4/1 5/1/1 6/2/1
f 1/4/1 6/2/1 2/3/1
f 2/4/5 6/1/5 7/2/5
f 2/4/5 7/2/5 3/3/5
f 3/2/2 7/3/2 8/4/2
f 3/2/2 8/4/2 4/1/2
f 4/2/6 8/3/6 5/4/6
f 4/2/6 5/4/6 1/1/6
f 5/1/3 8/2/3 7/3/3
f 5/1/3 7/3/3 6/4/3
Note that even though there are 12 total triangles, there are only 8 unique locations for each triangle vertex, so there is no point in listing them multiple times. Similarly, each vertex has only 1 of 4 total texture coordinates, and each vertex has 1 of 6 total normals. This scheme allows us to eliminate the redundancy of listing these values once for each triangle. Also note that many vertices can share a position, but each can have different texture coordinates and normals; for instance, each corner of the mesh is shared by at least 3 triangles, but each may face a different direction and therefore may have its own normal.
Before accepting the Github Classroom assignment, make sure you know
who you’re working with on this project. Then, click the Github
Classroom link in the Canvas assignment. The first group member to
accept the invite should create a new group. Name the group with
the two WWU usernames of the group members in alphabetical order,
followed by “a1”, all separated by an underscore. For example,
if I were working with Yudong Liu on this project, one of us would
create a group named liuy2_wehrwes_a1
and we’d both join
that group.
The process for setting up this project is similar to A0, except the
package lives inside a Meshes
directory:
$ cd path/to/repo/Meshes
$ julia --project
julia> ]
(Meshes) pkg> instantiate
pkg> <backspace>
julia> using Revise
julia> using Meshes
julia> gen_mesh("results/cube.obj", "cube") # should already work!
This project uses more Julia features than the prior one did. Here are a few concepts to help keep you oriented:
import
, using
The details here are mostly taken care of, but you’ll notice this
project is structured a bit differently from A0, in that it has one
separate file containing a are submodule of the
top-level Meshes
module. The code in
GfxBase.jl
is included an using include
statement, which works like you’d expect (as if the file’s contents were
included inline). Even after including the file, the code in
Meshes
still doesn’t have automatic access to its
functionality because it is wrapped in its own submodule declaration
(module GfxBase
). There are two ways to get access to names
in other modules:
import MyModule
provides access to qualified names in
MyModule
, as in MyModule.some_function()
using MyModule
exposes all names in
MyModule
that are explicitly export
ed. It also
has the same effect as import
such that all names,
regardless of whether they are export
ed, are accessible
when qualified with the module name.In this case, GfxBase
is defined locally (i.e., in the
same file via the include
), so we prefix it with a dot.
Here, using .GfxBase
imports the submodule and brings
Vec3
and Vec2
into global scope, since they
are export
ed in GfxBase.jl
.
Some functions you may find helpful:
LinearAlgebra
has lots of useful linear algebra-related
functions in addition to the dot
that we used in A0, such
as cross
and normalize
.range
function is there for you if you need a for
loop over something more interesting than start:step:end
.
Check out the documentation for some modes that may be helpful. If you
want to see the contents of a range, you can turn it into a list using
collect
.In this assignment, you will complete three tasks:
This assignment consists of a small number of tasks, each of which is fairly involved. For me, reading and understanding my solution code, especially for cylinder and sphere generation, is not much easier than writing it. For this reason, I strongly recommend pair programming as your means of collaboration for this assignment in particular. If you’re using VS Code, there’s a nice plugin for real-time collaborative editing that, combined with a voice or video chat, should make remote pair programming pretty seamless.
The skeleton code provided defines some useful data structures and includes code to read and write OBJ meshes to and from files. Below is a quick tour of the skeleton files and the functionality provided therein.
We’ve pulled the type aliases for the familiar Vec2
and
Vec3
into a submodule called GfxBase.jl
; other
common definitions will be added to this module in the next project. In
this assignment, the vertices and normals are represented as
Vec3
s, while texture coordinates are represented using
Vec2
s. Recall the basic usage of these types:
v = Vec3(2, 4, 1) # create a Vec3 object containing 2, 4, and 1
v[1] # returns 2.0; recall Julia is 1-indexed, and Vec3 stores Float64
Meshes.jl
is where you’ll be writing all your code.
Already included in this file are:
OBJMesh
and OBJTriangle
.read_obj
and write_obj
gen_mesh
and
est_normals
.cube_mesh
function that creates the cube mesh
I showed in lecture.The mesh data structure mirrors the OBJ file format closely, containing arrays of the four components described above:
positions
is an Array of Vec3
, each of
which is a vertex position.uvs
is an Array of Vec2
, each of which is
a texture coordinate.normals
is an Array of Vec3
, each of which
is a vertex normal.triangles
is an Array of OBJTriangle
s
describing each face in the mesh.mutable struct OBJMesh
positions::Array{Vec3, 1} # all vertex positions
uvs::Array{Vec2, 1} # all texture coordinates
normals::Array{Vec3, 1} # all vertex normals
triangles::Array{OBJTriangle, 1} # the OBJTriangles belonging to the mesh
end
The OBJTriangle
type stores the information for a single
triangle:
mutable struct OBJTriangle
positions::Array{Int, 1} # vertex position indices
uvs::Array{Int, 1} # vertex texture coordinate indices
normals::Array{Int, 1} # normal vector indices
end
Recall that the triangles are defined using indices of positions,
texture coordinates, and normals. Here, these are represented using 3
Arrays of Int
. In this assignment, the
positions
array must have 3 values, and the
uvs
and normals
arrays may have either 0 or 3
values, depending on whether the mesh has texture coordinates and/or
normals defined.
The cube_mesh
function is provided to you as an example
of creating a simple mesh. This creates an OBJMesh representing the cube
described in the previous section. You will write the two analogous
functions below this one; the first generates a cylinder mesh and the
second generates a sphere mesh.
meshgen.jl
This is a very small main program that implements a command-line tool
to expose the functionality in Meshes.jl
. It can be used as
follows:
(1) julia meshgen.jl -g <cube|sphere|cylinder> [-n divisionsU=32] [-m divisonsV=16] <outfile.obj>
(2) julia meshgen.jl -i <infile.obj> <outfile.obj>
In usage (1), a mesh the given geometry is generated and saved in OBJ
format to <outfile.obj>. When generating mesh geometry,
-n specifies the number of divisions in the u direction
(this is ignored by cube)
-m speicifes the number of divisions in the v direction
(this is ignored by cylinder and cube)
In usage (2), a mesh is loaded from <infile.obj>, any existing vertex
normals are discareded, and new vertex normals are estimated based on the
triangle geometry.
The presence of the -i flag overrides -g, -n, and -u; if an input file is
given, ignore all other arguments and perform normal estimation on the mesh
read from the given file, saving the result to <outfile.obj>.
That said, I don’t recommend testing this way. Each
time you launch julia
, you pay runtime cost for startup and
JIT compilation of the code, potentially including its dependencies.
You’ll find it much faster developing inside a
julia
REPL using the gen_mesh
and
est_normals
wrapper methods.
The cube_mesh()
function in Meshes.jl provides an
example of how to create a triangle mesh using the provided data
structures. In this task you will implement the functions
cylinder_mesh(divisionsU)
and
sphere_mesh(divisionsU, divisionsV)
, which compute and
store the vertices, texture coordinates, vertex normals, and triangle
faces and store them in an OBJMesh object.
Cylinders and spheres are different from cubes in that we can’t model them exactly with triangles, because they have curved surfaces. A triangle mesh approximating a smooth surface should have normal vectors stored at the vertices that indicate the direction normal to the exact surface at that point. When generating the shapes, you will generate points that are on the surface, and for each you should also calculate the normal vector that is perpendicular to the surface at that point. Additionally, the generated meshes will have texture coordinates; the details of these are described below for each geometry.
Note that you should take advantage of the indexed storage scheme where possible: for full credit, you should make sure that if two triangles meet at a position in space, that position should only be specified once in the resulting OBJ file. Similarly, if two vertices have both the same location and texture coordinates, those texture coordinates should only be specified once; if they share the same location and normal, then the normal should only be specified once. (Duplicate normals and texture coordinates are allowed, but only as long as they are used at different places on the mesh.)
When the method sphere_mesh(divisionsU, divisionsV)
is
correctly implemented, you will get an OBJ file of a sphere (32x16) by
running meshgen.jl
with arguments
-g sphere -n 32 -m 16 results/sphere.obj
, or calling
gen_mesh("results/sphere.obj", "sphere", 32, 16)
. You
should use the mesh
viewer tool that we’ve used in class. Pro tip: you
can drag and drop OBJ files and texture image files onto the browser
window to load them more quickly than using the file chooser buttons
(see below under Testing your implementation for details of this
tool).
An example result is shown below:
The cylinder has radius 1 and height 2 and is centered at the origin; its longitudinal axis is aligned with the y-axis. It is tessellated with n divisions arranged radially around the outer surface. The two ends of the cylinder are closed by disc-shaped caps parallel to the *x**z*-plane.
The vertices around the rims of the cylinder share 2 or more normals and texture coordinates, though they share the same positions. Each cap consists of n vertices arranged in a circle as well as a single point where the cap intersects the y-axis. This point is incorporated into each triangle that makes up the cap.
Along the cylinder’s shell (i.e., excluding its caps), texture
coordinates in the u dimension run from 0 to 1 in a
counterclockwise direction as viewed from the + y direction. There is a texture
seam (where u = 0 meets u = 1) along vertices that have a
z coordinate of −1. Multiple
texture coordinates occur at the same position in space to allow this
discontinuity. Coordinates run from 0 to 0.5 in the v
dimension, increasing in the + y direction. The texture
coordinates for the two caps are circles inscribed in the upper-left
(for the − y cap) and
upper-right (for the + y cap)
quadrants of the unit square in the uv-plane, with the + u direction corresponding to the
+ x direction in 3D space,
and the + v direction
corresponding to the − z
direction for the top cap, and the + z direction for the bottom cap.
The -m
flag (divisionsV
) is ignored for the
cylinder.
Specs illustration for the cylinder (for the case of n = 32)
The sphere has radius 1 and is centered at the origin in 3D coordinates. It is tessellated in latitude-longitude fashion, with n divisions around the equator and m divisions from pole to pole along each line of longitude. Note that m divisions requires m + 1 vertices from pole to pole. The North pole is at (0,1,0), the South pole at (0,−1,0), and points on the Greenwich meridian have coordinates (0,y,z) with z > 0.
The mesh is generated with vertex normals that are normal to the exact sphere, and with texture coordinates (u,v) where u depends only on longitude, with u = 0 at longitude 180 degrees West and u = 1 at 180 degrees East, and where v depends only on latitude, with v = 0 at the South Pole and v = 1 at the North pole. Each quadrilateral formed by two adjacent longitude lines and two adjacent latitude lines is divided on the diagonal to form two triangles.
The texture coordinates along the 180th meridian are duplicated: one texture coordinate has u = 0 and the other has u = 1, to enable the discontinuity required for correct wrapping of a image texture across the seam. The pole has n different texture coordinates, to enable nearly-appropriate texture in the row of triangles adjacent to the pole. Every other triangle around each pole becomes degenerate, i.e., collapses into a line; these degenerate triangles should be omitted.
Specs illustration for the sphere (for the case of n = 32 and m = 16)
If you have your sphere and cylinder correctly implemented, you can attach texture to your mesh in the viewer. Here is an example result for cylinder and sphere:
Recall the second use case for the meshgen
program:
julia meshgen.jl -i <infile.obj> <outfile.obj>
A corresponding wrapper function exists, which you can call as:
est_normals("outfile.obj", "infile.obj") # (note the argument order)
In this mode, the user provides an input OBJ mesh file, which the program reads in. The mesh is assumed to have no normals (if normals are included in the input file, they are ignored/discarded). The program then generates approximate normals at each vertex as described below, and writes the resulting mesh to the user-provided output file.
Since the original surface which the mesh approximates is forgotten (if there even was one), we need some way to make up plausible normals. There are a number of ways to do this, and we’ll use a simple one for this assignment: the normal at a vertex is the average of the geometric normals of the triangles that share this vertex.
Your first thought might be to do this as a loop over vertices, with an inner loop over the triangles that share that vertex:
for each vertex v
normal[v] = (0,0,0)
for each triangle t around v
normal[v] += normal of triangle
normal[v].normalize()
With the appropriate data structures, this is possible, but in our case there’s no efficient way to do the inner loop: our data structure tells us what vertices belong to a triangle, but the only way to find triangles that belong to a vertex is to search through the whole list of triangles. This is possible but would be quadratic in the mesh size, which is bad news for large meshes.
However, it’s simple to do it with the loops interchanged:
for each vertex v
normal[v] = (0,0,0)
for each triangle t
for each vertex v around t
normal[v] += normal of triangle t
for each vertex v
normal[v].normalize()
This way the inner loop can efficiently visit just the necessary vertices. Nifty!
Since your program just writes a file full of inscrutable numbers, you’ll be using the mesh viewer to assess your output. The viewer optionally shows the wireframe structure and vertex normals of the mesh. The slider controls the length of the blue normal bars - this can be useful to make normals appropriately sized to your mesh size and the zoom level you’re looking at. Coordinate axes can also be displayed; + x is red, + y is green, and + z is blue. If triangle faces are wound in the wrong direction, they appear yellow and will not be textured.
There are a number of other programs you can use to visualize meshes, for example Blender, ObjViewer, MeshLab, or p3d.in. Be careful, though! Some of these programs add normals to your meshes if they don’t already exist, or if they’re malformed.
To help you verify that your output is correct, you are provided
reference meshes produced using default settings the sphere and cylinder
geometry (sphere-reference.obj
and
cylinder-reference.obj
). Be sure to compare your output to
the reference one with textures, paying close attention to tricky areas
such as the poles of the sphere.
Also included are two meshes without normals (a bunny and a horse), as well as examples of what the meshes should look like when normals have been estimated. Be patient with the horse example, as it contains many triangles and may take a while to process.
In addition to the completed code, please generate the following
outputs and place them in your repo’s results
directory:
cylinder.obj
, a cylinder generated with default
settingscylinder_5.obj
, a cylinder generated with
-n 5
sphere.obj
, a sphere generated with default
settingssphere_5.obj
, a sphere generated with -n 5
and -m 16
(the default m
)sphere_4_5.obj
, a sphere generated with
-n 4
and -m 5
bunny.obj
, the output of estimating normals for
data/bunny-nonorms.obj
Fill out the A1 Survey on Canvas to let me know that you’ve submitted.
To submit, simply push your finished code and results to Github sometime before the deadline, then fill out the A1 Survey on Canvas to let me know that you’ve submitted. Please note that each partner needs to fill in the survey, and the assignment will not be considered submitted until both partners have done so.
The following extensions are grouped into two categories. Category 1 items should be somewhat easier than Category 2 items.
480 students may complete any of the following extensions for extra credit.
580 students must complete either
You are also welcome to propose your own extensions, but run them by me first to confirm that you will get credit for them.
In all cases, you must thoroughly document the extensions you have completed in a readme file included in your repository. Your readme can be a txt file, pdf file, or a webpage. Include a description of where to find the code that implements the extension, and instructions for how to run and test your code. If applicable, include any test data in your repository that will help convince me that your feature works.
A torus is a
doughnut-shaped surface defined by a major radius, affecting the size of
the hole, and a minor radius, affecting the thickness of the ring. Add
torus to the list of geometries generated by your program. Your code
should create a torus with major radius 1 and minor radius r
(controlled by an additional -r
flag with a default of
0.25). Its u coordinates are like the sphere, and the
v coordinate runs from 0 to 1 around the inside of the torus,
with the direction arranged so that the texture is right-reading from
the outside (i.e., the texture is not flipped when mapped to the
surface). Like the sphere, it has a seam on the −z half of the
yz-plane, and it has a similar seam around the inner surface of
the doughnut hole; vertices along each seam share a pair of texture
coordinates, and single vertex, at the position (0,0,r−1) where
the seams meet, shares 4 texture coordinates.
Write a function that compares two OBJMesh
objects
to determine whether they are equivalent. Use the following definition
of equivalency:
m1
, a face exists in m2
with the same vertex positions, texture coordinates, and normals (if
applicable)m2
, a face exists in m1
with the same vertex positions, texture coordinates, and normals (if
applicable)In addition to the two meshes, your function should take two
arguments, verbose
and epsilon
. If
verbose
is true, print a message describing each found
violation of the above properties. Consider floating-point values equal
if they are within epsilon
of each other.
The OBJ data format allows the reuse of positions, vertex
coordinates, and normals through indexing. However, this reuse is not
enforced. Extend your meshgen
utility with an option to
take in an uncompressed OBJ file and remove duplicate positions, texture
coordinates, and normals, adjusting vertex indices as
necessary.
The OBJ specification allows arbitrary polygons, not just triangles. Make a separate version of the file-reading code that can load meshes with arbitrary polygons. That is, when reading the mesh, you should take all faces that have 4 or more vertices and split them into triangles before adding the resulting triangles to the OBJMesh structure. For optional extra bonus goodness, investigate how to optimally triangulate each polygonal face. A common definition of optimality is to minimize the total perimeter of all triangles - this is guided by a practical desire to avoid long, thin triangles, which often end up causing artifacts in rendering and processing.
Many algorithms that operate on triangle meshes assume that the mesh is well-behaved; that is, it has some structure that is more predictable than just a soup of arbitrary triangles. One class of well-behaved mesh is known as a manifold mesh. Manifold meshes satisfy the following properties:
Extend your meshgen
utility to check if an input
triangle mesh is manifold or not. As a hint, it may be useful to create
a new data structure which, given the index of a triangle, allows you to
quickly find that triangle’s neighboring faces.
You may have noticed that for the sphere, the triangles get compressed near the poles. Provide an alternative tessellation approach to create a more even mesh; see icosphere as an example. This feature should be added as another geometry option in addition to the sphere specification described above.
Here are some resources and tips that you may find helpful in completing this project.
Points are earned for correctness and deducted for defficiencies in clarity or efficiency.xc
Deductions: