teaching machines

CS 488: Lecture 7 – Geometric Modeling

February 15, 2021 by . Filed under graphics-3d, lectures, spring-2021.

Dear students:

Our goal today is to generate a few models using code. The algorithmic description of shapes is sometimes called geometric modeling. Not all models can be easily generated via algorithms, and we are likely to build most of our models in a 3D modeling program rather than in code. However, programmatically generating shapes is instructive. It helps us develop an intimacy with the structure of a model. Today we will become intimate with a plane, a sphere, and a cylinder.

Plane

We’ve seen how to model a single quadrilateral with two separate triangles or four indexed vertices. Quadrilaterals are useful for modeling walls or floors. But what if we want a surface that isn’t so flat? We need to decimate that quadrilateral into a quilt of quadrilaterals. We’ll call this shape a plane. A mathematical plane goes on forever. Our plane will be bounded.

The table or grid of points of the plane can be generated with a couple of loops:

function generatePlane(width, height, nlatitudes, nlongitudes)
  positions = []

  for ilongitude through nlongitudes
    x = ilongitude / (nlongitudes - 1) * width - width * 0.5
    for ilatitude through nlatitudes
      y = ilatitude / (nlatitudes - 1) * height - height * 0.5
      positions.push(x, y, 0)

  return {positions}
}

When we upload these to the GPU and render them as points, we see the intersection points of our grid. To fill in the pixels between these intersections, we have two choices: enumerate separate positions for each triangle, or use indexed geometry. Given that a single vertex may be shared by as many as six triangles, indexed geometry is a better choice.

Let’s externalize our thinking about this grid with this diagram:

Given our algorithm above, what is the index of the vertex whose ilatitude is 0 and ilongitude is 0? That’s vertex 0. How about the one whose ilatitude is 1 and ilongitude is 0? That’s vertex 1. How about the one whose ilatitude is 1 and whose ilongitude is 0? That’s vertex 4.

In general, we can determine the index through this formula:

index = ilongitude * nlatitudes + ilatitude

Here’s the complete list of indices:

We iterate through this table using a fencepost algorithm and issue index-triplets.

function generatePlane(width, height, nlatitudes, nlongitudes)
  // generate positions

  indices = []
  for ilongitude to nlongitudes - 1
    iNextLongitude = ilongitude + 1

    for ilatitude to nlatitudes - 1
      iNextLatitude = ilatitude + 1

      // Bottom-left triangle
      indices.push([
        ilongitude * nlatitudes + ilatitude,
        iNextLongitude * nlatitudes + ilatitude,
        ilongitude * nlatitudes + iNextLatitude,
      ])

      // Top-right triangle
      indices.push([
        iNextLongitude * nlatitudes + ilatitude,
        iNextLongitude * nlatitudes + iNextLatitude,
        ilongitude * nlatitudes + iNextLatitude,
      ])

  return {positions, indices}

When we render this, our grid is nicely filled. But it doesn’t look any different than a simple quadrilateral. We add some extra details to the internal vertices, like random colors:

function generatePlane(width, height, nlatitudes, nlongitudes)
  positions = []
  colors = []

  for ilongitude in nlongitudes
    x = ilongitude / nlongitudes * width - width * 0.5
    for ilatitude in nlatitudes
      y = ilatitude / nlatitudes * height - height * 0.5
      positions.push(x, y, 0)
      colors.push(random(), random(), random())

  // generate faces

  return {positions, colors, faces}

Perturbing the positions is also possible. That’s how we’d generate terrain, which seems to be a rite of passage for every budding graphics developer.

To shade the plane, we need a normal at each vertex. Since the plane is flat, the normals all point the same direction. It feels silly to make this uniform normal an attribute, but we do it anyway, to prepare for less flat geometry:

function generatePlane(width, height, nlatitudes, nlongitudes)
  positions = []
  colors = []
  normals = []

  for ilongitude in nlongitudes
    x = ilongitude / nlongitudes * width - width * 0.5
    for ilatitude in nlatitudes
      y = ilatitude / nlatitudes * height - height * 0.5
      positions.push(x, y, 0)
      colors.push(random(), random(), random())
      normals.push(0, 0, 1)

  // generate faces

  return {positions, colors, normals, faces}

Sphere

Generating a sphere is surprisingly like generating a plane. They are shaped very differently, of course, but their faces are not connected differently. We can use very similar code to generate the indices. We start with the positions.

To calculate the positions, we could look up the parametric equations for a sphere, but we’re going to take a different approach that I think feels less like magic. We first sample the right half of a circle in the z = 0 plane, starting at -90 degrees at the bottom of the circle and working our way up to 90 degrees at the top. This gives us a set of seed points at various latitudes:

