Course outline
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.
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
A flat plane cannot wave
Change
PlaneGeometry(2, 2, 128, 4)toPlaneGeometry(2, 2, 4, 4). The wave becomes a zigzag of a few straight ramps, because only 5 vertices per row exist. - 2
Watch the numbers
With only 4 segments the printed range changes to
188 to 188and52 to 247: the stale wave is still one brightness, and the right one now has a few flat facets with abrupt changes.
Code & diagrams
Left: the old normal. Right: the new one. The brightness numbers are read from a small off-screen picture at time 0.
Console
Run the scene to see its console.Expected console output
stale normals: red channel 188 to 188
recomputed normals: red channel 50 to 250Nothing is drawn on screen until `frustumCulled` is turned off, and then the panel appears.
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.72Break 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.
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:
texture2Dworks 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(aMeshDepthMaterialwith the sameonBeforeCompiledisplacement). Topic 5.5 explains the shadow pass, and Phase 17 measures what each extra pass costs.
Remember this
- 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
How. Change
positionbeforemodelViewMatrixis applied:p.y += A * sin(K * p.x + uTime). A wave of heightAand wavenumberK(radians per metre) moves withuTime. 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
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)isA K cos(Kx + t), so the normal isnormalize(vec3(-slope, 1.0, 0.0)), and brightness runs from 50 to 250. - 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
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
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
Why does a wave look flat if you only move the vertices?
Why can a displaced mesh disappear when it is still on screen?
Practice
Add a second wave that travels along z and update the normal.
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