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:
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.
<!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>