In the triangle post the vertex shader had one job: pass coordinates through to the GPU unchanged. That is the minimal case. But the vertex shader is the first programmable stage in the pipeline, and it runs on the GPU in parallel across every vertex. You can do a lot more than just forward positions.
This post goes deeper into what the vertex stage can actually do: passing data to the fragment stage through varyings, uploading multiple streams of per-vertex data as interleaved attributes, deforming geometry with math, and drawing the same geometry many times with different uniforms.
Varyings: the bridge between stages
The vertex shader runs once per vertex. The fragment shader runs once per pixel. Between them, the GPU interpolates: it figures out all the pixels that fall inside the triangle and blends the values from the three corners based on how close each pixel is to each vertex.
A varying is how you tap into that interpolation. You write a value in the vertex shader, declare the same variable as varying in both shaders, and the fragment shader receives the smoothly blended result.
// Vertex shader
attribute vec2 aPos;
attribute vec3 aColor; // one color per vertex, from the buffer
varying vec3 vColor; // declare as varying - GPU will interpolate this
void main() {
vColor = aColor; // write to the varying
gl_Position = vec4(aPos, 0.0, 1.0);
}
// Fragment shader
precision mediump float;
varying vec3 vColor; // same name, same type - receives the interpolated value
void main() {
gl_FragColor = vec4(vColor, 1.0);
}The three vertices have three different colors. Every pixel inside the triangle gets a blend of all three, weighted by position. You write three values. The GPU produces thousands.
Nothing in the fragment shader code does the blending. The GPU hardware handles it between the two shader stages. The fragment shader just reads the result.
Interleaved attributes: one buffer, multiple streams
The previous triangle post used a single attribute: aPos, two floats per vertex. To add per-vertex color, you need a second attribute. The cleanest way is to pack everything into one buffer in an interleaved layout.
// Interleaved layout: x, y, r, g, b - 5 floats per vertex
const data = new Float32Array([
0.0, 0.65, 0.49, 0.36, 1.00, // top vertex: position + color
-0.6, -0.45, 0.18, 0.90, 0.65, // bottom-left: position + color
0.6, -0.45, 1.00, 0.55, 0.20, // bottom-right: position + color
])Then you describe each attribute with a stride (how many bytes to skip to reach the next vertex) and an offset (where this attribute starts within each vertex block).
const STRIDE = 5 * 4 // 5 floats × 4 bytes = 20 bytes per vertex
// aPos: 2 floats, starts at byte 0
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, STRIDE, 0)
// aColor: 3 floats, starts at byte 8 (after 2 floats of position)
gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, STRIDE, 2 * 4)Interleaved layouts are the standard for performance. All the data for one vertex lives at the same memory address, which is cache-friendly. The alternative is separate buffers per attribute (Array of Structures vs Structure of Arrays). For small geometry the difference is negligible; for large meshes it matters.
Deforming geometry in the vertex shader
Because the vertex shader runs on the GPU, it can modify positions before rasterization. This is how terrain waves, cloth simulation, morphing shapes, and particle systems work. You upload static geometry once and animate it entirely through uniforms.
The shader below takes a horizontal strip of triangles and displaces each vertex vertically using a sine function. The geometry is fixed. The wave exists purely in math.
attribute vec2 aPos;
uniform float uTime;
uniform float uAmp; // amplitude: how tall the wave gets
uniform float uFreq; // frequency: how many cycles across the strip
varying float vWave; // pass displacement to fragment for coloring
void main() {
float wave = sin(aPos.x * uFreq + uTime * 2.0) * uAmp;
vWave = wave / uAmp; // normalize to -1..1 for the fragment shader
gl_Position = vec4(aPos.x, aPos.y + wave, 0.0, 1.0);
}The fragment shader receives the vWave varying and uses it to blend between two colors, so the color follows the deformation. The wave is visible in both shape and color simultaneously.
The vertex shader displaces each vertex on the y-axis using sin(x × freq + time) × amp. The geometry lives on the GPU - only the two uniforms travel from JavaScript.
The JavaScript only updates two floats per frame: uAmp and uFreq. The geometry (60 quads = 360 vertices) never changes. Every position calculation happens in parallel on the GPU.
bufferData, a round trip across the CPU-GPU boundary every frame. Doing the math in a vertex shader keeps everything on the GPU. For 360 vertices the difference is small. For 360,000 it is not.Multiple draw calls: one buffer, many shapes
You do not need different geometry to draw different shapes. You can draw the same geometry multiple times in a row, changing uniforms between each call. Each draw call is independent, covering position, rotation, scale, and color.
// Same 3 vertices, drawn N times with different uniforms each time
for (const shape of shapes) {
gl.uniform1f(uAngle, shape.angle)
gl.uniform2f(uOffset, shape.x, shape.y)
gl.uniform1f(uScale, shape.scale)
gl.uniform3f(uColor, shape.r, shape.g, shape.b)
gl.drawArrays(gl.TRIANGLES, 0, 3)
}The vertex shader applies the transform to each vertex using the uniforms:
attribute vec2 aPos;
uniform float uAngle;
uniform vec2 uOffset;
uniform float uScale;
void main() {
// Rotate in 2D using a standard rotation matrix
float c = cos(uAngle);
float s = sin(uAngle);
vec2 rotated = vec2(
aPos.x * c - aPos.y * s,
aPos.x * s + aPos.y * c
);
gl_Position = vec4(rotated * uScale + uOffset, 0.0, 1.0);
}One buffer. Six draw calls. Each call sets different uAngle, uOffset, uScale, and uColor uniforms before drawing the same 3 vertices.
Six draw calls. Each one sets four uniforms and fires the same three vertices through the same shader. The GPU runs 18 vertex shader invocations total, three per call, each reading different uniform values.
This pattern scales to instanced rendering, where you upload all per-instance data into a buffer and draw thousands of copies in a single call. That is a WebGL2 feature and a significant optimization for particle systems.
What comes next
The vertex stage handles positions and passes data to the next stage. What the fragment shader does with that data is where most of the visual richness in WebGL comes from. Distance functions, UV coordinate tricks, animated gradients, and procedural textures all live there.
- →varyings. Pass any per-vertex data to the fragment stage. The GPU interpolates it across the primitive automatically.
- →Interleaved attributes. Pack any per-vertex data (position, color, UV, normals) into a single buffer using stride and offset.
- →GPU-side math. Deforming geometry via uniforms keeps everything on the GPU. No buffer uploads per frame.
- →Multiple draw calls. The same geometry drawn with different uniform sets is the basis of instancing and sprite batching.