SVG as a Rendering Target: What heerich.js Gets Right About Voxels Without WebGL
Source: lobsters
There is a recurring pattern in creative coding: someone picks a constrained output format and builds something that has no business looking as good as it does. heerich.js fits that pattern exactly. It is a tiny JavaScript engine that takes a 3D voxel scene description and renders it to SVG. No WebGL context, no canvas, no GPU pipeline. Just vector paths that your browser’s SVG renderer handles like any other markup.
At first glance that sounds like a toy. SVG for 3D rendering is the kind of idea you might dismiss after two seconds of thinking about draw calls. But spend a few more seconds and the trade-offs start to look deliberate rather than naïve.
The Geometry Problem SVG Voxels Have to Solve
A voxel scene is conceptually simple: a grid of cubes, each at some (x, y, z) coordinate, each potentially a different color or material. The challenge is projecting that grid into 2D while preserving the illusion of depth. The standard approach for this kind of renderer is isometric projection, which maps a 3D coordinate to a flat plane without perspective foreshortening.
The math is straightforward. For a voxel at grid position (x, y, z), the screen coordinates are:
screen_x = (x - y) * (tileWidth / 2)
screen_y = (x + y) * (tileHeight / 2) - z * voxelHeight
With typical tile dimensions of 64×32 pixels, that becomes:
const toScreen = (x, y, z, tw = 64, th = 32, vh = 32) => ({
sx: (x - y) * (tw / 2),
sy: (x + y) * (th / 2) - z * vh
});
Each cube exposes at most three faces: the top, the left side, and the right side. In an isometric view from the upper-right, you never see the bottom, back, or front. So for each visible voxel you generate three SVG polygons, each a parallelogram or rhombus defined by four screen-space points. The top face is a rhombus; the two side faces are parallelograms that share edges with the top.
For a voxel at (x, y, z), the six corners in screen space are:
const corners = {
top: toScreen(x, y, z+1),
right: toScreen(x+1, y, z+1),
front: toScreen(x+1, y+1, z+1),
left: toScreen(x, y+1, z+1),
br: toScreen(x+1, y+1, z),
bl: toScreen(x, y+1, z)
};
const topFace = [corners.top, corners.right, corners.front, corners.left];
const rightFace = [corners.right, corners.front, corners.br, toScreen(x+1, y, z)];
const leftFace = [corners.front, corners.left, corners.bl, corners.br];
That is all the geometry. The rest of the renderer’s job is ordering.
The Painter’s Algorithm and Why Sorting Is the Hard Part
SVG has no depth buffer. Elements render in document order, so whatever appears later in the DOM occludes whatever appeared earlier. To get correct overlap, you have to sort your voxels and emit them back-to-front. This is the painter’s algorithm, named after the way oil painters start with background and work forward.
For isometric voxel scenes the sort key is usually x + y + z or x + y - z depending on your coordinate conventions and camera angle. Voxels with a lower sum are further from the viewer and should be drawn first:
voxels.sort((a, b) => (a.x + a.y + a.z) - (b.x + b.y + b.z));
This works reliably for axis-aligned voxels with no partial transparency. The painter’s algorithm breaks down in two cases: cyclic overlaps (three objects each partially in front of the others) and transparent surfaces. Both of these are genuinely hard. BSP trees and per-pixel depth testing exist to handle them properly, but for opaque voxels in a grid the simple depth sort is correct.
The reason this matters for an SVG renderer specifically is that you cannot cheat. With a canvas or WebGL renderer you can enable depth testing and forget about ordering entirely; the GPU resolves it per-fragment. With SVG you are committed to getting the sort right in advance, because there is no mechanism to revise it during rasterization.
Why SVG at All
Once you accept that sorting is solvable, the question is what you get from SVG that you could not get from canvas or WebGL.
The most immediate answer is that SVG output is resolution-independent. A voxel scene rendered to SVG looks crisp at any display density, including high-DPI screens that would require doubling the canvas pixel buffer. This also means the output scales cleanly for print.
The more interesting answer is that SVG elements are DOM nodes. Every face of every voxel is a <polygon> or <path> you can target with CSS, animate with GSAP or the Web Animations API, bind to pointer events, or inspect in DevTools. You can hover over a specific face, add a CSS transition to color changes, or drive animations by updating element attributes rather than re-rendering a frame. This is a fundamentally different interaction model from canvas, where the pixels are opaque and you have to implement hit detection manually.
Consider a simple hover effect. In canvas you would implement it by tracking mouse coordinates, running inverse projection to find the voxel under the cursor, redrawing the scene with the target voxel highlighted. In SVG:
.voxel-face:hover {
fill: oklch(from var(--base-color) calc(l + 0.15) c h);
}
That CSS rule requires zero JavaScript and zero re-rendering. The browser handles the event routing and the style update. The same logic applies to animations, transitions, and ARIA attributes.
SVG also serializes cleanly to a string. A canvas frame is pixel data; an SVG scene is text. You can write the output to a file, embed it in HTML, open it in Inkscape, drop it into a design tool, or generate it server-side with Node.js and serve it as a static asset.
Prior Art and Where heerich.js Sits
Isometric SVG rendering has a modest but real history in JavaScript. isomer.js by Jordan Scales is probably the most referenced prior work. It renders isometric shapes to canvas using a similar projection and painter’s algorithm, but canvas rather than SVG. The API is clean and the results are good, but you lose the DOM benefits.
voxel.js was a more ambitious project from 2013-era JavaScript, built around three.js and WebGL. It was designed for game-like scenes with real-time updates and physics. That is a very different problem than heerich.js is solving.
For pure SVG isometric rendering, iso.js covers similar ground. There are also several CodePen experiments and Observable notebooks exploring the same projection.
heerich.js appears to be specifically optimized for conciseness and self-containment. The “tiny” in its description is doing real work: a small dependency footprint matters if you want to embed it in a notebook, a generative art sketch, or a web component without pulling in a build pipeline.
Performance Characteristics
SVG performance degrades with DOM size. Browsers have improved SVG rendering substantially over the past decade, but adding thousands of polygon elements still creates layout and paint overhead that a canvas renderer would avoid. For a scene with a few hundred voxels this is fine. For a Minecraft-scale world it is not.
The practical ceiling for an SVG voxel renderer is roughly in the hundreds to low thousands of visible faces before frame rates become an issue on mid-range hardware. This is not a limitation so much as a scope definition. heerich.js is not competing with game engines. It is competing with static images and canvas-based creative coding tools, and against those it has a real case.
One optimization that SVG-based renderers can apply is culling: if a voxel is entirely occluded by the voxels in front of it, do not emit its SVG elements at all. For dense scenes this can dramatically reduce DOM size. The check requires knowing whether any face of a voxel is visible from the current camera angle, which for isometric projection with opaque voxels reduces to checking the six neighboring grid positions.
const isVisible = (grid, x, y, z) => {
// In isometric view, a voxel contributes visible faces only if
// at least one of its three exposed sides is not blocked
return !grid.has(`${x},${y+1},${z}`) || // left face exposed
!grid.has(`${x+1},${y},${z}`) || // right face exposed
!grid.has(`${x},${y},${z+1}`); // top face exposed
};
This kind of occlusion culling is straightforward for axis-aligned voxel grids and can cut the element count substantially for anything resembling a solid object.
Lighting and Color
One of the most effective tricks in isometric voxel rendering is ambient occlusion simulation through face shading. The three visible faces of each voxel get slightly different lightness values: the top face is brightest (facing the light source directly), the right face is mid-tone, and the left face is darkest. This three-value shading is not physically based, but it reliably reads as three-dimensional to human perception.
In SVG you can express this as CSS custom properties per face class:
.face-top { fill: oklch(65% 0.18 var(--hue)); }
.face-right { fill: oklch(50% 0.15 var(--hue)); }
.face-left { fill: oklch(38% 0.12 var(--hue)); }
The perceptually uniform lightness in oklch means the shading steps look consistent across different hues, which is a subtle improvement over older HSL-based approaches where some hues appear to jump more than others at the same lightness delta.
When to Reach for This
A renderer like heerich.js is well-suited to a specific set of problems: generative art that needs to export cleanly, interactive diagrams where individual voxels respond to user input, server-side rendering of isometric scenes, and embedded visualizations in content-heavy pages where a full canvas setup would be overhead.
It is less suited to animation-heavy scenes, large worlds, or any case where you need real-time updates to hundreds of voxels simultaneously. For those use cases a canvas or WebGL renderer is the right tool.
What makes the project interesting is the constraint itself. Choosing SVG as the output format is a design decision that shapes everything downstream: the API surface, the performance envelope, the interaction model, and the kinds of effects that are easy versus hard. A tiny library that commits fully to its output format and solves the ordering problem correctly is more useful than a larger one that hedges.
The painter’s algorithm is old. Isometric projection is older. SVG is not new. heerich.js is interesting precisely because it combines these well-understood pieces into something small enough to read in an afternoon and useful enough to actually deploy.