Command Palette

Search for a command to run...

Hectal
Course outline
Topic 12.5Advanced ~20 min · step 5 of 7

Vertex Displacement

In one line

A vertex shader can move vertices: waves, wind, a swelling balloon. Lighting then needs a new normal, and three.js needs to know the mesh is no longer where its bounding sphere says.

0/7 · 0%

Think of it like this

A flag is a grid of points. To make it wave, you do not redraw it: you move each point up or down by a rule and the cloth follows. The vertex shader is that rule, run for every point on the GPU.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

displacement
Moving a vertex from where the geometry put it.
stale normal
A normal that still describes the surface before it was displaced.
bounding sphere
The smallest sphere around a geometry. three.js uses it to skip meshes that are out of view.
frustum culling
Skipping a mesh that is outside the camera's view before drawing it (Topic 7.1 shows the frustum, Topic 17.3 covers culling in full).

Step by step

01Two planes, one wave

Both planes use the same displacement. A define, RECOMPUTE, switches the normal: normal (flat) or the slope-based one.

02Compare numbers

A row of pixels across each wave at time 0 is read back. The stale wave is one grey level everywhere; the recomputed one has light and shadow.

03Lift a panel

The second example moves a small panel up by 3 m in the shader and looks at 3 m height. The bounding sphere is still at the origin, so frustum culling removes it; frustumCulled = false brings it back.

04Keep the formula and the height together

The slope line is the derivative of the height line. When you change one, change the other in the same edit.

Try it yourself

  1. 1

    A flat plane cannot wave

    Change PlaneGeometry(2, 2, 128, 4) to PlaneGeometry(2, 2, 4, 4). The wave becomes a zigzag of a few straight ramps, because only 5 vertices per row exist.

  2. 2

    Watch the numbers

    With only 4 segments the printed range changes to 188 to 188 and 52 to 247: the stale wave is still one brightness, and the right one now has a few flat facets with abrupt changes.

Code & diagrams

A wave with stale and with recomputed normals New tab

Left: the old normal. Right: the new one. The brightness numbers are read from a small off-screen picture at time 0.

idle

Console

Run the scene to see its console.

Expected console output

stale normals:     red channel 188 to 188
recomputed normals: red channel 50 to 250
Displaced far, culled early New tab

Nothing is drawn on screen until `frustumCulled` is turned off, and then the panel appears.

idle

Console

Run the scene to see its console.

Expected console output

frustumCulled = true (default) triangles drawn: 0
frustumCulled = false          triangles drawn: 2
bounding sphere centre y: 0 | radius: 0.72

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Change the wave, forget the normal

Change sin(K * p.x + uTime) to sin(K * p.x + uTime) * cos(2.0 * p.x) and leave the slope line as it is.

terminal
$ Run
── what you'll see ──
stale normals: red channel 1 to 188
recomputed normals: red channel 50 to 250
The numbers still look healthy, which is the trap: look at the picture. The right wave's bright and dark bands run as the old `sin` wave, not along the new bumps. The shape follows the new formula but the light follows the old one.

Myth vs fact

Myth

geometry.computeVertexNormals() fixes a shader displacement.

Fact

It reads the positions stored in the geometry, which a vertex shader never changes. The shader has to compute the new normal itself.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Displacement with a texture (a height map) is a texture read in the vertex shader: texture2D works in WebGL 2 vertex shaders. Normals then come from reading the map at two neighbouring texels.

  • ▸

    To keep the shadow right, give the mesh a customDepthMaterial (a MeshDepthMaterial with the same onBeforeCompile displacement). Topic 5.5 explains the shadow pass, and Phase 17 measures what each extra pass costs.

Remember this

  1. 1

    The problem this solves: moving thousands of vertices in JavaScript every frame costs CPU time and a buffer upload (Topic 4.6). In a vertex shader it costs almost nothing, and the geometry stays untouched.

  2. 2

    How. Change position before modelViewMatrix is applied: p.y += A * sin(K * p.x + uTime). A wave of height A and wavenumber K (radians per metre) moves with uTime. You can only move vertices that exist: a plane of 2 × 2 vertices cannot make a wave, so the geometry needs enough segments (the example uses 128).

  3. 3

    Stale normals. Lighting uses each vertex's normal. If you move the vertex but keep the old normal, the surface still looks flat: it is lit as if it had not moved. The left wave in the example has a single brightness along its whole length (188 in the red channel). The right wave recomputes the normal from the slope: the slope of A sin(Kx + t) is A K cos(Kx + t), so the normal is normalize(vec3(-slope, 1.0, 0.0)), and brightness runs from 50 to 250.

  4. 4

    When there is no formula. For noise or any shape without a derivative, compute the height at two nearby points and use the difference (finite differences), or compute the normal from the neighbours' positions. Both cost extra evaluations per vertex.

  5. 5

    Culling. three.js decides whether a mesh is on screen from the geometry's bounding sphere, computed from the original positions. Move the vertices far in the shader and three.js still thinks the mesh is where it was: when that sphere leaves the view the whole mesh vanishes, even though it should be on screen. Fix: mesh.frustumCulled = false, or set a bigger bounding sphere by hand. The second example draws zero triangles until it is turned off.

  6. 6

    Shadows and picking. The shadow pass and the raycaster use the original geometry: a displaced mesh casts the shadow of the flat one unless you give the shadow pass a displaced customDepthMaterial, and clicks hit the undisplaced mesh. For a wave this is minor; for a big movement it is a visible bug.

Explain it without notes

01

Why does a wave look flat if you only move the vertices?

02

Why can a displaced mesh disappear when it is still on screen?

Practice

01

Add a second wave that travels along z and update the normal.

02

Make the wave amplitude a uniform uAmplitude that you can change from JavaScript.

Done when you can

  • Displace a vertex in a shader

  • Recompute the normal from the slope

  • Say why a bounding sphere breaks culling

  • Name two things that still use the undisplaced geometry