Skip to Content
All

Procedural drawing with HTML5 canvas

 — #creativecoding#engineering

Introduction

HTML5, the fifth version of the Hypertext Markup Language, is a standard language used to structure and present content on the World Wide Web. It's been around for a years now. As the latest evolution of the HTML language, it introduced new features and capabilities that enhance the development of modern web applications, with advanced support for multimedia. HTML5 includes elements for embedding audio, video, and interactive graphics directly into web pages, reducing the reliance on third-party, and usually security flawed browser plugins. Its design aims to support multimedia and rich internet applications, making it a versatile and powerful tool for web developers.

Canvas

The canvas element in HTML5 provides a drawing surface that allows developers to create dynamic and interactive graphics using JavaScript. It is a rectangular area where one can draw shapes, text, and images dynamically through scripting. The canvas element is part of the HTML5 specification and is widely used for creating animations, games, data visualizations, and other graphical content on the web.

Key features of the `canvas`` element:

Drawing Context

Provides a 2D drawing context (vs a 3D context with WebGL) that allows developers to use JavaScript to draw on the canvas.

Scripting Support

JavaScript API to manipulate the canvas, allowing for dynamic and responsive graphics.

Versatility

Supports a wide range of drawing operations, including paths, rectangles, circles, text, and images.

Animation

Combined with JavaScript and requestAnimationFrame, the canvas is a powerful tool for creating smooth and interactive animations on the web.

Overall, the canvas element is a fundamental component of HTML5 that empowers web developers to create visually engaging and interactive content directly within the browser.

Why do I talk about Canvas

With robust support for (soon WebGPU) WebGL across all browsers, one might question the investment of time in what appears, in comparison, as somewhat suboptimal—leveraging of the GPU to build advanced visualizations.

Yet the canvas is intricately optimized and indeed taps into the GPU for efficient drawing operations. Despite lacking native 3D capabilities, the 2D canvas can surprisingly accommodate more than just two-dimensional scenes.

Note that even with 3D applications, the output is ultimately projected onto a flat 2D grid, generally comprising millions of cells (pixels) — yet is just a pure 2D plane.

While I wouldn't advocate utilizing the Canvas 2D context for building 3D applications, it serves as an excellent starting point for delving into creative coding. The canvas can be pushed to remarkable limits, presenting a delightful challenge to craft pseudo-3D visuals using the basic x and y drawing tools. It's a playground for unleashing creative potential and honing coding skills.

I love WebGL, but turned back to Canvas to fiddle with creative coding, it's more constrained, hence fuel the important aspects of Creativity.

Explainer - building a procedural drawing app

It unfolds in two parts. First, we set up the canvas with HTML(5) and CSS, ensuring it fits seamlessly into the window. Second, we dive into scripting, exploring specific functions and their code lines in reasonable depth. This walkthrough isn't a rigid tutorial; it encourages experimentation for unique results. Emphasis is on understanding principles rather than just following steps, fostering creativity in bringing the canvas to life. Have fun.

A live demo is available here. And the full source code is accessible on CodePen, here.

Part 1: HTML and CSS

Step 1: HTML Structure

<canvas id="canvas"></canvas>

This HTML snippet creates a canvas element with the id "canvas," where our animated artwork will be displayed.

That's it! That's all there is to add a canvas to your html.

I'm omitting the HTML headers and enclosing tags. This <canvas> tag is to be enclosed within the <body> </body> tags.

Step 2: CSS Styles

* { 
  margin: 0;
  padding: 0; 
  background: #101010;
}

html, body { 
  width: 100%; 
  height: 100%; 
  overflow: hidden;
} 

canvas {
  display: block;
}

The CSS sets the stage. It removes default margin and padding, sets a dark background color, and makes the canvas cover the entire window. The canvas itself is styled to be a block element with a matching background color.

Part 2: JavaScript Animation

The fun begins.

Step 3: Setting Up Variables

const points = [], tickSpeed = 5, base = 180, numPoints = 10, maxTicks = 650;
let ticks = 0, palette, max, blending;
const speedAdjuster = 2000;

Initialize variables for points, animation speed, color palette, maximum ticks, and blending options.

While those are hard coded values, we will tie those to a Data GUI element that will render on the page, allowing the user to override some of those values.

Step 4: Canvas and Context

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

Get the canvas and its 2D rendering context for drawing.

Step 5: GUI Initialization with dat.GUI

We will use dat.GUI, a duper popular library from Mr.Doob that provides control input elements that hook to variables via a simple APIs. We could build ourselves from scratch, but the explainer is to focus on the creative coding logic with Canvas.

The function here constructs a dat.GUI object allowing users to dynamically control three color palettes, animation speed (maxTicks), and blending modes. Providing a more engaging and interactive experience with the canvas animation.

1. const setupDatGui = () => {
2.   // Create a dat.GUI instance
3.   const gui = new dat.GUI();

4.   // Define the initial color palette
5.   palette = {
6.     color1: { h: 148, s: 0.8, v: 0.7 },
7.     color2: { h: 264, s: 0.9, v: 0.6 },
8.     color3: { h: 350, s: 0.9, v: 0.3 },
9.   };

10.   // Iterate through the color palette and add color controls to the GUI
11.   for (let i = 1; i < 4; i++) {
12.     // Add a color control for each color in the palette
13.     gui.addColor(palette, `color${i}`);
14.   }

15.   // Define the initial value for maxTicks
16.   max = { maxTicks: maxTicks };
  
17.   // Add a control for adjusting maxTicks with a range slider
18.   gui.add(max, 'maxTicks', maxTicks / 10, maxTicks * 2);

19.   // Define the initial blending mode
20.   blending = {
21.     blendingMode: 'lighter',
22.   };

23.   // Add a dropdown control for selecting the blending mode
24.   gui.add(blending, 'blendingMode', ['source-over', 'destination-over', 'lighter', 'xor']);
25. }
  • Line 1 creates a new instance of the dat.GUI library.
  • Starting at line 5, initializes a color palette with three colors defined in HSL (Hue, Saturation, Lightness) format.
  • Line 11 to 14, a loop that iterates through the color palette and adds color controls to the GUI for each color. The `gui.addColor`` method creates a color picker control.
  • line 16 sets up a control for adjusting the maxTicks variable using a range slider.
  • Line 18 specifies the range for the slider to be from maxTicks / 10 to maxTicks * 2. 10 and 2 are simply chosen because they provide a good range to achieve good procedural drawing results.
  • Line 20? This defines a control for adjusting the blending mode.
  • Finally, line 24 It provides a dropdown with options for different blending modes: 'source-over', 'destination-over', 'lighter', and 'xor'. And adds it to the interactive GUI.