function generateSphere(nlatitudes, nlongitudes)
  seeds = []
  for ilatitude to nlatitudes
    radians = ilatitude / (nlatitudes - 1) * pi - pi / 2
    seeds.append([cos radians, sin radians, 0])
  // ...

Then we spin these seed points around the y-axis to produce lines of longitude:

function generateSphere(nlatitudes, nlongitudes)
  // generate seeds

  positions = []
  normals = []
  for ilongitude to nlongitudes
    degrees = ilongitude / nlongitudes * 360
    rotation = Matrix4.rotateY(degrees)
    for ilatitude to nlatitudes
      position = rotation * seeds[ilatitude]
      positions.push(position)
      normals.push(position)

  return positions

When we render these positions as points, we see what looks like a globe. We can connect these points together exactly as we did for the plane:

function generateSphere(nlatitudes, nlongitudes)
  // generate positions

  indices = []
  for ilongitude to nlongitudes - 1
    iNextLongitude = ilongitude + 1

    for ilatitude to nlatitudes - 1
      iNextLatitude = ilatitude + 1

      // Bottom-left triangle
      indices.push([
        ilongitude * nlatitudes + ilatitude,
        ilongitude * nlatitudes + iNextLatitude,
        iNextLongitude * nlatitudes + ilatitude,
      ])

      // Top-right triangle
      indices.push([
        ilongitude * nlatitudes + iNextLatitude,
        iNextLongitude * nlatitudes + iNextLatitude,
        iNextLongitude * nlatitudes + ilatitude,
      ])

  return {positions, indices}

However, we want the last line of longitude to wrap around connect back to the first. A little modulus can help make that happen:

for ilongitude to nlongitudes
  iNextLongitude = (ilongitude + 1) % nlongitudes
  ...

We add these positions and indices to our vertex buffer objects and we have a filled sphere.

Cylinder

A plane is flat. A sphere is round. We now generate a shape that’s both round and flat: a cylinder. Cylinders exhibit the same connectivity as planes and spheres—save for the end caps. Let’s start by generating their positions with a little trigonometry. We’ll walk around the circle again:

function generateCylinder(radius, height, nlatitudes, nlongitudes)
  for ilongitude to nlongitudes
    radians = ilongitude / nlongitudes * 2 * pi
    x = radius * cos radians
    z = radius * sin radians

As with the plane and sphere, we walk along each line of longitude at regular latitudes:

function generateCylinder(radius, height, nlatitudes, nlongitudes)
  positions = []
  for ilongitude to nlongitudes
    radians = ilongitude / nlongitudes * 2 * pi
    x = radius * cos radians
    z = radius * sin radians
    for ilatitude to nlatitudes
      y = ilatitude / (nlatitudes - 1) * height - 0.5 * height
      positions.push(x, y, z)
      normals.push(cos radians, 0, sin radians) 

  // generate faces

  return {positions, normals, indices}

As with the sphere, the normals point outward. We zero out the y-coordinate since the normals don’t point up or down as we traverse up the cylinder.

To add end caps, we issue vertices independent of the cylinder wall. The vertices on the end caps have different normals than the ones on the wall, just as we saw with the vertices of the cube. We add the end caps one at a time, sampling the perimeter of each circle. To triangulate this circle, we include a vertex in the middle of the cap. The triangles all converge on this center point like slices of pizza:

function generateCylinder(radius, height, nlatitudes, nlongitudes)
  // generate wall positions
  // generate wall faces

  // Generate top cap.
  icenter = positions.length / 3
  positions.push(0, height * 0.5, 0)
  for ilongitude to nlongitudes
    radians = ilongitude / nlongitudes * 2 * pi
    x = radius * cos radians
    z = radius * sin radians
    positions.push(x, height * 0.5, z)
    indices.push(icenter, icenter + ilongitude, icenter + (ilongitude + 1) % nlongitudes)

  // Generate bottom cap.
  icenter = positions.length / 3
  for ilongitude to nlongitudes
    radians = ilongitude / nlongitudes * 2 * pi
    x = radius * cos radians
    z = radius * sin radians
    positions.push(x, -height * 0.5, z)
    indices.push(icenter, icenter + ilongitude, icenter + (ilongitude + 1) % nlongitudes)

  return {positions, normals, indices}

Horizon

We’ve picked off shapes whose normals are relatively easy to calculate. Next time we’ll look at how to compute normals for arbitrary shapes using just the geometry itself. This will give us the power to render non-algorithmic models.

TODO

Here’s your TODO list:

See you next time.

Sincerely,

P.S. It’s time for a haiku!

Vote for the best math
Geometry beats them all
‘Cuz the eyes have it