Custom Geometry¶
The built-in geometry objects (spheres, boxes, cones, cylinders, etc) can do a lot of work for us, but it's important to be able to build a custom object if we choose.
Unfortunately, there are two different ways of doing so. The old way and the new way.
In Revision 125 of Three.js, they made a radical change to the representation of geometry. The result was less intuitive for human programmers, but faster for the graphic card to process: performance won over ease of coding.
In CS 307, you can use the old way if you want, but it will change how you load THREE, TW and other JS code into your page. I have a working example, below, should you decide to go that route.
I also have a working example, below, of the new way.
Which way do I think you should learn and do?
- At this point, I don't have a lot of experience with the new way versus the old way, so I am fairly uncertain.
- It seems that loading R90 and other old versions from the CDN using the new module system is difficult. So, that means the using old geometry requires However, my intuition is that dividing your code into modules will be easier with the new way (after all, that's the point of the new module system). So, I think we should learn the new way.
But starting with the old way will clarify many concepts, so let's start there.
Custom Geometry the Old Way¶
Programming the old way, we
- create a Geometry object
- create a list of vertices as objects
- create a list of faces (triangles) also as objects
(In the new way, we won't have nice objects; we'll just have coordinates. )
Vertices¶
We start by listing the vertices of our figure. Here's the beginning of defining the barn:


- vertex 0 is the lower left front corner of the barn, at the origin (0,0,0)
- vertex 1 is the lower right front corner of the barn, at coordinates (30,0,0)
- vertex 2 is the upper right front "shoulder" of the barn, at coordinates (30,40,0)
- vertex 4 is the front "peak" of the barn, at coordinates (15,55,0)
- etc
We can create these vertices like this:
var barnGeometry = new THREE.Geometry();
// add vertices to the barnGeometry.vertices array -
// indices of this array correspond to the above labels
barnGeometry.vertices.push(new THREE.Vector3(0, 0, 0));
barnGeometry.vertices.push(new THREE.Vector3(30, 0, 0));
barnGeometry.vertices.push(new THREE.Vector3(30, 40, 0));
...
Faces¶
A face is a triangle. It is created by listing three vertices, which is done by giving the index of each vertex in the array of vertices.
Keep in mind:
- each triangular face has a front and back side
- by default, Three.js only renders the front side (the back side is not rendered, for speed)
So how do you define which side is the front?
- imagine you're looking at the side you want to define as the front side...
- ... and list the three vertices in a counterclockwise order, as viewed from this side
- Three.js will interpret this side as the front side


// front side of each of these faces is outside the barn
barnGeometry.faces.push(new THREE.Face3(0, 1, 2));
barnGeometry.faces.push(new THREE.Face3(0, 2, 3));
barnGeometry.faces.push(new THREE.Face3(3, 2, 4));
...
There are other steps, such as defining material indexes and such, but let's put that off for now and talk about defining geometry the new way. Fortunately, the concepts are all the same, but the implementation is different.
Transitioning¶
The following website explains the transition from geometry to buffergeometry
They also have a nice example of a tetrahedron, which I've adapted to our old way of setting up a project.
- The tetrahedron
The HTML file looks like this:
<body>
<script src="https://cs.wellesley.edu/~cs307/threejs/libs/three-r95.js"></script>
<script src="https://cs.wellesley.edu/~cs307/threejs/libs/OrbitControls-r95.js"></script>
<script src="https://cs.wellesley.edu/~cs307/threejs/libs/tw-sp21.js"></script>
<script src="main.js"></script>
The JS file looks like this:
// ================================================================
// Tetrahedron code adapted from
// https://sbcode.net/threejs/geometry-to-buffergeometry/ I skipped
// the computation of FlatVertexNormals and used Basic Material
let geometry = new THREE.Geometry();
geometry.vertices.push(
new THREE.Vector3(1, 1, 1), //a
new THREE.Vector3(-1, -1, 1), //b
new THREE.Vector3(-1, 1, -1), //c
new THREE.Vector3(1, -1, -1) //d
)
geometry.faces.push(new THREE.Face3(2, 1, 0),
new THREE.Face3(0, 3, 2),
new THREE.Face3(1, 3, 0),
new THREE.Face3(2, 3, 1));
// This useful function is also removed is later versions of Three.js
geometry.computeFlatVertexNormals();
const mesh = new THREE.Mesh(geometry,
new THREE.MeshNormalMaterial());
scene.add(mesh);
Custom Geometry the New Way¶
Let's take a few minutes to look at the Threejs information on custom buffer geometry
Hopefully, most of that code is clear to you. There are some JS tricks that you might not be familiar with. Let's discuss one
The following code iterates over the array vertices
, each element of
which is a dictionary. The code indexes into each dictionary, gets a
sub-array and spreads the sub-array into separate components
(numbers in this case), and pushes the components onto different
arrays like positions
and normals
.
const positions = [];
const normals = [];
const uvs = [];
for (const vertex of vertices) {
positions.push(...vertex.pos);
normals.push(...vertex.norm);
uvs.push(...vertex.uv);
}
So, given an array of dictionaries like this:
const vertices = [
// front
{ pos: [-1, -1, 1], norm: [ 0, 0, 1], uv: [0, 0], },
{ pos: [ 1, -1, 1], norm: [ 0, 0, 1], uv: [1, 0], },
{ pos: [-1, 1, 1], norm: [ 0, 0, 1], uv: [0, 1], },
{ pos: [-1, 1, 1], norm: [ 0, 0, 1], uv: [0, 1], },
{ pos: [ 1, -1, 1], norm: [ 0, 0, 1], uv: [1, 0], },
{ pos: [ 1, 1, 1], norm: [ 0, 0, 1], uv: [1, 1], },
];
The result is 3 "flat" arrays of numbers:
Custom Geometry Box¶
The code describe in the Threejs page about custom geometry is very cool, but it doesn't have all the features we want. In particular, there are lots of triangles, but no sides, so we can't have, say, a cube with sides that are different colors.
Here's what we might want:
Be sure to click and drag with the mouse, to look at the box from multiple directions.
Driver Code¶
Let's first look at the code that creates the scene:
//import three js and all the addons that are used in this script
import * as THREE from 'three';
import { TW } from 'tw';
import { boxGeometryWithMaterialGroups } from './boxGeometryWithMaterialGroups.js';
console.log(`Loaded Three.js version ${THREE.REVISION}`);
// for debugging
globalThis.THREE = THREE;
globalThis.TW = TW;
globalThis.boxGeometryWithMaterialGroups = boxGeometryWithMaterialGroups;
// Create an initial empty Scene
var scene = new THREE.Scene();
globalThis.scene = scene;
// ====================================================================
// Building Geometry
const box2geom = boxGeometryWithMaterialGroups(2, 4, 6);
const box2mat = (['blue', 'red', 'green', 'cyan', 'yellow', 'magenta']
.map( color => new THREE.MeshBasicMaterial({color})));
// Create a multicolor for the box
const box2 = new THREE.Mesh( box2geom, box2mat);
box2.name = 'box2';
scene.add(box2);
// ================================================================
// Create a renderer to render the scene
var renderer = new THREE.WebGLRenderer();
// TW.mainInit() initializes TW, adds the canvas to the document,
// enables display of 3D coordinate axes, sets up keyboard controls
TW.mainInit(renderer,scene);
// Set up a camera for the scene
var state = TW.cameraSetup(renderer,
scene,
{minx: -3, maxx: 3,
miny: -3, maxy: 3,
minz: -3, maxz: 3});
Now, that wasn't so bad, was it? One important part is that, using the new JS module system, we can dynamically import some additional JS, like this:
import { boxGeometryWithMaterialGroups } from './boxGeometryWithMaterialGroups.js';
That imports some code from a file named
boxGeometryWithMaterialGroups.js
that is in the same folder as our
main.js
. (Sorry for the long-winded name.)
That file defines a function when we will use in main. We'll look at the other file in a moment, but first, let's look at how the code is used. We call the function to create a geometry:
const box2geom = boxGeometryWithMaterialGroups(2, 4, 6);
We also need a material, of course. Actually, we are going to create an array of materials, each a solid color, and each color will be applied to a side of the box. Each side is a group and the code defines groups so that each side/group can be a different color. The following code creates an array of materials:
const box2mat = (['blue', 'red', 'green', 'cyan', 'yellow', 'magenta']
.map( color => new THREE.MeshBasicMaterial({color})));
The .map()
method takes an array of strings and returns an array of
THREE.MeshBasicMaterial
objects. Basic materials are just a single
color, regardless of lighting, viewpoint, etc. Very simple.
Then, we combine them into a mesh and add it to the scene:
// Create a multicolor for the box
const box2 = new THREE.Mesh( box2geom, box2mat);
box2.name = 'box2';
scene.add(box2);
So, the code builds a mesh from a six-sided box and array of six colors, with one color per side.
Coding Conventions¶
Before we look at the code that builds the geometry, let's set some conventions so that we can orient ourselves:
- The box is 2x4x6, with the origin in the center
- The x-axis goes to the right, so the x axis goes through the red face:
- x = [1, 0, 0], and red = [1, 0, 0]
- similarly, the y-axis goes up and through the green face
- the z-axis goes towards the viewer and through the blue face
- There are 8 points
- because the x-size is 2 and the origin is in the center, the x coordinates have values of ±1
- similarly, the y-size is 4, so the y coordinates are ±2
- the z-size is 6, so the z coordinates are ±3
- So, looking at a coordinate makes it easier to tell whether it's an x, y, or z, because a 1 is always an x, etc.
- There are 24=3*8 vertices, because each point can participate in several faces
- for example, the upper right point (1,2,3) is part of the blue, red and green sides of the box
As a reminder, here's the box:
Faces, Indexes, Vertices¶
Deep breath now.
- The box is a set of groups. Each group is a side of the box. So there are six groups.
- Each group has a
materialIndex
which is an index into an array of materials if one is supplied (which we do/did, above) - The material tells how to render that group. In this case, a
MeshBasicColor
- Each group is a collection of triangles (sometimes called faces, but in this context, I think that's confusing)
- The group defines the index of the first triangle, in the
indices
array, and number of indices in the group. - Remember, each index is one vertex.
- It takes 3 vertices to create a triangle.
- Here, the groups are all of size 6, because they have two triangles.
The Vertices Buffer¶
The Threejs description of custom geometry that we looked at earlier is accurate (the various vectors are one dimensional), but consequently hard to read. So, I'm going to lay things out differently.
Here are some vertices:
These vertices are numbered slightly differently than for the barn. For example, vertex 0 (the first column) has coordinates (-1, 2, 3), so it's the upper left vertex when we are looking at the front side (the blue side). It's normal vector is (0,0,1) which is parallel to the z axis.
The Indices Buffer¶
Now, to make the front (blue) face of the box, we need two triangles. We'll do that using indices into the array of vertices.
Here's how we will draw the front face with two triangles:
For this quad, the triangles are created by specifying the index of each vertex of the triangle. So, each triangle is 3 indices.
0, 1, 2 // the lower left triangle
0, 2, 3 // the upper right triangle
These are indices into the vertices array. Look back to see the coordinates.
- vertex 0, which is the upper left corner, has coordinates (-1, +2, +3)
- vertex 1, which is the lower left corner, has coordinates (-1, -2, +3)
- vertex 2, which is the lower right left corner, has coordinates (+1, -2, +3)
- vertex 3, which is the upper right corner, has coordinates (+1, +2, +3)
Note that you can ignore the Z coordinate, because they are all +3
Groups¶
Here's the group information for this first group:
bg.groups
Array [ {...}, {...}, {...}, {...}, {...} ];
> 0: Object { start: 0, count: 6, materialIndex: 0 }
...
So, this means that the group (the blue face) has
- 6 indices in it (and we saw what those were, namely
0, 1, 2, 0, 2, 3
) - that it starts at index 0 in the
indices
vector - that it corresponds to material 0 in the array of materials.
Code to Construct the Custom Geometry¶
It's time to read the code to construct this box of custom geometry:
import * as THREE from 'three';
import { TW } from 'tw';
export function boxGeometryWithMaterialGroups (xsize, ysize, zsize) {
const x2 = xsize/2, y2 = ysize/2, z2 = zsize/2;
// eight locations. The first four are given long-winded names, to
// make the code easier to read, and corresponding to the names in
// the reading.
const v0_upper_left = [ -x2, +y2, z2 ];
const v1_lower_left = [ -x2, -y2, z2 ];
const v2_lower_right = [ x2, -y2, z2 ];
const v3_upper_right = [ x2, +y2, z2 ];
// array of 8 locations/points
const p = [ v0_upper_left,
v1_lower_left,
v2_lower_right,
v3_upper_right,
// the ones in the back
[ +x2, +y2, -z2 ],
[ +x2, -y2, -z2 ],
[ -x2, -y2, -z2 ],
[ -x2, +y2, -z2 ]];
// We'll fill these arrays with our helpers
const vertices = [];
const indices = [];
// group code modeled on BoxGeometry
// https://github.com/mrdoob/three.js/blob/master/src/geometries/BoxGeometry.js
// Each group corresponds to a "face" (flat surface of the
// object), which consists of a set of triangles. We need to
// count the number of triangles in each group, and then do
// addGroup() at the end of a face. Each face/group will have its
// own normal and materialIndex
let bg = new THREE.BufferGeometry();
bg.type = 'BarnGeometry';
let groupStart = 0;
// let's use quad with the corners starting at the upper left of
// the texture and going counterclockwise
function quad(i1, i2, i3, i4, normal, materialIndex) {
let v1 = { pos: p[i1], norm: normal, uv: [0, 0] };
let v2 = { pos: p[i2], norm: normal, uv: [0, 1] };
let v3 = { pos: p[i3], norm: normal, uv: [1, 1] };
let v4 = { pos: p[i4], norm: normal, uv: [1, 0] };
let n = vertices.length; // index of v1
vertices.push(v1, v2, v3, v4);
// these are the two triangles
indices.push(n, n+1, n+2);
indices.push(n, n+2, n+3);
let numVertices = 6;
bg.addGroup(groupStart, numVertices, materialIndex);
groupStart += numVertices;
}
quad(0, 1, 2, 3, [0,0,1], 0); // front side (+z side)
quad(3, 2, 5, 4, [+1,0,0], 1); // +x side
quad(0, 3, 4, 7, [0,+1,0], 2); // top (+y side)
quad(7, 6, 1, 0, [-1,0,0], 3); // -x side
quad(4, 5, 6, 7, [0,0,-1], 4); // back
// Whew! Now, build the rest
TW.setBufferGeometryFromVertices(bg, vertices);
bg.setIndex(indices);
return bg;
}
Try the following code in a JS console for the customGeometryBox. This will give us some values we can look at
box2 = scene.getObjectByName('box2');
bg = box2.geometry;
d = TW.geometryInfo(bg);
bg.groups;
That's not easy coding to read, but do your best. We'll review it in class.
Mopping Up¶
There are just a few details still to mention.
The quad
Function¶
The quad
sub-function adds a pair of triangles to the array of
indices, to create a quadrilateral. Its arguments are four indices
into the array of points.
The quad
function creates a dictionary, like the ones at the top of
this page, that will eventually be flattened into buffers for WebGL.
We use something like the same 0, 1, 2, 0, 2, 3
idea that we saw
above, but now those are relative to wherever we are in the current
array of indices. That's what's going on with code like this:
indices.push(n, n+1, n+2);
indices.push(n, n+2, n+3);
The quad
function also updates the groupStart
variable.
The TW.setBufferGeometryFromVertices
Function¶
This function is just the trick from the Three.js custom geometry page, distilled into a single function. It's just this:
TW.setBufferGeometryFromVertices = function (geometry, vertices) {
// convert to flat arrays. This code is identical to the reference code
const positions = [];
const normals = [];
const uvs = [];
for (const vertex of vertices) {
positions.push(...vertex.pos);
normals.push(...vertex.norm);
uvs.push(...vertex.uv);
}
// create the buffer attributes
geometry.setAttribute(
'position',
new THREE.BufferAttribute(new Float32Array(positions), 3));
geometry.setAttribute(
'normal',
new THREE.BufferAttribute(new Float32Array(normals), 3));
geometry.setAttribute(
'uv',
new THREE.BufferAttribute(new Float32Array(uvs), 2));
return geometry;
}
Exercise¶
You'll note that I "accidentally" omitted the bottom. Write the code for that.