Learn Creative Coding (#74) - Raw WebGL: Understanding the Pipeline

in StemSocial5 hours ago

Learn Creative Coding (#74) - Raw WebGL: Understanding the Pipeline

cc-banner

For the last dozen episodes we've been working inside Three.js -- building scenes, placing lights, adding post-processing, wiring up physics. And Three.js is wonderful. It lets you think in terms of meshes, materials, cameras, and lights instead of GPU state and buffer bindings. But every single thing Three.js does for you eventually becomes a raw WebGL call. Every scene.add(mesh) results in buffer uploads, shader compilations, uniform bindings, and draw calls happening under the hood. You just don't see them.

This episode strips away the abstraction. We're going to talk directly to the GPU through the WebGL2 API. No Three.js, no libraries, no helpers. Just a canvas, a gl context, and us telling the graphics card exactly what to do. It sounds intimidating but honestly? Once you see how the pipeline fits together, Three.js makes way more sense. You stop thinking of it as magic and start seeing it as a convenience layer over operations you now understand.

Way back in episode 32 we wrote our first GLSL shaders inside Three.js's ShaderMaterial -- vertex shaders, fragment shaders, uniforms, varyings. That was GLSL the language. Today is WebGL the API. The bit that sets up everything the shader needs before it can run.

Why learn raw WebGL

Three reasons.

First: debugging. When something goes wrong in Three.js -- a mesh is invisible, a shader gives weird colors, performance drops off a cliff -- you need to understand what's happening at the GPU level to diagnose it. The browser's WebGL inspector shows you buffers, textures, draw calls, shader programs. If you don't know what those are, the inspector is useless.

Second: optimization. Knowing that each unique material means a separate shader program (expensive to switch), or that uploading new buffer data every frame costs bus bandwidth, or that too many draw calls bottleneck the CPU -- this knowledge lets you make informed decisions about how you structure your Three.js scenes.

Third: some effects are actually easier in raw WebGL. A full-screen fragment shader (like the shader playground we built in the shader arc) is literally 20 lines of WebGL setup plus your GLSL. In Three.js you need a camera, a scene, an orthographic setup, a plane geometry, a ShaderMaterial. More ceremony for the same result.

The rendering pipeline

Here's what happens when the GPU draws something, from start to finish:

Vertex data (arrays of numbers in GPU memory) flows into the vertex shader (runs once per vertex, transforms positions, passes data downstream). The transformed vertices get assembled into triangles. Those triangles get rasterized -- converted from vector geometry into a grid of fragments (potential pixels). Each fragment runs through the fragment shader (decides the final color). The colored fragments get written to the framebuffer (either the screen or a texture for later use).

That's the whole pipeline. Vertex in, pixel out. Everything else -- buffers, attributes, uniforms, textures, VAOs, FBOs -- is infrastructure to feed this pipeline. Three.js builds and manages all that infrastructure automatically. Today we do it by hand.

Getting a WebGL2 context

Everything starts with a canvas and a context:

const canvas = document.createElement('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.overflow = 'hidden';

const gl = canvas.getContext('webgl2');
if (!gl) {
  console.error('WebGL2 not supported');
}

gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.02, 0.02, 0.06, 1.0);

The gl object is your entire interface to the GPU. Every WebGL function is a method on this object. gl.bindBuffer, gl.createShader, gl.drawArrays, gl.uniform1f -- hundreds of methods that map directly to OpenGL ES 3.0 operations. Three.js has a renderer.getContext() method that returns this same gl object, by the way. It's right there under the hood.

WebGL2 is what we want -- it's based on OpenGL ES 3.0 and adds features like vertex array objects (mandatory, not optional), integer attributes, 3D textures, transform feedback, and more instancing options. All modern browsers support it. The older WebGL1 (OpenGL ES 2.0) is still around but there's no reason to target it anymore for creative coding.

Compiling shaders

Shaders are written in GLSL (which we already know from the shader arc) and need to be compiled and linked into a "program" 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;
}

function createProgram(gl, vertexSource, fragmentSource) {
  const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource);
  const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource);

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

createShader compiles a single shader (vertex or fragment). If there's a syntax error in your GLSL, getShaderInfoLog gives you the error message with line numbers -- same errors you'd see in Three.js's console warnings, but now you know where they come from.

createProgram takes a compiled vertex shader and a compiled fragment shader, links them together into a program. Linking is where the GPU verifies that outputs from the vertex shader (varyings) match inputs expected by the fragment shader, and that all types line up. A linked program is ready to use for drawing.

In Three.js, every Material compiles to a shader program. When you create a MeshStandardMaterial, Three.js generates GLSL source code for PBR lighting, compiles both shaders, and links the program. If you have 50 unique materials, that's 50 shader programs. Now you see why material count affects performance -- each program switch (calling gl.useProgram) has overhead.

Buffers: putting vertex data on the GPU

The GPU can't read from JavaScript arrays directly. Data needs to live in GPU memory, in buffers. You create a buffer, bind it (make it the "active" buffer for subsequent operations), and upload your data:

// a triangle: three vertices, each with x,y position
const vertices = new Float32Array([
  -0.5, -0.5,   // bottom left
   0.5, -0.5,   // bottom right
   0.0,  0.5    // top center
]);

const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

gl.ARRAY_BUFFER means this buffer holds vertex attribute data (positions, colors, normals, UVs -- per-vertex stuff). gl.STATIC_DRAW is a hint telling the GPU this data won't change often, so it can store it in fast memory. Use gl.DYNAMIC_DRAW if you'll update it every frame (like our particle systems).

This is exactly what BufferGeometry does internally in Three.js. When you create a new THREE.BoxGeometry(1, 1, 1), it generates vertex positions, normals, and UVs as Float32Arrays, creates WebGL buffers, and uploads the data. The geometry.attributes.position.array you've been manipulating in previous episodes? That's the CPU-side copy. Calling attributes.position.needsUpdate = true tells Three.js to re-upload that array to the GPU buffer.

Attributes: connecting buffers to shaders

Buffers are just blobs of numbers. The vertex shader needs to know how to interpret them -- which numbers are positions, which are colors, how they're laid out. That's what attribute configuration does:

const vertexSource = `#version 300 es
  in vec2 aPosition;

  void main() {
    gl_Position = vec4(aPosition, 0.0, 1.0);
  }
`;

const fragmentSource = `#version 300 es
  precision highp float;
  out vec4 fragColor;

  void main() {
    fragColor = vec4(0.3, 0.7, 1.0, 1.0);
  }
`;

const program = createProgram(gl, vertexSource, fragmentSource);

// get the location of the attribute in the shader
const posLoc = gl.getAttribLocation(program, 'aPosition');

// create and bind a Vertex Array Object (VAO)
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);

// bind the buffer and describe its layout
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(
  posLoc,      // attribute location
  2,           // number of components per vertex (x, y)
  gl.FLOAT,    // data type
  false,       // normalize?
  0,           // stride (0 = tightly packed)
  0            // offset (start at beginning)
);

#version 300 es at the top marks this as WebGL2 GLSL (GLSL ES 3.0). In WebGL2, attributes use in instead of the old attribute keyword. Fragment outputs use out vec4 instead of writing to gl_FragColor.

The vertexAttribPointer call is the critical connection. It says: "for the attribute at location posLoc, read from the currently bound ARRAY_BUFFER, taking 2 floats per vertex, starting at byte offset 0, with no stride (meaning the next vertex's data follows immediately)." This is how the GPU knows that every 8 bytes (2 floats x 4 bytes each) is one vertex's position.

Vertex Array Objects (VAOs)

In WebGL2, VAOs are mandatory (and they're the biggest convenience feature). A VAO bundles all the attribute state -- which buffers are bound, how attributes are configured, which attributes are enabled. Without VAOs you'd have to rebind everything before every draw call. With a VAO, you configure once, then just bind the VAO later and everything's ready:

// at setup time: create VAO, configure attributes
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// ... all bindBuffer + vertexAttribPointer calls ...
gl.bindVertexArray(null);  // unbind when done configuring

// at draw time: just bind and draw
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);

Think of a VAO as a saved configuration snapshot. Three.js creates a VAO for every geometry. When it draws a mesh, it binds that mesh's VAO (which restores all the buffer bindings and attribute layouts), sets the shader program, uploads uniforms, and issues the draw call. One VAO per geometry, one program per material -- that's the pattern.

Uniforms: passing data to shaders

Attributes are per-vertex. Uniforms are per-draw-call constants -- values that stay the same for all vertices in a single draw. Time, resolution, matrices, colors, textures. The same things we've been passing to ShaderMaterial in Three.js:

const vertexSource = `#version 300 es
  in vec2 aPosition;
  uniform float uTime;
  uniform vec2 uResolution;

  void main() {
    vec2 pos = aPosition;
    pos.y += sin(pos.x * 3.0 + uTime) * 0.2;
    gl_Position = vec4(pos, 0.0, 1.0);
  }
`;

// after gl.useProgram(program):
const timeLoc = gl.getUniformLocation(program, 'uTime');
const resLoc = gl.getUniformLocation(program, 'uResolution');

// in animation loop:
gl.useProgram(program);
gl.uniform1f(timeLoc, performance.now() / 1000.0);
gl.uniform2f(resLoc, canvas.width, canvas.height);

getUniformLocation returns a handle to the uniform variable inside the compiled program. Then uniform1f (1 float), uniform2f (2 floats), uniform3f, uniform4f, uniformMatrix4fv (4x4 matrix) set the value. The naming convention tells you the type: 1f = one float, 2f = two floats, 1i = one integer, 4fv = four floats as a vector/array.

In Three.js, when you write material.uniforms.uTime.value = t, it stores the value and later calls the appropriate gl.uniform* function during rendering. Each frame, Three.js iterates all uniforms for the active material and uploads their current values. Now you know what's happening when you set .value.

Draw calls: making pixels appear

Everything so far is setup. The draw call is the moment the GPU actually processes your data:

// clear the screen
gl.clear(gl.COLOR_BUFFER_BIT);

// use our shader program
gl.useProgram(program);

// bind the VAO (restores all attribute state)
gl.bindVertexArray(vao);

// set uniforms
gl.uniform1f(timeLoc, performance.now() / 1000.0);
gl.uniform2f(resLoc, canvas.width, canvas.height);

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

gl.drawArrays(mode, first, count) says: "using the currently bound program, VAO, and uniforms, draw count vertices starting from index first, interpreting them as mode (TRIANGLES, LINES, POINTS, etc.)."

For indexed geometry (where vertices are shared between triangles via an index buffer), there's gl.drawElements:

// index buffer
const indices = new Uint16Array([0, 1, 2, 2, 3, 0]);  // two triangles forming a quad
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

// draw indexed
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);

Indexed drawing is more efficient because shared vertices (corners of adjacent triangles) aren't duplicated in memory. A quad needs 4 unique vertices + 6 indices instead of 6 vertices with two duplicates. For complex meshes the saving is massive -- a cube has 8 unique corners but 36 indices (6 faces x 2 triangles x 3 vertices). Three.js uses indexed geometry by default for its built-in geometries.

Full example: animated triangle

Allez, let's put it all together. A complete raw WebGL2 program that draws an animated triangle with time-based vertex displacement and color:

const canvas = document.createElement('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.overflow = 'hidden';

const gl = canvas.getContext('webgl2');
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.02, 0.02, 0.06, 1.0);

// -- shaders --
const vertSrc = `#version 300 es
  in vec2 aPosition;
  in vec3 aColor;
  uniform float uTime;
  out vec3 vColor;

  void main() {
    vec2 pos = aPosition;
    pos.x += sin(uTime * 2.0 + aPosition.y * 4.0) * 0.1;
    pos.y += cos(uTime * 1.5 + aPosition.x * 3.0) * 0.08;
    gl_Position = vec4(pos, 0.0, 1.0);
    vColor = aColor;
  }
`;

const fragSrc = `#version 300 es
  precision highp float;
  in vec3 vColor;
  uniform float uTime;
  out vec4 fragColor;

  void main() {
    vec3 col = vColor;
    col *= 0.8 + sin(uTime * 3.0) * 0.2;
    fragColor = vec4(col, 1.0);
  }
`;

const program = createProgram(gl, vertSrc, fragSrc);

// -- geometry: positions interleaved with colors --
const data = new Float32Array([
  // x,    y,     r,   g,   b
  -0.6, -0.5,   1.0, 0.2, 0.3,
   0.6, -0.5,   0.2, 1.0, 0.3,
   0.0,  0.6,   0.3, 0.2, 1.0
]);

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);

// -- VAO --
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);

const posLoc = gl.getAttribLocation(program, 'aPosition');
const colLoc = gl.getAttribLocation(program, 'aColor');

