Canvas, WebGL & Three.js

This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve.

This section’s bibliography lists the reference material consulted while preparing these pages.

The <canvas> element is a blank bitmap that JavaScript draws into pixel by pixel, in contrast to the declarative, DOM-based shapes of SVG (see The DOM). It has two very different faces: a built-in 2D rendering context for paths, shapes, text, and images, and a WebGL context that hands the canvas over to the GPU for 3D (and accelerated 2D) rendering. This page covers the 2D context in depth, then introduces raw WebGL and the higher-level Three.js library that most real WebGL applications are actually built on.

2D Canvas

The 2D Rendering Context

Most of the Canvas drawing API is not defined on the <canvas> element itself but on a drawing context object obtained by calling getContext() on it. Passing "2d" returns a CanvasRenderingContext2D object used for all two-dimensional drawing:

let canvas = document.querySelector("#square");
let context = canvas.getContext("2d");   // a CanvasRenderingContext2D
context.fillStyle = "#f00";              // set fill color to red
context.fillRect(0, 0, 10, 10);          // fill a 10x10 square

canvas = document.querySelector("#circle");
context = canvas.getContext("2d");
context.beginPath();
context.arc(5, 5, 5, 0, 2 * Math.PI, true);  // add a circle to the path
context.fillStyle = "#00f";
context.fill();                              // fill the path with blue

A <canvas> element has only ever one context object — calling getContext("2d") again returns the same object, not a fresh one. The width and height attributes (and matching properties) set the number of pixels the canvas allocates, not just its on-screen CSS size; setting either property — even to its current value — clears the canvas, discards the current path, and resets every graphics attribute back to its defaults. For crisp output on high-DPI screens, size the canvas in CSS separately from its pixel buffer, multiplying the desired CSS size by window.devicePixelRatio before setting width/height.

The default coordinate system places the origin (0, 0) at the upper-left corner, with x increasing to the right and y increasing downward — the opposite vertical direction from typical math-class axes.

Paths

Like SVG, the Canvas API builds complex shapes out of paths, but instead of describing a path as a string it builds one imperatively through a sequence of method calls. A path is a series of subpaths, each a sequence of points connected by line or curve segments:

c.beginPath();       // start a new path
c.moveTo(100, 100);  // begin a subpath at (100, 100)
c.lineTo(200, 200);  // line from (100, 100) to (200, 200)
c.lineTo(100, 200);  // line from (200, 200) to (100, 200)

c.fill();             // fill the triangular area
c.stroke();           // stroke the two segments just drawn

beginPath() starts a brand-new path; moveTo(x, y) starts a new subpath at a point without drawing anything; lineTo(x, y) extends the current subpath with a straight line to a new point. Neither fill() nor stroke() alters the current path — calling one and then the other draws both a fill and an outline of the same shape, and forgetting to call beginPath() before starting a new shape silently appends to (and redraws) the old one.

An open subpath — one whose last point isn’t connected back to its first — is filled as though a straight line closed it, but only stroked along the segments actually drawn. closePath() explicitly connects the subpath’s end point back to its start, which is the right way to get a fully stroked outline (rather than adding a final lineTo() back to the start, which leaves a seam rather than a clean joined corner).

Curves and arcs extend a subpath the same way lineTo() does, connecting the current point to a new one:

c.beginPath();
c.arc(75, 100, 50, 0, 2 * Math.PI, false);  // full circle: center, radius, start/end angle, direction
c.fill();
c.stroke();

c.beginPath();
c.moveTo(325, 100);
c.arc(325, 100, 50, -Math.PI / 3, 0, true);  // pie-slice wedge: line to arc start, then the arc itself
c.closePath();                               // ...then back to the center

c.moveTo(525, 125);
c.quadraticCurveTo(550, 75, 625, 125);   // one control point
c.moveTo(625, 100);
c.bezierCurveTo(645, 70, 705, 130, 725, 100);  // two control points (cubic Bezier)

arc() draws a circle or arc of one; ellipse() is the same idea with independent x/y radii and a rotation; arcTo() specifies an arc via two target points and a radius, which is particularly convenient for rounded rectangle corners; quadraticCurveTo()/bezierCurveTo() add quadratic/cubic Bezier curves with one or two control points respectively. When two overlapping subpaths are wound in opposite directions — one clockwise, one counterclockwise — the nonzero winding rule used by fill() leaves the overlap unfilled, a handy trick for cutting a hole (e.g. a ring, or a hexagon with a triangular cutout) out of a filled shape.

Four dedicated rectangle methods round out path drawing: fillRect() and strokeRect() immediately fill or stroke a rectangle without touching the current path at all; clearRect() resets a rectangular region back to transparent black; rect() is the path-building counterpart, adding a rectangular subpath that still needs an explicit fill()/stroke() call.

Fill, Stroke, and Text Styles

