DEV Community

Cover image for Graphics Programming 101 in Node.js (Yes, It’s Actually Possible).
Sk
Sk

Posted on

Graphics Programming 101 in Node.js (Yes, It’s Actually Possible).

Graphics programming, besides being both intellectually and visually stimulating, is the nucleus of literally everything you see and experience on a screen.

From visual effects (VFX)

vfx

to particle systems

particles

to image detection/editing/engineering

Convolutions

to the rendering pipeline in that game engine, the beautiful UI, and much more.

But all of it stems from one simple fact.

If you take a screen:

_______________
|             |
|             |
|             |   <- screen (it’s 2D)
|             |
|_____________|
Enter fullscreen mode Exit fullscreen mode

and divide it into rows and columns:

|------------|
|------------|
|------------|
|------------|

     ^
     |
     imagine columns here
Enter fullscreen mode Exit fullscreen mode

then unwrap it into rows only:

row 1      row 2      .... row n
--------  --------     --------
--------  --------     --------
Enter fullscreen mode Exit fullscreen mode

You end up with an array.

But not a typical array.

It’s an array of bytes - contiguous memory, a raw chunk of memory called a buffer.

In Node.js, it looks like this:

const buf = Buffer.alloc(1024); // <- raw memory
Enter fullscreen mode Exit fullscreen mode

Now take that buffer and fill it with numbers between 0 and 255, using this simple formula:

(y * width + x) * 4; // 4 channels: r, g, b, a
Enter fullscreen mode Exit fullscreen mode

This maps screen coordinates (x, y), with (0, 0) being the top-left of the screen, directly into the buffer.

What you get are pixels:

[ R, G, B, A, R, G, B, A, R, G, B, A, ... ]  // Linear array
  ˄                   ˄                   ˄
Pixel 0            Pixel 1            Pixel 2
(x=0,y=0)         (x=1,y=0)          (x=2,y=0)
Enter fullscreen mode Exit fullscreen mode

Pixels give you an image.

Do that 60 times per second (FPS), or more, and you get an animated image.

That buffer is called a framebuffer.

That’s graphics programming 101.


To make this concrete, I put together a few examples below.

I extracted a renderer from my Node.js game engine into a standalone package
(yes, it’s possible - with a little C++ magic):

tessera.js
How I built A Graphics Renderer For node.js

That’s what we’ll use for the rest of this article.
All you need is Node.js, preferably a newer LTS version.


Installation

Create a new npm project and install the renderer:

npm i tessera.js
Enter fullscreen mode Exit fullscreen mode

Set up a simple render loop:

import { loadRenderer, PixelBuffer, DirtyRegionTracker } from "tessera.js";

const { Renderer, FULLSCREEN, RESIZABLE } = loadRenderer();
const renderer = new Renderer();

// width, height, title
if (!renderer.initialize(1920, 910, "Renderer")) {
    console.error("Failed to initialize renderer");
    process.exit(1);
}

renderer.setWindowState(RESIZABLE);
renderer.targetFPS = 60;

const canvas = new PixelBuffer(renderer, 650, 400); // framebuffer: every pixel lives here
canvas.clear(50, 1, 1, 255);

renderer.onRender(() => {
    // C++ calls you here before rendering, this is where you "draw"
    canvas.draw(0, 0); // top-left
});

function Loop() {
    renderer.input.GetInput(); // keyboard / mouse state
    // update would happen here
    if (renderer.step()) { // calls the render callback and draws to screen
        setImmediate(Loop);
    } else {
        console.log("loop ended");
        renderer.shutdown();
    }
}

Loop(); // non-blocking: step runs in C++, not JS

process.on("SIGINT", () => {
    console.log("\nshutting down gracefully...");
    renderer.shutdown();
    process.exit(0);
});
Enter fullscreen mode Exit fullscreen mode

All your rendering logic lives here:

renderer.onRender(() => {
});
Enter fullscreen mode Exit fullscreen mode

Let’s draw something simple, a diagonal line from the top-left corner:

const data = canvas.data; // "pointer" to the framebuffer
const tracker = new DirtyRegionTracker(canvas); // update only touched pixels

for (let i = 0; i <= canvas.width; i++) {
    const idx = (i * canvas.width + i) * 4;

    data[idx]     = 255; // r
    data[idx + 1] = 10;  // g
    data[idx + 2] = 20;  // b
    data[idx + 3] = 255; // a

    tracker.mark(i, i + 4);
}

tracker.flush();   // apply changes
canvas.upload();   // send to GPU

renderer.onRender(() => {
    canvas.draw(0, 0);
});
Enter fullscreen mode Exit fullscreen mode

A line is just a collection of points between two anchor points, a start and an end.

If you understood everything up to this point, you’ve already grasped the core of rasterization, turning data into pixels.

That’s true for all renderers, even 3D ones.

You’ll often see this quote about OpenGL:

The process of transforming 3D coordinates to 2D pixels is managed by the graphics pipeline of OpenGL.
The graphics pipeline can be divided into two large parts: the first transforms your 3D coordinates into 2D coordinates and the second part transforms the 2D coordinates into actual colored pixels.

And that second part?

That’s exactly what we just did.

but we can take it further.


Take the line example above.

If we draw four lines, two pairs of parallel ones, we get a stroked rectangle.

import { ShapeDrawer } from "tessera.js";

ShapeDrawer.strokeRect(
  canvas,
  100, 100,
  280, 100,
  5,          // thickness
  40, 45, 55, // r g b
  200         // a
);

canvas.upload(); // drawer utilities handle patching internally

renderer.onRender(() => {
  canvas.draw(0, 0);
});
Enter fullscreen mode Exit fullscreen mode

Under the hood, that’s really just four points connected by lines:

