CSCI 480 / 580 - Assignment 1: Mesh

Scott Wehrwein

Fall 2024

Quick facts:

Introduction

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.

Overview

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:

die 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: 1

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.

Review - The OBJ File Format

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.

Getting Started

Github Classroom

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.

Julia Setup

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!
Julia Langauge

This project uses more Julia features than the prior one did. Here are a few concepts to help keep you oriented:

Packages, Modules, Submodules, 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:

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 exported in GfxBase.jl.

Miscellaneous

Some functions you may find helpful:

Tasks - Overview

In this assignment, you will complete three tasks:

  1. Implement a function that generates a triangle mesh approximating a cylinder.
  2. Implement a function that generates a triangle mesh approximating a sphere.
  3. Implement a function that estimates vertex normals of an existing mesh based on the geometry of the triangles surrounding each vertex.
  4. Graduate students will complete one or two extensions; see the Extensions section below.

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.

Skeleton Code

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.

GfxBase.jl

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 Vec3s, while texture coordinates are represented using Vec2s. 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

Meshes.jl is where you’ll be writing all your code. Already included in this file are:

Mesh Data Structures

The mesh data structure mirrors the OBJ file format closely, containing arrays of the four components described above:

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.

cube_mesh()

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.

Tasks - Detail

1. Cylinder and Sphere Meshes

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: sphere

Cylinder Geometry - Details

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.

cylinder schematic Specs illustration for the cylinder (for the case of n = 32)

Sphere Geometry - Details

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.

sphere schematic 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:

3. Computing Vertex Normals

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!

Testing

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.

Submission

In addition to the completed code, please generate the following outputs and place them in your repo’s results directory:

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.

Extensions

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.

Category 1

Category 2

Resources and Tips

Here are some resources and tips that you may find helpful in completing this project.

Rubric

Points are earned for correctness and deducted for defficiencies in clarity or efficiency.xc

Deductions: