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 “s (“), ”s (”), and –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!