Always cooking something
← All posts
April 6, 2026·8 min read

Draw a Triangle: WebGL From Scratch

WebGLCanvasCreative Coding

The first time I opened the WebGL documentation I closed it almost immediately. Before you see a single pixel, you are creating buffers, compiling shaders, linking programs, binding attributes. Sixty lines of boilerplate and nothing on screen.

The thing is, every one of those lines exists for a reason. WebGL is not just a fancier canvas. It is a completely different mental model: you are not telling the CPU what to draw, you are programming the GPU directly. Once that clicks, the boilerplate stops feeling arbitrary.

This post walks through the minimum to draw a triangle. Not to get through it faster, but to explain what each step actually does. By the end you will have something interactive and a clear picture of the full pipeline.


What WebGL actually does

Canvas 2D is a drawing API. You call fillRect, arc, drawImage and the browser figures out the pixels. The CPU does all the work, one call at a time.

WebGL is different. You write two small programs in a language called GLSL, compile them, upload your geometry as raw numbers, and the GPU executes everything. The GPU runs thousands of tiny threads simultaneously: one per vertex, one per pixel. That parallelism is why WebGL can handle things canvas 2D cannot.

Those two programs are called shaders. You write them once, send them to the GPU at startup, and from then on each draw call just points to them.


The vertex shader

The vertex shader runs once per vertex. Its only job is to output a screen position. That position lives in clip space: a coordinate system where the center of the canvas is (0, 0), the top-right is (1, 1), and the bottom-left is (-1, -1). This range is called NDC, Normalized Device Coordinates.

glsl
attribute vec2 aPos;   // one vertex position, from your buffer

void main() {
  gl_Position = vec4(aPos, 0.0, 1.0);
  //                       z     w
  //            0.0 = no depth, 1.0 = standard perspective divide
}

attribute means the value changes per vertex. The GPU feeds in one coordinate pair for each vertex in your buffer. gl_Position is the built-in output. The vec4 takes (x, y, z, w): for 2D work, z is 0 and w is 1.


The fragment shader

After the vertex shader places the three corners, the GPU figures out which pixels fall inside the triangle. Then it runs the fragment shader once for every one of those pixels.

glsl
precision mediump float;   // float precision declaration, always needed
uniform vec4 uColor;       // same value for every fragment in this draw call

void main() {
  gl_FragColor = uColor;   // output this pixel's color as (r, g, b, a)
}

uniform means the value is set from JavaScript and stays the same for every fragment in a draw call. You set it once before drawing and the whole triangle gets that color. Change it and redraw and the whole triangle changes. Pick a color below and watch the uniform update in real time.

Interactive · Vertex shader + fragment shader
gl.uniform4f(uColor, 0.49, 0.36, 1.00, 1.00)

That is the full pipeline output. A vertex shader placing three points, a fragment shader coloring every pixel inside them, and a uniform connecting JavaScript to GLSL.


Buffers: getting data to the GPU

The vertex shader needs coordinates to work with. You upload them as a typed array via a buffer. JavaScript numbers live on the CPU. This copies them across to GPU memory.

js
const verts = new Float32Array([
   0.0,  0.65,   // vertex A (top center)
  -0.6, -0.45,   // vertex B (bottom left)
   0.6, -0.45,   // vertex C (bottom right)
])

const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW)

Then you describe the layout: each attribute is 2 floats, packed tightly, starting at offset 0.

js
const aPos = gl.getAttribLocation(prog, 'aPos')
gl.enableVertexAttribArray(aPos)
gl.vertexAttribPointer(
  aPos,       // which attribute
  2,          // 2 floats per vertex (x, y)
  gl.FLOAT,   // type
  false,      // normalize? no
  0,          // stride: 0 = tightly packed
  0           // offset: start from the beginning
)

Drag the handles below to move the vertices. The coordinates shown are in NDC, so notice how (0, 0) is center, not top-left. That trips everyone up at first.