Step 6: Resizing the Canvas

Users and their tendency to change the browser window's size. It really displeases the canvas element. Why? because it doesn't automatically resize. But not-an-issue. A few lines sorts that out.

const windowResize = () => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
};

Function to reset the canvas's width and height to fit perfectly the window's inner width and height.

Step 7: Initialization

Now the real real fun begins. Let's create random points, each point will serve as drawing pencils locations. We will then have the script moves those points. Effectively drawing on the canvas!

1. const init = () => {
2.   points.length = 0;
3.   ticks = 0;
4.
5.   for (let i = 0; i < numPoints; i++) points.push(createPoint());
6.
7.   for (let i = 0; i < points.length; i++) {
8.     let j = i;
9.     while (j == i) j = Math.floor(Math.random() * points.length);
10.     points[i].neighbor = points[j];
11.   }
12. };

We start with an Init function. all it does it defines two separate loop.

  • First loop, on line 5: Push many points into an array. The following steps covers what goes in in the createPoint() function.
  • The loop on line 7, iterates over each point, and define its neighbor. The neighbor property of the current point is in fact assigned to another randomly chosen point in the array, ensuring each point has a unique neighbor and that it does not reference itself.
  • Line 1, and 3 are just initializers, the ticks will be used as counter by the animation loop.

So we now have some init function, it initializes the points array, resets the ticks counter, creates a specified number of random points, and assigns each point a unique neighbor to establish connections for the animation.

Step 8: Creating a Point

We didn't cover what the invoked createPoint() was doing in the previous step. Here it is.

