jsguides

WebGL Basics: Shaders, Buffers, and GPU Rendering

WebGL (Web Graphics Library) is a JavaScript API that lets you render 2D and 3D graphics directly in the browser, backed by the GPU. Learning WebGL basics means understanding how shaders, buffers, and the GPU pipeline work together. 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, giving you actual control over how pixels get drawn. That power comes with 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. The precision mediump float declaration is required in fragment shaders to define the default floating-point precision for calculations.

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. Deleting failed shaders with gl.deleteShader() is good practice: it frees GPU resources that would otherwise leak.

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);

The buffer now holds the triangle coordinates on the GPU, but WebGL doesn’t yet know which attribute to connect to this data. You must explicitly wire the buffer to the shader attribute that should read from it.

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, those bytes are 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);

Before each frame, WebGL needs a clean starting point. The color buffer holds the pixel values from the previous frame, so clearing it with a background color prevents visual ghosting from accumulating across draws.

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);

With the program active and the viewport set, the final call sends vertex data through the pipeline. drawArrays reads vertices sequentially from the bound buffer, passes them through the vertex shader, rasterizes the resulting triangles, and runs the fragment shader for every pixel inside.

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. They are 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. Setting uniform values after gl.useProgram() is essential: WebGL stores uniforms per program, so switching programs resets which uniform locations are active.

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 bound. vertexAttribPointer 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.

Start with one triangle

WebGL is much easier to learn when you treat the first triangle as a real milestone rather than a throwaway demo. That tiny shape proves that the canvas, shaders, buffers, and draw call all agree with one another. Once that works, every later step becomes a variation on the same pipeline. If a later change breaks the triangle, you know the problem is in a narrow part of the rendering path and can debug it without guessing.

Check browser support early

Not every browser or device behaves the same way, so the first thing your app should do is confirm that a WebGL context exists. A small support check helps you show a fallback message before the user stares at a blank area and wonders whether the page failed. It also keeps your code honest about the environment it expects. If the browser cannot create the context, it is better to say so clearly than to fail later in a shader or buffer call.

Keep the first scene simple

WebGL projects get easier when the first screen is something you can explain in one sentence. A triangle, a solid background, and a clear color choice are enough to prove that the pipeline works. Once that is stable, you can add uniforms, texture loading, and animation one piece at a time. Starting small makes debugging much less frustrating because every new effect has a known baseline to compare against.

Think about redraw cost

Every frame in a WebGL app has a cost. If you redraw more than you need to, the browser spends time pushing pixels instead of staying responsive. That does not mean you should avoid animation, only that you should be deliberate about when a frame really needs to change. A steady redraw loop is fine for motion, but a static scene should stay quiet until something visible actually changes.

See Also