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 |
|---|---|
|
The root container — everything to be rendered (meshes, lights, groups) is added to it. |
|
Defines the viewpoint and projection. |
|
Owns the |
|
A drawable object: pairs a geometry (the shape’s vertex data, e.g. |
|
A light source ( |
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.