Trimesh
Big software is made by hiding complexity inside abstractions. Earlier we hid the state and behavior of positions and offsets in the Vector3
class. Now we hide the state and behavior of triangle meshes in the Trimesh
class. The class is a fundamental graphics primitive that will be used in nearly every graphics program we write. We want it to be useful even if some new graphics stack comes along, so we avoid coupling it to WebGL.
State
We've seen how triangle meshes may be expressed as sequences, fans, and indexed geometry. Neither sequences nor fans are an ideal choice for an all-purpose Trimesh
data structure. Sequences wastefully repeat vertices instead of sharing them. Fans assume a central vertex, but not all meshes radiate around a central vertex. We want shared vertices and arbitrary arrangements of triangles, so we'll have Trimesh
use indexed geometry. That means we need this state in our class:
- a list of vertex positions
- a list of faces, with each face being a tuple of 3 vertex indices
Since we're trying to avoid coupling to WebGL, let's use types that are convenient for us developers. Each vertex position will be a Vector3
, and each face will be an array of arrays, with each inner array holding exactly 3 integers.
Avoid the name vertices
for position state, here and elsewhere. Vertices are more than positions; they have many other attributes, like color and texture coordinates. We want each state variable to have a precise name.
Constructor
For behaviors, we need at the very least a constructor to initialize the state. Let's assume the client builds the state arrays and just hands them to us:
class Trimesh {
// ...
constructor(positions: Vector3[], faces: number[][]) {
this.positions = positions;
this.faces = faces;
}
}
class Trimesh { // ... constructor(positions: Vector3[], faces: number[][]) { this.positions = positions; this.faces = faces; } }
We'll add more statements to this constructor as we expand the class.
Counters
The clients of Trimesh
occasionally need to know how many vertices and faces a mesh has. We need a couple of accessor properties for these counts.
Bounding Box
Sometimes we need to know a mesh's bounding box—its minimum and maximum coordinates on each dimension. The bounding box is useful for collision detection and arranging or centering models. Let's add a computeMinMax
function that identifies the extrema by iterating through the positions
array:
class Trimesh {
// ...
min!: Vector3; // note the exclamation points!
max!: Vector3;
constructor(positions: Vector3[], faces: number[][]) {
// ...
this.computeMinMax();
}
computeMinMax() {
// Guess the min and max to be the first position.
this.min = this.positions[0].clone();
this.max = this.positions[0].clone();
// Try ousting the min and max.
for (let position of this.positions) {
if (position.x < this.min.x) {
this.min.x = position.x;
} else if (position.x > this.max.x) {
this.max.x = position.x;
}
if (position.y < this.min.y) {
this.min.y = position.y;
} else if (position.y > this.max.y) {
this.max.y = position.y;
}
if (position.z < this.min.z) {
this.min.z = position.z;
} else if (position.z > this.max.z) {
this.max.z = position.z;
}
}
}
}
class Trimesh { // ... min!: Vector3; // note the exclamation points! max!: Vector3; constructor(positions: Vector3[], faces: number[][]) { // ... this.computeMinMax(); } computeMinMax() { // Guess the min and max to be the first position. this.min = this.positions[0].clone(); this.max = this.positions[0].clone(); // Try ousting the min and max. for (let position of this.positions) { if (position.x < this.min.x) { this.min.x = position.x; } else if (position.x > this.max.x) { this.max.x = position.x; } if (position.y < this.min.y) { this.min.y = position.y; } else if (position.y > this.max.y) { this.max.y = position.y; } if (position.z < this.min.z) { this.min.z = position.z; } else if (position.z > this.max.z) { this.max.z = position.z; } } } }
The min
and max
instance variables are declared with !
. If we omit this, the Typescript compiler will complain that they aren't assigned in the constructor. When run in strict mode, the compiler asserts that all instance variables are initialized. We humans can see that these variables are assigned indirectly by a call to computeMinMax
, but the compiler isn't able to guarantee that. A subclass might override computeMinMax
and disrupt the assignment.
Flatteners
Clients of Trimesh
will need access to the vertex and face data, but usually they'll need it in compact form. Since typed arrays are what the VertexAttributes
class and WebGL expect, let's create a getter that returns the face data as a Uint32Array
. The constructor for Uint32Array
expects a one-dimensional array, but the faces
instance variable is an array of 3-arrays of this form:
[
[0, 1, 3],
[0, 3, 2],
...
]
[ [0, 1, 3], [0, 3, 2], ... ]
We first need to flatten this array-of-arrays into a single array of this form:
[0, 1, 3, 0, 3, 2, ...]
[0, 1, 3, 0, 3, 2, ...]
The Array.flat
method flattens the elements into a new array, which we pass to the Uint32Array
constructor in this getter:
class Trimesh {
// ...
faceBuffer() {
return new Uint32Array(this.faces.flat());
}
}
class Trimesh { // ... faceBuffer() { return new Uint32Array(this.faces.flat()); } }
We don't make faceBuffer
an accessor property as we did vertexCount
and faceCount
. Making a new array is a costly operation. Treating faceBuffer
as a cheaply accessed instance variable is a bad idea. We ensure that it looks like a potentially costly method call.
The position data must be packaged up in a Float32Array
. Unlike the faces, it's an array of Vector3
, having this form:
[
new Vector3(0, 0, 2),
new Vector3(1, 0, -3),
...
]
[ new Vector3(0, 0, 2), new Vector3(1, 0, -3), ... ]
We must turn each vector into an array and flatten them all to get an array of this form:
[0, 0, 2, 1, 0, -3, ...]
[0, 0, 2, 1, 0, -3, ...]
The Array.flatMap
method accomplishes both the conversion and the flattening in this getter:
class Trimesh {
// ...
positionBuffer() {
const xyzs = this.positions.flatMap(p => p.xyz);
return new Float32Array(xyzs);
}
}
class Trimesh { // ... positionBuffer() { const xyzs = this.positions.flatMap(p => p.xyz); return new Float32Array(xyzs); } }
The Trimesh
class is now a helpful abstraction. Clients may use it to create a new collection of VertexAttributes
with code like this:
const balloon = new Trimesh(positions, faces);
const attributes = new VertexAttributes();
attributes.addAttribute('position', balloon.vertexCount, 3, balloon.positionBuffer());
attributes.addIndices(balloon.faceBuffer());
const balloon = new Trimesh(positions, faces); const attributes = new VertexAttributes(); attributes.addAttribute('position', balloon.vertexCount, 3, balloon.positionBuffer()); attributes.addIndices(balloon.faceBuffer());
That leaves the challenging part: getting the initial positions and faces arrays. Let's see how to generate those with geometric algorithms.