the laundry

Software rendering in JavaScript for fun and no profit

I was thinking about writing a post on this topic for a while now as I'm very far from an expert in computer graphics and the resulting code is quite messy, littered with needless repetition and hardcoded magic numbers. In the end though, I learned a lot and there's hardly a better way to cement the knowledge than sharing it with others. Here goes hoping that this write-up is useful to someone, but even if it isn't, I'll be glad to have something to come back to if I ever decide to revisit the project.

Background

Some time ago, a streamer whose transmissions and Discord server I frequent (LCOLONQ) spoke about SOUPJAM2026 – a jam where your goal was to make a SOUP-like, a game resembling, to quote the jam's page, “an obscure Japanese minimalist art-game from 2007 where you walk around a series of small abstract rooms and generally soak in the atmosphere”. I was interested in exploring software rendering for a while now at that point, so I seized the opportunity and made that event my excuse to do just that. After thinking about which platform to use, I eventually settled on JavaScript. Its Canvas API methods getImageData and putImageData proved to be a perfect fit for someone who wanted to focus on the rendering rather than trying to wrangle various bits and pieces to ensure the code was cross-platform.

Eventually, after I reached the dreaded point where I would have to make assets for my SOUP, I realized that I didn't really want to make a SOUP-like – I wanted to make a software renderer capable of playing a SOUP-like and every step of the journey was leading up to that goal. The code is open source and available both on Codeberg and GitHub.

Finally, I didn't document the process throughout, so treat this post as a retrospective rather than a journal; I fucked around a lot and what I eventually found out is immortalized in Git history as commit hash 174a4412b5. Some details are gonna be intentionally omitted for brevity and there may have been more work on the project by the time you're reading this.

Setup

As mentioned above, we can use the Canvas API methods for our rendering needs, but we still need a canvas to access them! This is roughly how I went about setting one up. In index.html:

<canvas id="rosol" style="image-rendering: pixelated;"></canvas>

And in JavaScript:

const setUpViewport = () => {
  const w = Math.min(Math.max(window.innerWidth, 640), 1024);
  const h = (3 * w) / 4;

  state.canvas.width = w * state.scale;
  state.canvas.height = h * state.scale;

  state.canvas.style.width = w + "px";
  state.canvas.style.width = h + "px";

  state.ctx = state.canvas.getContext("2d");
  state.screenBuffer = state.ctx.createImageData(w * state.scale, h * state.scale);
};

state.canvas = document.getElementById("rosol");
window.addEventListener("resize", setUpViewport);
setUpViewport();

Now there's a bunch of things happening here. Starting with image-rendering: pixelated;, it ensures that whenever the canvas is upscaled, the rendering engine doesn't try to anti-alias whatever we put onto it. Next, setUpViewport fires once when the code is loaded and then whenever the browser window is resized, ensuring our canvas and the buffer get resized too. The first two lines clamp the size to be between 640x480 and 1024x768. Everything in the state object is global; I shoved a bunch of things there as it's way more convenient for small projects than passing the state around. state.scale might be of interest – throughout the project I changed its value a few times and eventually settled on 0.5, so the effective resolution the renderer targets is between 320x240 and 512x384. It's low enough to be decently performant on most semi-modern computers while also looking perfectly all right.

Game (render?) loop

When I first set out on this project, I vaguely remembered requestAnimationFrame, but haven't used it before, so I did some research first and stumbled upon Aleksandr's blog post which provided me with everything needed to set up a game loop the correct way. The boilerplate is quite simple:

const frame = (currentTime) => {
  const delta = currentTime - state.previousFrameTime;
  if (delta >= state.targetFrameTime) {
    update(currentTime, delta / 1000);
  }

  draw();
  state.ctx.putImageData(state.screenBuffer, 0, 0);

  requestAnimationFrame(frame);
}

frame(0);

We kick the renderer off by calling frame(0);. Then, at the end of that function, we supply it as an argument to requestAnimationFrame and it gets called during the next rendering opportunity. Following the advice from the aforementioned post, we draw our scene as often as the browser's engine allows us, but we cap the updates to some frames per second with the if (delta >= state.targetFrameTime) check. If you investigate the code, you'll see that state.targetFrameTime is simply hardcoded as 1000 / 60, meaning the renderer aims to run at 60 FPS. One notable difference from how game loops are done in other engines and frameworks is that requestAnimationFrame supplies its callback with the total amount of time passed since we started executing rather than the delta between the current frame and the previous one. That's a trivial fix, however – you'll see later that we update state.previousFrameTime in, well, update, and use that to get the delta.

Drawing pixels