.----------.
|          |
|   line   |
|          |
.----------.
Enter fullscreen mode Exit fullscreen mode

Once you accept that, it’s easy to go further.

Instead of fixed rectangles, you can define a list of arbitrary points and weave a line through them, now you’re drawing polygons.


Polygons → UI

Here’s a simple button built from a rounded polygon:

import { ShapeDrawer, PolygonDrawer } from "tessera.js";

class CreateButton {
  constructor(x, y, width, height, borderRadius, color = { r: 40, g: 45, b: 55, a: 200 }) {
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
    this.borderRadius = borderRadius;
    this.color = color;
    this.isHovered = false;
  }

  contains(px, py) {
    return (
      px >= this.x && px < this.x + this.width &&
      py >= this.y && py < this.y + this.height
    );
  }

  draw(canvas) {
    const points = PolygonDrawer.createRoundedRect(
      this.x,
      this.y,
      this.width,
      this.height,
      this.borderRadius
    );

    PolygonDrawer.fillPolygon(
      canvas,
      points,
      this.color.r,
      this.color.g,
      this.color.b,
      255
    );

    // Border
    PolygonDrawer.strokePolygon(
      canvas,
      points,
      2,
      this.isHovered ? 120 : 80,
      this.isHovered ? 140 : 100,
      this.isHovered ? 160 : 120,
      255
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Mouse Interaction

Detecting hover is just geometry.

import { InputMap } from "tessera.js";

const im = new InputMap(renderer.input);

const btn = new CreateButton(200, 200, 100, 48, 7);
btn.draw(canvas);
canvas.upload();

function Loop() {
  renderer.input.GetInput();

  const mouse = im.mousePosition;
  btn.isHovered = btn.contains(mouse.x, mouse.y);

  if (btn.isHovered) {
    console.log("button hovered");
  }
}
Enter fullscreen mode Exit fullscreen mode

At this point, you’re already building UI from scratch.


The point is simple: everything graphical you’ll ever see starts with a framebuffer.
Lines, shapes, buttons - it’s all just pixels.

So what about animation?

Same idea. You don’t draw once, you draw many times, usually ~60 times per second.

Let’s build my favorite example: a radial particle system.


Particle System Example

function createParticleSystem() {
  const particles = [];
  const centerX = canvas.width / 2;
  const centerY = canvas.height / 2;

  for (let i = 0; i < 100; i++) {
    particles.push({
      x: centerX,
      y: centerY,
      vx: (Math.random() - 0.5) * 4,
      vy: (Math.random() - 0.5) * 4,
      life: 1.0,
      decay: 0.0005 + Math.random() * 0.01,
      color: {
        r: Math.floor(100 + Math.random() * 155),
        g: Math.floor(100 + Math.random() * 155),
        b: Math.floor(200 + Math.random() * 55),
      },
    });
  }

  return particles;
}

const particles = createParticleSystem();
Enter fullscreen mode Exit fullscreen mode

Bring Them to Life

function animate() {
  const tracker = new DirtyRegionTracker(canvas);

  particles.forEach(p => {
    // Integrate motion
    p.x += p.vx;
    p.y += p.vy;
    p.life -= p.decay;

    // Bounce off walls
    if (p.x <= 0 || p.x >= canvas.width - 1) {
      p.vx *= -0.8;
      p.x = Math.max(0, Math.min(canvas.width - 1, p.x));
    }
    if (p.y <= 0 || p.y >= canvas.height - 1) {
      p.vy *= -0.8;
      p.y = Math.max(0, Math.min(canvas.height - 1, p.y));
    }

    // Radial pull toward center
    const dx = canvas.width / 2 - p.x;
    const dy = canvas.height / 2 - p.y;
    const dist = Math.sqrt(dx * dx + dy * dy);

    if (dist > 0) {
      p.vx += dx * 0.0001;
      p.vy += dy * 0.0001;
    }

    // Draw particle
    if (p.life > 0) {
      const size = Math.floor(p.life * 3) + 1;

      for (let py = -size; py <= size; py++) {
        for (let px = -size; px <= size; px++) {
          if (px * px + py * py <= size * size) {
            const x = Math.floor(p.x + px);
            const y = Math.floor(p.y + py);

            if (x >= 0 && x < canvas.width && y >= 0 && y < canvas.height) {
              const idx = canvas.coordToIndex(x, y);
              canvas.data[idx + 0] = p.color.r;
              canvas.data[idx + 1] = p.color.g;
              canvas.data[idx + 2] = p.color.b;
              canvas.data[idx + 3] = 255;
              tracker.mark(x, y);
            }
          }
        }
      }
    } else {
      // Respawn
      p.x = canvas.width / 2;
      p.y = canvas.height / 2;
      p.vx = (Math.random() - 0.5) * 4;
      p.vy = (Math.random() - 0.5) * 4;
      p.life = 1.0;
    }
  });

  tracker.flush();
  canvas.upload();
}
Enter fullscreen mode Exit fullscreen mode

Call it from the loop:

function Loop() {
  canvas.clear(15, 15, 35, 255); // deep space blue
  animate();

  if (renderer.step()) {
    setImmediate(Loop);
  }
}
Enter fullscreen mode Exit fullscreen mode

The particles naturally gravitate toward the center.
Tweak velocity, decay, or gravity constants and you get wildly different behaviors.


What you just learned is rasterization - the final stage of the graphics pipeline.

It’s the same idea behind charts, UI toolkits, visualizers, and real-time systems like games.

Once you understand pixels and a framebuffer, everything else is just creativity.

tessera.js repo

More from me:

Visualizing Evolutionary Algorithms in Node.js

Thanks for reading!

Find me here:

Top comments (0)