Why and how I made a 3D terrain renderer with React + the DOM

Saman Bemel Benrud • Dec 06 2024

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.

But first: why???

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.

How does it work?

The terrain renderer involves three steps:

  1. First, a height map is generated with a noise function and some smoothing to make it feel natural (see generateNaturalData.ts).
  2. The height map is processed to encode neighbor relationships (e.g., higher, lower, or flat) into a binary ‘signature.’ These signatures guide how ramps and slopes should be rendered for smooth transitions between tiles. (see processData.ts). Below are my notes as I worked through this step: ramp drawings
  3. Finally, these signatures are used to render tiles, using divs and CSS transforms and a bit of trigonometry (see Tile.tsx). Here are my notes again: shaky trigonometry

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.

Limitations

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.