\( \newcommand{\vecIII}[3]{\left[\begin{array}{c} #1\\\\#2\\\\#3 \end{array}\right]} \newcommand{\vecIV}[4]{\left[\begin{array}{c} #1\\\\#2\\\\#3\\\\#4 \end{array}\right]} \newcommand{\Choose}[2]{ { { #1 }\choose{ #2 } } } \newcommand{\vecII}[2]{\left[\begin{array}{c} #1\\\\#2 \end{array}\right]} \newcommand{\vecIII}[3]{\left[\begin{array}{c} #1\\\\#2\\\\#3 \end{array}\right]} \newcommand{\vecIV}[4]{\left[\begin{array}{c} #1\\\\#2\\\\#3\\\\#4 \end{array}\right]} \newcommand{\matIIxII}[4]{\left[ \begin{array}{cc} #1 & #2 \\\\ #3 & #4 \end{array}\right]} \newcommand{\matIIIxIII}[9]{\left[ \begin{array}{ccc} #1 & #2 & #3 \\\\ #4 & #5 & #6 \\\\ #7 & #8 & #9 \end{array}\right]} \)
Notes

Reading: Bézier Surfaces and Other 3D Shapes

This reading builds on the prior reading about Bézier curves, but now we look at surfaces: 2D "sheets". These are essentially quadrilaterals with wiggles, the same way that our curves were line segments with wiggles.

We'll then transition to using a 1D curve, including Bézier curves, as the silhouette of lathe geometry.

Then, we'll look at using a 1D curve, including Bézier curves, as the path for a tube geometry.

Finally, we'll take a quick look at extrusion geometries.

Representing Surface Patches

Using a parametric representation, each coordinate (of an XYZ point in 3-space) becomes a function of two new parameters, which we will call $s$ and $t$, just like we did with textures (which is why $u$ and $v$ are sometimes used instead).

If we had coefficients, $C_{ij}$, we could define the surface like this:

$$ \begin{array}{rcllll} P(s,t) &=& C_{00} + &C_{01}t + &C_{02}t^2 + &C_{03}t^3 + \\ & & C_{10}s + &C_{11}st + &C_{12}st^2 + &C_{13}st^3 + \\ & & C_{20}s^2 + &C_{21}s^2t + &C_{12}s^2t^2 + &C_{23}s^2t^3 + \\ & & C_{30}s^3 + &C_{31}s^3t + &C_{22}s^2t^2 + &C_{33}s^3t^3\\ \end{array} $$

or

$$ P(s,t) = \sum_{i=0}^{3}\sum_{j=0}^{3} C_{ij}s^it^j $$

Since there are 16 coefficients, we need 16 control points to specify the surface patch.

Alternatively, we can define the surface using the blending functions, based on the Bernstein Polynomials that we saw last time. Some useful references:

\[ b_{i,d}(t) = \Choose{d}{i} t^i(1-t)^{d-i} \]

The $d$ above is the degree of the curve. We'll use cubics, so $d=3$:

\[ b_{i}(t) = \Choose{3}{i} t^i(1-t)^{3-i} \]

So, a 1D Cubic Bézier curve would be:

\[ P(t) = \sum_{i=0}^3 C_i\cdot b_{i}(t) \]

where $C_i$ is the $i$th control point.

The 2D version is more complicated, but similar:

\[ P(s,t) = \sum_{i=0}^3 \sum_{j=0}^3 C_{ij}\cdot b_{i}(t)b_{j}(s) \]

Intuition

Note that the four edges of a Bézier surface are Bézier curves , which helps a great deal in understanding how to control them. That helps us to understand 12 of the 16 control points.

The four interior points are harder to interpret. Concentrating on one corner: if it lies in the plane determined by the three boundary points, the patch is locally flat. Otherwise, it tends to twist. This is hard to picture.

The twist is related to the double partial derivative of the curve, if that's helpful (but for most students it's not):

$$ \frac{\partial^2 p}{\partial u\partial v}(0,0) = 9(p_{00} - p_{01} + p_{10} - p_{11}) $$

However, if we think of the blending functions, these interior points are still pulling the patch towards them, so we can often get what we want with a little experimentation.

The following online visualiztion of Bézier surfaces is an excellent way to get some intuition. Take a few minutes to play around with it.

Dome Demo

The following demo of a "dome" is very symmetrical, but demonstrates a Bézier surface in Threejs:

surfaces/dome

Bézier Patch in Three.js

Before we dig into the code, let's visualize the control points:

16 control points for a Bézier surface: four rows of four
16 control points for a Bézier surface: four rows of four

Notice that the (0,0) corner of the surface is at the lower right. (Just like texture coordinates.)

However, I find it awkward to start in the lower right, so I like to write the control points down "top to bottom" and then simply reverse that list before creating the geometry. Rather like the flipY setting for texture-mapping.

Therefore, I defined the control points of the dome like this:

const topToBottom = [
    [ [0,3,0],  [2,4,1],  [4,4,1],  [6, 3, 0] ],
    [ [-1,2,0], [2,2,1],  [4,2,1],  [7, 2, 0] ],
    [ [-1,1,0], [2,1,1],  [4,1,1],  [7, 1, 0] ],
    [ [0,0,0],  [2,-1,0], [4,-1,0], [6, 0, 0] ],
];

Computing The Surface

Unlike Bézier curves, Threejs has decided to discard direct support for Bézier surfaces. Instead, we're expected to use an general class for 2D parametric surfaces, available not in the core Threejs, but in the addons.

ParametricGeometry

To load that class, we have to put the following in the top of our main.js file:

import { ParametricGeometry } from 'three/addons/geometries/ParametricGeometry.js';

The example on the Threejs website has the following code:

const geometry = new THREE.ParametricGeometry( klein, 25, 25 );
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const klein = new THREE.Mesh( geometry, material );
scene.add( klein );

Even their example is flawed, since the klein variable is used twice, once as the first argument to ParametricGeometry and again as the variable holding the resulting mesh. So, JavaScript would complain about the second assignment. Or maybe the Threejs folks are making a very obscure and erudite joke about Klein Bottles.

That argument needs to be a function of three arguments, s, t, and P. The (s,t) are the parameters of the location on the surface, the P is a Vector3 that the function should fill with the point on the surface. In other words, it's up to us to compute the point. Fortunately, we know how to do that, as we saw at the top of this reading, but which we'll repeat here:

\[ P(s,t) = \sum_{i=0}^3 \sum_{j=0}^3 C_{ij}\cdot b_{i}(t)b_{j}(s) \]

Computing the Blending Functions

So, we have to define the blending functions ourselves. Okay, deep breath, we can build this up. Let's first remind ourselves of the binomial coefficient:

$$ \Choose{n}{i} = \frac{n!}{(n-i)!i!} $$

Let's take an example:

$$ \Choose{10}{6} = \frac{10!}{6!4!} $$

If we cancel the 6!, we get:

$$ \frac{10\cdot 9\cdot 8\cdot 7}{4\cdot 3\cdot 2\cdot 1} $$

which we can rewrite as:

$$ \frac{10}{1}\cdot \frac{9}{2} \cdot\frac 83 \cdot \frac74 $$

In general:

$$ \Choose{n}{k} = \prod_{i=1}^k \frac{n+1-i}{i} $$

Here's a simple JS function to compute exactly that:

function binomial(n, k) {
  let res = 1;
  for (let i = 1; i <= k; i++) {
    res = res * (n + 1 - i) / i;
  }
  return res;
}

The bernstein coefficient is just:

\[ B_{i,n}(t) = \Choose{n}{i} t^i (1-t)^{n-i} \]

Here's a JS function to compute that:

function bernstein(n, i, t) {
  return binomial(n, i) * Math.pow(t, i) * Math.pow(1 - t, n - i);
}

Now, we can a point on the curve like this:

\[ P(s,t) = \sum_{i=1}^3 \sum_{j=1}^3 C_{ij} B_{i,3}(s) B_{j,3}(t) \]

Here's a JS function to compute that, except that instead of returning $P(s,t)$ it stores the point in the target argument:

function bicubicBézier(s, t, target) {
  target.set(0, 0, 0);
  for (let i = 0; i <= 3; i++) {
    for (let j = 0; j <= 3; j++) {
      const b_i = bernstein(3, i, s);
      const b_j = bernstein(3, j, t);
      // because s and i go across the rows, left to right,
      // i needs to be the *second* argument to the
      // controlPoints
      const cp = controlPoints[j][i];
      target.x += cp.x * (b_i * b_j);
      target.y += cp.y * (b_i * b_j);
      target.z += cp.z * (b_i * b_j);
    }
  }
}

Note that this function references a global variable controlPoints that contains a 2D array of control points. (Later, we'll see how to improve that.) Meanwhile, the bicubicBézier function is exactly what we need as an argument to the ParametricGeometry function. So, we can make the dome like this:

function makeDome(slices=8, stacks=8, showMesh=false) {
    const geometry = new ParametricGeometry(bicubicBézier, slices, stacks);
    const material = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    return mesh;
}
makeDome(8,8,true);

And there's our demo to create a cubic Bézier surface:

surfaces/dome

General-Purpose Helper Functions

The function bicubicBézier is really useful for creating the surface, but it "hard-wires" the global variable that contains the control points, which makes it specific to this surface instead of being a general-purpose helper function. Let's see if we can do better.

What we need is to be able to dynamically create a function like bicubicBézier, where the dynamically created function is able to refer to a supplied array of control points. Fortunately, JavaScript has a powerful mechanism called closures, and we can use them to good effect here.

The following function does the trick:

/* Function that returns a function suitable as an argument to
   THREE.ParametricGeometry. That function uses the argument
   controlPoints to compute a point on the 2D cubic Bézier, given
   parameters s and t. The 'target' argument is a Vector3 object which
   is used to store the result. The returned function has no return
   value. */

function bicubicBézierFunction(controlPoints) {
    return function bicubicBézier(s,t,target) {
        target.set(0, 0, 0);
        for (let i = 0; i <= 3; i++) {
            for (let j = 0; j <= 3; j++) {
                const b_i = bernstein(3, i, s);
                const b_j = bernstein(3, j, t);
                // because s and i go across the rows, left to right,
                // i needs to be the *second* argument to the
                // controlPoints
                const cp = controlPoints[j][i];
                target.x += cp.x * (b_i * b_j);
                target.y += cp.y * (b_i * b_j);
                target.z += cp.z * (b_i * b_j);
            }
        }
    }
}

Most of that code is the bicubicBézier function, which we've seen before, so ignore that. Let's focus on the structure of the overall function:

function bicubicBézierFunction(controlPoints) {
    return function bicubicBézier(s,t,target) {
        ...
    }
}

You see that that the return value is a function. We said at the beginning of the course that in JavaScript, functions are first class, which means they can be treated like data, including passing them as argument to other functions. Here, we are returning a function.

It happens that I named that returned function (bicubicBézier), but I could just as well have left it anonymous; it works exactly the same either way. In short:

bicubicBézierFunction returns a dynamically created function called bicubicBézier

So, how do we use this? Here's how we can build our dome:

function makeDome(slices=8, stacks=8, showMesh=false) {
    const paramFunction = TW.bicubicBézierFunction(domeControlPoints)
    const geometry = new ParametricGeometry(paramFunction, slices, stacks);
    const material = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
}
makeDome(8,8,true);

Notice the first line of makeDome, which is a call to TW.bicubicBézierFunction passing in the desired control points. The return value is a function that we turn around and pass to ParametricGeometry. We never need the name of the function, so it could have been anonymous. All the rest of the code is exactly the same. Here's a demo using this new coding technique:

surfaces/dome2

I've put the helper functions binomial and bernstein into TW, as well as bicubicBézierFunction.

Flag

We can make a different surface, looking a little like a flag flapping in the wind, and texture-map a flag texture onto it. We can even use material and lighting. The surface normals are computed from the Bézier surface.

surfaces/flag

The Utah Teapot

One of the important milestones in the history of computer graphics was the Utah Teapot, created by CG pioneer Martin Newell

It was created in 1975 as a demonstration of the power of cubic Bézier patches. (The story is that Allen Newell and his colleagues had developed the idea of using Bézier patches to model curved surfaces, and that they were looking for a good example. One day, Professor Newell was looking at his wife's teapot and decided that it would be perfect.) Each Bézier patch handles a small part of the teapot. Since a Bézier curve can do a good approximation of a quarter circle, each Bézier patch goes one-quarter of the way around the pot (or the spout or the handle). So it takes 4 of them to go around the teapot. In the full teapot, there are 32 4x4 cubic Bézier patches.

Threejs provides the teapot, more in recognition of its historic importance than because you will find important uses of teapots in your scenes.

Teapot Geometry

It is, naturally, in the addons; so you load it like this:

import { TeapotGeometry } from 'three/addons/geometries/TeapotGeometry.js';

Here's a demo:

surfaces/teapot

and here's a screenshot from that demo:

screenshot of Utah teapot

While the notion of modeling a teapot may seem silly, I think the result is pretty darn impressive. And this was done in 1975!

There is an homage to the Utah Teapot in the Toy Story, which was another important milestone in the history of CG. That movie, made in 1995, was the first feature-length film made entirely with computer graphics. Here's part of a frame from the movie:

Utah teapot with Buzz Lightyear in Toy Story

Lathe Geometries

Now that we've seen some bicubic Bézier surfaces, let's turn to other kinds of curved surfaces. An important and useful one is a LatheGeometry

In wood-working and metal-working, a lathe is a machine for making objects like table legs and such, where every cross-section is a circle. (A potter's wheel is a similar idea.) Here's a picture:

carpenter using a lathe
Carpenter using a lathe. Photo by William Warby

We can do the same thing in Threejs: all we need is information about the radius of the object at each point along its axis.

In Threejs, the axis of symmetry (the axis around which lathe "rotates") is the Y axis. So, each circular cross-section lies in a plane of constant Y.

Having chosen a Y value, we only need the radius of the circular cross section. We will represent that with an X value.

Lathe Funnel

Let's start with a very simple example. Suppose our data looks like this:

X Y
1 0
2 10
15 22

What that means is that at Y=0, the radius is 1, when Y=10, the radius is 2, and when Y=22, the radius is 15. The resulting lathe object might look like this:

lathe/funnel

(I have a funnel in my kitchen at home that looks a lot like this.)

The code is remarkably simple:

const outline = [
    [1, 0],
    [2, 10],
    [15, 22 ]
];

function makeFunnel(outline) {
    // these are Vector2, not Vector3
    const pts = outline.map(a => new THREE.Vector2(...a));
    const geo = new THREE.LatheGeometry(pts);
    const mat = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide });
    const mesh = new THREE.Mesh( geo, mat );
    scene.add(mesh);
    return mesh;
}
makeFunnel(outline);

Lathe Kiss

Armed with our knowledge of Bézier curves, we could compute the outline (silhouette) of the lathe object using Bézier curves.

Let's make an object that looks a bit like a Hershey's KissTM:

lathe/kiss

To do this, let's start by creating the silhouette, using control points of a Bézier curve. Note that this will be a 2D curve, since we only need XY values, not Z:

const kissControlPoints = [
    [0, 20],
    [8, 4],
    [12, 5],
    [12, 0],
];

Try to picture that. For example, the (0,20) means that the radius at the top of the kiss (Y=20) is zero. At the bottom of the kiss (Y=0), the radius is 12.

TODO: add picture of these control points

Now, we need to create a Bézier object, sample it to get a list of 2D points, and build our lathe geometry:

/* given the control points for a Bézier curve that is the silhouette
 * of a lathe geometry, create an return a lathe geometry with that
 * silhouette. The "stacks" variable is the number of circular
 * cross-sections (Y values) and the "slices" variable is the number
 * of segments on each circle (think of it as slices of pizza).
*/

function makeBézierLatheGeometry(cp, stacks, slices) {
    // note that these are Vector2, not Vector3
    const vecs = cp.map( a => new THREE.Vector2(...a) );
    // note that this is CubicBézierCurve not CubicBézierCurve3
    const curve = new THREE.CubicBézierCurve(...vecs);
    const pts = curve.getPoints(stacks);
    const geo = new THREE.LatheGeometry(pts, slices);
    return geo;
}

Finally, we make the mesh in the usual way:

function makeKiss() {
    const geo = makeBézierLatheGeometry(kissControlPoints, 20, 32);
    const mat = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide });
    const mesh = new THREE.Mesh( geo, mat );
    scene.add(mesh);
    return mesh;
}
makeKiss();

Amazing!

Lathe Bottle

We could do the same thing with our coke bottle silhouette and create a 3D coke bottle using lathe geometry.

lathe/bottle

Tube Geometries

Another way to create a 3D object from a 1-dimensional curve is to use the curve as the central axis and move a circle along that axis, creating a tube. To do that, Threejs provides a TubeGeometry

In their example, which is pretty good, the central axis is a custom sine curve, defined as a new class called CustomSinCurve as a subclass of the Threejs Curve class. However, we haven't learned how to define classes (as in Object-Oriented Programming), so that might be difficult to understand.

Instead, let's use a class we have already used, namely CubicBézierCurve3 which we used last time to define the s-curve, the box-curve, and others. Let's create a tube out of the box-curve:

tubes/box-tube

The code is surprisingly easy, given our earlier work with the s-curve1 (where we explicitly create an instance of CubicBézierCurve3) and the box-curve Bézier curve.

function makeTube() {
    scene.remove(tube);
    const vecs = controlPoints.map( a => new THREE.Vector3(...a) );
    const curve = new THREE.CubicBézierCurve3(...vecs);
    const geo = new THREE.TubeGeometry(curve,
                                       params.tubularSegments,
                                       params.radius,
                                       params.radialSegments,
                                       params.closed);
    const mat = new THREE.MeshNormalMaterial();
    tube = new THREE.Mesh( geo, mat );
    scene.add(tube);
}
makeTube();

In the code above, instead of sampling the Bézier curve to get a bunch of points, we supply the object to THREE.TubeGeometry, because it will sample the curve as it needs to.

The other arguments to TubeGeometry are straightforward:

  • the number of segments along the tube
  • the radius of the tube
  • the number of segments around the tube (like the slices above)
  • whether to join one end to the other.

If you'd like to do more with tube geometries and/or would like to learn how to define custom classes in JS, let me know.

Extrude

Another way to create a 3D object from a lower-dimensional shape is extrusion. Maybe when you were a child, you played with clay or Play-DohTM with a device like this:

clay extrusion device and some clay tubes

You put clay in at one end and force it through a 2D shape, yielding a long piece of clay with the 2D shape as its cross-section.

Threejs provides this feature via ExtrudeGeometry

In a way, extrusion is like a tube, except that the 2D shape is not a circle (and the axis is straight). Unfortunately, we have to learn a new language to define the 2D shape. This is done with the Shape class. We won't go deeply into this language, but we can if you would like. For now, let's look at one of the Threejs demos, namely the one for

ShapeGeometry

It's a heart shape! Moreover, it's made from Bézier pieces, so with just a little work, we can easily understand this language. A few ideas to note, first. The API is Turtle Graphics, which means that you imagine a turtle dragging a pen around the 2D Cartesian plane, making lines as it moves. It can lift the pen to stop drawing and move to another location, and then it can put the pen back down.

Take a moment to go to the Wikipedia page on Turtle graphics, which has a nice animation:

Turtle Graphics

In a moment, we'll try to understand the Threejs heart shape, but first, let's start with our heart shape. Here's the demo:

shapes/heart

Here's the code:

// from our original heart shape, but not used
const controlPoints = [
    [0,2,0]
    [1,3,0],
    [1,1,0],
    [0,0,0],
];

const heartShape = new THREE.Shape();

heartShape.moveTo(0,2);
heartShape.bezierCurveTo( 1, 3, // heading towards here
                          1, 1, // finishing as if from here
                          0, 0 ); // finishing here
heartShape.bezierCurveTo( -1, 1,  // heading towards here
                          -1, 3,  // finishing as if from here
                          0, 2 ); // finishing here

const geometry = new THREE.ShapeGeometry( heartShape );
const material = new THREE.MeshBasicMaterial( { color: "red" } );
const mesh = new THREE.Mesh( geometry, material ) ;
scene.add( mesh );

We start by moving the turtle to (0,2), which is the dent at the top of the heart, and it's our first control point. Then, we draw a cubic Bézier curve to the origin, around the right side of the heart.

The bezierCurveTo method takes six arguments, and these are pairs of (x,y) coordinates, so it's better to think of it as taking three arguments that are points.

But don't we need four points to draw a cubic Bézier? Yes! The method omits the first control point, because that is implicit: it's the current location of the turtle. So, for the first Bézier, we implicitly start at (0,2), because of the previous moveTo.

Then, without picking up the pen, we draw our second Bézier, ending back at the dent. Notice how the control points are in the reverse order, because we are now moving up the left side of the heart.

When that's all done, and we've created our shape, we hand that Shape object to ShapeGeometry, and the rest is straightforward.

The Threejs heart code is written in an odd way, so I've made a few modifications, but the result works:

shapes/heart3js

Here's the relevant code:

const heartShape = new THREE.Shape();

heartShape.moveTo( 5, 5 );
heartShape.bezierCurveTo( 5, 5,
                          4, 0,
                          0, 0 );
heartShape.bezierCurveTo( -6, 0,
                          -6, 7,
                          -6, 7 );
heartShape.bezierCurveTo( -6, 11,
                          -3, 15.4,
                          5, 19 );
heartShape.bezierCurveTo( 12, 15.4,
                          16, 11,
                          16, 7 );
heartShape.bezierCurveTo( 16, 7,
                          16, 0,
                          10, 0 );
heartShape.bezierCurveTo( 7, 0,
                          5, 5,
                          5, 5 );

const geometry = new THREE.ShapeGeometry( heartShape );
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const mesh = new THREE.Mesh( geometry, material ) ;
scene.add( mesh );

With a bit of graph paper and some patience, we can follow this shape.

Another inspiring example is this one by Jos Dirksen, author of an excellent book on Learning Threejs. To respect his copyright, this example will only be available on campus (or with htaccess credentials — ask Scott):

Example of Extrude Geometry

The Threejs site also has some very nice examples:

Webgl geometry shapes

If you want to learn more about the methods for drawing 2D shapes, the important Threejs classes are

The Threejs shape API is very similar to the 2D WebGL canvas API, which has excellent documentation on the Mozilla Developer's Network. The Mozilla Developer's Network (MDN) are the people who create Firefox. It's a fantastic resource, highly recommended.

Here's a link to their Canvas Tutorial

Summary

In this reading, we've learned a number of ways of creating geometry objects in Threejs:

  • 2D Bézier surfaces, which we used for the dome and the flag
  • Lathe geometries, where the silhouette is series of points, including a 1D Bézier curve
  • Tube geometries, where the axis of the tube is a Curve object, particularly a BézierCurve3 object
  • Extrude geometries, where the axis is a straight line, but the object is a 2D shape, defined by Turtle graphics, including Bézier curves

To create 2D Bézier surfaces, we had to learn about ParametricGeometry which takes an argument that is a function of (s, t, v) where (s,t) are the parameters of the location on the surface, and v is the Vector3 to store the computed point in. We wrote code to compute the Bernstein blending functions for Bézier curves and created a function to create argument functions to ParametricGeometry.

The ParametricGeometry object is very general, so we could use it define other kinds of surfaces.

For some of the techniques in this reading, we've just begun to dig into the complexities, but we have a good start for learning more. Talk to Scott if you want to go deeper.

Demos