Skip to content
Rendering · Pipeline stagesIntermediate

Vertex shader

The program that runs once per vertex, places it on screen and hands values to the fragment shader.

Live demo coming soon

How it works

A mesh is a list of vertices: positions, usually with a normal and a UV each, grouped into triangles. Before anything can be drawn, each vertex has to be placed on the screen. The vertex shader does that. It runs once per vertex, reads that vertex’s attributes, and must write gl_Position: where the vertex lands, in a coordinate system called clip space. Anything else it wants the fragment shader to know, it writes as a varying.

From the model to the screen

A vertex position starts in the model’s own coordinates (object space): the numbers the 3D artist exported, with the model’s origin at (0, 0, 0). Getting it on screen is a chain of matrix products:

  1. the model matrix places the object in the world (world space);
  2. the view matrix moves the world so the camera sits at the origin, looking down −z (view space);
  3. the projection matrix squeezes the camera’s view volume into clip space, and for a perspective camera puts the distance into w.
#version 300 es
in vec3 aPosition;
in vec3 aNormal;
uniform mat4 uModel;
uniform mat4 uViewProjection; // view and projection, multiplied once on the host
out vec3 vWorldNormal;

void main() {
    vec4 world = uModel * vec4(aPosition, 1.0);
    vWorldNormal = mat3(uModel) * aNormal; // fine while the model has no non-uniform scale
    gl_Position = uViewProjection * world;
}

The vertex shader stops at clip space. Dividing by w, which makes far things smaller, happens next, in the fixed step of rasterization.

Moving vertices

Because it owns the position, the vertex shader can also move it. Waves, flags in the wind, swaying grass and breathing creatures are all vertex displacement: compute an offset from the time and the position, add it before projecting.

// a ripple along the surface normal, before the matrices
float height = 0.05 * sin(aPosition.x * 8.0 + uTime * 2.0);
vec3 moved = aPosition + aNormal * height;

Three limits shape every effect built this way:

  • It sees one vertex at a time. It can’t look at its neighbors, add vertices or remove them.
  • Detail comes from the mesh. A quad has four vertices, so no displacement makes it wave; a flag needs a subdivided plane. The fragment shader can fake small detail, but the silhouette only changes where vertices are.
  • Normals don’t follow. Moving vertices leaves the stored normals pointing the old way, so lighting still shows the flat surface. The shader must recompute the normal, usually from the derivative of the same function that moved the vertex.

Cheap, then expensive

A mesh usually has far fewer vertices than the pixels it covers, so work done per vertex and interpolated is cheaper than the same work per pixel. That is why lighting was once computed per vertex. The catch is that interpolation is linear: anything that curves faster than the triangles, like a sharp highlight, comes out wrong.

The vertex shader also draws full-screen passes. With no buffer at all, it can make one triangle that covers the whole screen from gl_VertexID, the index of the vertex being processed:

#version 300 es
out vec2 vUv;

void main() {
    // vertices 0, 1, 2 → (0, 0), (2, 0), (0, 2): a triangle twice the screen's size
    vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
    vUv = corner;
    gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}

The parts outside the screen are clipped away, and the visible part has UVs from 0 to 1.

In Godot and on this site

In a Godot spatial shader, the vertex stage is the vertex() function. VERTEX and NORMAL arrive in model space and Godot applies the model, view and projection matrices after the function returns. render_mode world_vertex_coords hands them over in world space instead, render_mode skip_vertex_transform leaves the model and view transforms to you (Godot still applies the projection), and writing POSITION sets the clip-space position directly.

On this site, a card that moves vertices names a vertex entry in its render.json: Inputs <name>(Inputs i), which returns i with a new position and normal in world space. The frame projects the result and interpolates it into effect().

Further reading