WebGL Basics

· 6 min read · Updated April 18, 2026 · intermediate
javascript web graphics webgl

WebGL (Web Graphics Library) is a JavaScript API that lets you render 2D and 3D graphics directly in the browser, backed by the GPU. It exposes OpenGL ES functionality through the HTML Canvas element, without requiring any plugins. Every major browser supports it.

Unlike the Canvas 2D API, WebGL gives you programmable rendering — you write two small programs called shaders that run on the GPU. That means actual control over how pixels get drawn, but also more upfront boilerplate before you see your first triangle.

Setting Up a WebGL Context

Start with a <canvas> element and call getContext("webgl"):

<canvas id="glCanvas" width="640" height="480"></canvas>
<script type="module">
const canvas = document.querySelector("#glCanvas");
const gl = canvas.getContext("webgl");

if (!gl) {
  console.error("WebGL not supported in this browser");
}
// gl is now a WebGLRenderingContext
</script>

getContext("webgl") returns null if WebGL isn’t available — always null-check before doing anything else. Some devices only support WebGL 2 (getContext("webgl2")), so you may need feature detection if you need newer capabilities.

Shaders: Vertex and Fragment

WebGL has two shader stages that you must provide:

Vertex shader — runs once per vertex. It receives vertex data from JavaScript and outputs a final position for each vertex.

Fragment shader — runs once per pixel. It outputs the final color for each fragment (pixel).

Write shaders in GLSL (OpenGL Shading Language), embedded as strings in your JavaScript:

const vertexShaderSource = `
  attribute vec4 aPosition;
  void main() {
    gl_Position = aPosition;
  }
`;

const fragmentShaderSource = `
  precision mediump float;
  void main() {
    gl_FragColor = vec4(1.0, 0.5, 0.2, 1.0); // orange
  }
`;

The attribute keyword declares a value passed from JavaScript per vertex. gl_Position is a built-in output — every vertex shader must set it. gl_FragColor is the fragment shader’s output color.

Compiling Shaders

Shaders need to be compiled before use:

function createShader(gl, type, source) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.error("Shader compile error:", gl.getShaderInfoLog(shader));
    gl.deleteShader(shader);
    return null;
  }
  return shader;
}

const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);

If compilation fails, gl.getShaderInfoLog() gives you the error message. GLSL syntax errors are common when starting out — check the console.

Linking a Program

A WebGL program combines a vertex shader and a fragment shader:

function createProgram(gl, vertexShader, fragmentShader) {
  const program = gl.createProgram();
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);

  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error("Program link error:", gl.getProgramInfoLog(program));
    gl.deleteProgram(program);
    return null;
  }
  return program;
}

const program = createProgram(gl, vertexShader, fragmentShader);

Once linked, the program is ready to use. Call gl.useProgram(program) to activate it for rendering.

Drawing Geometry: Buffers and Attributes

Data flows from JavaScript arrays into the GPU through buffers. To draw a triangle, you need:

  1. A buffer holding vertex positions
  2. An attribute that reads from that buffer
  3. A draw call
// Define vertices for a single triangle
const positions = new Float32Array([
  0.0,  0.5,  // top vertex
  -0.5, -0.5, // bottom left
   0.5, -0.5  // bottom right
]);

// Create a buffer
const positionBuffer = gl.createBuffer();

// Bind it to the WebGL state machine
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

// Upload data to the GPU
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);

Now wire the buffer to the attribute in the vertex shader:

const positionAttribLocation = gl.getAttribLocation(program, "aPosition");

gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.enableVertexAttribArray(positionAttribLocation);

// Describe the format of the data to WebGL
gl.vertexAttribPointer(
  positionAttribLocation, // which attribute
  2,                       // how many components per vertex (x, y = 2)
  gl.FLOAT,               // data type
  false,                  // normalize (not needed for floats)
  0,                      // stride (0 = tightly packed)
  0                       // offset in buffer
);

vertexAttribPointer tells WebGL how to interpret the raw bytes in the buffer — in this case, pairs of floats representing x/y coordinates.

The Render Loop and Viewport

Before drawing, set the viewport. WebGL draws into a portion of the canvas called the viewport:

gl.viewport(0, 0, canvas.width, canvas.height);

Clear the color buffer (the background) before each frame:

gl.clearColor(0.0, 0.0, 0.0, 1.0);  // black, fully opaque
gl.clear(gl.COLOR_BUFFER_BIT);

Then issue a draw call. The most common is drawArrays for sequential vertices:

gl.useProgram(program);
gl.drawArrays(gl.TRIANGLES, 0, 3);  // draw 3 vertices as a triangle

The arguments: draw as triangles, start at vertex 0, draw 3 vertices.

Uniforms: Passing Data to Shaders

Uniforms are values that stay constant across all vertices/fragments in a draw call — useful for colors, transformation matrices, or time values.

Declare a uniform in your GLSL:

uniform vec4 uColor;

Get its location in JavaScript:

const colorLocation = gl.getUniformLocation(program, "uColor");

// Set it before drawing
gl.uniform4f(colorLocation, 1.0, 0.0, 0.0, 1.0); // red

Update uniforms every frame if their values change. Failing to set a uniform leaves it at its default (usually zero), which produces black output if you expected a visible color.

Animation: requestAnimationFrame

Real WebGL applications redraw every frame:

function render(time) {
  time *= 0.001; // convert ms to seconds

  gl.clear(gl.COLOR_BUFFER_BIT);

  // Update time-based uniforms here

  gl.drawArrays(gl.TRIANGLES, 0, 3);

  requestAnimationFrame(render);
}

requestAnimationFrame(render);

requestAnimationFrame syncs your render loop to the display’s refresh rate (typically 60fps). It also passes a timestamp so you can drive animation with real time.

Resizing the Canvas

Canvas elements have a pixel resolution (the drawing buffer) and a displayed size (CSS). WebGL uses the pixel resolution, not the CSS size. If they don’t match, content looks stretched or blurry.

Match the WebGL viewport to the device pixel ratio:

function resizeCanvas(canvas, gl) {
  const dpr = window.devicePixelRatio || 1;
  const displayWidth  = Math.floor(canvas.clientWidth  * dpr);
  const displayHeight = Math.floor(canvas.clientHeight * dpr);

  if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
    canvas.width  = displayWidth;
    canvas.height = displayHeight;
    gl.viewport(0, 0, displayWidth, displayHeight);
  }
}

window.addEventListener("resize", () => resizeCanvas(canvas, gl));
resizeCanvas(canvas, gl);

This keeps sharp rendering on high-DPI (Retina) screens while allowing CSS to control the display layout independently.

Common Pitfalls

GLSL syntax errors — GLSL is strict. Missing semicolons, wrong types, and mismatched vector sizes all cause silent black output or cryptic console errors. The shader compiler is not forgiving.

Forgetting to set uniforms — A uniform left unset defaults to zero. If your fragment shader outputs vec4(uColor.r, 0, 0, 1) and uColor is unset, you get black pixels regardless of what you intended.

Buffer not boundvertexAttribPointer reads from whatever buffer is currently bound to ARRAY_BUFFER. If you modify or rebind the buffer after setting up the attribute pointer, the attribute stops working correctly.

Viewport mismatch — If the canvas resize handler doesn’t update the viewport, WebGL continues drawing into a smaller area than expected, leaving blank space.

WebGL context lost — On some devices (especially mobile), the GPU may reclaim the context under memory pressure. Handle the webglcontextlost event and recreate resources on webglcontextrestored.

See Also