1. const createPoint = () => {
2.   const x = Math.random() * canvas.width;
3.   const y = Math.random() * canvas.height;
4.   const a = Math.random() * Math.PI;
5.   const dx = Math.cos(a);
6.   const dy = Math.sin(a);
7.   const hue = (Math.random() * 100 + base) % 360;
8.   const color = createGradient();
9.  
10.  return { x, y, dx, dy, hue, color };
11. };
  • Line 2 and 3 generates a random value for the x-coordinate and y-coordinate within the canvas width.
  • Line 4 generates a random Angle in Radians:
  • Calculating a directional increment for X (dx) at line 5, and for Y (dy) at line 6, off the radiant. In essence, this sets a direction relative to the random radiant value, and the cos and sine mathematical functions.
  • Then, some random Hue value within a specific range: 0 to 360 degrees.
  • Calling a gradient generator function at line 8. Which is covered in a following step.
  • Finally returning the lot on line 10.

Step 9: Creating a Gradient with Control Colors

Didn't cover what was happening in the createGradient, let's do it.

1. const createGradient = () => {
2.   const saturationFactor = 1;
3.   const color1 = `hsla(${palette.color1.h}, ${palette.color1.s * 100}%, ${palette.color1.v * 100}%, 0.05)`;
4.   const color2 = `hsla(${palette.color2.h}, ${palette.color2.s * 100}%, ${palette.color2.v * 100}%, 0.05)`;
5.   const color3 = `hsla(${palette.color3.h}, ${palette.color3.s * 100}%, ${palette.color3.v * 100}%, 0.05)`;
6.
7.   const middleSaturation = (palette.color1.s + palette.color3.s) / 2 * saturationFactor;
8.   const middleColor = `hsla(${palette.color2.h}, ${middleSaturation * 100}%, ${palette.color2.v * 100}%, 0.05)`;
9.
10.   return gradientFromColors(color1, middleColor, color3);
11.  };

A constant saturationFactor is defined, usually used to adjust the saturation of the middle color, if ever needed.

  • Line 3 defines Color1: A string representing the first color in the gradient, derived from the HSL values of palette.color1.
  • Line 4 and 5 do the same for color2 and color3.
  • A value for middleSaturation is calculated as the average of the saturations of palette.color1 and palette.color3, line 7.
  • Line 10 returns the result of the gradientFromColors function, given the 3 colors generated colors.

Following step explains what gradientFromColors does.

Step 10: Creating Gradient from 3 Colors

That function creates a rather simple linear gradient, off 3 colors, with a stop point right in the middle

1. const gradientFromColors = (color1, middleColor, color3) => {
2.   const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
3.   gradient.addColorStop(0, color1);
4.   gradient.addColorStop(0.5, middleColor);
5.   gradient.addColorStop(1, color3);
6.   
7.   return gradient;
8. };
  • Line 2 creates a linear gradient object using the canvas context (ctx) with starting coordinates at (0, 0) and ending coordinates at the width and height of the canvas (canvas.width, canvas.height).
  • Line 3 add color stop (for color1) at the 0 (distance).
  • Line 4 adds the middle stop point of the gradient, at 50% of the total length.
  • And line 5 adds the third color stop at the end of the gradient (100% distance) with the color specified by color3.

Step 10: Updating Point Position

A function to update the point. This will get invoked at each animation frame. There seem to be quite a bit going on there, but hold tight, it's not really that complicated.

