Vertex Normals
This renderer shows a single triangle and its face normal:
However, this renderer is misleading. Properties in WebGL are per vertex. There is no way in WebGL to associate a solitary face normal with an entire triangle. What's really happening is that the face normal is duplicated at all three vertices.
Normals must be defined as vertex attributes, just as we define positions and colors:
attributes.addAttribute('position', vertexCount, 3, positions);
attributes.addAttribute('color', vertexCount, 3, colors);
attributes.addAttribute('normal', vertexCount, 3, normals);
attributes.addAttribute('position', vertexCount, 3, positions);
attributes.addAttribute('color', vertexCount, 3, colors);
attributes.addAttribute('normal', vertexCount, 3, normals);Suppose a vertex is shared between multiple faces. What normal should it have? We have a couple of options.
Disconnected Faces
The simplest approach is to disconnect the faces from each other by duplicating any shared vertices. Then each vertex is assigned its face's normal. This renderer defaults to rendering its cube with disconnected faces:
At first glance, the cube appears to only have 8 vertices. In truth, it has 24. Each position appears in the buffer three times for the three faces and three normals with which it's associated.
Shared Vertices
The other alternative is to keep the faces connected and give each vertex a normal that is the average of its adjacent faces' normals. Toggle the checkbox in the cube renderer above to see this alternative. It doesn't look good. The averaged normals point off in diagonal directions that don't correspond to any face. Sharing vertices doesn't make sense when faces form sharp bends like they do on a cube.
Sharing vertices does make sense for smooth shapes, like this sphere:
With sharing enabled, the sharp bends are smoothed out. Sharing vertices means we can significantly reduce the number of triangles and still get a pleasant rendering.
To compute a vertex's average normal, we sum up the normals of its adjacent faces and then normalize the summed vector, which we can do as we process each triangle:
export class Trimesh {
computeNormals() {
const normals = this.positions.map(_ => new Vector3(0, 0, 0));
for (let face of this.faces) {
// ...compute face normal...
normals[face[0]] = normals[face[0]].add(faceNormal);
normals[face[1]] = normals[face[1]].add(faceNormal);
normals[face[2]] = normals[face[2]].add(faceNormal);
}
this.normals = normals.map(normal => normal.normalize());
}
}
export class Trimesh {
computeNormals() {
const normals = this.positions.map(_ => new Vector3(0, 0, 0));
for (let face of this.faces) {
// ...compute face normal...
normals[face[0]] = normals[face[0]].add(faceNormal);
normals[face[1]] = normals[face[1]].add(faceNormal);
normals[face[2]] = normals[face[2]].add(faceNormal);
}
this.normals = normals.map(normal => normal.normalize());
}
}The vertex normals must be initialized to zero vectors before the face normals start accumulating. And they need to averaged after they are accumulated. Usually averaging is done by dividing the sum by the size of the population. In the case of normals, we can just normalize them.
Develop a feeling for how the algorithm works in this renderer:
The blue normals are the face normals. They are averaged together to form the orange vertex normals. Observe how these normals change as you drag the vertex positions around.
Now we can solve the problem of flat shading of our 3D models. Complete these steps to shade a model by its shape:
computeNormals on the Trimesh.in variable named normal and an out variable named mixNormal. Assign normal to mixNormal.in variable named mixNormal. Assign the normal to the color. Because of the interpolation, the normal is no longer normalized. Write normalize(mixNormal) to renormalize.Rerender the grid, cylinder, cone, and sphere and enjoy their false but illustrative coloring.