const stride = 5 * 4;  // 5 floats per vertex, 4 bytes each

gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0);

gl.enableVertexAttribArray(colLoc);
gl.vertexAttribPointer(colLoc, 3, gl.FLOAT, false, stride, 2 * 4);

gl.bindVertexArray(null);

// -- uniforms --
const timeLoc = gl.getUniformLocation(program, 'uTime');

// -- render loop --
function frame(now) {
  requestAnimationFrame(frame);
  const t = now / 1000.0;

  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.useProgram(program);
  gl.bindVertexArray(vao);
  gl.uniform1f(timeLoc, t);
  gl.drawArrays(gl.TRIANGLES, 0, 3);
}

requestAnimationFrame(frame);

This is interleaved attribute data -- position and color packed together for each vertex (x, y, r, g, b, x, y, r, g, b, ...). The stride of 20 bytes (5 floats x 4 bytes) tells the GPU how far to jump between consecutive vertices of the same attribute. The offset of 8 (2 floats x 4 bytes) for the color attribute says "skip the first two floats (position), then read three floats (color)."

Interleaved data is typically faster than seperate buffers because of cache locality -- the GPU reads all data for one vertex in one cache line fetch intead of jumping between different memory locations. Three.js uses separate buffers by default (one for positions, one for normals, one for UVs) because it's simpler to update individual attributes, but for static geometry, interleaved is technically optimal.

Textures

Loading and using textures in raw WebGL involves more steps than you're used to from Three.js, but the concepts are identical:

function loadTexture(gl, url) {
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);

  // placeholder pixel while loading
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0,
    gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([128, 128, 128, 255]));

  const img = new Image();
  img.crossOrigin = 'anonymous';
  img.onload = function () {
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);

    // filtering
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

    // wrapping
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);

    gl.generateMipmap(gl.TEXTURE_2D);
  };
  img.src = url;

  return texture;
}

// using the texture in a shader:
// uniform sampler2D uTexture;
// ... fragColor = texture(uTexture, vUv);

// binding at draw time:
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, myTexture);
gl.uniform1i(gl.getUniformLocation(program, 'uTexture'), 0);

The placeholder pixel trick prevents a visual glitch while the image loads -- without it, the texture would be undefined (black or garbage) until the image finishes downloading. Three.js's TextureLoader does the same thing internally.

gl.activeTexture(gl.TEXTURE0) selects a texture unit (slot). gl.bindTexture binds our texture to that unit. gl.uniform1i(loc, 0) tells the shader "the sampler called uTexture should read from texture unit 0." If you need multiple textures in one shader, bind them to different units (TEXTURE0, TEXTURE1, etc.) and set the uniform integer accordingly.

The filtering and wrapping parameters are what Three.js exposes as texture.minFilter, texture.magFilter, texture.wrapS, texture.wrapT. Same concepts, same OpenGL constants under the hood.

Framebuffers: rendering to textures

The final piece of the pipeline: framebuffer objects (FBOs). Instead of rendering to the screen, you render to a texture. Then you can use that texture as input to another shader pass. This is the foundation of ALL post-processing -- bloom, blur, color grading, edge detection, everything from ep069.

function createFramebuffer(gl, width, height) {
  const fbo = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);

  // color attachment: a texture that will receive the render output
  const colorTexture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, colorTexture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0,
    gl.RGBA, gl.UNSIGNED_BYTE, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
    gl.TEXTURE_2D, colorTexture, 0);

  // check completeness
  if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
    console.error('Framebuffer not complete');
  }

  gl.bindFramebuffer(gl.FRAMEBUFFER, null);  // unbind, back to screen

  return { fbo, colorTexture };
}

To use it: bind the FBO, render your scene (it goes into the texture instead of the screen), unbind the FBO, then draw a fullscreen quad using the texture as input with a post-processing shader. That's the entire multi-pass rendering pattern. Three.js's EffectComposer does exactly this for each pass in the post-processing chain.

Creative exercise: fullscreen shader playground

Allez, the payoff. A minimal raw WebGL2 setup that draws a full-screen quad with a custom fragment shader. Basically the shader playground from our shader arc (ep032-045), but now you understand every single line of the setup code. Mouse position and time as uniforms, resolution for aspect correction. The simplest creative coding setup in raw WebGL:

const canvas = document.createElement('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.overflow = 'hidden';

const gl = canvas.getContext('webgl2');

// fullscreen quad: two triangles covering the entire clip space
const quadVerts = new Float32Array([
  -1, -1,   1, -1,   -1, 1,
  -1,  1,   1, -1,    1, 1
]);

const quadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW);

const vertSrc = `#version 300 es
  in vec2 aPosition;
  out vec2 vUv;

  void main() {
    vUv = aPosition * 0.5 + 0.5;  // remap -1..1 to 0..1
    gl_Position = vec4(aPosition, 0.0, 1.0);
  }
`;

const fragSrc = `#version 300 es
  precision highp float;
  in vec2 vUv;
  uniform float uTime;
  uniform vec2 uResolution;
  uniform vec2 uMouse;
  out vec4 fragColor;

  void main() {
    vec2 uv = vUv;
    uv.x *= uResolution.x / uResolution.y;  // aspect correction

    // distance from mouse
    vec2 mouse = uMouse;
    mouse.x *= uResolution.x / uResolution.y;
    float d = length(uv - mouse);

    // animated rings
    float rings = sin(d * 30.0 - uTime * 4.0) * 0.5 + 0.5;
    rings *= smoothstep(0.8, 0.0, d);

    // color from position and time
    vec3 col = vec3(0.0);
    col.r = rings * (0.5 + 0.5 * sin(uTime * 0.7));
    col.g = rings * (0.5 + 0.5 * sin(uTime * 0.7 + 2.1));
    col.b = rings * (0.5 + 0.5 * sin(uTime * 0.7 + 4.2));

    // background gradient
    col += vec3(uv.y * 0.05, uv.x * 0.03, 0.06);

    fragColor = vec4(col, 1.0);
  }
`;

const program = createProgram(gl, vertSrc, fragSrc);

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const posLoc = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);

const timeLoc = gl.getUniformLocation(program, 'uTime');
const resLoc = gl.getUniformLocation(program, 'uResolution');
const mouseLoc = gl.getUniformLocation(program, 'uMouse');

let mouseX = 0.5, mouseY = 0.5;
canvas.addEventListener('mousemove', (e) => {
  mouseX = e.clientX / canvas.width;
  mouseY = 1.0 - e.clientY / canvas.height;  // flip Y
});

function frame(now) {
  requestAnimationFrame(frame);
  const t = now / 1000.0;

  gl.viewport(0, 0, canvas.width, canvas.height);
  gl.useProgram(program);
  gl.bindVertexArray(vao);

  gl.uniform1f(timeLoc, t);
  gl.uniform2f(resLoc, canvas.width, canvas.height);
  gl.uniform2f(mouseLoc, mouseX, mouseY);

  gl.drawArrays(gl.TRIANGLES, 0, 6);
}

requestAnimationFrame(frame);

window.addEventListener('resize', () => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
});

That's a complete fullscreen shader playground in raw WebGL2. ~60 lines of setup plus whatever fragment shader you write. The fragment shader above draws animated concentric rings that follow the mouse -- swap it for any GLSL you've written in the shader arc and it works immediately. Same uniforms (time, resolution, mouse), same UV coordinates, same GLSL language. Just without Three.js in between.

Compare this to the Three.js version from ep032: you needed a Scene, Camera (orthographic), PlaneGeometry, ShaderMaterial, and a renderer. Here it's a buffer, a VAO, a program, and three uniforms. Neither is better -- Three.js gives you the 3D pipeline (cameras, meshes, lights, post-processing) for free. Raw WebGL is leaner when you don't need any of that.

The full picture: comparing raw WebGL to Three.js

Let me map the Three.js concepts to their raw WebGL equivalents so the connection is concrete:

Three.jsRaw WebGL
BufferGeometryVertex buffers + VAO + attribute layout
MaterialShader program (vertex + fragment GLSL)
MeshVAO + program + uniform values + draw call
scene.add()Adding to your own draw list
renderer.render()Iterating draw list: bind VAO, use program, set uniforms, draw
ShaderMaterial uniformsgl.uniform*() calls
geometry.attributes.positiongl.bindBuffer + gl.bufferData
texture.needsUpdategl.texImage2D (re-upload)
WebGLRenderTargetFramebuffer object + texture attachment
EffectComposerMultiple FBOs, ping-ponging textures between passes

