Patrick Burris

Software Developer

WebGL Renderer Section 1 - Intro

Wrapping WebGL

The next several posts are going to step through the WebGL API as we build a set of wrapper classes. We are creating the classes for a couple reasons: first, a lot of the WebGL objects take several lines to set up and several more lines when we need to use them. Second, to interact with WebGL we have to use a context object and it is a hassle to pass that context around our application.

While we discuss WebGL and we start building our abstractions, we are also going to be working on building a CPU renderer (no fancy GL stuff, just plain TypeScript), where we render triangles pixel-by-pixel to get a feel for what WebGL is doing under the hood.

Rasterization

Rasterization is the process of converting vector object (like an SVG or just a list of points) into a grid-based image, a.k.a. pixels. [IMAGE DESCRIPTION HERE]

Let's start the project

This is going to be a front-end TypeScript project. I am going to use Vite to create the project files and folder. Run the following commands:

npm create vite@latest

Select 'Vanilla' and 'TypeScript' after naming your project (any name will do, don't overthink it!).

cd [project-name]
npm install
npm run dev

That will get you into the project folder, install all the dependencies, and then run the application. You should see the URL of your server. Vite defaults to port 5173, so you should see the starter page at http://localhost:5173/

Please check ViteJS documentation for more information on Vite specific questions.

Fresh Vite project startup page

You can go ahead and delete src/counter.ts and src/typescript.svg, then delete the contents of the src/style.css file, and finally delete everything from the src/main.ts except for the top import of the CSS file.

Now we can add just a bit of CSS to center the canvas element we are going to create and append to the body. Add the following to the src/style.css file

body, html {
  margin: 0;
}

body {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

canvas {
  border: 1px solid #555;
}

This will center the canvas element and add a 1 pixel, gray, border around it.

HTML5 Canvas and the 2D context

The HTML5 canvas element was standardized in 2006 and came with an API that allows the developer to draw shapes, lines and images on the canvas surface. Eventually this context API was expanded to include the 2 WebGL versions as well as WebGPU (not covered here).

The project that we are going to be working on will use that original drawing context: CanvasRenderingContext2D.

Rasterizer code

Now that we have our project setup and ready to start, we can add the entry point: our main function. Everything that our application does, happens inside of that main function. We will need to startup each system, load files, and then run our main loop. We will run our main loop until we encounter an issue or until the user navigates away from the page.

async function main() {
  const loop = () => {
    requestAnimationFrame(loop);
  }
  main();
}
main();

Inside of main.ts, we create the skeleton of the entire application, we establish our main function as well as the main loop. We are using requestAnimationFrame to make sure that our loop function only gets ran at a frequency matching the user's monitor's refresh rate (usually 60 frame per second). This will help limit the amount of CPU we can take up, which will ensure a smoother experience than if we hogged all of the resources.

async function main() {
  const canvas = document.createElement("canvas");
  canvas.width = 1024;
  canvas.height = 768;
  document.body.appendChild(canvas);
  const ctx = canvas.getContext("2d");

  // check to make sure we could get the 2d context from the canvas
  // all browsers should support the 2d context, but we will leave it here
  // just in case
  if (ctx === null) {
    throw new Error("could not create canvas 2d rendering context");
  }
  // ...

The call to canvas.getContext('2d') is the context discussed above. For more information on different canvas rendering contexts, check here.

This is a pretty basic pattern: Create the canvas, set the width/height, then append it to the DOM and get the canvas context you want. The context always has the possibility of being 'null', that just means that the context is not supported on the device you are getting null on. I have never ran into a device that is unable to get the 2d context, but you might find some that don't support webgl or webgl2. This is why we are checking to see if ctx is null before moving on. If ctx is null, we are just aborting and throw an Error.

  const loop = () => {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    ctx.fillStyle = "orange";
    ctx.fillRect(100, 100, 250, 250);

    requestAnimationFrame(loop);
  };
  loop();

3. simple example - clearRect, change fillStyle, then draw an orange rectangle

  if (ctx === null) {
    throw new Error("could not create canvas 2d rendering context");
  }

  let imageData = ctx.createImageData(ctx.canvas.width, ctx.canvas.height);

4a. Inside main() extract the imageData to use in a second

function setPixel(
  imageData: ImageData,
  x: number,
  y: number,
  color: [number, number, number, number]
) {
  if (x < 0 || y < 0 || x > imageData.width - 1 || y > imageData.height - 1) {
    return;
  }
  const idx = (y * imageData.width + x) * 4;
  imageData.data[idx + 0] = color[0];
  imageData.data[idx + 1] = color[1];
  imageData.data[idx + 2] = color[2];
  imageData.data[idx + 3] = color[3];
}

4b. Create a setPixel Function that takes out ImageData object

  const loop = () => {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    for (let y = 100; y < 350; y++) {
      for (let x = 100; x < 350; x++) {
        setPixel(imageData, x, y, [255, 165, 0, 255]);
      }
    }
    ctx.putImageData(imageData, 0, 0);
    imageData = ctx.createImageData(ctx.canvas.width, ctx.canvas.height);

    requestAnimationFrame(loop);
  };

4c. Update the loop, remove the fillStyle and fillRect calls with a nested for loop calling setPixel followed by putImageData

4d. Explain Screen Space vs. Clip Space

function setPixelScreenSpace(
  imageData: ImageData,
  x: number,
  y: number,
  color: [number, number, number, number]
) {
  y = imageData.height - y; // invert y to put it at the bottom
  return setPixel(imageData, x, y, color);
}

5. Screen Space example

function setPixelClipSpace(
  imageData: ImageData,
  x: number,
  y: number,
  color: [number, number, number, number]
) {
  x = Math.floor(((x + 1) / 2) * imageData.width);
  y = Math.floor(((y + 1) / 2) * imageData.height);
  setPixelScreenSpace(imageData, x, y, color);
}

6a. Clip Space example

  const pixelX = 1 / 1024;
  const pixelY = 1 / 768;

  const loop = () => {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    for (let y = -0.25; y < 0.25; y += pixelY) {
      for (let x = -0.25; x < 0.25; x += pixelX) {
        setPixelClipSpace(imageData, x, y, [255, 165, 0, 255]);
      }
    }
    ctx.putImageData(imageData, 0, 0);
    imageData = ctx.createImageData(ctx.canvas.width, ctx.canvas.height);

    requestAnimationFrame(loop);
  };

6b. Clip Space example continued

7. The idea so far is that we can think about an image as an array of pixels and we draw to that image by writing the array and that we can apply any coordinate system over that array.

  let t = 0;
  const loop = () => {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    for (let x = -1; x <= 1; x += pixelX) {
      setPixelClipSpace(
        imageData,
        x,
        Math.sin(x * 15 + t) / 3.0,
        [255, 125, 0, 255]
      );
    }
    ctx.putImageData(imageData, 0, 0);
    imageData = ctx.createImageData(ctx.canvas.width, ctx.canvas.height);

    t += 0.1;
    requestAnimationFrame(loop);
  };
  loop();

8. Animated sin wave example

class Canvas {
  private ctx: CanvasRenderingContext2D;
  private canvasElement: HTMLCanvasElement;
  private imageData: ImageData;

  constructor() {
    const canvas = document.createElement("canvas");
    canvas.width = 1024;
    canvas.height = 768;
    document.body.appendChild(canvas);
    const ctx = canvas.getContext("2d");

    if (ctx === null) {
      throw new Error("could not create canvas 2d rendering context");
    }
    this.ctx = ctx;
    this.canvasElement = canvas;
    this.imageData = this.ctx.getImageData(
      0,
      0,
      ctx.canvas.width,
      ctx.canvas.height
    );
  }

  public get width(): number {
    return this.ctx.canvas.width;
  }

  public get height(): number {
    return this.ctx.canvas.height;
  }

  public startFrame() {
    this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
  }

  public renderFrame() {
    this.ctx.putImageData(this.imageData, 0, 0);
    this.imageData = this.ctx.createImageData(
      this.ctx.canvas.width,
      this.ctx.canvas.height
    );
  }

  public setPixel(
    x: number,
    y: number,
    color: [number, number, number, number]
  ) {
    if (
      x < 0 ||
      y < 0 ||
      x > this.imageData.width - 1 ||
      y > this.imageData.height - 1
    ) {
      return;
    }
    const idx = (y * this.imageData.width + x) * 4;
    this.imageData.data[idx + 0] = color[0];
    this.imageData.data[idx + 1] = color[1];
    this.imageData.data[idx + 2] = color[2];
    this.imageData.data[idx + 3] = color[3];
  }
}

9. Refactor canvas code into a class

function setPixelScreenSpace(
  canvas: Canvas,
  x: number,
  y: number,
  color: [number, number, number, number]
) {
  y = canvas.height - y; // invert y to put it at the bottom
  return canvas.setPixel(x, y, color);
}

function setPixelClipSpace(
  canvas: Canvas,
  x: number,
  y: number,
  color: [number, number, number, number]
) {
  x = Math.floor(((x + 1) / 2) * canvas.width);
  y = Math.floor(((y + 1) / 2) * canvas.height);
  setPixelScreenSpace(canvas, x, y, color);
}

export async function main() {
  const canvas = new Canvas();

  const pixelX = 0.5 / 1024;
  const pixelY = 0.5 / 768;

  let t = 0;
  const loop = () => {
    canvas.startFrame();
    for (let x = -1; x <= 1; x += pixelX) {
      setPixelClipSpace(
        canvas,
        x,
        Math.sin(x * 15 + t) / 3.0,
        [255, 125, 0, 255]
      );
    }
    canvas.renderFrame();

    t += 0.1;
    requestAnimationFrame(loop);
  };
  loop();
}
main();

9. Refactor part 2 - use canvas class to clean up main function

10. Move canvas class into its own file

LINK TO SECTION 1 PART 1 HERE