Earlier in setUpViewport, you probably saw the assignment to state.screenBuffer. That buffer is what we care about the most for this use case and what we put onto the screen as shown in the previous section. The code for putting arbitrarily colored pixels at arbitrary coordinates is delightfully simple:

const drawPixel = (x, y, color) => {
  if (x < 0 || x >= state.width || y < 0 || y >= state.height) return;

  let offset = (x + state.screenBuffer.width * y) * 4;
  state.screenBuffer.data[offset++] = color.r;
  state.screenBuffer.data[offset++] = color.g;
  state.screenBuffer.data[offset++] = color.b;
  state.screenBuffer.data[offset++] = 255;
}

Some bound checking at the beginning is convenient when we use this function while rendering triangles since they might start off completely visible on the screen, but after we're done transforming, projecting, and whatnot, they might lie outside it. In that case, we simply return, no point in processing a pixel that wouldn't be shown on the screen. Then, an offset is calculated. This is due to the fact that a buffer obtained from the canvas is not a two-dimensional array, but rather a contiguous (I somehow wrote this word correctly first try) block where every pixel is stored as 4 values in the RGBA format. After the calculation, we just need to overwrite the data at the correct spot and our pixels are neatly shown on the screen after the next state.ctx.putImageData(...) call.

Actually rendering things

I pondered about which direction to take this post in. The title promises software rendering, then at the beginning I mention that it's gonna be a reference for the future me, and finally I tell you how to set up a game loop in JavaScript before explaining how to put our simplest primitive (a pixel) onto the screen. The thing is, there are plenty of resources that are much higher quality regarding how to actually render the triangles that make up our scene. I could attempt an explanation of the whole process, but some more math-y bits are beyond me at the moment! Hence, I decided to detail some of the SOUP-specific things while pointing to superior resources when necessary. With that explained, let's preach!

Dmitry's tinyrenderer was my initial approach which ended up not being suitable due to its focus on rendering a single frame rather than being used in a real-time engine. Despite that, I learned a lot from it and I especially appreciated how much of the math the author works out from first principles rather than throwing black boxes at you.

javidx9's YouTube series formed the rendering foundation of the entire project after the first attempt. Contrary to the previous course, javidx9 glosses over some details, but rarely so, and the whole thing is extremely high quality. Most things in my project pertaining to actually rendering triangles were learned from these videos.

Finally, Computer Graphics from Scratch and Scratchapixel are two resources I mostly used as references, but I saw them recommended online many times over while working on the renderer and what I did gleam for them was very informative, so they make the cut!

Billboarding

With that out of the way, let's continue.

SOUP is a very simple game. You walk around a series of cube-shaped rooms; a number of rooms comprises a night, and there's a handful of nights, that's the gist. There's no inherent goal other than to explore and discover the artwork making up the experience. One other thing stands out – ART (capitalization intentional). In Bapalon, which is an engine for creating SOUP-likes even without a coding background (and it's also what served me as a reference while implementing various features), ART is the name of the file containing the texture to be shown in the middle of a given room. It uses a technique called "billboarding" and only while working on this renderer did I learn that it has such a simple name. You might remember it from old games, where trees and other objects would always face the camera no matter where you were standing. Since implementing it wasn't covered by the resources I mentioned above, my solution might not be the best way to do it, but it worked well enough for my case and I'm actually quite proud of figuring it out mostly from scratch. Anyway, enough yapping, here's the code:

const center = new Vec3D(0, 0, 0);

const cameraOntoXz = { ...state.camera, y: 0 };
const centerToCamera = normalizeVec(subVectors(cameraOntoXz, center));

let angle = Math.acos(vecDotProduct(normal, centerToCamera)) + Math.PI;
// https://stackoverflow.com/a/2150475
if (normal.x * centerToCamera.z - normal.z * centerToCamera.x < 0)  {
    angle = -angle;
}
const rot = makeRotMatY(angle);

transformedTriangle.vertices = transformedTriangle.vertices.map(
  (vertex) => multiplyVecByMat(vertex, rot)
);

As you can see, I use a variable called normal in this snippet. Normals are normalized (i.e. of length 1) vectors perpendicular to the vertex/surface. I calculate them beforehand in the rendering pipeline as they're used for a bunch of different things in computer graphics and in my case – for determining which triangles are facing the camera and therefore should be drawn.

Knowing that I have such a vector at the ready, I figured I can calculate another vector from the triangle's (the one that's to be a billboard) center to the camera, compute the angle between it and the normal, and rotate it by the result.

And that's exactly what the code above does. First I create a vector representing a point in the center of the scene (as that's where ART is always going to be located). Then, I project the camera vector onto the y = 0 plane – I'm only interested in the rotation around the y axis, so the height information is not needed. Afterwards, calculate a vector from the projected camera to the center with a simple subtraction. Note that this too needs to be normalized, otherwise the calculations won't work when pairing it with the normal.