1. const updatePoint = (point) => {
2.   point.x += point.dx;
3.   point.y += point.dy;
4.   if (point.x < 0 || point.x >= canvas.width) point.dx *= -1;
5.   if (point.y < 0 || point.y >= canvas.height) point.dy *= -1;
6.   ctx.strokeStyle = point.color;
7.   ctx.lineWidth = 2;
8.   ctx.beginPath();
9.   // Set blending mode for curve overlap, some will overlap!
10.  ctx.globalCompositeOperation = blending.blendingMode;
11.  
12.  ctx.moveTo(point.x, point.y);
13.  ctx.lineTo(point.neighbor.x, point.neighbor.y);
14.  ctx.stroke();
15. };
  • Line 2 and 3 increments the x-coordinate (horizontally) and y-coordinate (vertically) of the point respectively.
  • Line 4 and 5 Check whether the point reaches the boundary of the canvas, if so bounces (reverse) its direction.
  • Line 6 set Stroke Style. The canvas offers pencil settings, we want it to draw with the point's color.
  • Line 7 is another canvas feature, the pencil width, and 2 pixel will do. You may tweak that if you would like.
  • We begin tracing a path at line 8. It tells the canvas to start a spline which we will control its movement in the following lines.
  • Line 10 set the Blending Mode. This determines how newly drawn shapes blend with existing content.
  • We Move along tracing the path as a straight line up to the point.x and y position, at line 12.
  • Then move the drawing line to its random Neighbor point! That's on line 13.
  • Line 14, finally draw the stroke on the traced path. Effectively making it visible on the canvas

Step 11: Drawing Function

Moving the points need to be invoked in a loop to draw each step of the procedural piece. We define a draw function that involves requestAnimationFrame to trigger at the appropriate timing rate. Needed to draw at the same speed regardless what the executing machine's speed is.

1. const draw = () => {
2.   if (ticks > max.maxTicks) return;
3.   for (let n = 0; n < tickSpeed * max.maxTicks / speedAdjuster; n++) {
4.     for (let i = 0; i < points.length; i++) {
5.       updatePoint(points[i]);
6.     }
7.     ticks++;
8.   }
9.   
10.  // request next animation frame 
11.  // likely in about: 1, divided by monitor refresh rate
12.  requestAnimationFrame(draw);
13. };
  • First we check tickets limit, indicative it is done drawing all steps, and exit the loop.
  • The outer loop is just a trick to make the speed dynamic, based on the maxTick. The more ticks to go through, the less steps, hence faster progressing of drawing the shapes.
  • Then we have the nested loop on line 4. Iterating through each and every point and triggers the update explained in the previous step.
  • After completing the loops, the function schedules the next animation frame by calling requestAnimationFrame. This creates a fluid cycle, ensuring continuous and stable animation.

Comment on Frame Rate:

As the inline comment suggests, the request for the next animation frame is likely to occur approximately every 1/60 seconds. Assuming a 60fps graphical card and monitor.

Step 12: Start drawing

We are almost done, define a start function completes setting up the canvas and invoke the draw() function

1. const start = () => {
2.   // Setup the canvas
3.   ctx.translate(0.5, 0.5); // Anti-aliasing hack/trick
4.   ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas
5.   
6.   // Initialize points
7.   init();
8.   ticks = 0; // Reset ticks
9.   
10.  // Start the animation loop
11.  draw();
12. };
  • Line 3: Translates the canvas context by 0.5 pixels in both the x and y directions. This is an anti-aliasing trick to improve rendering quality, substantially.
  • Line 4: Clears the entire canvas, providing a clean slate for new content.
  • Line 7: Calls the init function to initialize the points on the canvas.
  • Line 8: Resets the ticks variable to 0, marking the start of a new animation sequence.
  • Line 11: Calls the draw function to kickstart the animation loop.

Step 13. Kickstart

Finally, add some event handlers to catch window resizing, and clicks on the canvas to start drawing a new piece. At the end of the script file.

1. // Event listeners
2. window.addEventListener("resize", windowResize, false);
3. canvas.addEventListener("click", () => start());
4. windowResize(); // Initial window resize
5. 
6. // Kickstart the whole process
7. setupDatGui(); // Initialize Dat.GUI interface
8. start(); // Start the animation loop
  • Line 2: Adds a resize event listener to the window. When the window is resized, the windowResize function is called to adjust the canvas size accordingly.
  • Line 3: Adds a click event listener to the canvas. When the canvas is clicked, it triggers the start function, initiating the animation.
  • Line 4: Immediately calls windowResize() after setting up the event listeners to ensure proper initial sizing.
  • Line 7: Calls setupDatGui() to initialize the Dat.GUI interface for user tweaks.
  • Line 8: Calls start() to initiate the animation loop.

You are down, load this up a browser and you shall see the procedural drawing and a nice control widget to tweak parameters.