Interactive · Vertex positions in NDC
A
B
C
A (0.00, 0.65)B (-0.60, -0.45)C (0.60, -0.45)

Drag the handles. NDC: center is (0, 0), top-right is (1, 1), bottom-left is (-1, -1).

Why Float32Array? The GPU expects 32-bit floats, not JavaScript's default 64-bit doubles. Using the wrong type is one of the most common sources of silent WebGL bugs. Always use Float32Array for vertex data.

The draw call

After all that setup: one line.

js
gl.drawArrays(gl.TRIANGLES, 0, 3)
//             ^             ^ ^
//             primitive     | vertex count
//                           start index

gl.TRIANGLES means: take vertices in groups of 3 and draw a filled triangle for each group. Start at index 0, use 3 vertices. The GPU runs the vertex shader 3 times, then the fragment shader for every pixel inside the result.

Everything else (compiling shaders, linking the program, uploading the buffer) happens once at startup. The draw call itself is cheap. Changing a uniform and calling drawArrays again redraws with the new value.


Uniforms: passing data to the GPU

You have already seen one uniform: uColor in the first demo. The pattern always looks the same. Get the location once, set the value before each draw.

js
// At init
const uColor = gl.getUniformLocation(prog, 'uColor')

// Before each draw (or whenever the value changes)
gl.uniform4f(uColor, r, g, b, 1.0)   // 4 floats: RGBA
gl.uniform1f(uTime,  t)               // 1 float:  a number
gl.uniform2f(uRes,   w, h)            // 2 floats:  vec2

You can pass uniforms to both shaders. Pass uTime to the vertex shader and you can rotate, scale, or deform. Pass it to the fragment shader and you can shift colors. The GPU reads the same value for every vertex and every pixel in that draw call.

Interactive · uTime in vertex and fragment shaders
Speed1.0×

uTime increments every frame. Vertex shader uses it to rotate. Fragment shader uses it for color.

The vertex shader uses uTime to rotate each vertex around the origin. The fragment shader receives a separate uColor uniform, computed in JavaScript from the same t value using sine waves and passed via gl.uniform3f. The GPU gets the result; it never sees the math that produced it.

glsl
// Vertex shader: uTime rotates each vertex
uniform float uTime;
void main() {
  float c = cos(uTime);
  float s = sin(uTime);
  vec2 rotated = vec2(
    aPos.x * c - aPos.y * s,
    aPos.x * s + aPos.y * c
  );
  gl_Position = vec4(rotated * 0.65, 0.0, 1.0);
}

// Fragment shader: uColor is computed in JS and passed each frame
uniform vec3 uColor;
void main() {
  gl_FragColor = vec4(uColor, 0.92);
}

The full picture

The complete setup is about 50 lines, but the structure is always the same:

  • Write shaders. Two GLSL strings: one vertex, one fragment. Compile and link them into a program.
  • Upload geometry. Float32Array into a buffer. Describe the layout to the vertex attribute.
  • Set uniforms. Any values the shader needs that do not change per vertex: colors, time, resolution, matrices.
  • Draw. gl.clear() then gl.drawArrays(). That is the whole render step.
  • Animate. Update your uniforms, call drawArrays again. Everything else persists between frames.
Most of what WebGL does beyond the triangle is a variation of this. More triangles share a bigger buffer. Textures are uniforms of type sampler2D. Transformations areuniform mat4 matrices. The fragment shader gets more complex. But the pipeline stays the same: shaders, buffer, uniforms, draw.

A natural next step is drawing a quad: two triangles that share an edge, forming a rectangle. From there it is a short walk to mapping a texture onto it, which is how image processing shaders work. If you want to skip ahead, Depth Frames is a canvas 2D experiment that fakes the same depth-layering effect without shaders, which makes for an interesting comparison once you know how the GPU version would work.

newsletter

Stay in the loop

New experiments, articles, and tools — straight to your inbox. No spam, unsubscribe anytime.