As you can see from the comment, the next few bits were heavily aided by Stack Overflow. I was too excited about continuing to write the renderer to properly sit down and understand how to derive the math necessary to get the angle I was interested in. Finally though, after it's been calculated, we make a rotation matrix around the y axis and multiply our triangle's vertices by that.

Billboarding 2: Electric Boogaloo

While the above approach is completely fine for ART that's always at the origin, I eventually needed to rework it slightly. Another feature of a SOUP-like is the so-called “MINI”, a tiny sprite wandering around the room. After inspecting it in Bapalon, I noticed that it's also billboarded (billboarding? I don't know!).

As a reader who's better at math (or at computer graphics) might notice, the “wandering around” part is the problem. Not only am I assuming the center of the sprite is at the origin, rotation matrices also assume rotation around the origin. After a bunch of aimless trial and error due to forgetting the latter property, I ended up with the following:

const center = new Vec3d(
  (transformedTriangle.vertices[0].x + transformedTriangle.vertices[2].x) / 2,
  0,
  (transformedTriangle.vertices[0].z + transformedTriangle.vertices[2].z) / 2,
);

// Snip snip...

transformedTriangle.vertices = transformedTriangle.vertices.map(
  (vertex) => {
    vertex = subVectors(vertex, center);
    vertex = multiplyVecByMat(vertex, rot);
    vertex = sumVectors(vertex, center);
    return vertex;
  }
);

This code as is might not work for everybody since it's highly dependent on the order of your vertices (in the center calculation), but it was perfectly fine for my use case. The second part, while simple in hindsight, was a huge revelation after I learned that's how you rotate around a point different than origin. Offset the vertex by the vector representing its center (therefore moving the origin to the original position of your vertex), then perform the rotation as normal, and then offset the vertex back to get your rotated point.

Alpha compositing

There's another aspect contributing to SOUP's enticingly abstract aesthetic and Bapalon calls it “LAYER”. In each room, you can optionally add such a file and it's overlaid on top of your screen. It's amazing how creative one can get with such a simple tool, seriously, go check out Bapalon's sample rooms! It meant I had some more work to do, however, because while the overlaying itself was a matter of reusing the code I was using for texture interpolation and therefore no problem, you might recall that when drawing pixels, we always set the alpha channel to 255. Some LAYERs are indeed fully opaque, but others aren't, so I needed to learn how to draw them when there's already something else on the screen. This led me to Bartosz's post. Excuse another tangent, but I sincerely recommend checking out his other stuff too. I already knew about his blog as I've read the post about the Moon and it's clear that he poured an insane amount of care into it; every post that I've checked is just as high quality. Anyway, Bartosz indirectly taught me how to do alpha compositing (also covering use cases much more advanced than mine), and the code that follows is straightforward:

let outR = color.r;
let outG = color.g;
let outB = color.b;

if (withAlphaCompositing) {
  const srcR = color.r / 255,
    srcG = color.g / 255,
    srcB = color.b / 255,
    srcA = color.a / 255;
  const destR = state.screenBuffer.data[offset + 0] / 255,
    destG = state.screenBuffer.data[offset + 1] / 255,
    destB = state.screenBuffer.data[offset + 2] / 255;

  outR = (srcR * srcA + destR * (1 - srcA)) * 255;
  outG = (srcG * srcA + destG * (1 - srcA)) * 255;
  outB = (srcB * srcA + destB * (1 - srcA)) * 255;
}

The only real oddity is the normalization to a [0,1] range first and then back to [0,255]. It's likely possible to avoid it by doing 255 - srcA, but I recall trying that and it not working for whatever reason. It's not like this code is very “hot” anyway, it's just one overlay on top of a rather small viewport, so I didn't fret too much about it. The formula is taken straight from Bartosz's post above and I imagine he's gonna do a much better job explaining it than I ever could!

Conclusion

I'm aware that reading this is quite a disjointed ride with me jumping from topic to topic, but I hope the resources I linked are enough to learn and research whatever I have omitted. If you're curious to see the finished (for some definitions of “finished”) project, take a look at the demo included in the repository. Oh, and yes, I do painstakingly put my &ldquo;s (“), &rdquo;s (”), and &ndash;es (–) while writing these posts, I don't use LLMs in any capacity to the point where I actively try to ignore the AI summary Google gives me whenever DuckDuckGo is not sufficient for a search. It's a weird sentence to top off a post on something completely unrelated, but if it makes me seem at least a little bit more human, then so be it. Alright, ok, thanks, bye!