Drawing attributes — color, line width, font — live as properties on the context object rather than as arguments to fill()/stroke()/fillText(). This separates graphics state from drawing commands, similar to how CSS separates presentation from HTML content:

c.fillStyle = "#ccc";     // solid color -- any valid CSS color string
c.strokeStyle = "#008";
c.lineWidth = 5;          // in CSS pixels, applied at stroke() time
c.lineCap = "round";      // "butt" (default), "round", or "square"
c.lineJoin = "round";     // "miter" (default), "round", or "bevel"

c.fill();
c.stroke();

fillStyle/strokeStyle also accept a CanvasGradient (from createLinearGradient() or createRadialGradient(), with stops added via addColorStop(offset, cssColor)) or a CanvasPattern (from createPattern(imageOrCanvas, repetition)) for gradient or image fills:

let fade = c.createLinearGradient(0, 0, canvas.width, canvas.height);
fade.addColorStop(0.0, "#88f");   // light blue at the start
fade.addColorStop(1.0, "#fff");   // fading to white at the end
c.fillStyle = fade;

Because graphics attributes live on the shared context object rather than being scoped to a call, save() pushes the current attributes (plus the current transform and clipping region, but not the current path) onto a stack, and restore() pops it back — the standard way to change a color or transform temporarily without disturbing whatever code runs before or after.

Text is drawn with fillText(text, x, y) (using fillStyle) or strokeText(text, x, y) (outlining the glyphs using strokeStyle), governed by the font property (a CSS font shorthand string), textAlign ("start", "left", "center", "right", "end"), and textBaseline ("alphabetic", "top", "middle", "bottom", and a couple of script-specific values). measureText(text) returns a TextMetrics object — its width property is useful for centering a string before drawing it:

c.font = "bold 24px sans-serif";
c.textAlign = "center";
let width = c.measureText("Hello, canvas").width;
c.fillText("Hello, canvas", canvas.width / 2, 40);

Images

drawImage() copies pixels from a source image — an <img>, another <canvas>, or a <video> element (a single frame) — onto the canvas, in one of three argument forms:

c.drawImage(img, x, y);                          // draw at (x, y), unscaled, full source image
c.drawImage(img, x, y, width, height);            // draw scaled to fit a destination rectangle
c.drawImage(img,                                  // nine-argument form: crop, then place
  sx, sy, sWidth, sHeight,                        // source rectangle, in the image's own pixels
  dx, dy, dWidth, dHeight);                        // destination rectangle, in the canvas's coordinates

If the source <img>/<video> element is still loading, drawImage() silently does nothing — wait for a load event before drawing it. Going the other direction, canvas.toDataURL() (a method of the <canvas> element itself, not the context) exports the canvas’s current contents as a PNG data: URL, suitable for assigning directly to an <img> element’s src.

Pixel Manipulation

getImageData(x, y, width, height) returns an ImageData object holding the raw R, G, B, A bytes of a rectangular region, always measured in the canvas’s default (untransformed) coordinate system. Its .data property is a Uint8ClampedArray — four consecutive bytes per pixel, laid out row by row, left to right and top to bottom, with out-of-range writes clamped to 0..255 rather than wrapping. After modifying that buffer in place, putImageData(imageData, x, y) writes it back to the canvas — ignoring every graphics attribute (no compositing, no globalAlpha, no shadows):

function invertColors(c, x, y, w, h) {
  let imageData = c.getImageData(x, y, w, h);
  let data = imageData.data;             // Uint8ClampedArray, 4 bytes per pixel: R, G, B, A
  for (let i = 0; i < data.length; i += 4) {
    data[i] = 255 - data[i];         // R
    data[i + 1] = 255 - data[i + 1]; // G
    data[i + 2] = 255 - data[i + 2]; // B
    // data[i + 3] is alpha -- left untouched
  }
  c.putImageData(imageData, x, y);
}

createImageData(width, height) (or createImageData(otherImageData)) allocates an empty ImageData of matching dimensions, useful as a separate output buffer for image-processing algorithms that shouldn’t mutate their input in place.

The Render Loop

Canvas drawing is imperative and immediate — nothing about the API animates on its own. An animated scene is drawn by clearing the canvas (or a dirty region of it) and redrawing every frame’s worth of shapes, driven by requestAnimationFrame() rather than setInterval() so the browser can align each redraw with its own display refresh and pause the loop in a background tab. See Animations via JavaScript for the render-loop pattern itself (scheduling, delta-time-based motion, cancellation); the loop’s body for a canvas scene typically looks like:

