I just finished polishing this DOM-based 3D terrain renderer and figured now would be a good time to write about it. Click or use arrow keys to move around, and click-and-drag to rotate:
github.com/samanpwbb/terrain-experiment ↗
If you look closely, it is entirely made of divs:
Curious how it works? Read on.
A couple years ago I released Wilderplace, a game that uses React & the DOM for rendering. I overcame all sorts of problems: performance limits, sprite sheet size limits, layer compositing bugs, implementing frame-by-frame animation in a functional reactive codebase. In the end I released a game that doesn’t feel like it was made with a bunch of divs.
There was one problem that I did not overcome: I tried building true 3D into the game, but struggled with the math. Here’s a page from my notes:
My desperate note from 2019
I hit a wall when faced with pretty basic trigonometry! The kind I learned in high school and have since forgotten.
Post-Wilderplace, I had a new mission: try to build up some graphics programming fundamentals, and learn trigonometry properly. Next time I need to draw a ramp, I wanted to know how to do it. So, I built this terrain renderer.
The terrain renderer involves three steps:
In addition to the individual tile transforms, the entire terrain can be dragged around in 3D space. There is a parent element with a CSS transform like rotateX(var(--base-x)) rotateZ(var(--base-z))
. The base-x
and base-z
CSS variables get updated in the drag handler, which move the scene up and down, and left and right respectively. This requires setting transform-style: 'preserve-3d'
on the parent element.
This is a cool demo, but there are limitations that make it impractical.
For one, performance is predictably pretty bad, especially as tile count increases. Still, I wouldn’t hesitate to ship a Steam game that used this renderer. A lot can be done with a few dozen tiles and I imagine there is a lot optimizing yet to be done.
There was one big problem that I just couldn’t work out. Tile seams. Look closely:
Lots of seams. Seams are especially prominent where clip-path is used to slice divs intro triangles. Maybe my math is slightly off somewhere. More likely, this is a result of subpixel rounding and anti-aliasing quirks in browser renderers. I tried a few hacky approaches like scaling everything up and back down, or adding a slight outline or box shadow, but nothing looked good.
The approach I landed on was to use an SVG filter. The filter type that fixes seams is <feMorphology> with operator="dilate"
. But just applying dilate looked strange, so I added a pixel effect as well:
It works great in Chrome and I love the final result. In Firefox, performance is tanked by the filter, and Safari fails to render the filter at all. Good enough for a demo or a Steam game made with Electron.
Play with the final demo, or check out terrain-experiment on GitHub! If you have any questions or comments, I’m on bluesky and mastodon.