DEV Community

Cover image for How I Built a Graphics Renderer in Node.js
Sk
Sk

Posted on

How I Built a Graphics Renderer in Node.js

Anything remotely intensive in Node.js that needs visuals: tracing, telemetry, profiling, you basically have no choice but to depend on the browser.

And the browser, while great, idles at around ~2GB of memory minimum.
Not to mention constant alt-tabbing.

But why?

Node.js is just as much C++ as the next browser. There’s nothing stopping it from rendering things.

I remember trying to profile a real-time system once, it was awful. Constant context switching to the browser, memory exploding, losing flow. It felt wrong.

So I built a software renderer, you know the thing responsible for everything visual you see in the digital world, from UIs to games.
It’s backed by raylib, C++, and shaders and it’s fast.

For example, this animated noise demo:

tessera.js

800 × 600 = 480,000 pixels
Per pixel: 2 trig calls
That’s ~960,000 trig calls per frame <- very expensive

Unoptimized, it still clocks around 90 FPS.

Threaded, it jumps to about 200 FPS.

tessera.js

I’m also working on my own real-time profiler, shard.js, using the same renderer

shard.js

All of this is possible because of one simple idea:

the framebuffer.

Once you understand that, everything clicks.


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 get 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 to this linear memory 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 the framebuffer.

If you understand that, you now understand graphics programming 101.

So what does this have to do with Node.js and my renderer?


Thinking About Node.js

Here’s a rough mental model for Node.js:

average Node.js usage -> via JavaScript
 -------------------------------------
|                                    |
      v8 / bytecode
      whole world of C++
Enter fullscreen mode Exit fullscreen mode

So the question becomes:

What if I put a buffer between the world of C++ and JavaScript?

Because that’s literally all you need to paint the screen.

This isn’t new, it’s a classic systems technique: shared memory.
And Node.js gives us exactly that:

const buf = new SharedArrayBuffer(size);
Enter fullscreen mode Exit fullscreen mode

Threads use this all the time for zero-copy access. It’s blazingly fast, but also easy to mess up. Shared memory bugs can get nasty fast.

So I built a controlled version of that idea.

This is roughly how the core of tessera.js works.


How Tessera Works

import { PixelBuffer, loadRenderer } from "tessera.js"; // raw memory
const { Renderer, FULLSCREEN, RESIZABLE } = loadRenderer();

const renderer = new Renderer();
renderer.initialize(1920, 910, "Renderer");
const canvas = new PixelBuffer(renderer, 1200, 800);
Enter fullscreen mode Exit fullscreen mode

Under the hood:

  • C++ creates two buffers (double buffering): a read buffer and a write buffer
  • The read buffer is what’s currently on screen
  • C++ creates a GPU texture and links it to that buffer
  • JavaScript requests the current read buffer (raw memory, insanely fast)

From there, JavaScript just writes pixels:

const data = canvas.data;
const tracker = new DirtyRegionTracker(canvas);

// random star-like noise (direct memory access)
for (let i = 0; i < 2000; i++) {
    const x = Math.floor(Math.random() * width);
    const y = Math.floor(Math.random() * height);
    const brightness = 100 + Math.random() * 155;
    const idx = (y * width + x) * 4;

    data[idx]     = brightness;
    data[idx + 1] = brightness;
    data[idx + 2] = brightness;
    data[idx + 3] = 255;

    tracker.mark(x, y);
}
Enter fullscreen mode Exit fullscreen mode

Then you notify C++ only about what changed:

tracker.flush(); // send dirty regions only
Enter fullscreen mode Exit fullscreen mode

And issue one cheap command to update the GPU texture:

canvas.upload(); // very cheap
Enter fullscreen mode Exit fullscreen mode

The screen updates.

While JavaScript is mutating memory, C++ keeps rendering the last valid texture.
Both worlds run in tandem.

What makes this powerful is raw memory.

Raw memory is absurdly fast in Node.js. And because canvas.data is a SharedArrayBuffer, we can write to it from multiple threads.

canvas.data // SharedArrayBuffer
Enter fullscreen mode Exit fullscreen mode

That’s how the animated noise example, which is insanely expensive, jumps from ~80 FPS to ~200 FPS when threaded.


You can check out the tessera.js repo for more examples and docs.

Below is a full, minimal demo you can run locally.


Demo

npm i tessera.js
Enter fullscreen mode Exit fullscreen mode

Your js file:

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

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

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);
canvas.clear(1, 1, 1, 255);

const data = canvas.data;
const width = canvas.width;
const height = canvas.height;
const tracker = new DirtyRegionTracker(canvas);

// random star noise
for (let i = 0; i < 2000; i++) {
    const x = Math.floor(Math.random() * width);
    const y = Math.floor(Math.random() * height);
    const brightness = 100 + Math.random() * 155;
    const idx = (y * width + x) * 4;

    data[idx]     = brightness;
    data[idx + 1] = brightness;
    data[idx + 2] = brightness;
    data[idx + 3] = 255;

    tracker.mark(x, y);
}

tracker.flush();
canvas.upload();

renderer.onRender(() => {
    renderer.clear({ r: 0, g: 0, b: 0, a: 50 });
    canvas.draw(0, 0);

    renderer.drawText(
        `FPS: ${renderer.FPS} | Buffer: ${canvas.width}x${canvas.height} | Memory: ${(canvas.width * canvas.height * 4 / 1024).toFixed(1)}KB`,
        { x: 20, y: 750 },
        16,
        { r: 1, g: 1, b: 1, a: 1 }
    );
});

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

    if (renderer.step()) {
        setImmediate(Loop);
    } else {
        renderer.shutdown();
    }
}

Loop();

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

Here's an optimized version that maintains your casual, direct tone:


More from me:

Visualizing Evolutionary Algorithms in Node.js

tessera.js repo

Thanks for reading!

Find me here:

Top comments (0)