Three.js is a scene graph manager + shader generator + state tracker built on top of these raw calls. Every Three.js operation eventually becomes one or more of the raw WebGL calls we've used today. The abstraction is valuable -- managing hundreds of objects, dealing with lights, PBR materials, shadow maps, skeleton animation -- that's thousands of lines of WebGL state management you'd have to write yourself. But for minimal setups (fullscreen shaders, single-mesh effects, compute-like workloads), raw WebGL can be cleaner and more direct.

When to use raw WebGL vs Three.js

Use Three.js when: you need multiple objects, a camera system, lighting, post-processing chains, physics integration, model loading, or any of the dozens of features it provides. Basically, any time you'd spend more than an hour reimplementing what Three.js gives you for free.

Use raw WebGL when: you're building a fullscreen shader effect, a minimal demo, a specific GPU technique that doesn't fit Three.js's material model, or when you need absolute control over draw order and state for performance reasons. Also useful for understanding what's happening under the hood when debugging Three.js.

Mix them: Three.js exposes renderer.getContext() -- you can grab the gl object and issue raw WebGL calls alongside Three.js rendering. Advanced creative coders do this for custom compute passes, manual buffer manipulation, or specialized rendering techniques that don't fit the standard material/mesh pattern.

What's ahead

We've seen what Three.js abstracts away. The raw rendering pipeline -- buffers, shaders, attributes, uniforms, VAOs, textures, framebuffers -- is a sequence of state-setting operations that culminate in a draw call. Understanding this level means you can debug anything, optimize with intention, and know when raw access gives you something the abstraction can't.

Next episode we're going to build environments -- terrain, skyboxes, atmosphere, fog. The kind of scene that feels like a place you could exist in, not just a collection of objects floating in void. Procedural worlds.

't Komt erop neer...

  • WebGL2 is the raw GPU API that Three.js is built on. canvas.getContext('webgl2') gives you the gl object -- every Three.js operation eventually becomes gl.something() calls. Understanding this level helps you debug, optimize, and build effects that don't fit standard abstractions
  • The rendering pipeline: vertex data flows into the vertex shader (per-vertex, transforms positions), gets rasterized into fragments, then the fragment shader runs per-fragment to produce colors. Everything else -- buffers, attributes, uniforms, VAOs, textures, FBOs -- is infrastructure to feed this pipeline
  • Buffers hold vertex data in GPU memory. Create with gl.createBuffer(), bind with gl.bindBuffer(gl.ARRAY_BUFFER, buf), upload with gl.bufferData(). This is what BufferGeometry does internally -- needsUpdate = true triggers a re-upload via bufferData
  • Shaders are GLSL source code compiled with gl.compileShader() and linked into programs with gl.linkProgram(). Each unique Material in Three.js compiles to a separate shader program. Program switching (gl.useProgram) has overhead -- that's why minimizing unique materials improves performance
  • Attributes are per-vertex inputs. vertexAttribPointer describes the memory layout: how many components, what type, stride between vertices, byte offset within each vertex. VAOs bundle all attribute configuration so you configure once, bind the VAO later, and everything's restored
  • Uniforms are per-draw constants set with gl.uniform1f, uniform2f, uniformMatrix4fv etc. Same as ShaderMaterial's uniforms.uTime.value -- Three.js calls these functions internally each frame for every active uniform
  • gl.drawArrays (non-indexed) or gl.drawElements (indexed with element buffer) triggers actual rendering. Each draw call processes vertices through the pipeline with the currently bound program, VAO, and uniforms
  • Textures: create, bind, upload image data with gl.texImage2D, set filtering/wrapping params. Assign to texture units (gl.activeTexture(gl.TEXTURE0)), tell shader which unit to sample from (gl.uniform1i(loc, 0))
  • Framebuffers: render to a texture instead of the screen. Create FBO, attach a texture as color attachment, bind FBO before drawing. The foundation of all post-processing -- EffectComposer is just multiple FBOs with fullscreen quad passes between them
  • A fullscreen shader playground in raw WebGL is ~60 lines: one buffer (fullscreen quad), one VAO, one program, uniforms for time/resolution/mouse. Leaner than the Three.js equivalent when you don't need the 3D pipeline

Sallukes! Thanks for reading.

X

@femdev