Vertex displacement
Purpose
Before the GPU rasterizes a mesh into pixels, a vertex shader runs on every vertex — moving those points in 3D space is how you make flags flap, grass sway, and flesh wobble.
Key insight
Every lesson before this has worked on a canvas someone else drew. The mesh was fixed; you painted it. The vertex shader is the stage before that — it runs once per vertex, receives the vertex's original position, and outputs a (possibly changed) position. Offset each vertex by sin(time + worldPosition) and a flat plane becomes a rippling banner.
The silhouette actually changes, because the triangles themselves have moved — unlike anything in the fragment layer, which can only paint within the shape it was given. Push sway and watch the outline of the plane breathe. That's only possible here, at the vertex stage.
Two costs: (1) lighting normals were computed from the original mesh, so they're stale on the now-displaced surface — you can see it in slightly-off shading on the crests; (2) detail is limited by mesh density, because the GPU interpolates linearly between vertices.
Break it
Crank sway to max and watch the crests. Notice the faceted look near peaks — the displacement is smooth mathematically, but the mesh only has a finite number of vertices, so the GPU interpolates in straight lines between them. You're seeing the polygons. Teaches: vertex work is coarse. A smooth-looking wind on a flag requires either a dense mesh or tessellation — this is where Gerstner waves (L25) need a subdivided water plane, and where POM (L26) cheats by not touching geometry at all.
Also notice the lighting on the crests looks slightly wrong — highlights don't quite track the bumps. Those are the stale normals from the flat plane, still pointing straight up while the surface has tilted underneath them. Fixing that means recomputing normals in the vertex shader, which is extra work this lesson skips on purpose.
Direct Claude
// VERTEX STAGE — pre-fragment coordinate layer.
// Runs once per vertex BEFORE the mesh is rasterized.
uniform float u_time;
uniform float u_sway;
varying vec3 vNormal;
varying float vDisplace;
// Cheap 3D value noise for the wind field
float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
float noise(vec3 p) {
vec3 i = floor(p); vec3 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(mix(hash(i+vec3(0,0,0)), hash(i+vec3(1,0,0)), f.x),
mix(hash(i+vec3(0,1,0)), hash(i+vec3(1,1,0)), f.x), f.y),
mix(mix(hash(i+vec3(0,0,1)), hash(i+vec3(1,0,1)), f.x),
mix(hash(i+vec3(0,1,1)), hash(i+vec3(1,1,1)), f.x), f.y),
f.z);
}
void main() {
// Each vertex starts at its rest position. We push it ALONG the normal
// by a noise value — so flat plane vertices rise and fall like wind.
float n = noise(position * 1.2 + vec3(u_time * 0.6));
float offset = (n - 0.5) * u_sway;
vec3 displaced = position + normal * offset;
vDisplace = offset;
vNormal = normal; // STALE — the flat-plane normal, not the new surface
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}
// FRAGMENT STAGE — simple Lambert-ish shading using the stale normal.
// The slightly wrong highlights on crests are a feature of this lesson.
varying vec3 vNormal;
varying float vDisplace;
void main() {
vec3 light = normalize(vec3(0.5, 0.8, 0.4));
float shade = max(0.0, dot(normalize(vNormal), light));
vec3 warm = vec3(0.96, 0.74, 0.52);
vec3 cool = vec3(0.38, 0.52, 0.66);
vec3 base = mix(cool, warm, 0.5 + vDisplace * 2.0);
vec3 col = base * (0.25 + 0.85 * shade);
gl_FragColor = vec4(col, 1.0);
}