Indexed Geometry

Indexed Geometry and Vertex Normals

An index (Indexed Drawing) lets triangles share vertices, and shared vertices share one normal: computeVertexNormals() averages the face normals around each vertex, so the surface shades smoothly. Moving positions does not update normals, so recompute them after any deformation:

Stale and recomputed normals on a bent page, run in NodeJavaScript
import * as THREE from 'three';
const page = new THREE.PlaneGeometry(1.2, 0.8, 32, 1);          // a page, bent into a wave
const position = page.attributes.position, normal = page.attributes.normal;
for (let v = 0; v < position.count; v++) {
  position.setZ(v, 0.06 * Math.sin(position.getX(v) * 14));
}
const show = (what) => console.log(what, normal.getX(3).toFixed(3), normal.getZ(3).toFixed(3));
show('stale normal x, z:');
page.computeVertexNormals();
show('recomputed x, z:  ');
console.log(position.count, 'indexed vertices,', page.toNonIndexed().attributes.position.count,
  'after toNonIndexed()');
Output
stale normal x, z: 0.000 1.000
recomputed x, z:   -0.545 0.838
66 indexed vertices, 192 after toNonIndexed()

Until the recompute, every normal points along +z and the wave lights like a flat sheet. toNonIndexed() gives each triangle its own vertices, so recomputed normals shade flat facets; flatShading: true on the material gets that look without extra vertices.

A bending page with stale normals, recomputed smooth normals and flat facetsHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
  canvas { display: block; max-width: 100%; }
  .labels { display: flex; width: 600px; max-width: 100%; text-align: center; }
  .labels div { flex: 1; }
</style>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="labels"><div>stale normals (all +z)</div><div>computeVertexNormals()</div><div>toNonIndexed(): flat facets</div></div>
<script type="module">
import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 220);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(30, 600 / 220, 0.1, 50);
camera.position.set(0, 0.9, 3.7);
camera.lookAt(0, 0, 0);
scene.add(new THREE.AmbientLight('#ffffff', 0.3));
const sun = new THREE.DirectionalLight('#ffffff', 3);
sun.position.set(-3, 2, 2);                              // raking light shows the waves
scene.add(sun);

const material = new THREE.MeshStandardMaterial({ color: '#fff3e0', roughness: 0.5, side: THREE.DoubleSide });
// Indexed pages: 33 × 2 vertices shared by 64 triangles
const pages = [0, 1, 2].map((i) => {
  const geometry = new THREE.PlaneGeometry(1.2, 0.8, 32, 1);
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.x = (i - 1) * 1.45;
  mesh.rotation.x = -0.5;
  scene.add(mesh);
  return mesh;
});

function bend(geometry, t) {
  const position = geometry.attributes.position;
  for (let v = 0; v < position.count; v++) {
    position.setZ(v, 0.06 * Math.sin(position.getX(v) * 14 + t));
  }
  position.needsUpdate = true;                        // re-upload the edited array
}

renderer.setAnimationLoop((time) => {
  const t = time / 400;
  bend(pages[0].geometry, t);                         // moved, but normals still point along +z
  bend(pages[1].geometry, t);
  pages[1].geometry.computeVertexNormals();           // averaged around each shared vertex: smooth
  // Flat version: each triangle gets its own three vertices, so its normals are the face normal
  const flat = pages[1].geometry.toNonIndexed();
  flat.computeVertexNormals();
  pages[2].geometry.dispose();
  pages[2].geometry = flat;
  renderer.render(scene, camera);
});
</script>