7. Graphics and Rendering APIs
HTML and CSS (Chapter 2) lay out boxes of text, but charts, maps, diagrams, games, photo editors and 3D product viewers need direct control over shapes and pixels. The web platform offers three complementary technologies for this: SVG, a vector format whose shapes live in the DOM; the Canvas 2D API, a bitmap you paint with JavaScript; and WebGL, which runs your own programs on the GPU for hardware-accelerated 2D and 3D rendering. Understanding how each one models an image, and what that model costs, is what lets you choose the right tool and keep the result sharp, accessible and fast.
<a id="71-svg-api-for-vector-rendering"></a>
7.1 SVG API for Vector Rendering
This subchapter covers Scalable Vector Graphics: how vector images differ from bitmaps, how an SVG document is structured, and how to draw, style, transform, reuse, script, animate and add interaction to its shapes.
Vector vs Raster Graphics
A screen is a grid of pixels, so every image eventually becomes a grid of coloured dots. There are, however, two very different ways of describing an image before that happens.
- A raster (or bitmap) image stores the dots themselves: a fixed grid of pixels, each with a colour. A 200×200 PNG is 40,000 colour values.
- A vector image stores instructions: "a circle centred at (100, 100) with radius 80, filled red". The dots are computed only when the image is displayed, at whatever size is needed.
An analogy: a raster image is a mosaic. Up close you see the tiles, and to make it twice as big you can only make the tiles bigger. A vector image is a set of instructions handed to a painter ("draw a circle this big, here"). Ask for a bigger picture and the painter simply paints it bigger, with the same crisp edges.
What happens when you scale.
Raster circle, 8x8 pixels Same raster scaled 2x Vector circle scaled 2x
(each pixel becomes 2x2) (re-rasterised at new size)
. . # # # # . . . . . . # # # # # # # # . . . . smooth edge computed
. # # # # # # . . . . . # # # # # # # # . . . . from the equation
# # # # # # # # . . # # # # # # # # # # # # . . (x-cx)^2 + (y-cy)^2 = r^2
# # # # # # # # . . # # # # # # # # # # # # . . at the new resolution,
... ... jagged "staircase" edges so no staircase appears
Both kinds are rasterised in the end, because the display is raster. The difference is when: a vector image postpones rasterisation until the final size and pixel density are known. This matters more than ever on high-density displays, where one CSS pixel may be 2 or 3 device pixels (window.devicePixelRatio). A vector icon is sharp at every density, while a bitmap icon needs a separate file per density (srcset, Chapter 2).
Comparison.
| Aspect | Raster (PNG, JPEG, WebP, AVIF, GIF) | Vector (SVG, also fonts and PDF) |
|---|---|---|
| Stores | A grid of pixel colours | Geometric shapes, paths and text |
| Scaling up | Blurs or pixelates | Stays sharp at any size |
| File size grows with | Number of pixels | Number and complexity of shapes |
| Best for | Photographs, complex textures, camera output | Icons, logos, charts, diagrams, maps, UI illustrations |
| Editing individual parts | Hard (pixels have no identity) | Easy (each shape is an object) |
| Rendering cost | Fixed: copy pixels | Grows with shape count and effects |
| Text inside | Burned in, not selectable | Real text, selectable and searchable |
Where the web technologies sit. SVG is a vector format. Canvas 2D has a vector-like drawing API (arc, lineTo), but its result is a bitmap: once drawn, the circle is just pixels. WebGL also produces a bitmap, rasterised by the GPU. This distinction drives most of the trade-offs in this chapter.
A vector image is just text. Here is a complete SVG icon. It is small, compresses well with gzip or Brotli, and can be edited in any text editor:
<!-- check.svg: 3 shapes, under 300 bytes, sharp at 16px or 1600px -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<circle cx="12" cy="12" r="11" fill="#2e7d32"/>
<path d="M6 12 l4 4 l8 -8" fill="none" stroke="white" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
A photograph, by contrast, has no simple geometric description: converting it to vectors ("tracing") produces either a huge file or a cartoon-like approximation. Choose by content, not by habit.
Key Takeaways
- Raster images store pixels at a fixed resolution. Vector images store shapes and are rasterised on demand at any size.
- Vectors stay sharp on any display density and are usually smaller for flat, geometric artwork. Rasters are the right choice for photographs and rich textures.
- Vector rendering cost grows with the number of shapes, raster cost with the number of pixels.
- SVG is vector and lives in the DOM. Canvas and WebGL produce bitmaps, even though you draw on them with geometric commands.
Practice
- (Beginner) Save the
check.svgexample and a 24×24 PNG screenshot of it. Display both with<img>atwidth="384"and describe the difference. Compare the file sizes before and after gzip.- (Intermediate) For each of the following, choose raster or vector and justify the choice: a company logo, a product photo, a line chart with 50 points, a scatter plot with 500,000 points, a satellite map tile, a QR code.
- (Interview) "Why might an SVG be the wrong choice for a chart even though it is resolution-independent?" Hint: think about what each data point costs in the DOM and how rendering time grows with element count.
SVG Document Structure and Coordinate System
Before drawing anything, you need to know what an SVG document is, how it gets into a page, and where "(10, 20)" actually is on screen.
SVG is XML. SVG (Scalable Vector Graphics) is an XML vocabulary standardised by the W3C (SVG 1.1, with SVG 2 adding many refinements). Each shape is an element, each property an attribute. A standalone .svg file must declare the SVG namespace so that XML parsers know what <circle> means:
<?xml version="1.0" encoding="UTF-8"?> <!-- optional XML prolog -->
<svg xmlns="http://www.w3.org/2000/svg" <!-- required in standalone files -->
width="300" height="200" <!-- size of the viewport -->
viewBox="0 0 300 200"> <!-- user coordinate system (see Transformations) -->
<title>Two shapes</title> <!-- accessible name, also a tooltip -->
<desc>A blue rectangle partly covered by an orange circle.</desc>
<defs> <!-- definitions: not drawn by themselves -->
<linearGradient id="sky"> ... </linearGradient>
</defs>
<rect x="20" y="20" width="160" height="100" fill="steelblue"/> <!-- painted first -->
<circle cx="170" cy="110" r="60" fill="orange"/> <!-- painted on top -->
</svg>
Painting order is document order. SVG uses the painter's algorithm: later elements are painted over earlier ones. CSS z-index does not apply to shapes inside an SVG, so to bring a shape to the front you move it later in the document (for example svg.append(shape)).
Ways to put SVG on a page.
| Method | Example | Scriptable from page | Styleable by page CSS | Cached separately |
|---|---|---|---|---|
| Inline SVG | <svg>...</svg> inside HTML | Yes (part of the DOM) | Yes | No (part of the HTML) |
| Image | <img src="logo.svg" alt="..."> | No (scripts inside never run) | No | Yes |
| CSS background | background-image: url(icon.svg) | No | No | Yes |
| Embedded document | <object data="map.svg" type="image/svg+xml"> or <iframe> | Through contentDocument, same-origin only | No | Yes |
Inline SVG is what the rest of this subchapter assumes. Inside HTML, the parser knows <svg> and switches to the SVG namespace automatically, so xmlns can be omitted.
The coordinate system. SVG uses a 2D Cartesian system, but with one twist that surprises everyone who remembers school maths: y grows downward.
(0,0) ───────────────────────────► x (300,0)
│
│ ● (60, 40)
│
│ ● (200, 120)
│
▼
y (0,200) (300,200)
- The origin
(0, 0)is the top-left corner of the viewport. - Coordinates are in user units. Without a
viewBox, one user unit equals one CSS pixel. With aviewBox, user units are scaled to fit the viewport, which is what makes SVG "scalable". - Coordinates can be fractional and negative. Content outside the viewport is clipped (unless
overflow: visible). - Angles in
rotate()are in degrees and, because y points down, positive angles rotate clockwise.
Accessibility. A meaningful inline SVG should expose a name to assistive technologies, while a decorative one should be hidden:
<!-- meaningful graphic -->
<svg role="img" aria-labelledby="chart-title" viewBox="0 0 400 200">
<title id="chart-title">Monthly sales, January to June</title>
<!-- ... -->
</svg>
<!-- decorative icon next to visible text -->
<button><svg aria-hidden="true" focusable="false">...</svg> Save</button>
SVG files exported from design tools often contain editor metadata, unused definitions and excessive decimal places. An optimiser such as SVGO routinely shrinks them by 30 to 70 percent.
Key Takeaways
- SVG is an XML vocabulary. Standalone files need
xmlns="http://www.w3.org/2000/svg"; inline SVG in HTML does not. - Only inline SVG (and same-origin
<object>) can be styled and scripted from the page.<img>and CSS backgrounds treat SVG as a sealed image. - The origin is top-left and y points down. Positive rotations are clockwise.
- Elements are painted in document order. There is no
z-indexfor shapes. - Give meaningful SVG
role="img"and a<title>, and hide decorative SVG witharia-hidden="true".
Practice
- (Beginner) Write a standalone SVG of 400×300 user units containing a sun (circle) in the top-right corner and a ground strip (rectangle) along the bottom. Open it directly in the browser, then include it with
<img>and inline.- (Intermediate) Put an SVG containing a
<script>that changes a shape's colour on load into a page three ways: inline,<img>and<object>. Record where the script runs and explain why.- (Interview) "How would you bring an SVG shape to the front when the user hovers it?" Hint: consider the painting model of SVG and what DOM operation changes it.
Basic Shapes and the Path Element
SVG gives you a handful of convenient shape elements for common cases and one universal element, <path>, that can draw anything. In fact every basic shape is just a shorthand for a particular path.
Basic shapes.
| Element | Key attributes | Draws |
|---|---|---|
<rect> | x, y, width, height, optional rx, ry (corner radii) | Rectangle, optionally rounded |
<circle> | cx, cy, r | Circle |
<ellipse> | cx, cy, rx, ry | Axis-aligned ellipse |
<line> | x1, y1, x2, y2 | Straight segment (needs a stroke to be visible) |
<polyline> | points="x1,y1 x2,y2 ..." | Open chain of segments |
<polygon> | points="..." | Closed chain (last point joins the first) |
<svg viewBox="0 0 320 120" width="320" height="120">
<rect x="10" y="10" width="80" height="60" rx="8" fill="#90caf9"/>
<circle cx="140" cy="40" r="30" fill="#ef9a9a"/>
<ellipse cx="220" cy="40" rx="40" ry="20" fill="#a5d6a7"/>
<line x1="10" y1="100" x2="300" y2="100" stroke="black"/> <!-- no fill: lines have no area -->
<polyline points="10,90 60,80 110,95 160,70" fill="none" stroke="purple"/>
<polygon points="270,10 300,70 240,70" fill="#ffcc80"/> <!-- a triangle -->
</svg>
The path element. <path d="..."> describes an outline with a compact command language. Think of it as instructions to a pen: "lift the pen and move to (10, 10), draw a line to (50, 10), draw a curve to ...". Each command is a letter followed by numbers. An uppercase letter uses absolute coordinates, a lowercase letter uses coordinates relative to the current pen position.
| Command | Parameters | Meaning |
|---|---|---|
M / m | x y | Move to (start a new sub-path without drawing) |
L / l | x y | Line to |
H / h | x | Horizontal line to |
V / v | y | Vertical line to |
Z / z | none | Close path (straight line back to the sub-path start) |
C / c | x1 y1 x2 y2 x y | Cubic Bézier curve with two control points |
S / s | x2 y2 x y | Smooth cubic: first control point mirrored from the previous curve |
Q / q | x1 y1 x y | Quadratic Bézier curve with one control point |
T / t | x y | Smooth quadratic: control point mirrored |
A / a | rx ry rotation large-arc sweep x y | Elliptical arc to (x, y) |
<svg viewBox="0 0 300 120" width="300" height="120">
<!-- A house: absolute moves and lines, then close -->
<path d="M 20 110 V 60 L 60 20 L 100 60 V 110 Z" fill="#ffe0b2" stroke="#6d4c41"/>
<!-- The same outline with relative commands: each step is an offset from the previous point -->
<path d="m 120 110 v -50 l 40 -40 l 40 40 v 50 z" fill="none" stroke="#6d4c41"/>
<!-- An S-shaped cubic curve -->
<path d="M 220 100 C 220 20, 290 100, 290 20" fill="none" stroke="crimson" stroke-width="3"/>
</svg>
Bézier curves. A curve is defined by its end points and control points that "pull" the curve towards them without the curve passing through them:
P1 (control) P2 (control)
o . . . . . . . . .o
. . ___ .
. ____--- .
. __--- .
●--'' ●
P0 (start) P3 (end)
"M P0 C P1, P2, P3": the curve leaves P0 heading towards P1
and arrives at P3 coming from the direction of P2.
S and T mirror the previous control point, which guarantees a smooth join (no visible corner) between consecutive curves.
Arcs. The A command is the trickiest. Given a start point, an end point and radii, there are generally four possible elliptical arcs. Two flags pick one: large-arc (1 = the arc longer than 180°) and sweep (1 = drawn in the positive-angle, clockwise direction). Arcs are what you need for pie and donut charts:
// Build the "d" attribute of a pie slice centred at (cx, cy)
function pieSlice(cx, cy, r, startAngle, endAngle) { // angles in radians, 0 = 3 o'clock
const x1 = cx + r * Math.cos(startAngle);
const y1 = cy + r * Math.sin(startAngle); // y down => angles go clockwise
const x2 = cx + r * Math.cos(endAngle);
const y2 = cy + r * Math.sin(endAngle);
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
// move to centre, line to arc start, arc to end (sweep=1), back to centre
}
// A 30% slice starting at 12 o'clock
const start = -Math.PI / 2;
document.querySelector("#slice").setAttribute("d", pieSlice(100, 100, 80, start, start + 0.3 * 2 * Math.PI));
Fill rules and holes. A path can contain several sub-paths (several M commands). Whether an inner sub-path becomes a hole depends on fill-rule. With evenodd, a point is inside if a ray from it crosses the outline an odd number of times, so a circle inside a circle produces a ring regardless of direction. With the default nonzero, the inner sub-path must be drawn in the opposite direction to become a hole.
<!-- Donut: outer square and inner square, evenodd makes the inner one a hole -->
<path fill-rule="evenodd" fill="teal"
d="M 10 10 H 110 V 110 H 10 Z M 40 40 H 80 V 80 H 40 Z"/>
Paths also expose geometry helpers used for animation and layout: path.getTotalLength() returns the outline length, and path.getPointAtLength(d) returns the point at distance d along it.
Key Takeaways
- Use
<rect>,<circle>,<ellipse>,<line>,<polyline>and<polygon>for simple shapes.<path>can draw any outline. - Path commands are letters followed by numbers. Uppercase is absolute, lowercase is relative to the current point.
- Bézier curves are shaped by control points the curve does not pass through.
SandTcreate smooth joins. - The arc command needs two flags (
large-arc,sweep) to choose one of four possible arcs. fill-rule="evenodd"is the simplest way to cut holes in a shape.
Practice
- (Beginner) Draw a five-pointed star twice: once with
<polygon>and once with<path>using onlyM,LandZ. Then switch the path to relative commands.- (Intermediate) Using the
pieSlicefunction, writerenderPie(svg, values)that draws one slice per value with a different colour, so that the slices together make a full circle. Handle the case of a single value equal to 100% (a full circle cannot be one arc).- (Interview) "Why does the SVG arc command need both a large-arc flag and a sweep flag?" Hint: draw two ellipses of the given radii through the same two points and count the arcs.
Text, Groups and Reusable Symbols
Real graphics need labels, need to treat several shapes as one unit, and often repeat the same artwork many times. SVG provides text elements, grouping, and a definition-and-reuse mechanism for all three needs.
Text. <text> draws real, selectable, searchable text. Its x and y set the baseline position of the first character, not the top-left corner:
<svg viewBox="0 0 300 120" width="300" height="120" font-family="system-ui, sans-serif">
<line x1="150" y1="0" x2="150" y2="120" stroke="#ddd"/> <!-- guide at x=150 -->
<text x="150" y="30" text-anchor="start">start</text> <!-- begins at x -->
<text x="150" y="60" text-anchor="middle">middle</text> <!-- centred on x -->
<text x="150" y="90" text-anchor="end">end</text> <!-- ends at x -->
<text x="10" y="112" font-size="14">
Total: <tspan font-weight="bold" fill="crimson">42</tspan> <!-- styled run of text -->
<tspan dy="-6" font-size="9">*</tspan> <!-- shifted up 6 units -->
</text>
</svg>
text-anchor(start,middle,end) controls horizontal alignment, anddominant-baseline(for examplemiddleorhanging) controls vertical alignment.<tspan>styles or repositions part of a text (x,yfor absolute,dx,dyfor relative offsets).- SVG text does not wrap automatically. Break lines yourself with several
<tspan x="..." dy="1.2em">, or embed HTML with<foreignObject>when you need real paragraph layout. <textPath href="#curve">lays text along any path, for example a curved label on a map.
Groups. <g> groups elements. Attributes and transforms set on the group are inherited by its children, and the group can be styled, moved, hidden or listened to as one unit:
<g transform="translate(200, 40)" fill="gold" stroke="black" class="marker">
<circle r="12"/> <!-- inherits fill and stroke -->
<text y="30" text-anchor="middle" stroke="none" fill="black">Rome</text>
</g>
Define once, use many times. <defs> holds content that is not rendered directly. <use> renders a copy of any element by reference, and <symbol> is a container designed for reuse that can have its own viewBox:
| Element | Rendered by itself | Own viewBox | Typical use |
|---|---|---|---|
<g> | Yes | No | Group visible shapes |
<defs> | No | No | Hold gradients, filters, markers, templates |
<symbol> | No (only through <use>) | Yes | Icons and reusable artwork |
<use> | Yes (a copy of its target) | No | Instantiate a symbol or element at x, y with width, height |
Icon sprites. The most common real-world use is an icon system: all icons as symbols in one sprite, each one drawn with <use>. Using currentColor lets each instance take its colour from CSS color:
<!-- Sprite, usually at the top of <body> or in an external icons.svg -->
<svg style="display:none">
<symbol id="icon-heart" viewBox="0 0 24 24">
<path fill="currentColor"
d="M12 21 C 5 15, 2 12, 2 8 A 5 5 0 0 1 12 6 A 5 5 0 0 1 22 8 C 22 12, 19 15, 12 21 Z"/>
</symbol>
<symbol id="icon-star" viewBox="0 0 24 24">
<polygon fill="currentColor" points="12,2 15,9 22,9 16.5,14 18.5,21 12,17 5.5,21 7.5,14 2,9 9,9"/>
</symbol>
</svg>
<!-- Instances: same artwork, any size, colour from CSS -->
<button class="like">
<svg width="20" height="20" aria-hidden="true"><use href="#icon-heart"/></svg> Like
</button>
<svg width="48" height="48" style="color: goldenrod"><use href="#icon-star"/></svg>
<!-- External sprite file (must be same-origin) -->
<svg width="24" height="24"><use href="/icons.svg#icon-star"/></svg>
.like { color: #999; }
.like:hover { color: crimson; } /* the heart turns red: fill="currentColor" follows */
Styling inside <use>. The copy created by <use> lives in a closed shadow tree, so selectors from the page such as .like path cannot reach it. Style instances through inherited properties (fill, stroke, color) set on the <use> element, through currentColor, or through CSS custom properties (fill: var(--icon-accent) inside the symbol).
Older code writes xlink:href instead of href. Plain href is the SVG 2 form and works in all current browsers.
Key Takeaways
<text>positions by baseline. Align withtext-anchoranddominant-baseline, style runs with<tspan>, and break lines manually because SVG text does not wrap.<g>groups elements so they share attributes, transforms and event handling.<defs>stores non-rendered definitions.<symbol>(with its ownviewBox) plus<use>is the standard pattern for reusable icons.- Content cloned by
<use>cannot be selected from outside. Use inherited properties,currentColorand CSS custom properties to vary instances.
Practice
- (Beginner) Build a sprite with three icons (home, search, user) as
<symbol>s and display them at 16, 32 and 64 pixels in three different colours using only CSS.- (Intermediate) Write a function
wrapText(textEl, string, maxWidth)that splits a string into words and appends<tspan>lines so that no line is wider thanmaxWidth, usinggetComputedTextLength()on a temporary<tspan>to measure.- (Interview) "Your icon component uses
<use href="#icon">and a designer wants the icon to have two colours that change on hover. How would you do it?" Hint: think about what crosses the shadow boundary of<use>: inheritance,currentColorand custom properties.
Fills, Strokes, Gradients and Filters
Shapes are only geometry until they are painted. SVG paints every shape in two layers, the fill (its interior) and the stroke (its outline), and either can be a flat colour, a gradient or a pattern. Filters then add image-processing effects such as blur and shadows.
Attributes or CSS. Most painting properties can be set as presentation attributes (fill="red") or as CSS (fill: red). Presentation attributes have the lowest priority: any CSS rule overrides them, even * { fill: black }. This is convenient, because exported SVG can carry defaults in attributes while your stylesheet overrides them.
.chart .bar { fill: steelblue; } /* wins over fill="..." attributes */
.chart .bar:hover { fill: orange; }
Fill and stroke properties.
| Property | Values and notes |
|---|---|
fill, stroke | Colour, none, currentColor or url(#paintServerId) |
fill-opacity, stroke-opacity, opacity | 0 to 1. opacity applies to the whole element after painting |
fill-rule | nonzero (default) or evenodd |
stroke-width | Width in user units. The stroke is centred on the outline, half inside and half outside |
stroke-linecap | butt (default), round, square: the shape of open line ends |
stroke-linejoin | miter (default), round, bevel: the shape of corners |
stroke-dasharray, stroke-dashoffset | Dash pattern (for example "8 4") and where the pattern starts |
vector-effect | non-scaling-stroke keeps the stroke width constant when the shape is scaled |
paint-order | stroke paints the stroke under the fill (useful for outlined text) |
stroke-linecap: butt ═══════| round ═══════) square ═══════]
(ends at the point) (half-circle) (extends by half the width)
stroke-linejoin: miter: sharp pointed corner round: rounded corner bevel: corner cut off flat
Gradients. A gradient is a paint server defined in <defs> and referenced with url(#id). Colour stops are placed along the gradient at offsets from 0 to 1 (or 0% to 100%):
<svg viewBox="0 0 300 120" width="300" height="120">
<defs>
<!-- left-to-right by default (x1=0 y1=0 x2=1 y2=0), relative to each shape's bounding box -->
<linearGradient id="sunset" x1="0" y1="0" x2="0" y2="1"> <!-- top to bottom -->
<stop offset="0" stop-color="#ff9800"/>
<stop offset="0.6" stop-color="#e91e63"/>
<stop offset="1" stop-color="#3f51b5"/>
</linearGradient>
<!-- centre highlight that fades to transparent; fx, fy move the focal point -->
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5" fx="0.35" fy="0.35">
<stop offset="0" stop-color="white" stop-opacity="0.9"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="300" height="120" fill="url(#sunset)"/>
<circle cx="230" cy="50" r="40" fill="#ffeb3b"/>
<circle cx="230" cy="50" r="40" fill="url(#glow)"/> <!-- layered on top -->
</svg>
By default, gradient coordinates use gradientUnits="objectBoundingBox": 0 to 1 spans the bounding box of the shape being painted, so one gradient fits any shape. Set gradientUnits="userSpaceOnUse" to give the gradient absolute coordinates shared by all shapes (for example one continuous gradient across all bars of a chart). spreadMethod (pad, reflect, repeat) decides what happens beyond the end stops. <pattern> works the same way but tiles arbitrary SVG content, for example hatching in a chart.
Clipping, masking and markers. <clipPath> cuts a shape to the outline of another (hard edges), <mask> uses the luminance of another graphic as variable transparency (soft edges), and <marker> attaches artwork such as arrowheads to the ends or vertices of lines and paths, which is essential for diagrams:
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 Z" fill="context-stroke"/> <!-- same colour as the line where supported -->
</marker>
</defs>
<line x1="20" y1="20" x2="180" y2="20" stroke="black" marker-end="url(#arrow)"/>
Filters. A <filter> is a small image-processing graph. Each filter primitive (fe...) takes an input image (in), produces an output (result) and can be chained to other primitives. SourceGraphic and SourceAlpha are the element itself and its alpha channel:
<defs>
<!-- Hand-built drop shadow: blur the alpha, offset it, then draw the original on top -->
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%"> <!-- room for the blur -->
<feGaussianBlur in="SourceAlpha" stdDeviation="3" result="blur"/>
<feOffset in="blur" dx="2" dy="3" result="offsetBlur"/>
<feMerge>
<feMergeNode in="offsetBlur"/> <!-- bottom layer: the shadow -->
<feMergeNode in="SourceGraphic"/> <!-- top layer: the original -->
</feMerge>
</filter>
<!-- Same result with the shortcut primitive -->
<filter id="shadow2"><feDropShadow dx="2" dy="3" stdDeviation="3" flood-opacity="0.4"/></filter>
</defs>
<rect x="20" y="20" width="120" height="60" rx="10" fill="white" filter="url(#shadow)"/>
Other useful primitives include feColorMatrix (desaturate, recolour), feTurbulence (procedural noise, paper and cloud textures) and feDisplacementMap (distortion). The CSS filter property can apply an SVG filter to HTML elements too (filter: url(#shadow)), and simple CSS filter functions (blur(), drop-shadow()) are shorthands for common filter graphs. Filters are computed per pixel and can be expensive on large or animated areas.
Key Takeaways
- Every shape has a fill and a stroke. Strokes are centred on the outline and are shaped by
linecap,linejoinanddasharray. - Presentation attributes lose to any CSS rule, so stylesheets can override exported SVG.
- Gradients and patterns are paint servers in
<defs>, used withurl(#id). Their default coordinates are relative to each shape's bounding box. <clipPath>gives hard-edged cut-outs,<mask>soft transparency,<marker>arrowheads and line decorations.- Filters chain primitives through
inandresult. They are powerful but cost per-pixel work.
Practice
- (Beginner) Draw a button-shaped rounded rectangle with a vertical linear gradient, a 1-unit darker stroke and a drop shadow. Then override its fill colour from a stylesheet on hover.
- (Intermediate) Draw five bars of different heights. Apply one gradient with the default
objectBoundingBoxunits and then withuserSpaceOnUse, and explain the visual difference. Add an arrowhead marker to an x-axis line.- (Interview) "Why does a 1-unit stroke on a shape positioned at integer coordinates sometimes look blurry, and how can you fix it?" Hint: the stroke is centred on the geometry; where do its edges fall relative to the pixel grid?
Transformations and viewBox Scaling
Two mechanisms change where and how big things appear without editing their coordinates: the transform attribute moves, rotates and scales individual elements, and the viewBox scales the entire drawing to fit its viewport. Together they are what puts the "Scalable" in SVG.
The transform attribute. A transform maps the element's coordinate system to its parent's:
| Function | Effect |
|---|---|
translate(tx, ty) | Shift by tx, ty |
scale(s) or scale(sx, sy) | Scale around the origin. A negative factor mirrors |
rotate(angle) / rotate(angle, cx, cy) | Rotate clockwise by degrees around the origin, or around (cx, cy) |
skewX(angle), skewY(angle) | Shear along one axis |
matrix(a, b, c, d, e, f) | Arbitrary affine transform: x' = a·x + c·y + e, y' = b·x + d·y + f |
Every combination of these is one 3×3 matrix, which is why matrix() can express them all:
| x' | | a c e | | x |
| y' | = | b d f | · | y | translate(tx,ty) = matrix(1, 0, 0, 1, tx, ty)
| 1 | | 0 0 1 | | 1 | scale(s) = matrix(s, 0, 0, s, 0, 0)
Order matters. A list of transforms is applied to the shape's points from right to left. An equivalent way to read it, which many people find easier, is left to right, with each function changing the coordinate system that the next one works in:
<!-- Moves the coordinate system to (100, 50), then rotates it: the square spins in place at (100, 50) -->
<rect width="40" height="40" x="-20" y="-20" transform="translate(100, 50) rotate(45)"/>
<!-- Rotates first, then translates along the ROTATED axes: the square ends up somewhere else -->
<rect width="40" height="40" x="-20" y="-20" transform="rotate(45) translate(100, 50)"/>
Drawing a shape centred on (0, 0) and then translating it, as above, is a habit worth adopting: rotations and scales then happen around the shape's centre.
CSS transforms on SVG. CSS transform also works on SVG elements, but with a different default origin. For SVG elements, transform-origin defaults to 0 0 of the SVG's user space (transform-box: view-box), not to the element's centre as for HTML. To rotate a shape around its own centre with CSS:
.gear {
transform-box: fill-box; /* percentages and origin refer to the shape's own bounding box */
transform-origin: center;
transition: transform 0.4s;
}
.gear:hover { transform: rotate(90deg); }
viewBox: a camera over the drawing. viewBox="minX minY width height" defines which rectangle of user space is visible. The browser maps that rectangle onto the viewport (the element's width × height on the page). An analogy: the drawing is a large sheet of paper, the viewBox is the window of a camera aimed at part of it, and the viewport is the photo print size.
User space (infinite) Viewport on the page (600 x 300 CSS px)
┌──────────────────────────────┐ ┌────────────────────────────────────────┐
│ viewBox="0 0 100 50" │ scaled │ │
│ ┌───────────────┐ │ x6 to fit │ every user unit is now 6 CSS px │
│ │ ● ▲ │ │ ─────────► │ ● ▲ │
│ │ │ │ │ │
│ └───────────────┘ │ └────────────────────────────────────────┘
└──────────────────────────────┘
When the aspect ratio of the viewBox differs from that of the viewport, preserveAspectRatio decides what happens:
| Value | Behaviour | CSS analogy |
|---|---|---|
xMidYMid meet (default) | Scale uniformly so the whole viewBox fits, centred | object-fit: contain |
xMidYMid slice | Scale uniformly to cover the viewport, cropping overflow | object-fit: cover |
none | Stretch non-uniformly to fill exactly | object-fit: fill |
xMinYMin ..., xMaxYMax ... | Align to the left/top or right/bottom instead of centring | object-position |
Responsive SVG. Give the SVG a viewBox, author everything in those units, and let CSS size the element:
<svg viewBox="0 0 100 60" class="chart" role="img" aria-label="Bar chart">
<rect x="10" y="20" width="15" height="40"/> <!-- coordinates in a fixed 100x60 space -->
<rect x="35" y="5" width="15" height="55"/>
</svg>
<style>
.chart { width: 100%; height: auto; } /* height follows the 100:60 ratio */
</style>
Zooming and panning. Because the viewBox is just four numbers, changing it zooms and pans the whole drawing, and every shape is re-rasterised sharply:
const svg = document.querySelector("#map");
let vb = { x: 0, y: 0, w: 1000, h: 600 };
function setViewBox() {
svg.setAttribute("viewBox", `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
}
function zoom(factor, cx, cy) { // zoom around the user-space point (cx, cy)
vb.x = cx - (cx - vb.x) / factor; // keep (cx, cy) fixed on screen
vb.y = cy - (cy - vb.y) / factor;
vb.w /= factor; // factor > 1 zooms in (smaller visible area)
vb.h /= factor;
setViewBox();
}
A nested <svg> element creates a new viewport with its own viewBox, which is a convenient way to place a self-contained sub-drawing (for example a chart inside a dashboard) with its own coordinate system.
Key Takeaways
transformcomposestranslate,scale,rotate,skewandmatrixinto one affine matrix. The order changes the result.- Draw shapes around
(0, 0)and position them withtranslateso that rotations and scales happen around their centre. - CSS transforms on SVG use the user-space origin by default. Set
transform-box: fill-box; transform-origin: centerto transform around the shape itself. viewBoxmaps a rectangle of user space onto the viewport, andpreserveAspectRatioworks likeobject-fit.- A
viewBoxwith CSS width andheight: automakes SVG responsive. Animating theviewBoximplements zoom and pan.
Practice
- (Beginner) Draw a clock face in a
viewBox="-100 -100 200 200"(origin at the centre). Draw twelve hour marks with a single<line>definition reused through<use>androtate(30 * i).- (Intermediate) Implement mouse-wheel zoom around the cursor and drag-to-pan for an SVG map by updating its
viewBox. Use thezoomfunction above and convert the cursor position to user units (see the next two topics).- (Interview) "What is the difference between
width/heightandviewBoxon an<svg>element?" Hint: one says how big the window is on the page, the other says which part of the drawing appears in that window.
Scripting SVG through the DOM
Inline SVG elements are DOM nodes like any other (Chapter 6): they inherit from Element through SVGElement, so querySelector, classList, addEventListener and remove all work. This is what makes SVG a natural choice for data-driven graphics. There are, however, a few SVG-specific rules.
Creating elements requires the namespace. document.createElement("circle") creates an HTML element called circle, which is unknown and never renders. SVG elements must be created in the SVG namespace:
const SVG_NS = "http://www.w3.org/2000/svg";
const wrong = document.createElement("circle"); // HTMLUnknownElement: invisible
const right = document.createElementNS(SVG_NS, "circle"); // SVGCircleElement: renders
right.setAttribute("cx", 50); // ordinary attributes need no namespace
right.setAttribute("cy", 50);
right.setAttribute("r", 20);
right.classList.add("dot");
document.querySelector("svg").append(right);
Markup strings are an alternative. When innerHTML or insertAdjacentHTML is used on an element that is already inside an <svg>, the HTML parser creates SVG elements correctly:
svg.insertAdjacentHTML("beforeend", `<circle cx="80" cy="50" r="10" fill="tomato"/>`);
The usual caution from Chapter 6 applies twice over: never insert untrusted strings, because SVG can carry <script> elements, on* attributes and javascript: links.
Reading and writing geometry. Attributes are the simplest interface. SVG also exposes typed "animated" properties (circle.r.baseVal.value), but they are verbose and rarely used. The most useful SVG-specific methods measure geometry:
| Method | Returns |
|---|---|
el.getBBox() | Bounding box in the element's own user units (ignores its transform and stroke) |
el.getBoundingClientRect() | Bounding box in viewport CSS pixels (like HTML elements) |
path.getTotalLength() / getPointAtLength(d) | Outline length and a point along it |
textEl.getComputedTextLength() | Rendered width of a text run |
el.getCTM() / el.getScreenCTM() | Matrix from the element's coordinates to its viewport / to the screen |
shape.isPointInFill(point) / isPointInStroke(point) | Hit tests in user units |
// Fit a background rectangle behind a label, whatever its length
const label = svg.querySelector("#label");
const box = label.getBBox();
const bg = document.createElementNS(SVG_NS, "rect");
Object.entries({ x: box.x - 4, y: box.y - 2, width: box.width + 8, height: box.height + 4, rx: 3 })
.forEach(([k, v]) => bg.setAttribute(k, v));
bg.setAttribute("fill", "#fff8e1");
label.before(bg); // insert before so it is painted underneath
A data-driven bar chart. A small helper that creates elements from an attribute object keeps chart code readable:
function svgEl(tag, attrs = {}, parent) {
const el = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value);
if (parent) parent.append(el);
return el;
}
function barChart(container, data, { width = 400, height = 200, pad = 24 } = {}) {
const svg = svgEl("svg", { viewBox: `0 0 ${width} ${height}`, role: "img" });
const max = Math.max(...data.map((d) => d.value));
const barW = (width - pad * 2) / data.length;
const frag = document.createDocumentFragment(); // build off-DOM, insert once
data.forEach((d, i) => {
const h = (d.value / max) * (height - pad * 2);
const g = svgEl("g", { transform: `translate(${pad + i * barW}, ${height - pad})` }, frag);
svgEl("rect", { x: 2, y: -h, width: barW - 4, height: h, class: "bar", "data-index": i }, g);
svgEl("text", { x: barW / 2, y: 16, "text-anchor": "middle", "font-size": 11 }, g)
.textContent = d.label; // textContent is safe with user data
});
svg.append(frag);
container.replaceChildren(svg);
return svg;
}
barChart(document.querySelector("#chart"), [
{ label: "Jan", value: 30 }, { label: "Feb", value: 45 }, { label: "Mar", value: 22 },
]);
Libraries such as D3.js formalise this idea with data joins, which match data items to existing elements and decide which elements to add, update or remove when the data changes.
Loading an external SVG into the DOM. An SVG in <img> cannot be scripted. To manipulate an external file, fetch it and insert its root:
const text = await fetch("/img/floorplan.svg").then((r) => r.text());
const doc = new DOMParser().parseFromString(text, "image/svg+xml");
const root = doc.documentElement; // the <svg> element
// If the file is not fully trusted, sanitise it (for example with DOMPurify) before inserting
document.querySelector("#plan").append(document.importNode(root, true));
Performance limits. Each shape is a DOM node with style, layout and event bookkeeping. A few hundred to a few thousand elements are comfortable. Tens of thousands, especially when animated, become slow, and that is the point to consider Canvas (7.2) or WebGL (7.3).
Key Takeaways
- Inline SVG elements are regular DOM nodes, so all DOM APIs from Chapter 6 apply.
- Create SVG elements with
document.createElementNS("http://www.w3.org/2000/svg", tag).createElementproduces invisible HTML elements. - Use
setAttributefor geometry, andgetBBox,getTotalLengthandgetScreenCTMto measure. - Build charts from data with a small element-creation helper and a
DocumentFragment, and set text withtextContent. - Sanitise untrusted SVG before inserting it, and move to Canvas or WebGL when element counts reach the tens of thousands.
Practice
- (Beginner) Create 20 circles at random positions with
createElementNS. Then try the same withcreateElementand inspect both in DevTools to see the difference.- (Intermediate) Extend
barChartwith a y-axis (line plus five tick labels) and anupdate(newData)function that reuses existing<rect>elements, adds missing ones and removes extra ones instead of rebuilding the chart.- (Interview) "Why does
document.createElement('circle')not draw anything inside an<svg>?" Hint: consider how the DOM decides which interface an element implements, and what the HTML parser does differently when it sees<circle>inside<svg>.
SVG Events and Interactivity
Because SVG shapes are DOM elements, they receive the same events as HTML elements (6.3): click, pointerdown, pointermove, keydown and so on, with capturing, bubbling and delegation. The interesting differences are in what counts as a hit and which coordinates you get.
Hit testing follows the geometry. An HTML element is hit anywhere inside its rectangular box. An SVG shape is hit only where it is actually painted: a click in the empty corner of a circle's bounding box does not hit the circle. The pointer-events property controls this precisely:
pointer-events | Shape receives events when the pointer is over |
|---|---|
visiblePainted (default) | Its fill or stroke, if painted (not none) and visible |
visible | Its fill or stroke area, painted or not, if visible |
painted | Painted areas, even if visibility: hidden |
fill / stroke | Only the fill area / only the stroke area |
all | Fill or stroke area, regardless of painting and visibility |
none | Never: events pass through to what is underneath |
A frequent surprise: a shape with fill="none" is clickable only on its thin stroke. Use fill="transparent" or pointer-events: all to make the interior clickable, and add a wide invisible stroke to make thin lines easier to hit:
<!-- Visible 1px line, plus an invisible 12px "hit area" on top -->
<g class="edge">
<line x1="0" y1="0" x2="200" y2="80" stroke="#555"/>
<line x1="0" y1="0" x2="200" y2="80" stroke="transparent" stroke-width="12"/>
</g>
pointer-events: none is equally useful the other way round: labels drawn over shapes should not steal clicks from the shapes beneath them.
From screen pixels to SVG units. event.clientX and clientY are in viewport CSS pixels, but shapes are positioned in user units, which differ whenever a viewBox or a transform is involved. getScreenCTM() returns the matrix from user units to screen pixels, so its inverse converts the other way:
// Convert a pointer event to the user coordinates of `svg` (or of any element inside it)
function toUserSpace(event, el) {
const point = new DOMPoint(event.clientX, event.clientY);
return point.matrixTransform(el.getScreenCTM().inverse());
}
Drag and drop with pointer events. Pointer events (6.3) cover mouse, touch and pen with one code path. Pointer capture keeps delivering pointermove to the dragged element even when the pointer moves faster than the shape:
const svg = document.querySelector("#board");
let drag = null; // { el, dx, dy } while dragging
svg.addEventListener("pointerdown", (e) => {
const el = e.target.closest(".draggable"); // event delegation on the root
if (!el) return;
const p = toUserSpace(e, svg);
drag = { el, dx: p.x - +el.getAttribute("cx"), dy: p.y - +el.getAttribute("cy") };
el.setPointerCapture(e.pointerId); // keep receiving events for this pointer
el.classList.add("dragging");
});
svg.addEventListener("pointermove", (e) => {
if (!drag) return;
const p = toUserSpace(e, svg);
drag.el.setAttribute("cx", p.x - drag.dx); // keep the grab offset
drag.el.setAttribute("cy", p.y - drag.dy);
});
svg.addEventListener("pointerup", () => {
drag?.el.classList.remove("dragging");
drag = null;
});
.draggable { cursor: grab; touch-action: none; } /* stop touch scrolling from stealing the drag */
.draggable.dragging { cursor: grabbing; }
.draggable:hover { stroke: black; stroke-width: 2; } /* :hover works on shapes too */
Tooltips. A <title> child gives a shape a native tooltip and an accessible name with no code. For richer tooltips, position an HTML element using the shape's getBoundingClientRect() or the pointer's clientX/clientY.
Keyboard and accessibility. Shapes are not focusable by default. An interactive shape needs tabindex="0", an appropriate role (for example button), an accessible name (<title> or aria-label) and keyboard handlers. Alternatively, wrap shapes in an SVG <a href="...">, which is focusable and keyboard-activatable like an HTML link.
<circle cx="50" cy="50" r="20" class="draggable" tabindex="0" role="button"
aria-label="Node A, press arrow keys to move"><title>Node A</title></circle>
Key Takeaways
- SVG shapes receive standard DOM events, so delegation, pointer events and pointer capture work as in HTML.
- Hit testing follows painted geometry.
fill="none"makes interiors unclickable;pointer-eventsand transparent hit areas fix it. - Convert
clientX/clientYto user units withnew DOMPoint(x, y).matrixTransform(el.getScreenCTM().inverse()). - Use
<title>for simple tooltips, andtabindex,role, accessible names and key handlers (or SVG<a>) for keyboard access.
Practice
- (Beginner) Draw a 3×3 grid of squares with
fill="none"and a stroke. Make a click on any square toggle its fill, first observing the problem with unfilled shapes and then fixing it withpointer-events.- (Intermediate) Build a node graph editor: draggable circles connected by lines whose end points follow the circles while dragging. Make it work inside an SVG with
viewBox="0 0 100 100"displayed at 600×600 pixels, and support moving the focused node with arrow keys.- (Interview) "A user reports that dragging works on desktop but scrolls the page on a phone. What is happening and how do you fix it?" Hint: look at how the browser decides whether a touch gesture is a scroll before your handlers run.
SVG Animation with SMIL and CSS
Motion helps users follow changes: a bar growing to its new value, a line drawing itself, a loading spinner. SVG can be animated in three ways: declaratively inside the SVG with SMIL elements, with CSS transitions and keyframes, or imperatively with JavaScript (requestAnimationFrame, 5.1, or the Web Animations API, 6.2).
SMIL animation elements. SMIL (Synchronized Multimedia Integration Language) animations are child elements that animate their parent:
| Element | Animates |
|---|---|
<animate> | Any attribute, numeric or colour (r, x, fill, d, points, viewBox, ...) |
<animateTransform> | The transform attribute (type="rotate", scale, translate, ...) |
<animateMotion> | Position along a path, optionally rotating to follow it |
<set> | Sets a value at a moment without interpolation |
<svg viewBox="0 0 120 60" width="240" height="120">
<!-- Spinner: rotate around (30, 30) forever -->
<circle cx="30" cy="30" r="20" fill="none" stroke="#1976d2" stroke-width="5"
stroke-dasharray="90 40" stroke-linecap="round">
<animateTransform attributeName="transform" type="rotate"
from="0 30 30" to="360 30 30" dur="1s" repeatCount="indefinite"/>
</circle>
<!-- Pulse on click: the animation starts when the circle is clicked -->
<circle cx="90" cy="30" r="10" fill="tomato">
<animate attributeName="r" values="10;20;10" dur="0.6s" begin="click"
calcMode="spline" keySplines="0.4 0 0.2 1; 0.4 0 0.2 1"/> <!-- easing per segment -->
</circle>
</svg>
Key SMIL attributes are attributeName, from/to or values (a semicolon-separated list), dur, repeatCount, begin (a time, an event such as click, or another animation's end such as intro.end), fill="freeze" (keep the final value instead of reverting) and keyTimes/keySplines for timing control. SMIL can animate attributes that CSS cannot reach in every browser, such as points, viewBox and path data. Like CSS embedded in the file, it also keeps running inside an SVG loaded with <img>, where scripts never run (although an <img> receives no events, so begin="click" will not fire there). Chrome once announced its deprecation, then reversed the decision, and it is supported in all modern browsers.
Moving along a path.
<path id="route" d="M10 80 C 60 0, 140 160, 190 80" fill="none" stroke="#ccc"/>
<polygon points="-6,-4 6,0 -6,4" fill="black"> <!-- a small arrow centred on (0,0) -->
<animateMotion dur="3s" repeatCount="indefinite" rotate="auto">
<mpath href="#route"/> <!-- follow the path, turning with it -->
</animateMotion>
</polygon>
CSS animation. CSS transitions and keyframes (Chapter 2) work on SVG presentation properties: fill, stroke, opacity, stroke-dasharray, stroke-dashoffset and transform. SVG 2 also turned several geometry attributes into CSS properties (cx, cy, r, rx, ry, x, y, width, height, and d for paths), although support for animating them, especially d, is not uniform, so check compatibility. CSS is usually the best choice for hover states and state transitions in inline SVG, because it lives with the rest of your styles and respects prefers-reduced-motion.
The line-drawing effect. A classic technique makes a path appear to draw itself: make the dash as long as the whole path, then slide the dash pattern from "all gap" to "all dash":
<svg viewBox="0 0 200 100" width="400" height="200">
<path class="draw" d="M10 90 L60 30 L100 60 L190 10" fill="none" stroke="teal" stroke-width="3"/>
</svg>
.draw {
stroke-dasharray: var(--len); /* one dash as long as the path, then an equal gap */
stroke-dashoffset: var(--len); /* shift the pattern so the gap covers the path */
animation: draw 2s ease-out forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } } /* slide the dash into view */
@media (prefers-reduced-motion: reduce) {
.draw { animation: none; stroke-dashoffset: 0; }
}
// Measure the path once and give CSS the exact length
const path = document.querySelector(".draw");
path.style.setProperty("--len", path.getTotalLength());
pathLength="1" on the path is an alternative: it tells the browser to treat the path as 1 unit long for dash calculations, so stroke-dasharray: 1; stroke-dashoffset: 1 works without JavaScript.
Morphing. Interpolating between two d values (with SMIL <animate attributeName="d">, CSS d, or a library) works only if both paths have the same number and type of commands. Otherwise browsers jump instead of interpolating, and libraries such as Flubber or GSAP's MorphSVG are used to normalise the paths.
Choosing an approach.
| Approach | Strengths | Limitations |
|---|---|---|
| SMIL | Works inside <img>-embedded SVG, event-based sequencing, path motion, any attribute | Separate syntax, harder to control from JavaScript, no reduced-motion media query inside |
| CSS | Familiar, declarative, hover and state classes, media queries, often GPU-composited transform/opacity | Only CSS properties; geometry property support varies |
| JavaScript (rAF, Web Animations API, GSAP) | Full control, data-driven, physics, interruption and sequencing | More code; runs on the main thread unless only transform/opacity are animated via WAAPI |
Key Takeaways
- SVG can be animated with SMIL elements, CSS, or JavaScript.
- SMIL (
<animate>,<animateTransform>,<animateMotion>,<set>) animates any attribute, sequences withbegin, and works in SVG used as an image. - CSS animates fill, stroke, opacity, transform and dash properties well, and should be the default for interactive inline SVG.
- The line-drawing effect animates
stroke-dashoffsetfrom the path length to zero. - Morphing requires paths with matching command structure. Always provide a
prefers-reduced-motionalternative.
Practice
- (Beginner) Create a loading spinner twice: once with
<animateTransform>and once with a CSS@keyframesrotation usingtransform-box: fill-box.- (Intermediate) Animate a line chart so that the line draws itself over 1.5 seconds and then each data point fades in one after another. Use
pathLengthfor the line and CSSanimation-delaycomputed from the point's index. Disable the effect underprefers-reduced-motion.- (Interview) "You need an animated logo that will be embedded with
<img>in third-party sites. Which animation technique do you use and why?" Hint: list what does and does not run inside an SVG loaded as an image.
<a id="72-html5-canvas-api"></a>
7.2 HTML5 Canvas API
This subchapter covers the <canvas> element and its 2D drawing API: how a bitmap drawing surface differs from the DOM, how to draw and style shapes, text and images, how the state stack and transforms work, how to read and write raw pixels, and how to build animations, interaction and exports on top of it.
Canvas Element and the 2D Rendering Context
Some graphics are too numerous or change too fast to be DOM elements: a particle system with 20,000 sparks, a game redrawn 60 times per second, a heat map with a million cells, a drawing app. For these, HTML offers <canvas>: a rectangle of pixels that JavaScript paints directly. The canvas element itself does nothing; all drawing goes through a rendering context object obtained from it.
<canvas id="scene" width="600" height="300">
<!-- Fallback content: shown if canvas is unsupported, and exposed to assistive technologies -->
<p>A bar chart of monthly sales: January 30, February 45, March 22.</p>
</canvas>
const canvas = document.getElementById("scene");
const ctx = canvas.getContext("2d"); // CanvasRenderingContext2D, or null if unsupported
ctx.fillStyle = "steelblue";
ctx.fillRect(20, 20, 200, 100); // x, y, width, height in canvas pixels
Context types. getContext(type) decides what kind of drawing the canvas supports. A canvas can only ever have one context type: once you have asked for "2d", asking for "webgl" returns null.
| Type | Interface | Used for |
|---|---|---|
"2d" | CanvasRenderingContext2D | 2D drawing with paths, images and text (this subchapter) |
"webgl", "webgl2" | WebGLRenderingContext, WebGL2RenderingContext | GPU rendering with shaders (7.3) |
"webgpu" | GPUCanvasContext | The newer WebGPU API |
"bitmaprenderer" | ImageBitmapRenderingContext | Displaying an ImageBitmap efficiently |
The 2D context accepts options as a second argument: { alpha: false } declares the canvas opaque (allowing faster compositing), { willReadFrequently: true } keeps it on the CPU when you call getImageData often, and { colorSpace: "display-p3" } enables a wide-gamut colour space.
Two sizes: the bitmap and the box. This is the most common source of canvas bugs. A canvas has:
- a drawing buffer, set by the
widthandheightattributes (default 300×150), which is the actual number of pixels you draw into; - a display size, set by CSS, which is how large the element appears on the page.
If they differ, the browser stretches the bitmap to fit the box, and everything becomes blurry or distorted:
<canvas width="300" height="150" style="width:600px; height:300px">
drawing buffer 300x150 displayed at 600x300 CSS px
┌───────────────┐ ┌───────────────────────────────┐
│ ● sharp │ stretched │ ● blurry, each buffer pixel │
│ │ ──────────► │ covers 2x2 CSS pixels │
└───────────────┘ └───────────────────────────────┘
On a high-density screen the same problem appears even when the two sizes match in CSS pixels, because each CSS pixel is several device pixels. The fix is to size the buffer in device pixels and scale the context so that you can keep drawing in CSS pixels:
function setupCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const { width, height } = canvas.getBoundingClientRect(); // CSS size from the layout
canvas.width = Math.round(width * dpr); // buffer in device pixels
canvas.height = Math.round(height * dpr);
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 1 drawing unit = 1 CSS pixel
return { ctx, width, height }; // draw in CSS-pixel coordinates
}
// Re-run when the element's size changes (ResizeObserver, 6.4), then redraw
new ResizeObserver(() => {
const { ctx, width, height } = setupCanvas(canvas);
draw(ctx, width, height);
}).observe(canvas);
Setting width or height (even to the same value) clears the canvas and resets all context state, including transforms and styles. That is why the transform above is set after resizing, every time.
Drawing off the main thread. OffscreenCanvas is a canvas that is not attached to the DOM. It can be created in a Web Worker (6.4), or a page canvas can hand its rendering over to a worker, so that expensive drawing never blocks user input:
// main.js
const offscreen = document.querySelector("#scene").transferControlToOffscreen();
const worker = new Worker("render-worker.js");
worker.postMessage({ canvas: offscreen }, [offscreen]); // transfer, not copy
// render-worker.js
self.onmessage = ({ data }) => {
const ctx = data.canvas.getContext("2d");
function frame(t) {
ctx.clearRect(0, 0, data.canvas.width, data.canvas.height);
ctx.fillRect(100 + Math.sin(t / 500) * 80, 50, 40, 40); // drawing happens in the worker
requestAnimationFrame(frame); // available in dedicated workers
}
requestAnimationFrame(frame);
};
Accessibility. A canvas is a single opaque image to assistive technologies. Provide fallback content that describes the information (or a data table), and give interactive canvas content keyboard support and an accessible alternative.
Key Takeaways
<canvas>is a bitmap. All drawing goes through a context fromgetContext("2d" | "webgl2" | ...), and a canvas can have only one context type.- The
width/heightattributes set the drawing buffer. CSS sets the display size. Mismatches cause blur. - For sharp output, size the buffer as CSS size ×
devicePixelRatioand scale the context by the same ratio. - Changing
widthorheightclears the canvas and resets the context state. OffscreenCanvasmoves rendering into a worker. Fallback content keeps canvas information accessible.
Practice
- (Beginner) Draw a 1-pixel-wide diagonal line on a canvas sized only with CSS (
width: 600px) and no attributes. Then applysetupCanvasand compare the sharpness on your display.- (Intermediate) Make a canvas that always fills its parent container, stays sharp on any display density, and redraws a centred circle whenever the container is resized. Verify that dragging the window between monitors with different densities updates it (hint: a
matchMediaquery on the currentresolutionindppxfires achangeevent whendevicePixelRatiochanges).- (Interview) "A designer complains that the charts drawn on canvas look blurry on their laptop but fine on an external monitor. What is happening?" Hint: compare the device pixel ratio of both screens with the size of the canvas drawing buffer.
Immediate Mode vs Retained Mode Rendering
SVG and Canvas can produce the same picture, yet programs that use them look completely different. The reason is a fundamental difference in who remembers what is on screen.
- In retained mode, the graphics system keeps a model of the scene: a tree of objects (the DOM for HTML and SVG). You change the model ("move this circle"), and the system works out what to redraw.
- In immediate mode, you issue drawing commands ("paint a circle here") and the system turns them into pixels and forgets them. There is no circle object afterwards, only coloured pixels. To move the circle, you clear and repaint the picture yourself, using a model that you keep.
An analogy: SVG is a felt board with cut-out shapes. To move a shape you pick it up and put it elsewhere. Canvas is a painting. To "move" something you must paint over the old position and paint it again, and the painting cannot tell you what was under the brush.
Retained mode (SVG, DOM) Immediate mode (Canvas 2D, WebGL)
your code ──► scene tree (kept by browser) your code ──► your own model (arrays, objects)
│ circle, rect, text │
▼ browser redraws on change ▼ you redraw every change
pixels on screen draw calls ──► pixels (no memory)
The same "move a circle" task in both:
// SVG: change the object, the browser repaints
circleEl.setAttribute("cx", 200);
// Canvas: keep your own model, then clear and redraw everything
const circles = [{ x: 100, y: 80, r: 20, color: "tomato" }, { x: 300, y: 60, r: 30, color: "teal" }];
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const c of circles) {
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fillStyle = c.color;
ctx.fill();
}
}
circles[0].x = 200; // update the model...
render(); // ...and repaint the frame
Consequences.
| Aspect | Retained (SVG) | Immediate (Canvas) |
|---|---|---|
| Who stores the scene | The browser (DOM nodes) | You (plain JavaScript data) |
| Cost per object | A DOM node with styles and events, even if idle | Only its draw call, and nothing when not drawn |
| Updating one object | Change one attribute | Redraw the frame (or the affected region) |
| Events and hit testing | Built in, per shape | Implemented by you (see Animation Loops and Hit Detection) |
Styling with CSS, :hover | Yes | No |
| Accessibility, text selection | Native | Fallback content only |
| Scaling and zooming | Re-rendered sharply | Must redraw at the new scale |
| Performance sweet spot | Up to a few thousand mostly static elements | Thousands to tens of thousands of frequently changing objects |
Choosing a graphics technology. As a rule of thumb:
| Need | Good fit |
|---|---|
| Icons, logos, diagrams, interactive charts with hundreds of marks, accessibility | SVG |
| Many moving objects, games, image editing, pixel effects, charts with tens of thousands of points | Canvas 2D |
| Hundreds of thousands to millions of points, 3D, shader effects, maps with smooth zoom | WebGL (7.3) |
| Text-heavy UI, forms, layout | HTML and CSS |
Hybrids and retained layers. Real applications often combine them: a canvas for 50,000 scatter points with SVG or HTML on top for axes, labels and tooltips. Libraries such as Konva, Fabric.js and PixiJS build a retained-mode scene graph (objects, events, hit testing) on top of an immediate-mode canvas or WebGL context, giving you object-level convenience with canvas-level performance.
Key Takeaways
- Retained mode keeps a scene model and redraws it for you. Immediate mode draws pixels and forgets the commands.
- With canvas, you own the model: keep your objects in data structures and redraw from them.
- SVG gives per-object events, CSS, accessibility and crisp scaling. Canvas gives low per-object cost and pixel-level control.
- Choose SVG for modest numbers of accessible, interactive shapes, Canvas for many frequently changing objects, and WebGL for massive or 3D scenes. Hybrids are common.
Practice
- (Beginner) Draw 100 circles at random positions in SVG and in canvas. Make each one move 1 pixel to the right on a button click, and compare how much code each version needs.
- (Intermediate) Benchmark animating N moving circles in SVG and canvas for N = 100, 1,000, 10,000 and 50,000, measuring frames per second with
requestAnimationFrametimestamps. Plot where each approach drops below 60 fps on your machine.- (Interview) "You are building a stock-chart component that must show 200,000 points, smooth zoom, and tooltips on hover. Which rendering approach do you choose?" Hint: consider separating the data layer from the annotation layer, and how hover can be resolved without per-point DOM nodes.
Drawing Paths, Shapes and Curves
The 2D context draws in the same kind of coordinate system as SVG: origin at the top-left, x to the right, y down, and units in canvas pixels (or CSS pixels after the scaling shown earlier). Rectangles have dedicated methods; every other shape is built as a path and then filled or stroked.
Rectangles. These draw immediately, without a path:
ctx.fillStyle = "#90caf9";
ctx.fillRect(10, 10, 100, 60); // filled rectangle
ctx.strokeStyle = "#1565c0";
ctx.strokeRect(10, 10, 100, 60); // outline only
ctx.clearRect(30, 30, 20, 20); // make a transparent hole (erases pixels)
Paths. Drawing anything else follows the same recipe: start a path, describe it with pen movements, then paint it.
| Method | Adds to the current path |
|---|---|
beginPath() | Nothing: discards the previous path and starts a new one |
moveTo(x, y) | Starts a new sub-path at a point (pen up) |
lineTo(x, y) | Straight line from the current point |
arc(x, y, r, start, end, ccw = false) | Circular arc around (x, y). Angles in radians |
arcTo(x1, y1, x2, y2, r) | Arc tangent to two lines (rounded corners) |
ellipse(x, y, rx, ry, rotation, start, end) | Elliptical arc |
quadraticCurveTo(cpx, cpy, x, y) | Quadratic Bézier (SVG Q) |
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) | Cubic Bézier (SVG C) |
rect(x, y, w, h), roundRect(x, y, w, h, radii) | Rectangle sub-path, optionally rounded |
closePath() | Straight line back to the sub-path start |
Then paint with fill(), stroke(), or use the path as a clipping region with clip().
Angles. arc measures angles in radians from the positive x-axis (3 o'clock). Because y points down, increasing angles go clockwise:
-π/2 (12 o'clock)
│
π (9 o'clock) ─────●───── 0 (3 o'clock)
│
π/2 (6 o'clock)
full circle: arc(x, y, r, 0, Math.PI * 2)
// A smiley face built from four paths
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(100, 100, 60, 0, Math.PI * 2); // head
ctx.fillStyle = "gold";
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.arc(78, 85, 6, 0, Math.PI * 2); // left eye
ctx.moveTo(128, 85); // move the pen, or a line joins the two eyes
ctx.arc(122, 85, 6, 0, Math.PI * 2); // right eye (starts at angle 0 = x 128)
ctx.fillStyle = "black";
ctx.fill();
ctx.beginPath();
ctx.arc(100, 105, 30, 0.15 * Math.PI, 0.85 * Math.PI); // smile: lower half, clockwise
ctx.stroke();
The beginPath trap. The current path persists until beginPath() is called. If you forget it, each stroke() strokes all sub-paths added so far: earlier shapes get redrawn in the new colour, and drawing gets slower every frame as the path keeps growing.
// Bug: second stroke() also re-strokes the first line in red
ctx.moveTo(0, 0); ctx.lineTo(100, 0); ctx.strokeStyle = "blue"; ctx.stroke();
ctx.moveTo(0, 20); ctx.lineTo(100, 20); ctx.strokeStyle = "red"; ctx.stroke(); // both lines red
// Fix: call ctx.beginPath() before each new shape
Reusable paths with Path2D. A Path2D object records a path once so it can be drawn many times, tested for hits, and even built from SVG path data:
function starPath(cx, cy, outer, inner, points = 5) {
const p = new Path2D();
for (let i = 0; i < points * 2; i++) {
const r = i % 2 === 0 ? outer : inner; // alternate outer and inner vertices
const a = -Math.PI / 2 + (i * Math.PI) / points; // start at 12 o'clock
const x = cx + r * Math.cos(a);
const y = cy + r * Math.sin(a);
i === 0 ? p.moveTo(x, y) : p.lineTo(x, y);
}
p.closePath();
return p;
}
const star = starPath(200, 100, 50, 20);
ctx.fillStyle = "goldenrod";
ctx.fill(star); // no beginPath needed with Path2D
ctx.stroke(star);
const heart = new Path2D("M12 21 C5 15 2 12 2 8 A5 5 0 0 1 12 6 A5 5 0 0 1 22 8 C22 12 19 15 12 21Z");
ctx.fill(heart); // SVG path syntax works directly
fill(path, "evenodd") selects the even-odd fill rule, as in SVG, for shapes with holes.
Crisp lines. A 1-pixel line at integer coordinates straddles two pixel rows (the stroke is centred on the line), so it is drawn as two half-intensity rows that look blurry. Offset odd-width lines by half a pixel:
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(10, 20.5); // y = 20.5: the stroke covers exactly row 20
ctx.lineTo(200, 20.5);
ctx.stroke();
Key Takeaways
- Rectangles have direct methods (
fillRect,strokeRect,clearRect). Everything else is a path followed byfill(),stroke()orclip(). - Always call
beginPath()before a new shape, andmoveTo()before starting a disconnected sub-path. - Angles are in radians, start at 3 o'clock and increase clockwise.
Path2Dmakes paths reusable, testable for hits, and constructible from SVG path data.- Offset 1-pixel lines by 0.5 to align them with the pixel grid.
Practice
- (Beginner) Draw a house (square, triangular roof, door, two windows) using only paths, with a different fill colour for each part.
- (Intermediate) Write
drawPieChart(ctx, values, colors, cx, cy, r)usingarc, then turn it into a donut chart by drawing the ring as one path filled with"evenodd". Add a 1-pixel white separator between slices.- (Interview) "A canvas animation gets slower and slower over time, and earlier shapes change colour. What is the likely cause?" Hint: think about what the current path contains if it is never reset.
Fill Styles, Strokes, Gradients and Patterns
A canvas context is like a painter holding one brush and one pencil. You choose the paint first (by setting properties on the context) and then make strokes. Every subsequent fill() or stroke() uses whatever paint is currently set.
Paint sources. fillStyle and strokeStyle accept three kinds of value:
| Value | Example |
|---|---|
| Any CSS colour string | "tomato", "#1e88e5", "rgb(0 0 0 / 50%)", "hsl(200 80% 50%)" |
A CanvasGradient | ctx.createLinearGradient(...) and friends |
A CanvasPattern | ctx.createPattern(image, "repeat") |
Line properties. These mirror the SVG stroke properties:
| Property | Values |
|---|---|
lineWidth | Width in drawing units, centred on the path |
lineCap | "butt" (default), "round", "square" |
lineJoin | "miter" (default), "round", "bevel" |
miterLimit | Maximum miter length before a join is bevelled |
setLineDash([dash, gap, ...]), lineDashOffset | Dashed lines and their offset (animate it for "marching ants") |
globalAlpha | Transparency multiplier (0 to 1) for everything drawn afterwards |
Gradients. Gradients are created from the context with geometry in canvas coordinates, then given colour stops:
// Linear: from point (x0, y0) to point (x1, y1)
const sky = ctx.createLinearGradient(0, 0, 0, 200); // top to bottom
sky.addColorStop(0, "#0d47a1");
sky.addColorStop(1, "#90caf9");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, 400, 200);
// Radial: between two circles (x0, y0, r0) and (x1, y1, r1)
const ball = ctx.createRadialGradient(290, 70, 5, 300, 80, 40); // offset inner circle = highlight
ball.addColorStop(0, "white");
ball.addColorStop(1, "crimson");
ctx.fillStyle = ball;
ctx.beginPath();
ctx.arc(300, 80, 40, 0, Math.PI * 2);
ctx.fill();
// Conic: colours sweep around a centre, starting at an angle (colour wheels, gauges)
const wheel = ctx.createConicGradient(0, 100, 100);
["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]
.forEach((c, i, all) => wheel.addColorStop(i / (all.length - 1), c));
An important difference from SVG: a canvas gradient is defined in canvas space, not relative to each shape. A gradient from x=0 to x=100 applied to a rectangle at x=300 shows only the final colour. Create the gradient at the shape's position, or translate the context before drawing (gradients follow the current transform).
Patterns. createPattern(source, repetition) tiles an image, another canvas or a video frame. A small canvas makes a convenient procedural tile:
// Diagonal hatching tile drawn on a tiny offscreen canvas
const tile = new OffscreenCanvas(8, 8);
const t = tile.getContext("2d");
t.strokeStyle = "#999";
t.beginPath();
t.moveTo(0, 8); t.lineTo(8, 0);
t.stroke();
ctx.fillStyle = ctx.createPattern(tile, "repeat"); // "repeat", "repeat-x", "repeat-y", "no-repeat"
ctx.fillRect(20, 220, 160, 60);
Shadows and filters. shadowColor, shadowBlur, shadowOffsetX and shadowOffsetY add a shadow to everything drawn while they are set, and ctx.filter accepts CSS filter strings such as "blur(4px) grayscale(1)" (support for it arrived late in some browsers, so check compatibility). Both are applied per draw call and are expensive: in animations, pre-render shadowed artwork once to an offscreen canvas and draw that instead.
Example: a progress ring.
function progressRing(ctx, cx, cy, r, fraction) {
const start = -Math.PI / 2; // 12 o'clock
ctx.lineWidth = 12;
ctx.lineCap = "round";
ctx.beginPath(); // background track
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = "#e0e0e0";
ctx.setLineDash([]);
ctx.stroke();
const grad = ctx.createConicGradient(start, cx, cy); // colour follows the ring
grad.addColorStop(0, "#43a047");
grad.addColorStop(1, "#1e88e5");
ctx.beginPath(); // progress arc
ctx.arc(cx, cy, r, start, start + fraction * Math.PI * 2);
ctx.strokeStyle = grad;
ctx.stroke();
ctx.fillStyle = "#333";
ctx.font = "bold 20px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${Math.round(fraction * 100)}%`, cx, cy);
}
Key Takeaways
- Set
fillStyle,strokeStyleand line properties first, then draw. They stay in effect until changed. - Styles can be CSS colours, gradients (linear, radial, conic) or patterns.
- Canvas gradients live in canvas coordinates (affected by the current transform), not in each shape's bounding box as in SVG.
setLineDashandlineDashOffsetcreate dashed and animated outlines.- Shadows and
ctx.filterare expensive per draw. Cache shadowed artwork for animation.
Practice
- (Beginner) Draw a sunset scene: a linear-gradient sky, a radial-gradient sun, and a ground with a hatched pattern.
- (Intermediate) Draw ten rectangles across the canvas, all filled with the same horizontal gradient object. Then fill each one with its own gradient spanning only that rectangle, and explain the difference. Finally, animate
lineDashOffsetto create a "marching ants" selection outline.- (Interview) "Why does a gradient look correct on one shape and appear as a flat colour on another shape drawn with the same
fillStyle?" Hint: in which coordinate system are the gradient's start and end points defined?
Drawing Text and Images
Most canvas applications need labels and images: chart titles, game sprites, photo editing, thumbnails. Canvas draws both as pixels, which brings power (any transformation and effect) and limits (no layout, no selection, no accessibility).
Text. Text is styled with properties and drawn with fillText or strokeText:
ctx.font = "italic 600 24px Inter, system-ui, sans-serif"; // CSS font shorthand
ctx.textAlign = "center"; // "start" (default), "end", "left", "right", "center": relative to x
ctx.textBaseline = "middle"; // "alphabetic" (default), "top", "middle", "bottom", "hanging", "ideographic"
ctx.fillStyle = "#222";
ctx.fillText("Hello canvas", 200, 50); // optional 4th argument: maxWidth (squeezes text)
ctx.lineWidth = 4;
ctx.strokeStyle = "white";
ctx.strokeText("Outlined", 200, 100); // stroke first...
ctx.fillText("Outlined", 200, 100); // ...then fill, for a halo effect
textBaseline relative to the y coordinate:
top ────────── ┌─────────┐
hanging ────── │ Hxgly │
middle ─────── │ │
alphabetic ─── │_________│ <- default: letters sit on y, descenders go below
bottom ─────── └─────────┘
Measuring and wrapping. Canvas has no line wrapping. measureText() returns a TextMetrics object (with width, actualBoundingBoxAscent, actualBoundingBoxDescent, ...) that lets you implement it:
function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
const words = text.split(" ");
let line = "";
for (const word of words) {
const test = line ? `${line} ${word}` : word;
if (ctx.measureText(test).width > maxWidth && line) {
ctx.fillText(line, x, y); // line is full: draw it and start a new one
line = word;
y += lineHeight;
} else {
line = test;
}
}
if (line) ctx.fillText(line, x, y); // draw the last line
return y + lineHeight; // where the next paragraph would start
}
Web fonts. If a web font has not loaded yet, canvas silently draws with the fallback font, and since pixels are not reflowed later, the wrong font stays on screen. Wait for the font explicitly:
await document.fonts.load("600 24px Inter"); // resolves when the font is ready
drawEverything();
Images. drawImage has three forms:
ctx.drawImage(img, dx, dy); // natural size at (dx, dy)
ctx.drawImage(img, dx, dy, dw, dh); // scaled to dw x dh
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh); // crop a source rectangle, draw it scaled
source image canvas
┌──────────────────────────┐ ┌───────────────────────────┐
│ (sx,sy) │ │ │
│ ┌────────┐ │ drawImage │ (dx,dy) │
│ │ sw x sh│ │ ──────────► │ ┌──────────────┐ │
│ └────────┘ │ │ │ dw x dh │ │
└──────────────────────────┘ │ └──────────────┘ │
└───────────────────────────┘
Sources include HTMLImageElement, HTMLVideoElement (draws the current frame), another canvas, OffscreenCanvas, ImageBitmap, SVGImageElement and VideoFrame. An image must be loaded and decoded before it can be drawn, otherwise nothing appears:
async function loadImage(url) {
const img = new Image();
img.src = url;
await img.decode(); // resolves once the image is decoded and ready to draw
return img;
}
// Or decode off the main thread into an ImageBitmap (works in workers too)
const bitmap = await createImageBitmap(await (await fetch("/sprites.png")).blob());
Sprite sheets. Games pack animation frames into one image and draw one frame at a time with the nine-argument form:
const FRAME_W = 64, FRAME_H = 64, FRAMES = 8;
function drawSprite(ctx, sheet, frameIndex, x, y) {
const sx = (frameIndex % FRAMES) * FRAME_W; // pick the column of the current frame
ctx.drawImage(sheet, sx, 0, FRAME_W, FRAME_H, x, y, FRAME_W, FRAME_H);
}
When scaling images, ctx.imageSmoothingEnabled = false keeps pixel art crisp (nearest-neighbour), while ctx.imageSmoothingQuality = "high" improves downscaled photographs.
Cross-origin images and tainting. You may draw any image, but if it comes from another origin without CORS permission, the canvas becomes tainted: from then on, getImageData, toDataURL and toBlob throw a SecurityError. This stops pages from reading pixels of images they could not otherwise access (for example a user's private photos on another site). To draw a cross-origin image and still read pixels, request it with CORS and have the server allow it:
const img = new Image();
img.crossOrigin = "anonymous"; // must be set BEFORE src; server must send Access-Control-Allow-Origin
img.src = "https://images.example-cdn.com/photo.jpg";
Key Takeaways
- Text uses
font(CSS shorthand),textAlign,textBaseline,fillTextandstrokeText. There is no wrapping: measure withmeasureTextand break lines yourself. - Wait for web fonts with
document.fonts.loadand for images withimg.decode()orcreateImageBitmapbefore drawing. drawImagedraws at natural size, scaled, or cropped and scaled (sprite sheets).- Cross-origin images without CORS taint the canvas and block pixel reading and export. Set
crossOrigin = "anonymous"and serve proper headers. - Canvas text is pixels: not selectable, searchable or accessible.
Practice
- (Beginner) Draw a meme generator: load an image, draw it to fill the canvas, then draw top and bottom captions in white with a black outline, centred and wrapped to the canvas width.
- (Intermediate) Build a thumbnail generator: the user selects an image file, and the canvas produces a 200×200 centre-cropped thumbnail using the nine-argument
drawImage, preserving aspect ratio (crop the longer dimension).- (Interview) "Why does
getImageDatathrow aSecurityErrorafter drawing an image from a CDN, and how would you fix it?" Hint: think about what a tainted canvas protects against and which two sides (client and server) must cooperate.
The Canvas State Stack
Every property you have set so far (fillStyle, lineWidth, font, transforms, clipping) is part of the context's drawing state. Because all drawing code shares one context, any function that changes the state affects everything drawn after it. The state stack solves this: save() pushes a copy of the current state, and restore() pops it back.
An analogy: before borrowing a colleague's desk, you take a photo of how everything is arranged. You can rearrange freely while you work, and when you are done you put everything back exactly as in the photo. Nested save() calls are a stack of such photos.
What the state contains.
| Saved and restored | Not part of the state |
|---|---|
fillStyle, strokeStyle, globalAlpha, globalCompositeOperation | The current path |
lineWidth, lineCap, lineJoin, miterLimit, line dash | The pixels already drawn |
font, textAlign, textBaseline, direction | |
shadowColor, shadowBlur, shadowOffsetX/Y, filter, imageSmoothingEnabled | |
| The current transformation matrix | |
| The current clipping region |
ctx.save() ctx.save() ctx.restore() ctx.restore()
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ │ │ state B │ <- top │ │ │ │
│ state A │ <- top │ state A │ │ state A │ <- top │ (empty) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
current = A, then current = B, then current = B (popped) current = A (popped)
changed to B changed to C
Self-contained drawing functions. Wrap every function that changes state in save()/restore(), so that callers never see side effects:
function drawButton(ctx, x, y, w, h, label) {
ctx.save(); // snapshot the caller's state
try {
ctx.translate(x, y); // local coordinates: (0,0) = button corner
ctx.shadowColor = "rgb(0 0 0 / 30%)";
ctx.shadowBlur = 6;
ctx.fillStyle = "#1e88e5";
ctx.beginPath();
ctx.roundRect(0, 0, w, h, 8);
ctx.fill();
ctx.shadowColor = "transparent"; // no shadow on the text
ctx.fillStyle = "white";
ctx.font = "16px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, w / 2, h / 2);
} finally {
ctx.restore(); // caller's transform, colours and font are back
}
}
Clipping can only be undone by restore(). clip() intersects the current clipping region with the current path. There is no "unclip" method, so the standard pattern is to save, clip, draw, and restore:
ctx.save();
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.clip(); // everything drawn now is limited to the circle
ctx.drawImage(avatar, 50, 50, 100, 100); // circular avatar
ctx.restore(); // clipping removed
Hierarchical drawing. The stack naturally matches hierarchical models: each level saves, applies its own transform relative to its parent, draws its children, and restores. A robot arm whose forearm moves with the upper arm:
function drawArm(ctx, shoulderAngle, elbowAngle) {
ctx.save();
ctx.translate(200, 200); // shoulder position
ctx.rotate(shoulderAngle);
ctx.fillRect(0, -8, 100, 16); // upper arm, in shoulder space
ctx.save();
ctx.translate(100, 0); // elbow at the end of the upper arm
ctx.rotate(elbowAngle); // relative to the upper arm's direction
ctx.fillRect(0, -6, 80, 12); // forearm, in elbow space
ctx.restore();
ctx.restore();
}
Pitfalls.
- Every
save()needs exactly onerestore(). A missingrestore()(for example after an earlyreturnor an exception) leaks state into all later drawing;try/finallyprevents it. - Extra
restore()calls on an empty stack are silently ignored, which can hide bugs. save()/restore()are cheap but not free. In tight loops drawing thousands of identical objects, set shared state once outside the loop instead of saving per object.ctx.reset(), available in recent browsers, clears the canvas and resets all state and the stack at once.
Key Takeaways
- The drawing state includes styles, line and text settings, shadows, the transform and the clipping region, but not the current path or the pixels.
save()pushes a copy of the state andrestore()pops it. Wrap every state-changing helper in a balanced pair, ideally withtry/finally.restore()is the only way to remove a clipping region.- Nested save/translate/rotate/restore blocks model hierarchical objects naturally.
Practice
- (Beginner) Write a function that draws a red dashed rectangle without
save/restore, then call it before drawing a blue circle. Observe the leaked state, then fix the function.- (Intermediate) Draw a solar system with the sun at the centre, a planet orbiting it, and a moon orbiting the planet, using nested
save/translate/rotate/restoreand animating the angles.- (Interview) "Why might a canvas drawing occasionally appear rotated or clipped after an error occurred in an unrelated drawing function?" Hint: consider what happens to the state stack when an exception skips a
restore()call.
Transformations and Compositing
Transforms change where subsequent drawing lands, and compositing changes how new pixels combine with the pixels already on the canvas. Together they enable rotation, mirroring, zooming, erasing, masking and blending effects.
Transform methods. The context keeps a current transformation matrix (the same 2D affine matrix as SVG's matrix(a, b, c, d, e, f)). Each transform method multiplies it; it affects only drawing done afterwards, never pixels already on the canvas.
| Method | Effect |
|---|---|
translate(x, y) | Move the origin |
rotate(angle) | Rotate around the origin, in radians, clockwise |
scale(sx, sy) | Scale. Negative values mirror |
transform(a, b, c, d, e, f) | Multiply by an arbitrary matrix |
setTransform(a, b, c, d, e, f) / setTransform(domMatrix) | Replace the matrix |
resetTransform() | Back to identity |
getTransform() | The current matrix as a DOMMatrix |
Rotating around a centre. rotate turns around the current origin (initially the top-left corner). To spin an object in place, move the origin to its centre, rotate, and draw it centred on (0, 0):
function drawRotatedRect(ctx, cx, cy, w, h, angle) {
ctx.save();
ctx.translate(cx, cy); // 1. origin to the rectangle's centre
ctx.rotate(angle); // 2. rotate the coordinate system around it
ctx.fillRect(-w / 2, -h / 2, w, h); // 3. draw centred on the new origin
ctx.restore();
}
Two more idioms: ctx.scale(-1, 1) mirrors horizontally (for example a game character facing left, after translating by the sprite width), and a zoomable canvas view is ctx.setTransform(zoom * dpr, 0, 0, zoom * dpr, panX * dpr, panY * dpr) at the start of each frame.
Mapping the pointer through a transform. When the view is transformed, a click at canvas pixel (x, y) corresponds to a different point in your world coordinates. Invert the matrix:
function canvasToWorld(ctx, x, y) {
return ctx.getTransform().invertSelf().transformPoint(new DOMPoint(x, y));
}
Compositing. globalAlpha sets overall transparency for subsequent drawing. globalCompositeOperation sets the rule for combining the new pixels (source) with the existing ones (destination). The default, "source-over", paints new shapes on top. The other Porter-Duff operators and blend modes enable effects that would otherwise need masks:
| Operation | Result |
|---|---|
source-over (default) | New over old |
destination-over | New behind old (paint a background last) |
source-in | New, only where old exists (fill a shape with a texture) |
source-atop | New on top of old, only where old exists (shading inside a shape) |
destination-in | Keep old only where new exists (masking) |
destination-out | Erase old where new is drawn (eraser, scratch cards) |
lighter | Add colours (glows, particles) |
copy | Replace with new only |
multiply, screen, overlay, darken, lighten, difference, ... | Photoshop-style blend modes, same as CSS mix-blend-mode |
Destination: a blue square already on the canvas. Source: a red circle overlapping its right edge.
Operation Inside square only Overlap (square + circle) Inside circle only
─────────────── ────────────────── ───────────────────────── ──────────────────
source-over blue red red
destination-over blue blue red
source-in transparent red transparent
destination-in transparent blue transparent
destination-out blue transparent (hole) transparent
source-atop blue red transparent
Example: a scratch card.
// Cover layer drawn on a canvas positioned over the prize text
ctx.fillStyle = "#b0bec5";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = "destination-out"; // from now on, drawing erases
ctx.lineWidth = 30;
ctx.lineCap = "round";
let last = null;
canvas.addEventListener("pointermove", (e) => {
if (e.buttons !== 1) { last = null; return; } // only while pressed
const p = { x: e.offsetX, y: e.offsetY };
ctx.beginPath();
ctx.moveTo((last ?? p).x, (last ?? p).y);
ctx.lineTo(p.x, p.y);
ctx.stroke(); // stroke colour does not matter: it erases
last = p;
});
Clipping (with clip(), previous topic) gives hard-edged masks with any path. Composite operations give masks based on drawn pixels, including soft edges (for example destination-in with a radial gradient for a spotlight that fades out).
Key Takeaways
- Transforms affect subsequent drawing only.
translate/rotate/scale/transformmultiply the matrix,setTransformreplaces it. - To rotate an object in place, translate to its centre, rotate, and draw centred on the origin.
- Use
getTransform().invertSelf().transformPoint(...)to map pointer coordinates into transformed world coordinates. globalCompositeOperationcontrols how new pixels combine with existing ones:destination-outerases,source-in/destination-inmask,lighteradds, blend modes mix.
Practice
- (Beginner) Draw an analogue clock that shows the current time, drawing the hands with
translateto the centre androtateby the appropriate angle. Update it every second.- (Intermediate) Implement a pannable, zoomable canvas grid: drag to pan, wheel to zoom around the cursor, and display the world coordinates under the cursor using the inverse transform.
- (Interview) "How would you implement an eraser tool in a canvas drawing app that has a background image?" Hint: think about layering canvases, which layer the eraser should affect, and which composite operation removes pixels.
Pixel Manipulation with ImageData
Everything so far drew shapes and let the browser compute the pixels. Sometimes you need the pixels themselves: image filters (grayscale, brightness, blur), colour pickers, flood fill, generative art, or analysing a webcam frame. The ImageData API gives raw access to the canvas bitmap.
The data layout. ctx.getImageData(sx, sy, sw, sh) returns an ImageData object with width, height and data, a Uint8ClampedArray (Chapter 4) containing 4 bytes per pixel (red, green, blue, alpha, each 0 to 255), row by row from the top-left:
pixel (0,0) pixel (1,0) pixel (2,0) ... pixel (0,1) starts at index width*4
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬─────
│ R │ G │ B │ A │ R │ G │ B │ A │ R │ G │ B │ A │ ...
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴─────
0 1 2 3 4 5 6 7 8 9 10 11
index of pixel (x, y) = (y * width + x) * 4 data.length = width * height * 4
"Clamped" means out-of-range writes are clamped instead of wrapped: storing 300 gives 255, storing -20 gives 0, which is exactly what image filters need.
Read, modify, write. ctx.putImageData(imageData, dx, dy) writes pixels back. It replaces pixels directly, ignoring the transform, globalAlpha, compositing and clipping.
function grayscale(ctx, w, h) {
const image = ctx.getImageData(0, 0, w, h);
const d = image.data;
for (let i = 0; i < d.length; i += 4) { // step 4 bytes = 1 pixel
// Perceived brightness: the eye is most sensitive to green, least to blue
const y = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
d[i] = d[i + 1] = d[i + 2] = y; // alpha (d[i + 3]) unchanged
}
ctx.putImageData(image, 0, 0);
}
function invert(ctx, w, h) {
const image = ctx.getImageData(0, 0, w, h);
const d = image.data;
for (let i = 0; i < d.length; i += 4) {
d[i] = 255 - d[i];
d[i + 1] = 255 - d[i + 1];
d[i + 2] = 255 - d[i + 2];
}
ctx.putImageData(image, 0, 0);
}
Neighbourhood filters. Blur, sharpen and edge detection compute each output pixel from its neighbours using a convolution kernel. They must read from the original pixels and write to a separate output buffer, otherwise already-modified pixels would feed into later ones:
function convolve(ctx, w, h, kernel) { // kernel: 3x3 array of 9 weights
const src = ctx.getImageData(0, 0, w, h).data;
const out = ctx.createImageData(w, h); // fresh, fully transparent buffer
const dst = out.data;
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
let r = 0, g = 0, b = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const i = ((y + ky) * w + (x + kx)) * 4;
const k = kernel[(ky + 1) * 3 + (kx + 1)];
r += src[i] * k; g += src[i + 1] * k; b += src[i + 2] * k;
}
}
const o = (y * w + x) * 4;
dst[o] = r; dst[o + 1] = g; dst[o + 2] = b; dst[o + 3] = 255; // clamped automatically
}
}
ctx.putImageData(out, 0, 0);
}
convolve(ctx, w, h, [0, -1, 0, -1, 5, -1, 0, -1, 0]); // sharpen
convolve(ctx, w, h, [-1, -1, -1, -1, 8, -1, -1, -1, -1]); // edge detection
Performance.
- Canvases are often GPU-backed, so
getImageDataforces a slow copy from GPU memory. Create the context with{ willReadFrequently: true }when reading often (for example every video frame). - Read once, loop over the array, write once. Never call
getImageDataper pixel. - Process large images in a Web Worker (6.4), transferring
imageData.data.bufferinstead of copying it. - A
Uint32Arrayview over the same buffer reads and writes a whole pixel in one operation (note that the byte order within each 32-bit value is the platform's, which is little-endian on virtually all devices, giving0xAABBGGRR). - For heavy, real-time filters, a WebGL fragment shader (7.3) is usually much faster, because it processes pixels in parallel on the GPU.
Coordinates and caveats. getImageData works in buffer pixels: on a high-density canvas scaled by devicePixelRatio, a 100×100 CSS-pixel area is 200×200 buffer pixels at a ratio of 2. Reading from a tainted canvas throws a SecurityError (see Drawing Text and Images). Pixels with low alpha may not round-trip exactly, because browsers store colours premultiplied by alpha internally.
Key Takeaways
getImageDatareturns RGBA bytes in aUint8ClampedArray, row-major. Pixel(x, y)starts at index(y * width + x) * 4.putImageDatawrites pixels directly, ignoring transforms, alpha and compositing.- Neighbourhood filters (blur, sharpen, edges) must read from a source copy and write to a separate output buffer.
- Read once and write once, use
willReadFrequentlyfor frequent reads, and move heavy work to workers or WebGL. - Pixel access works in buffer pixels and is blocked on tainted canvases.
Practice
- (Beginner) Load a photo onto a canvas and add buttons for grayscale, invert and a brightness slider (add a value to each channel).
- (Intermediate) Implement a "paint bucket" flood fill: when the user clicks a pixel, replace the connected region of similar colour (within a tolerance) with a chosen colour, using an explicit stack instead of recursion to avoid stack overflows.
- (Interview) "How would you apply a real-time filter to a 1080p webcam stream at 30 fps in the browser?" Hint: estimate the number of pixel operations per second, then compare main-thread JavaScript, a worker, and a GPU fragment shader.
Animation Loops and Hit Detection
A canvas animation is a flip-book: each frame, you update your model and repaint the whole picture. And because the canvas has no objects, it cannot tell you which "object" the user clicked. Both problems are solved in your own code.
The animation loop. requestAnimationFrame (5.1) calls your function before each screen repaint, typically 60 times per second (or 120 on high-refresh displays), and pauses in background tabs. Use the timestamp it provides to compute the elapsed time, so that movement speed does not depend on the frame rate:
const balls = Array.from({ length: 50 }, () => ({
x: Math.random() * 400, y: Math.random() * 300,
vx: (Math.random() - 0.5) * 200, // velocity in pixels per SECOND
vy: (Math.random() - 0.5) * 200,
r: 5 + Math.random() * 15,
}));
function update(dt) { // dt in seconds
for (const b of balls) {
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < b.r || b.x > W - b.r) b.vx *= -1; // bounce off the walls
if (b.y < b.r || b.y > H - b.r) b.vy *= -1;
}
}
function render() {
ctx.clearRect(0, 0, W, H); // wipe the previous frame
ctx.fillStyle = "#26a69a";
for (const b of balls) {
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();
}
}
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.1); // cap: avoid huge jumps after a tab switch
last = now;
update(dt);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
For physics that must behave identically on every machine (games, simulations), use a fixed time step: accumulate elapsed time and run update(1 / 60) as many times as fits, then render once.
Making frames cheaper.
| Technique | Idea |
|---|---|
| Layered canvases | Stack several canvases with CSS. Draw the static background once, redraw only the dynamic layer |
| Offscreen caching | Pre-render complex or shadowed artwork into an OffscreenCanvas once, then drawImage it each frame |
| Dirty rectangles | Clear and redraw only the regions that changed |
| Batch by state | Group objects by colour so each fillStyle is set once, and fill many sub-paths with one fill() |
| Skip invisible work | Do not draw objects outside the visible area |
| Avoid per-frame shadows and filters | They are among the most expensive operations |
Hit detection. To make canvas objects interactive, map the pointer position to your model. First convert the event position into canvas drawing coordinates (accounting for CSS scaling and, for isPointInPath, the buffer resolution):
function pointerPos(canvas, e) {
const rect = canvas.getBoundingClientRect();
return {
css: { x: e.clientX - rect.left, y: e.clientY - rect.top }, // CSS pixels
buffer: { // buffer pixels
x: (e.clientX - rect.left) * (canvas.width / rect.width),
y: (e.clientY - rect.top) * (canvas.height / rect.height),
},
};
}
Then pick one of these strategies:
| Strategy | How | Best for |
|---|---|---|
| Geometric maths | Test the point against each shape (distance to centre < radius, inside rectangle bounds) | Simple shapes, most cases |
isPointInPath / isPointInStroke | Keep a Path2D per object and ask the context (point in buffer pixels; the path is tested with the current transform) | Complex outlines and curves |
| Colour picking | Draw every object on a hidden canvas in a unique colour, read the pixel under the pointer, map colour to object | Arbitrary shapes, images, pixel-accurate tests |
| Spatial index | Store objects in a grid or quadtree and test only nearby ones | Tens of thousands of objects |
Whatever the strategy, test objects topmost first (the reverse of drawing order), so that the object visually on top wins:
// Click to select and drag balls, using geometric hit testing in CSS pixels
let dragging = null;
canvas.addEventListener("pointerdown", (e) => {
const { x, y } = pointerPos(canvas, e).css;
for (let i = balls.length - 1; i >= 0; i--) { // last drawn = on top = tested first
const b = balls[i];
if ((x - b.x) ** 2 + (y - b.y) ** 2 <= b.r ** 2) { // inside the circle (no sqrt needed)
dragging = b;
b.vx = b.vy = 0;
canvas.setPointerCapture(e.pointerId);
break;
}
}
});
canvas.addEventListener("pointermove", (e) => {
if (!dragging) return;
Object.assign(dragging, pointerPos(canvas, e).css); // the next frame draws it at the new position
});
canvas.addEventListener("pointerup", () => { dragging = null; });
Key Takeaways
- Animate with
requestAnimationFrame: update the model by elapsed time, clear, and redraw. Cap large time steps and consider a fixed step for physics. - Optimise with layered canvases, offscreen caching, dirty rectangles, state batching and culling.
- Canvas has no objects to click, so you implement hit testing: geometry,
isPointInPathwithPath2D, colour picking, or a spatial index. - Convert pointer coordinates carefully (CSS pixels vs buffer pixels), and test objects in reverse drawing order.
Practice
- (Beginner) Animate a single ball bouncing inside the canvas with frame-rate-independent motion. Verify its speed is the same when you throttle the CPU in DevTools.
- (Intermediate) Extend the bouncing balls so that hovering a ball highlights it and clicking removes it. Then implement the colour-picking technique on a hidden canvas and compare its results with geometric testing for overlapping balls.
- (Interview) "How would you detect which of 100,000 small polygons drawn on a canvas the user clicked, in under a millisecond?" Hint: combine a coarse spatial structure to reduce candidates with an exact test on the few that remain.
Exporting Canvas Content
Because a canvas is a bitmap, saving it is straightforward: the browser can encode its pixels as an image file. Typical uses are "download as PNG" for charts and drawings, uploading edited photos or signatures, copying to the clipboard, and even recording animations as video.
Encoding methods.
| Method | Returns | Notes |
|---|---|---|
canvas.toDataURL(type, quality) | A data: URL string, synchronously | Base64 is about 33% larger than the binary data and blocks the main thread while encoding |
canvas.toBlob(callback, type, quality) | A Blob via callback, asynchronously | Preferred for downloads and uploads |
offscreenCanvas.convertToBlob({ type, quality }) | A promise of a Blob | Works in workers |
canvas.captureStream(fps) | A MediaStream of the canvas | Record with MediaRecorder or send over WebRTC |
type is "image/png" (default, lossless), "image/jpeg" or "image/webp" (lossy, with quality from 0 to 1). If the browser cannot encode a requested type, it falls back to PNG. JPEG has no alpha channel, so transparent areas become black unless you paint a background first.
Download as a file.
function canvasToBlob(canvas, type = "image/png", quality) {
return new Promise((resolve, reject) =>
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("Encoding failed"))), type, quality)
); // promise wrapper around the callback API
}
async function downloadCanvas(canvas, filename = "drawing.png") {
const blob = await canvasToBlob(canvas);
const url = URL.createObjectURL(blob); // temporary URL pointing at the blob
const a = document.createElement("a");
a.href = url;
a.download = filename; // download instead of navigating
a.click();
URL.revokeObjectURL(url); // release the memory
}
Upload and clipboard.
// Upload, e.g. a signature pad
const blob = await canvasToBlob(signatureCanvas, "image/png");
const form = new FormData();
form.append("signature", blob, "signature.png");
await fetch("/api/contracts/42/signature", { method: "POST", body: form });
// Copy to the clipboard (requires a user gesture and a secure context)
await navigator.clipboard.write([new ClipboardItem({ "image/png": canvasToBlob(canvas) })]);
Recording video.
const stream = canvas.captureStream(30); // 30 fps
const recorder = new MediaRecorder(stream, { mimeType: "video/webm" });
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = () => {
const video = new Blob(chunks, { type: "video/webm" });
// download or upload `video` like any other blob
};
recorder.start();
setTimeout(() => recorder.stop(), 5000); // record 5 seconds
High-resolution export. The export has exactly the resolution of the drawing buffer. For print-quality output, render the scene to a separate, larger canvas (for example at 3× scale with ctx.scale(3, 3)) and export that one, which is easy because your drawing code works from your own model.
Converting SVG to PNG. A common bridge between 7.1 and this subchapter: serialise the SVG, load it as an image, draw it on a canvas, and export.
async function svgToPng(svgEl, scale = 2) {
const xml = new XMLSerializer().serializeToString(svgEl); // markup including xmlns
const svgUrl = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml" }));
const img = new Image();
img.src = svgUrl;
await img.decode();
const { width, height } = svgEl.getBoundingClientRect();
const canvas = new OffscreenCanvas(Math.round(width * scale), Math.round(height * scale));
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height); // rasterise at scale
URL.revokeObjectURL(svgUrl);
return canvas.convertToBlob({ type: "image/png" });
}
Styles from the page's external stylesheets do not travel with the serialised SVG, so inline the needed styles or use presentation attributes before exporting.
Restrictions. All export methods throw a SecurityError on a tainted canvas. And because subtle rendering differences between devices make exported pixels slightly unique, canvas export is a known fingerprinting technique; some privacy-focused browsers add noise to it or ask for permission.
Key Takeaways
- Prefer asynchronous
toBlob(orconvertToBlob) overtoDataURLfor downloads and uploads. - PNG is lossless with transparency. JPEG and WebP accept a quality setting, and JPEG loses transparency.
- Download with an object URL and an
<a download>, upload withFormData, copy withClipboardItem, and record video withcaptureStreamandMediaRecorder. - Exports have the buffer's resolution. Render to a larger canvas for high-resolution output.
- Tainted canvases cannot be exported.
Practice
- (Beginner) Build a minimal drawing app (freehand lines with pointer events) with "Download PNG" and "Download JPEG (80%)" buttons. Compare the file sizes and explain what happens to transparent areas in the JPEG.
- (Intermediate) Add a "Copy chart" button to the SVG bar chart from 7.1 that converts it to a PNG at 2× resolution and writes it to the clipboard. Make sure the bar colours set in an external stylesheet appear in the exported image.
- (Interview) "Why might
toDataURL()throw an error on a canvas that displays perfectly fine?" Hint: consider where the canvas's content came from and what browsers protect.
<a id="73-webgl-scene-api"></a>
7.3 WebGL Scene API
This subchapter introduces WebGL, the browser's low-level API for the GPU: how the rendering pipeline turns triangles into pixels, how shaders, buffers, matrices and textures fit together, how lighting and visibility are computed, and how scene graphs and libraries such as three.js build on all of it.
GPU Rendering Pipeline Overview
Canvas 2D runs your drawing commands one after another. That is fine for thousands of shapes, but a 3D scene at 1920×1080 must compute a colour for about two million pixels, 60 times per second, often with lighting maths for each one. A CPU with a handful of cores cannot keep up. A GPU (Graphics Processing Unit) has thousands of simple cores designed to run the same small program on many pieces of data at once.
An analogy: a CPU is a few master chefs who can cook anything, one dish at a time. A GPU is a kitchen with a thousand line cooks who each do the same simple step to a different plate simultaneously. Graphics is exactly that kind of work: the same calculation for every vertex, then the same calculation for every pixel.
What WebGL is. WebGL is a JavaScript binding to OpenGL ES, standardised by the Khronos Group: WebGL 1 is based on OpenGL ES 2.0, WebGL 2 on OpenGL ES 3.0. It is deliberately low-level. There is no "draw a cube" or "add a light": WebGL can only draw points, lines and triangles, and you supply small programs, called shaders, that run on the GPU to position them and colour them.
The pipeline. Every draw call sends data through a fixed sequence of stages. Two of them are programmable:
JavaScript (CPU)
┌───────────────────────────────────────────────┐
│ vertex data in buffers, uniforms, textures │
│ gl.drawArrays / gl.drawElements │
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ 1. VERTEX SHADER (programmable) │ runs once per vertex:
│ outputs gl_Position in clip space │ "where does this corner go?"
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ 2. Primitive assembly, clipping, │ vertices grouped into triangles,
│ perspective divide, viewport transform │ off-screen parts removed, mapped to pixels
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ 3. RASTERIZATION (fixed) │ which pixels does each triangle cover?
│ produces fragments, interpolates varyings │ one "fragment" per covered pixel
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ 4. FRAGMENT SHADER (programmable) │ runs once per fragment:
│ outputs a colour │ "what colour is this pixel?"
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ 5. Per-fragment tests and blending │ scissor, stencil, depth test, blending
└───────────────────────┬───────────────────────┘
▼
framebuffer (the canvas drawing buffer) ──► composited into the page by the browser
Coordinate conventions. The vertex shader outputs positions in clip space. After the GPU divides by the fourth component w (the perspective divide), coordinates are in normalised device coordinates (NDC): x and y from -1 to +1 across the canvas, regardless of its pixel size, and y points up, the opposite of Canvas 2D and SVG.
y = +1
▲
│
x = -1 ─────┼────► x = +1 whole canvas = the square from (-1,-1) to (+1,+1)
│
y = -1
WebGL is a state machine. Most calls do not take the object they act on as an argument. Instead you bind an object (a buffer, a texture, a program) to a global slot and subsequent calls operate on whatever is bound. Forgetting what is currently bound is the source of many WebGL bugs.
Division of labour.
| CPU (JavaScript) | GPU (shaders and fixed stages) |
|---|---|
| Load models and images, upload them to GPU memory once | Store buffers and textures |
| Update per-frame values (time, camera, object transforms) | Transform every vertex in parallel |
| Decide what to draw and issue draw calls | Rasterise triangles into fragments |
| Handle input, physics, application logic | Compute every pixel's colour in parallel |
The key performance rule follows directly: data should be uploaded once and reused, and the number of draw calls per frame should be kept low, because each call has CPU overhead while the GPU handles the per-vertex and per-pixel work cheaply.
Key Takeaways
- GPUs run the same small program on many vertices or pixels in parallel, which is what graphics needs.
- WebGL is a thin binding to OpenGL ES that draws only points, lines and triangles. Everything else is your code.
- The pipeline is: vertex shader, primitive assembly and clipping, rasterisation, fragment shader, per-fragment tests and blending, framebuffer.
- Vertex shaders output clip-space positions. After the divide by
w, visible coordinates span -1 to +1 with y pointing up. - WebGL is a bind-then-operate state machine. Upload data once and minimise draw calls.
Practice
- (Beginner) A triangle covers 50,000 pixels on screen. How many times does the vertex shader run for it, and roughly how many times does the fragment shader run? Explain why.
- (Intermediate) Convert the canvas pixel
(150, 75)on a 600×300 canvas to NDC, and convert the NDC point(-0.5, 0.5)back to canvas pixels. Pay attention to the y direction.- (Interview) "Why is drawing 10,000 objects with 10,000 draw calls slow even when each object is a single triangle?" Hint: compare the cost each draw call adds on the CPU and driver side with the work the GPU actually does per triangle.
WebGL Context and Rendering Loop
A WebGL program starts like a Canvas 2D one: get a context from a <canvas>. From there, every frame follows the same skeleton: adjust the drawing size if needed, clear, set per-frame values, draw.
Getting a context.
const canvas = document.querySelector("#gl");
const gl = canvas.getContext("webgl2", {
antialias: true, // multisample anti-aliasing of edges (default true)
alpha: true, // drawing buffer has an alpha channel (page shows through)
depth: true, // allocate a depth buffer (needed for 3D, see Depth Testing)
stencil: false,
premultipliedAlpha: true, // colours are premultiplied by alpha when composited
preserveDrawingBuffer: false, // buffer may be cleared after each frame is shown (faster)
powerPreference: "high-performance", // prefer a discrete GPU on dual-GPU laptops
});
if (!gl) {
// WebGL 2 unavailable (old device, blocklisted driver, disabled): fall back or explain
canvas.replaceWith(Object.assign(document.createElement("p"), {
textContent: "Your browser or device does not support WebGL 2.",
}));
}
| Version | Based on | Notable features |
|---|---|---|
WebGL 1 ("webgl") | OpenGL ES 2.0 | Universal support. Many features only through extensions |
WebGL 2 ("webgl2") | OpenGL ES 3.0 | GLSL ES 3.00, vertex array objects, instancing, 3D textures, multiple render targets, non-power-of-two textures without limits. Supported by all current browsers |
WebGPU ("webgpu") | Modern native APIs (Vulkan, Metal, Direct3D 12) | Compute shaders, explicit pipelines, WGSL shading language. The successor to WebGL, now shipping in the major browsers |
Optional capabilities are queried with gl.getExtension("EXT_color_buffer_float"), which returns an object (or null if unsupported).
Size and viewport. The same buffer-versus-CSS-size rules as Canvas 2D apply. In addition, WebGL must be told which part of the buffer to draw into with gl.viewport, which maps NDC (-1 to +1) to pixels:
function resizeToDisplay(canvas, gl) {
const dpr = Math.min(window.devicePixelRatio, 2); // cap: 3x density quadruples fragment work
const w = Math.round(canvas.clientWidth * dpr);
const h = Math.round(canvas.clientHeight * dpr);
if (canvas.width !== w || canvas.height !== h) { // only when it really changed
canvas.width = w;
canvas.height = h;
}
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
}
The render loop. Unlike desktop OpenGL, there is no "swap buffers" call: when your requestAnimationFrame callback returns, the browser presents whatever was drawn.
function render(time) { // time in milliseconds
resizeToDisplay(canvas, gl);
const t = time / 1000;
gl.clearColor(0.5 + 0.5 * Math.sin(t), 0.2, 0.4, 1.0); // RGBA, each 0..1 (not 0..255)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // clear colour and depth together
// per frame: gl.useProgram(...), set uniforms, bind vertex arrays, gl.drawArrays/drawElements(...)
requestAnimationFrame(render);
}
requestAnimationFrame(render);
With preserveDrawingBuffer: false (the default), the buffer may be discarded after it is shown, so always clear and redraw everything each frame, and read pixels or call toBlob within the same frame that drew them.
Context loss. GPUs are shared resources. The browser can take a WebGL context away at any time: after a driver crash or reset, when too many contexts are open, or when the system reclaims GPU memory. All your buffers, textures and programs are then gone. Robust applications listen for it:
canvas.addEventListener("webglcontextlost", (e) => {
e.preventDefault(); // signals "I will handle restoration"; otherwise it never comes back
cancelAnimationFrame(frameId);
});
canvas.addEventListener("webglcontextrestored", () => {
initResources(gl); // recreate shaders, buffers and textures from your own data
frameId = requestAnimationFrame(render);
});
Debugging. gl.getError() returns the first recorded error code (such as gl.INVALID_OPERATION) but is slow, so call it only while debugging. Browsers print WebGL warnings to the console, and tools such as Spector.js capture a frame and show every call and the bound state at each step.
Key Takeaways
- Get a context with
canvas.getContext("webgl2", options), check fornull, and fall back gracefully. WebGPU is the newer successor API. - Size the drawing buffer from CSS size and
devicePixelRatio(often capped), then callgl.viewport. - Each frame: resize if needed,
clearColor/clear, set state and uniforms, draw. The browser presents the frame when the callback returns. - Handle
webglcontextlost(callpreventDefault) andwebglcontextrestoredby recreating GPU resources. - Use console warnings,
getError(sparingly) and Spector.js to debug.
Practice
- (Beginner) Create a WebGL 2 context and animate the clear colour through the hues of the rainbow over 10 seconds. Show a message on devices without WebGL 2.
- (Intermediate) Use the
WEBGL_lose_contextextension (gl.getExtension("WEBGL_lose_context").loseContext()) to simulate a context loss after 3 seconds andrestoreContext()after 5, and make your animation recover correctly.- (Interview) "Why might a WebGL app render a black canvas when you take a screenshot with
toDataURL()from a button click?" Hint: consider whatpreserveDrawingBuffer: falseallows the browser to do between the frame being shown and your click handler running.
Vertex and Fragment Shaders with GLSL
Shaders are the heart of WebGL. They are small programs written in GLSL ES (OpenGL Shading Language), a C-like language, compiled by the browser and driver, and executed on the GPU. Every WebGL drawing needs a program: one vertex shader linked with one fragment shader.
- The vertex shader runs once per vertex. Its job is to output the vertex's clip-space position in
gl_Position, plus any values the fragment shader will need. - The fragment shader runs once per covered pixel (fragment). Its job is to output a colour.
GLSL essentials.
| Feature | Examples |
|---|---|
| Scalar types | float, int, uint, bool (no automatic int-to-float conversion: write 1.0, not 1) |
| Vectors | vec2, vec3, vec4 (also ivec*, bvec*) |
| Matrices | mat2, mat3, mat4 (column-major) |
| Samplers | sampler2D (a texture, see Textures and Sampling) |
| Swizzling | v.xy, v.rgb, v.zyx, color.a: read or rearrange components |
| Built-in functions | dot, cross, normalize, length, mix, clamp, smoothstep, step, sin, pow, texture |
| Storage qualifiers (WebGL 2) | in (input), out (output), uniform (constant for a draw call) |
| Precision | precision highp float; is required in fragment shaders |
WebGL 2 shaders start with #version 300 es, which must be the very first characters of the source: not even a blank line may precede it. WebGL 1 uses the older GLSL ES 1.00 syntax (attribute, varying, gl_FragColor).
A first triangle, without any buffers. WebGL 2 gives each vertex its index in gl_VertexID, so a shader can generate positions by itself. This isolates the shader mechanics from data uploading (next topic):
const vertexSrc = `#version 300 es
// Three hard-coded corners, chosen by the vertex index (0, 1, 2)
const vec2 corners[3] = vec2[3](vec2(0.0, 0.7), vec2(-0.7, -0.6), vec2(0.7, -0.6));
const vec3 colors[3] = vec3[3](vec3(1, 0, 0), vec3(0, 1, 0), vec3(0, 0, 1));
out vec3 v_color; // passed to the fragment shader, interpolated
void main() {
v_color = colors[gl_VertexID];
gl_Position = vec4(corners[gl_VertexID], 0.0, 1.0); // x, y, z, w (w = 1: no perspective)
}`;
const fragmentSrc = `#version 300 es
precision highp float;
in vec3 v_color; // the interpolated value for THIS fragment
out vec4 outColor; // the colour written to the canvas
void main() {
outColor = vec4(v_color, 1.0);
}`;
Compiling and linking. Shaders are compiled at run time, so errors appear as messages from the driver, which you must check explicitly:
function compileShader(gl, type, source) {
const shader = gl.createShader(type); // gl.VERTEX_SHADER or gl.FRAGMENT_SHADER
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader); // e.g. "ERROR: 0:7: 'v_colour' : undeclared identifier"
gl.deleteShader(shader);
throw new Error(`Shader compile error:\n${log}`);
}
return shader;
}
function createProgram(gl, vsSource, fsSource) {
const program = gl.createProgram();
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, vsSource));
gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, fsSource));
gl.linkProgram(program); // checks that outputs and inputs match up
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(`Program link error:\n${gl.getProgramInfoLog(program)}`);
}
return program;
}
const program = createProgram(gl, vertexSrc, fragmentSrc);
gl.useProgram(program);
gl.drawArrays(gl.TRIANGLES, 0, 3); // 3 vertices, gl_VertexID = 0, 1, 2
Interpolation. The vertex shader sets v_color only at the three corners. The rasteriser blends (interpolates) it across the triangle according to each fragment's distance from the corners, which is why this triangle shows a smooth red-green-blue gradient. Interpolated values are the main channel from vertex shader to fragment shader: colours, texture coordinates, normals and positions all travel this way.
red (vertex 0)
▲
╱ ╲ a fragment near the top-left edge gets mostly red and green;
╱ ● ╲ one in the middle gets roughly equal red, green and blue
╱ ╲
green ────────────── blue
(vertex 1) (vertex 2)
Fragment shaders as image generators. A fragment shader can compute colour from the pixel position (gl_FragCoord) and a time uniform alone. Drawing one full-screen triangle and doing all the work in the fragment shader is the basis of post-processing effects and of sites such as Shadertoy:
#version 300 es
precision highp float;
uniform vec2 u_resolution; // canvas size in pixels
uniform float u_time; // seconds
out vec4 outColor;
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y; // centred, aspect-correct
float d = length(uv); // distance from the centre
float ring = smoothstep(0.02, 0.0, abs(d - 0.3 - 0.05 * sin(u_time * 3.0))); // pulsing ring
outColor = vec4(vec3(ring) * vec3(0.2, 0.8, 1.0), 1.0);
}
Key Takeaways
- A WebGL program is a vertex shader (per vertex, writes
gl_Position) linked with a fragment shader (per fragment, writes a colour). - GLSL is strictly typed and vector-oriented:
vec/mattypes, swizzling, built-in maths, no implicit int-to-float conversion. - In WebGL 2,
#version 300 esmust be the first line, and fragment shaders need aprecisiondeclaration. - Always check
COMPILE_STATUSandLINK_STATUSand print the info logs. - Values written by the vertex shader (
out) are interpolated across the triangle and read by the fragment shader (in).
Practice
- (Beginner) Run the buffer-less triangle. Change the fragment shader to output the same colour everywhere, then to output only the green channel of
v_color.- (Intermediate) Draw a full-screen triangle (three vertices at
(-1,-1),(3,-1),(-1,3)) and write a fragment shader that renders an animated checkerboard whose square size is 40 CSS pixels at any display density.- (Interview) "Your shader works on your laptop but fails to compile on a colleague's phone. What would you check?" Hint: think about the version line, precision qualifiers, implicit type conversions and the info log.
Buffers, Attributes and Uniforms
Hard-coding geometry in shaders does not scale. Real applications upload their data from JavaScript to GPU memory and tell the shaders how to read it. There are three main ways to feed data to shaders, each matching a different frequency of change:
| Kind | Varies | Lives in | Declared in GLSL as |
|---|---|---|---|
| Attribute | Per vertex | Buffer objects | in vec3 a_position; (vertex shader) |
| Uniform | Per draw call (same for all vertices and fragments) | Program state | uniform mat4 u_matrix; |
| Texture | Per lookup coordinate | Texture objects | uniform sampler2D u_texture; |
| Varying | Per fragment (interpolated) | Produced by the vertex shader | out in vertex, in in fragment |
Buffers. A buffer is a block of GPU memory. You fill it from a typed array (Chapter 4), usually once:
// Interleaved data: x, y, r, g, b for each vertex
const vertices = new Float32Array([
// x y r g b
0.0, 0.7, 1.0, 0.0, 0.0,
-0.7, -0.6, 0.0, 1.0, 0.0,
0.7, -0.6, 0.0, 0.0, 1.0,
]);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo); // bind to the ARRAY_BUFFER slot...
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); // ...then upload into whatever is bound
The usage hint tells the driver how the data will be used: STATIC_DRAW (set once, drawn many times), DYNAMIC_DRAW (changed often), STREAM_DRAW (changed every frame). gl.bufferSubData updates part of an existing buffer without reallocating it.
Attributes: describing the layout. The buffer is just bytes. vertexAttribPointer tells WebGL how to slice them into per-vertex values for each attribute:
byte offset: 0 4 8 12 16 20 24 ...
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬───
│ x │ y │ r │ g │ b │ x │ y │ ...
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴───
└── vertex 0 (stride = 5 floats = 20 bytes) ──┘└── vertex 1 ...
a_position: size 2, stride 20, offset 0
a_color: size 3, stride 20, offset 8
const vsSource = `#version 300 es
layout(location = 0) in vec2 a_position; // fixed attribute locations: no lookup needed
layout(location = 1) in vec3 a_color;
uniform vec2 u_offset; // one value for the whole draw call
out vec3 v_color;
void main() {
v_color = a_color;
gl_Position = vec4(a_position + u_offset, 0.0, 1.0);
}`;
// A Vertex Array Object (VAO) records the attribute configuration below
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 20, 0); // location, size, type, normalize, stride, offset
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 20, 8);
gl.bindVertexArray(null); // done recording
Without layout(location = ...), look up locations with gl.getAttribLocation(program, "a_position"). The VAO (WebGL 2, or the OES_vertex_array_object extension in WebGL 1) is important: instead of repeating all attribute calls before each draw, you bind one VAO per mesh.
Uniforms. Uniforms are set on the currently used program, through locations looked up once after linking:
const program = createProgram(gl, vsSource, fsSource);
const uOffset = gl.getUniformLocation(program, "u_offset"); // cache this: lookups are slow
function render(time) {
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program); // uniforms apply to this program
gl.uniform2f(uOffset, Math.sin(time / 1000) * 0.3, 0.0); // move the triangle left and right
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
The setter name encodes the type: uniform1f (one float), uniform3fv (a vec3 from an array), uniform1i (an int, also used for samplers), uniformMatrix4fv(location, false, float32Array) (a mat4; the transpose argument must be false in WebGL 1). WebGL 2 also offers uniform buffer objects, which share a block of uniforms (such as the camera matrices) between many programs.
Key Takeaways
- Attributes vary per vertex and come from buffers. Uniforms are constant per draw call. Textures are sampled. Varyings are interpolated from vertex to fragment shader.
- Upload vertex data with
createBuffer,bindBufferandbufferData, using a typed array and a usage hint. vertexAttribPointer(location, size, type, normalize, stride, offset)describes how bytes map to attributes. Strides and offsets are in bytes.- Record attribute setup in a VAO once per mesh, and bind the VAO before drawing.
- Look up uniform locations once, call
useProgrambefore setting uniforms, and pick the setter matching the GLSL type.
Practice
- (Beginner) Change the example to use two separate buffers (one for positions, one for colours) instead of one interleaved buffer, adjusting stride and offset accordingly.
- (Intermediate) Draw 1,000 small triangles at random positions from a single buffer with one draw call. Add a
u_timeuniform and make each triangle bob up and down with a phase derived from its position, entirely in the vertex shader.- (Interview) "When would you put a value in an attribute rather than a uniform?" Hint: ask how often the value changes across the vertices of one draw call, and what it would cost to split the draw to use uniforms instead.
Geometry, Meshes and Index Buffers
The GPU only understands simple primitives, so every object in a 3D scene, from a cube to a character, is a mesh: a list of vertices plus a description of how they connect into triangles. How you organise that data affects memory, speed and visual quality.
Primitive modes. The first argument of a draw call says how to interpret the vertex sequence:
| Mode | Interpretation of vertices v0, v1, v2, v3, ... |
|---|---|
gl.POINTS | Each vertex is a point (size set by gl_PointSize in the vertex shader) |
gl.LINES | Pairs: (v0, v1), (v2, v3), ... |
gl.LINE_STRIP / gl.LINE_LOOP | Connected chain / closed chain |
gl.TRIANGLES | Triples: (v0, v1, v2), (v3, v4, v5), ... |
gl.TRIANGLE_STRIP | Each new vertex forms a triangle with the previous two |
gl.TRIANGLE_FAN | Each new vertex forms a triangle with the previous one and v0 |
Lines are always 1 pixel wide on most platforms (gl.lineWidth is effectively ignored), so thick lines are drawn as thin triangles.
The duplication problem. A square (quad) is two triangles, which with gl.TRIANGLES need 6 vertices, although the square has only 4 corners. A cube needs 36. Each duplicated vertex is uploaded and processed again.
Index buffers. Store each unique vertex once, and describe the triangles with a separate list of indices:
v3 ─────── v2 vertex buffer: v0 (-0.5,-0.5) v1 (0.5,-0.5) v2 (0.5,0.5) v3 (-0.5,0.5)
│ ╱ │
│ ╱ │ index buffer: 0, 1, 2, 0, 2, 3
│ ╱ │ └ tri A ┘ └ tri B ┘
v0 ─────── v1 4 vertices + 6 small integers instead of 6 full vertices
const positions = new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5]);
const indices = new Uint16Array([0, 1, 2, 0, 2, 3]); // counter-clockwise triangles
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); // stride 0 = tightly packed
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); // the VAO remembers this binding
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
gl.bindVertexArray(null);
// Draw: count = number of indices, type matches the typed array, offset in bytes
gl.bindVertexArray(vao);
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);
Uint16Array indices address up to 65,536 vertices per mesh. Larger meshes need Uint32Array with gl.UNSIGNED_INT (built into WebGL 2, an extension in WebGL 1). GPUs also cache recently transformed vertices, so indexed meshes avoid re-running the vertex shader for shared corners.
What counts as "one vertex". A vertex is a unique combination of all its attributes, not just a position. A cube has 8 corners, but each corner belongs to three faces that point in different directions. For flat-shaded lighting each face needs its own normal at that corner, so the cube needs 24 vertices (4 per face × 6 faces) and 36 indices. Smooth objects such as spheres can share vertices between neighbouring triangles because the normal is the same for all of them.
Winding order. The order of a triangle's vertices as seen on screen, clockwise or counter-clockwise, defines which side is its front. WebGL's default front face is counter-clockwise. Keep winding consistent across a mesh, because back-face culling (see Depth Testing, Blending and Culling) relies on it.
Generating geometry. Many shapes are easiest to compute. A filled circle as an indexed triangle fan around a centre vertex:
function circleMesh(segments = 32, radius = 0.5) {
const positions = [0, 0]; // vertex 0: the centre
const indices = [];
for (let i = 0; i <= segments; i++) {
const a = (i / segments) * Math.PI * 2;
positions.push(Math.cos(a) * radius, Math.sin(a) * radius);
if (i > 0) indices.push(0, i, i + 1); // centre, previous rim point, this rim point
}
return { positions: new Float32Array(positions), indices: new Uint16Array(indices) };
}
Loading models. Complex meshes are authored in 3D tools (Blender, Maya) and exported. glTF 2.0 (.gltf/.glb), often called "the JPEG of 3D", is the standard web format: it stores buffers in GPU-ready layouts together with materials, textures, scene hierarchy and animations. OBJ and FBX are older alternatives.
Instancing. To draw many copies of the same mesh (trees, particles, chart markers), instanced drawing renders all of them in one call. Per-instance data (such as offsets or colours) goes in an attribute that advances once per instance instead of once per vertex:
gl.vertexAttribDivisor(2, 1); // attribute 2 advances per instance
gl.drawElementsInstanced(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0, 10000); // 10,000 copies
Key Takeaways
- All geometry is made of points, lines and triangles. Meshes combine vertex attributes with triangle connectivity.
- Index buffers store each unique vertex once and list triangles as indices, saving memory and vertex-shader work. Draw them with
drawElements. - A vertex is a unique combination of attributes, so hard edges (different normals or UVs) need duplicated positions.
- Counter-clockwise winding marks front faces by default. Keep it consistent.
- Use glTF for authored models and instancing for many copies of one mesh.
Practice
- (Beginner) Draw the indexed quad, then change it to a hexagon using
circleMesh(6). Count the vertices and indices, and compare with a non-indexed version.- (Intermediate) Write
gridMesh(cols, rows)that produces a flat grid of(cols + 1) × (rows + 1)vertices and the indices for2 × cols × rowstriangles, all counter-clockwise. Render it in wireframe by drawing the edges withgl.LINESfrom a second index buffer.- (Interview) "Why does a cube with sharp edges need 24 vertices rather than 8?" Hint: think about which attributes besides position each vertex carries and whether neighbouring faces can agree on them.
Model, View and Projection Matrices
Meshes are authored in their own convenient coordinates: a unit cube centred at the origin, a character standing at (0, 0, 0). To draw a scene, each mesh must be placed in the world, seen from a camera, and projected onto the flat screen, ending in the clip space the vertex shader must output. This is done with three 4×4 matrices.
An analogy: taking a photo. You arrange the props on a stage (model matrix), you walk around with the camera to choose a viewpoint (view matrix), and you choose a lens, wide-angle or telephoto (projection matrix).
The chain of spaces.
local (object) space ──model──► world space ──view──► camera (eye) space ──projection──► clip space
│
divide by w (GPU) ▼
screen pixels ◄──viewport── NDC (-1..+1)
in the vertex shader: gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
(read right to left: model is applied to the vertex first)
Why 4×4 and homogeneous coordinates. A 3×3 matrix can rotate and scale a 3D point but cannot translate it. Adding a fourth coordinate w (1 for points, 0 for directions such as normals) lets a 4×4 matrix express translation too, and lets the projection matrix encode perspective in w. The GPU then divides x, y and z by w, which makes distant objects (with larger w) smaller.
The three matrices.
| Matrix | Converts | Typically built from |
|---|---|---|
| Model | Local space to world space | Translation × rotation × scale of the object (scale applied first) |
| View | World space to camera space | The inverse of the camera's transform, usually from lookAt(eye, target, up) |
| Projection | Camera space to clip space | perspective(fieldOfView, aspect, near, far) or ortho(left, right, bottom, top, near, far) |
The view matrix is an inverse because moving the camera right is the same as moving the whole world left. By convention, the camera looks down its negative z-axis.
| Projection | Effect | Used for |
|---|---|---|
| Perspective | Distant objects appear smaller, parallel lines converge | Realistic 3D, games, product viewers |
| Orthographic | No size change with distance, parallel lines stay parallel | CAD, 2D-in-3D, isometric games, shadow maps |
Writing a perspective matrix by hand. Matrix libraries do this for you, but seeing it once removes the mystery. WebGL expects matrices in column-major order (each group of four numbers is a column):
function perspective(fovY, aspect, near, far) {
const f = 1 / Math.tan(fovY / 2); // larger field of view => smaller f => objects smaller
const nf = 1 / (near - far);
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * nf, -1, // -1 copies -z into w, enabling the perspective divide
0, 0, 2 * far * near * nf, 0,
]);
}
Using a matrix library. In practice, use gl-matrix, a fast, column-major library designed for WebGL:
import { mat4 } from "gl-matrix";
const projection = mat4.create();
const view = mat4.create();
const model = mat4.create();
function updateMatrices(time, aspect) {
mat4.perspective(projection, Math.PI / 4, aspect, 0.1, 100); // 45° vertical FOV, near 0.1, far 100
mat4.lookAt(view, [0, 2, 6], [0, 0, 0], [0, 1, 0]); // eye, target, up direction
mat4.identity(model);
mat4.translate(model, model, [0, 0.5, 0]); // place the object...
mat4.rotateY(model, model, time / 1000); // ...spin it around its own y axis...
mat4.scale(model, model, [1, 1, 1]); // ...(scale is applied to vertices first)
}
// In the render loop
updateMatrices(time, gl.drawingBufferWidth / gl.drawingBufferHeight);
gl.uniformMatrix4fv(uProjection, false, projection);
gl.uniformMatrix4fv(uView, false, view);
gl.uniformMatrix4fv(uModel, false, model);
#version 300 es
layout(location = 0) in vec3 a_position;
uniform mat4 u_model, u_view, u_projection;
void main() {
gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
}
Libraries apply operations like translate then rotateY by multiplying on the right, so the last call is applied to the vertices first. That is why the code reads "translate, rotate, scale" while the vertices are scaled, then rotated, then translated.
Choosing near and far. The depth buffer's precision is concentrated near the camera. A tiny near value (such as 0.0001) with a large far wastes precision and causes flickering surfaces (see Depth Testing). Push near out as far as your scene allows.
Key Takeaways
- Vertices go from local space to world (model), to camera (view), to clip space (projection). The GPU then divides by
wand maps to pixels. - 4×4 matrices with homogeneous coordinates express translation and perspective. Points have
w = 1, directionsw = 0. - The vertex shader computes
projection * view * model * position. Matrix order matters and reads right to left. - The view matrix is the inverse of the camera's transform, usually built with
lookAt. Perspective and orthographic projections suit different needs. - WebGL matrices are column-major. Use a library such as gl-matrix and pass
falsefor transpose.
Practice
- (Beginner) Render a cube with the matrices above. Change the field of view from 30° to 90° and the camera distance, and describe the effect of each.
- (Intermediate) Draw a 5×5 grid of cubes with one VAO and 25 draw calls, each with its own model matrix. Implement an orbit camera: dragging horizontally rotates the eye position around the target, and the wheel changes the distance.
- (Interview) "Why do we use 4×4 matrices for 3D graphics instead of 3×3?" Hint: consider which transformation is not linear in 3D and how the extra coordinate also enables the perspective divide.
Textures and Sampling
Real surfaces have detail: wood grain, brick, labels, faces. Modelling every detail as geometry would need millions of triangles. Instead, an image called a texture is "wrapped" onto the geometry, and the fragment shader looks up (samples) a colour from it for each pixel.
Texture coordinates. Each vertex gets a texture coordinate, usually called UV, saying which point of the image it corresponds to. UVs range from 0 to 1 across the image, independent of its pixel size. The rasteriser interpolates them across the triangle, so each fragment knows exactly where to sample:
texture image (any size) quad on screen
(0,1) ┌─────────────┐ (1,1) v3 (uv 0,1) ┌────────────┐ v2 (uv 1,1)
│ ▲ WebGL │ │ ▲ WebGL │
│ ▲▲▲ │ mapped via UVs ──► │ ▲▲▲ │
(0,0) └─────────────┘ (1,0) v0 (uv 0,0) └────────────┘ v1 (uv 1,0)
Note: WebGL's texture origin (0,0) is the bottom-left; images are stored top row first.
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) flips images on upload to match.
Creating and uploading a texture. Images load asynchronously, so a common pattern is to create the texture immediately with a 1×1 placeholder pixel, then replace it when the image arrives:
function loadTexture(gl, url) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// Placeholder: one opaque grey pixel, usable right away
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"; // cross-origin textures REQUIRE CORS approval
img.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); // level 0 = full size
gl.generateMipmap(gl.TEXTURE_2D); // build the smaller levels (see below)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
};
img.src = url;
return texture;
}
Unlike Canvas 2D, which merely taints itself, WebGL refuses to upload a cross-origin image without CORS permission and throws a SecurityError, because shaders could otherwise leak its pixels through timing. Besides images, texImage2D accepts canvases (for dynamic text labels), videos (the current frame, re-uploaded every frame for video textures), ImageBitmaps and typed arrays (for data textures).
Sampling in the shader.
// vertex shader passes the UV through
layout(location = 1) in vec2 a_uv;
out vec2 v_uv;
// ... v_uv = a_uv;
// fragment shader
uniform sampler2D u_texture;
in vec2 v_uv;
out vec4 outColor;
void main() {
outColor = texture(u_texture, v_uv * 4.0); // * 4.0 with REPEAT wrapping tiles the image 4x4 times
}
A sampler uniform is set to a texture unit number, not to the texture itself:
gl.activeTexture(gl.TEXTURE0); // choose unit 0
gl.bindTexture(gl.TEXTURE_2D, brickTexture); // put the texture in unit 0
gl.uniform1i(gl.getUniformLocation(program, "u_texture"), 0); // sampler reads from unit 0
Filtering. A texture's pixels (texels) rarely line up one-to-one with screen pixels. Filtering decides how to compute a colour between texels:
| Parameter | Option | Effect |
|---|---|---|
TEXTURE_MAG_FILTER (texture enlarged) | NEAREST | Blocky, crisp texels (pixel art) |
LINEAR | Smooth blend of the 4 nearest texels | |
TEXTURE_MIN_FILTER (texture shrunk) | NEAREST / LINEAR | Sample the full-size image: shimmering and moiré on distant surfaces |
LINEAR_MIPMAP_LINEAR | Blend between the two closest mipmap levels: smooth and stable ("trilinear") |
Mipmaps. When a 1024×1024 texture covers only 20×20 screen pixels (a distant wall), each pixel would have to average about 2,500 texels; sampling only a few produces noisy, flickering results. Mipmaps are pre-computed, progressively halved copies of the texture (1024, 512, 256, ..., 1), and the GPU picks the level that matches the on-screen size:
level 0: 1024x1024 level 1: 512x512 level 2: 256x256 ... level 10: 1x1
┌──────────────┐ ┌───────┐ ┌───┐ ▪
│ │ │ │ └───┘ extra memory for all levels: about +33%
│ │ └───────┘
└──────────────┘
A frequent beginner bug: the default TEXTURE_MIN_FILTER is NEAREST_MIPMAP_LINEAR, which requires mipmaps. A texture without mipmaps and without a changed min filter is incomplete and samples as black. Either call generateMipmap or set the min filter to LINEAR.
Wrapping. TEXTURE_WRAP_S and TEXTURE_WRAP_T (horizontal and vertical) decide what happens for UVs outside 0 to 1: REPEAT tiles, MIRRORED_REPEAT tiles with mirroring, CLAMP_TO_EDGE stretches the edge texels. In WebGL 1, textures whose dimensions are not powers of two (such as 1000×600) cannot use mipmaps or repeat wrapping; WebGL 2 removes this restriction.
Beyond colour. Textures also store non-colour data: normal maps (surface detail for lighting), roughness maps, shadow maps, and lookup tables. Rendering into a texture through a framebuffer object enables post-processing (blur, bloom, colour grading) and mirrors. For large apps, GPU-compressed formats (KTX2 with Basis Universal) cut texture memory and upload time significantly.
Key Takeaways
- Textures map images onto geometry through per-vertex UV coordinates from 0 to 1, interpolated per fragment and sampled with
texture(). - Create, bind and upload with
texImage2D, using a placeholder until the image loads. Flip Y on upload to match WebGL's bottom-left origin. - Samplers are assigned texture unit numbers with
uniform1i, and textures are bound to units withactiveTextureandbindTexture. - Use
LINEAR_MIPMAP_LINEARwithgenerateMipmapfor minified textures. Without mipmaps, set the min filter toLINEARor the texture samples black. - Cross-origin images need CORS. WebGL rejects them outright rather than tainting.
Practice
- (Beginner) Texture a quad with a photo. Then show what happens without
UNPACK_FLIP_Y_WEBGL, and without either mipmaps or a changed min filter.- (Intermediate) Render a large ground plane receding into the distance with a tiled checkerboard texture (
REPEAT, UVs multiplied by 50). CompareLINEARandLINEAR_MIPMAP_LINEARminification and describe the artefacts. Then use a<video>element as an animated texture.- (Interview) "Why do textures on distant surfaces shimmer when the camera moves, and how do mipmaps fix it?" Hint: think of how many texels fall within one screen pixel and what happens when only one or four of them are sampled.
Lighting and Shading Models
A uniformly coloured sphere looks like a flat disc: our eyes read 3D shape mainly from how brightness varies across a surface. The GPU has no notion of light, so lighting is maths you write in shaders. Real-time graphics approximate light with simple, fast models that look convincing.
Normals. Lighting depends on which way a surface faces. A normal is a unit vector perpendicular to the surface, stored per vertex as an attribute and interpolated per fragment. When an object is rotated or scaled, its normals must be transformed too, with the normal matrix: the inverse transpose of the upper-left 3×3 of the model (or model-view) matrix. Using the model matrix directly distorts normals under non-uniform scaling.
import { mat3 } from "gl-matrix";
const normalMatrix = mat3.create();
mat3.normalFromMat4(normalMatrix, model); // inverse transpose of the 3x3 part
gl.uniformMatrix3fv(uNormalMatrix, false, normalMatrix);
The vectors involved.
N (surface normal)
L ▲ V
light ◄──╲ │ ╱──► camera (viewer)
╲ │ ╱
H ◄──╲│╱ H = normalize(L + V): the "half vector" between light and view
──────────────●────────────── surface
P (point being shaded)
All vectors are unit length and point AWAY from P.
Components of the classic model.
| Term | Formula (unit vectors) | Models |
|---|---|---|
| Ambient | ambientColor * baseColor | Light bounced around the scene, so nothing is completely black |
| Diffuse (Lambert) | max(dot(N, L), 0.0) * lightColor * baseColor | Matte surfaces: brightest when facing the light, dark when facing away |
| Specular (Phong) | pow(max(dot(R, V), 0.0), shininess), with R = reflect(-L, N) | Shiny highlights that move with the viewer |
| Specular (Blinn-Phong) | pow(max(dot(N, H), 0.0), shininess) | Same idea, cheaper and better-behaved; the usual choice |
The Lambert term comes from geometry: a beam of light hitting a surface at an angle spreads over a larger area, so each point receives less energy, proportional to the cosine of the angle, which is dot(N, L) for unit vectors. shininess controls highlight size: high values give a small, sharp highlight (polished metal), low values a broad one (plastic).
Light types.
| Light | Direction L | Intensity |
|---|---|---|
| Directional (sun) | The same for every point | Constant |
| Point (bulb) | normalize(lightPos - P) | Falls off with distance (attenuation) |
| Spot | Like a point light | Also limited to a cone |
Blinn-Phong with a directional light. Doing the calculation per fragment, with interpolated normals, gives smooth highlights:
#version 300 es
// Vertex shader (the version line must stay first)
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
uniform mat4 u_model, u_view, u_projection;
uniform mat3 u_normalMatrix;
out vec3 v_normal;
out vec3 v_worldPos;
void main() {
vec4 world = u_model * vec4(a_position, 1.0);
v_worldPos = world.xyz;
v_normal = u_normalMatrix * a_normal; // normal in world space
gl_Position = u_projection * u_view * world;
}
#version 300 es
// Fragment shader (the version line must stay first)
precision highp float;
in vec3 v_normal;
in vec3 v_worldPos;
uniform vec3 u_lightDir; // direction TOWARDS the light, world space, normalised
uniform vec3 u_cameraPos; // camera position in world space
uniform vec3 u_baseColor;
out vec4 outColor;
void main() {
vec3 N = normalize(v_normal); // re-normalise: interpolation shortens vectors
vec3 L = normalize(u_lightDir);
vec3 V = normalize(u_cameraPos - v_worldPos);
vec3 H = normalize(L + V);
vec3 ambient = 0.15 * u_baseColor;
vec3 diffuse = max(dot(N, L), 0.0) * u_baseColor;
float spec = dot(N, L) > 0.0 ? pow(max(dot(N, H), 0.0), 64.0) : 0.0; // no highlight on the dark side
vec3 specular = spec * vec3(1.0); // white highlight
outColor = vec4(ambient + diffuse + specular, 1.0);
}
Where to compute lighting.
| Approach | Lighting computed | Look | Cost |
|---|---|---|---|
| Flat shading | Once per triangle (one normal per face) | Faceted, low-poly style | Lowest |
| Gouraud shading | Per vertex, colours interpolated | Smooth, but highlights blur or vanish between vertices | Low |
| Phong shading | Per fragment, normals interpolated | Smooth, accurate highlights | Higher, standard on modern GPUs |
Beyond Blinn-Phong. Modern engines use physically based rendering (PBR): materials are described by physical parameters such as base colour, metalness and roughness, and the shading equations conserve energy, so materials look right under any lighting, including image-based lighting from environment maps. three.js's MeshStandardMaterial and glTF's material model are PBR. Real scenes also need shadows (usually shadow maps: render depth from the light's point of view and compare) and correct colour space handling (lighting maths in linear space, converted to sRGB for display).
Key Takeaways
- Lighting is computed in shaders from normals, light direction and view direction. Transform normals with the normal matrix.
- Lambert diffuse is
max(dot(N, L), 0). Blinn-Phong specular ispow(max(dot(N, H), 0), shininess). Add a small ambient term. - Directional, point and spot lights differ in how
Land intensity are computed. - Per-fragment (Phong) shading gives the best highlights. Always re-normalise interpolated vectors.
- Production renderers use PBR materials, shadow maps and linear-space lighting.
Practice
- (Beginner) Light a rotating cube with only the diffuse term, then add ambient, then specular. Describe what each term contributes.
- (Intermediate) Replace the directional light with a point light that orbits the object, including distance attenuation
1.0 / (1.0 + k * d * d). Implement both Gouraud and Phong shading on a low-polygon sphere and compare the highlights.- (Interview) "Why can't you transform normals with the same matrix as positions?" Hint: apply a non-uniform scale (stretch along x) to a surface tilted at 45° and check whether the transformed normal is still perpendicular to it.
Depth Testing, Blending and Culling
Once triangles are transformed and shaded, the GPU must decide which fragments end up on screen. When objects overlap, the nearer one should hide the farther one; transparent objects must mix with what lies behind them; and triangles facing away from the camera need not be drawn at all. Three fixed-function stages handle this: depth testing, blending and face culling.
The visibility problem. SVG and Canvas use the painter's algorithm: draw back to front, and later drawings cover earlier ones. In 3D this fails: sorting thousands of objects every frame is expensive, and intersecting or cyclically overlapping triangles cannot be sorted correctly at all.
The depth buffer (z-buffer). WebGL keeps, for every pixel, the depth of the nearest fragment drawn so far. Each new fragment's depth is compared with the stored value. If it is nearer, it is drawn and its depth stored; otherwise it is discarded. Draw order no longer matters for opaque objects.
pixel (120, 80): depth buffer starts at 1.0 (farthest)
draw wall fragment depth 0.70 → 0.70 < 1.00 pass: colour = wall, depth = 0.70
draw cube fragment depth 0.40 → 0.40 < 0.70 pass: colour = cube, depth = 0.40
draw floor fragment depth 0.85 → 0.85 > 0.40 FAIL: discarded, cube stays visible
// Context created with { depth: true } (the default)
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS); // pass if nearer (default)
// every frame:
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // reset depths to 1.0, or old frames block new ones
Z-fighting. When two surfaces are at nearly the same depth (a poster on a wall), limited depth precision makes them flicker as they win alternately from pixel to pixel. Fixes: increase the near plane distance, reduce the far/near ratio, move the surfaces apart, or use gl.polygonOffset to bias one surface's depth.
Face culling. For a closed object such as a cube, triangles facing away from the camera are always hidden by the front ones. Culling discards them before rasterisation, roughly halving fragment work. The GPU decides which side faces the camera from the on-screen winding order (counter-clockwise front faces by default):
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK); // discard back faces (default)
gl.frontFace(gl.CCW); // counter-clockwise = front (default)
If a model suddenly appears inside-out or with missing faces after enabling culling, its winding is inconsistent or reversed. Do not cull open or double-sided geometry such as leaves, flags or planes seen from both sides.
Blending. With blending enabled, the fragment's colour (source) is combined with the colour already in the framebuffer (destination) instead of replacing it:
result = source * srcFactor + destination * dstFactor
gl.blendFunc(src, dst) | Result | Use |
|---|---|---|
SRC_ALPHA, ONE_MINUS_SRC_ALPHA | Standard "over" transparency | Glass, UI panels, fading objects |
ONE, ONE_MINUS_SRC_ALPHA | Same, for premultiplied-alpha colours | Textures and canvases with premultiplied alpha |
ONE, ONE | Additive: colours add up | Fire, sparks, glows, light beams |
DST_COLOR, ZERO | Multiplicative | Tinting, darkening |
Transparency and depth together. Transparent objects break the "order does not matter" property of the depth buffer: if a glass pane is drawn first and writes its depth, an object behind it drawn later fails the depth test and never appears through the glass. The standard recipe:
function renderFrame(scene, camera) {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// 1. Opaque objects: any order (front to back is fastest), depth test and depth writes on
gl.disable(gl.BLEND);
gl.depthMask(true);
for (const obj of scene.opaque) draw(obj);
// 2. Transparent objects: sorted back to front, depth TEST on but depth WRITES off
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false); // do not hide other transparent objects
const sorted = [...scene.transparent].sort(
(a, b) => distanceTo(camera, b) - distanceTo(camera, a) // farthest first
);
for (const obj of sorted) draw(obj);
gl.depthMask(true); // restore for the next frame's clear
}
Other per-fragment tests. The scissor test (gl.scissor) restricts drawing to a rectangle, useful for split screens and UI panels. The stencil buffer stores a small integer per pixel that draws can write and test, enabling masks, portals, mirrors and outline effects.
Key Takeaways
- The depth buffer stores the nearest depth per pixel, so opaque objects can be drawn in any order. Enable
DEPTH_TESTand clear the depth buffer every frame. - Z-fighting comes from limited depth precision. Keep
nearas large as possible and avoid coplanar surfaces. - Back-face culling skips triangles facing away from the camera, relying on consistent winding.
- Blending combines source and destination colours with
blendFunc: standard alpha, premultiplied alpha, additive or multiplicative. - Draw opaque objects first, then transparent ones sorted back to front with depth writes disabled.
Practice
- (Beginner) Render two overlapping cubes with depth testing disabled, then enabled, and swap their draw order in both cases. Explain the results.
- (Intermediate) Render a scene with an opaque floor and five semi-transparent coloured panes at different depths, orbiting the camera around them. Show the artefacts when panes are not sorted, then implement sorting and
depthMask(false). Add an additive-blended particle fountain.- (Interview) "Why can't the depth buffer alone handle transparent objects correctly?" Hint: a depth buffer stores a single depth per pixel; ask what a transparent surface needs to know about what lies behind it, and when.
Scene Graphs and High-Level Libraries
Even a spinning, lit, textured cube takes a couple of hundred lines of raw WebGL. Applications need much more: hierarchies of objects, cameras, many materials, model loading, shadows, picking and animation. Two ideas manage this complexity: scene graphs to organise the scene, and libraries that implement the common machinery.
Scene graphs. Many scenes are hierarchical: a wheel is attached to a car, a hand to an arm, a moon orbits a planet that orbits a sun. A scene graph is a tree of nodes, each with a local transform relative to its parent. A node's world matrix is its parent's world matrix multiplied by its own local matrix, so moving a parent moves all of its descendants, exactly like the nested save/restore blocks of Canvas (7.2) or nested <g transform> elements in SVG (7.1).
Scene
└── Sun local: rotate(t) world = Sun
├── Earth local: translate(10,0,0) · rotate(5t) world = Sun · Earth
│ └── Moon local: translate(2,0,0) world = Sun · Earth · Moon
└── Mars local: translate(16,0,0) world = Sun · Mars
A minimal implementation:
import { mat4 } from "gl-matrix";
class SceneNode {
constructor(mesh = null) {
this.mesh = mesh; // what to draw (optional: groups have none)
this.local = mat4.create(); // transform relative to the parent
this.world = mat4.create(); // computed: transform relative to the scene
this.children = [];
}
add(child) { this.children.push(child); return child; }
updateWorld(parentWorld = null) {
if (parentWorld) mat4.multiply(this.world, parentWorld, this.local);
else mat4.copy(this.world, this.local);
for (const child of this.children) child.updateWorld(this.world); // depth-first
}
traverse(fn) {
fn(this);
for (const child of this.children) child.traverse(fn);
}
}
// Each frame: animate local transforms, propagate, then draw every node with a mesh
sun.updateWorld();
sun.traverse((node) => node.mesh && drawMesh(node.mesh, node.world));
Engines add more on top: frustum culling (skip nodes outside the camera's view), sorting by material to reduce state changes, and separating opaque and transparent objects as in the previous topic.
three.js. The most widely used WebGL library provides a scene graph, cameras, lights, PBR materials, loaders (glTF and many others), shadows, post-processing and a renderer that also targets WebGPU. A complete lit, rotating, orbit-controllable cube:
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const canvas = document.querySelector("#scene");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // the DPR handling from 7.2
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x202530);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); // fov, aspect, near, far
camera.position.set(3, 2, 5);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1), // vertices, normals, UVs, indices
new THREE.MeshStandardMaterial({ color: 0x2196f3, roughness: 0.4 }) // PBR material = shaders
);
scene.add(cube); // scene graph node
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(5, 10, 7);
scene.add(sun);
const controls = new OrbitControls(camera, canvas); // drag to orbit, wheel to zoom
controls.enableDamping = true;
function resize() {
const { clientWidth: w, clientHeight: h } = canvas;
renderer.setSize(w, h, false); // false: do not overwrite the CSS size
camera.aspect = w / h;
camera.updateProjectionMatrix(); // rebuild the projection matrix
}
new ResizeObserver(resize).observe(canvas);
renderer.setAnimationLoop((time) => { // requestAnimationFrame wrapper
cube.rotation.y = time / 1000; // updates the node's local transform
controls.update();
renderer.render(scene, camera); // updateWorld + draw calls, for you
});
Every concept from this subchapter is still there, only hidden: BoxGeometry builds buffers and an index buffer, the material compiles shaders with Blinn-Phong's successor (PBR), PerspectiveCamera builds the projection and view matrices, and renderer.render traverses the scene graph, culls, sorts, sets uniforms and issues draw calls.
The library landscape.
| Library | Focus |
|---|---|
| three.js | General-purpose 3D: largest ecosystem, examples and community |
| Babylon.js | Full 3D engine: physics, inspector, GUI, XR, strong tooling |
| PixiJS | Fast 2D rendering on WebGL/WebGPU: games, interactive graphics |
| react-three-fiber | three.js as declarative React components (Chapter 14) |
| A-Frame | Declarative HTML-like markup for WebXR scenes |
| deck.gl, regl-scatterplot | Large-scale data visualisation (millions of points) |
| CesiumJS, MapLibre GL | 3D globes and vector maps |
| twgl.js, regl | Thin helpers that remove WebGL boilerplate while staying close to the API |
Raw WebGL or a library?
| Situation | Choice |
|---|---|
| Typical 3D product viewer, configurator, game, data scene | A library (three.js, Babylon.js) |
| Learning how GPUs work, or a custom renderer with unusual needs | Raw WebGL (or WebGPU) |
| One special effect or GPU-accelerated 2D/data rendering | Thin helper (twgl, regl) or raw shaders |
| Need for compute shaders (simulations, ML) or the newest GPU features | WebGPU directly or through a library's WebGPU renderer |
Understanding the raw API remains valuable even when you use a library: debugging performance (too many draw calls, overdraw, texture memory), writing custom shaders (ShaderMaterial in three.js) and reading engine documentation all assume the concepts covered in this subchapter.
Key Takeaways
- A scene graph is a tree of nodes with local transforms. World matrices are computed by multiplying down the tree, so children follow their parents.
- Engines add frustum culling, material sorting and opaque/transparent ordering on top of the graph.
- three.js packages geometry, materials (shaders), cameras (matrices), lights and a renderer that traverses the graph and issues draw calls.
- Choose a library for most applications, thin helpers or raw WebGL for special needs or learning, and WebGPU for compute and modern features.
- The low-level concepts remain essential for debugging, custom shaders and performance work.
Practice
- (Beginner) Rebuild the three.js cube example, then replace the cube with a glTF model loaded with
GLTFLoaderand add a ground plane that receives shadows.- (Intermediate) Use the
SceneNodeclass with your raw WebGL cube renderer to build a solar system (sun, two planets, one moon) where every body spins on its own axis and orbits its parent. Then build the same scene in three.js usingObject3Dgroups and compare the code.- (Interview) "A three.js scene with 5,000 separate meshes runs at 15 fps. What would you investigate and change?" Hint: look at the renderer's draw-call count (
renderer.info), and consider instancing, merging geometries and sharing materials.
<a id="8-the-jquery-library"></a>