function frame(timestamp) {
  c.clearRect(0, 0, canvas.width, canvas.height);
  // ...update positions, then redraw every shape for this frame...
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

WebGL

JavaScript: The Definitive Guide gives WebGL only two passing mentions and no dedicated coverage. Everything in this section and the next comes from general/official documentation rather than the book.

What WebGL Is

WebGL is a low-level binding to a GPU-accelerated 2D/3D graphics API, obtained from a canvas the same way the 2D context is — canvas.getContext("webgl2") (or "webgl" for the older WebGL 1, which tracks OpenGL ES 2.0 where WebGL 2 tracks OpenGL ES 3.0). Where the 2D context offers ready-made shapes, fills, and text, a WebGL context offers almost none of that: it is a thin JavaScript wrapper around the GPU’s own programming model — buffers of vertex data, small GPU-executed programs called shaders, and matrices to project 3D coordinates onto the 2D canvas.

Why Raw WebGL Is Verbose

Drawing even a single colored triangle in raw WebGL means writing two shader programs in GLSL (OpenGL’s shading language), compiling and linking them, uploading vertex data into GPU buffers, describing that buffer’s layout to the shader by hand, and only then issuing a draw call:

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

const vertexShaderSource = `#version 300 es
  in vec2 position;
  void main() {
    gl_Position = vec4(position, 0.0, 1.0);
  }
`;
const fragmentShaderSource = `#version 300 es
  precision mediump float;
  out vec4 outColor;
  void main() {
    outColor = vec4(1.0, 0.0, 0.0, 1.0);  // solid red
  }
`;

function compile(gl, type, source) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  return shader;
}
const program = gl.createProgram();
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, vertexShaderSource));
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragmentShaderSource));
gl.linkProgram(program);
gl.useProgram(program);

const positions = new Float32Array([0, 0.5, -0.5, -0.5, 0.5, -0.5]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);

const positionLoc = gl.getAttribLocation(program, "position");
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);

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

That is roughly thirty lines of setup for one flat-colored triangle, with none of it reusable once a scene needs a second shape, a camera, lighting, or textures — projection and model matrices, depth testing, and scene management are all left entirely to application code. This verbosity is deliberate: WebGL exposes the GPU’s actual programming model rather than a scene-graph abstraction over it, trading convenience for control.

When to Reach for a Library

The full WebGL API — shader compilation, buffer layouts, framebuffers, texture units, extensions — is large enough that this page does not attempt to document it in depth; the authoritative reference is the WebGL 2.0 Specification. For anything beyond a trivial demo, reach for a higher-level library that manages the scene graph, shaders, and matrices for you — Three.js, covered next, is the most widely used.

Three.js

Three.js has zero coverage in JavaScript: The Definitive Guide. This section is written entirely from general/official knowledge; see the Three.js documentation for authoritative details.

The Scene/Camera/Renderer/Mesh Object Model

Three.js wraps WebGL in an object-oriented scene graph, so application code manipulates Scene, Camera, Mesh, and Light objects instead of buffers and shader source. The core pieces of every Three.js application:

Object Role

THREE.Scene

The root container — everything to be rendered (meshes, lights, groups) is added to it.

THREE.Camera

Defines the viewpoint and projection. PerspectiveCamera (field of view, aspect ratio, near/far clip planes) is the common choice for 3D scenes; OrthographicCamera has no perspective foreshortening.

THREE.WebGLRenderer

Owns the <canvas> and its WebGL context, and draws (render(scene, camera)) a scene from a camera’s point of view into it.

THREE.Mesh

A drawable object: pairs a geometry (the shape’s vertex data, e.g. BoxGeometry, SphereGeometry) with a material (how its surface responds to light and color, e.g. MeshStandardMaterial, MeshBasicMaterial).

THREE.Light

A light source (DirectionalLight, PointLight, AmbientLight, …​) added to the scene; most materials besides MeshBasicMaterial need at least one light to be visible at all.

A Mesh’s geometry and material are deliberately separate objects: the same `BoxGeometry can be reused across several meshes with different materials, and the same material can be shared across meshes with different geometries, without either one being duplicated.

A Minimal Spinning Cube

The smallest complete Three.js scene sets up the four core objects above, adds one lit mesh, and drives rotation from a requestAnimationFrame() render loop — the same pattern used for 2D canvas animation:

import * as THREE from "three";

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  75,                                    // vertical field of view, in degrees
  window.innerWidth / window.innerHeight, // aspect ratio
  0.1,                                    // near clip plane
  100                                      // far clip plane
);
camera.position.z = 3;

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);  // the <canvas> Three.js created

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x3388ff });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const directional = new THREE.DirectionalLight(0xffffff, 1);
directional.position.set(2, 2, 3);
scene.add(directional);

function frame() {
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.013;
  renderer.render(scene, camera);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Every moving part here maps onto the object model above: the Scene holds the cube and its lights, the PerspectiveCamera defines the view, WebGLRenderer performs one render() call per frame, and the Mesh’s own `rotation property is simply mutated between frames — no manual matrix math, buffer uploads, or shader source anywhere in sight.

Further Reading

Three.js’s object model extends well past this minimal example — loaders for external 3D model formats, physically based materials and shadow maps, post-processing passes, OrbitControls-style camera interaction, and a large library of pre-built geometries. The Three.js documentation is the authoritative reference for all of it, along with runnable examples for most of the library’s features.