---
title: "generic: a hand-written 3D engine in JavaScript, ported to the ESP8266"
url: https://michalasobczak.pl/posts/generic-3d-engine-in-javascript-ported-to-esp8266.html
author: "Michał Sobczak"
description: "A 3D renderer written from scratch in 2013 on a plain HTML canvas, with texturing, light, occlusion culling and collision detection, and its projection math later ported to Lua on an ESP8266 microcontroller. Vertices per second on an Intel Atom tablet vs. an 80/160 MHz chip."
published: 2026-10-25T10:00:00.000Z
updated: 2026-10-25T10:00:00.000Z
language: en
categories: ["Hobby"]
tags: ["3D", "Programming", "Hardware", "Smart Home & IoT"]
license: CC BY-NC-ND 4.0 (https://creativecommons.org/licenses/by-nc-nd/4.0/)
---
# generic: a hand-written 3D engine in JavaScript, ported to the ESP8266

> **Disclaimer:** the projects described here are the result of my own independent work, done without the use of AI. This article itself was written with the help of AI.

Most of my graphics experiments start from the same piece of code. [generic](https://github.com/michalasobczak/generic) is a small 3D engine I wrote in early 2013 for Android and Windows Phone browsers, without WebGL and without any 3D library. Everything, from rotation matrices to texture mapping, is plain JavaScript drawing on a 2D canvas.

In 2018 I cleaned it up, published it, and used it as the reference implementation for ports to the [Commodore 64](https://michalasobczak.pl/posts/commodore-64-restoration-and-3d-in-basic-c-and-assembly.html), an [x86 boot sector](https://michalasobczak.pl/posts/x86-bootloader-2d-and-3d-graphics-in-nasm.html) and the [ESP8266](https://github.com/michalasobczak/esp8266) microcontroller.

![Tiled map rendered by the generic JavaScript 3D engine](https://michalasobczak.pl/media/projects/generic-3d.png)

## What the engine does

| Module (`js/env/`) | Responsibility |
| --- | --- |
| `3d.js` | rotation matrices, 3D → 2D projection, matrix and trig caches |
| `camera.js` | camera position and rotation around a point |
| `model.js` | loading OBJ models, faces, transformations |
| `map.js`, `mapSector.js` | world made of square tiles |
| `occlusionCulling.js` | sorting models by distance (painter's algorithm) |
| `lightSource.js` | shading faces by distance from the camera |
| `collisionDetector.js` | simple collision checks |
| `elements/texture.js` | texturing faces from a BMP file |

Math vectors and matrices come from the Sylvester library, path finding from PathFinding.js, and the rest is my own code.

### Rotation with a cache

The rotation matrix is the product of three matrices around X, Y and Z. Computing it is expensive and the camera angle changes rarely, so the result is cached by angle, and the trigonometric functions go through their own cache (`getT`):

```javascript
getM = function(th) {
  var lookup = ""+th.x+th.y+th.z;
  var existingEntry = cacheM[lookup];
  if (existingEntry !== undefined) {
    return existingEntry;
  }
  var d1 = [
    1, 0,                0                  ,
    0, getT("cos",th.x), -1*getT("sin",th.x),
    0, getT("sin",th.x), getT("cos",th.x)   ];
  var d2 = [
    getT("cos",th.y),    0, getT("sin",th.y),
    0,                   1, 0               ,
    -1*getT("sin",th.y), 0, getT("cos",th.y)];
  var d3 = [
    getT("cos",th.z), -1*getT("sin",th.z), 0,
    getT("sin",th.z), getT("cos",th.z),    0,
    0,                0,                   1];
  var newEntry = matM(matM(d1,d2),d3);
  cacheM[lookup] = newEntry;
  return newEntry;
}
```

### Painter's algorithm and "light"

There is no z-buffer. `OcclusionCulling` keeps a list of models, updates each one's distance from the camera every frame and sorts the list from far to near, so closer faces are simply painted over farther ones.

Lighting is a trick, not a physical model: the face colour is brightened or darkened by a percentage that depends on the distance between the camera and the face centre. Results are cached per colour and percentage, because string ↔ hex conversion in JavaScript was surprisingly expensive on a 2013 phone:

```javascript
getFaceColor: function(faceCenter, requestedColor) {
  var distance = getDistance3(camera.position, faceCenter);
  return this.getColor(requestedColor, 20-(distance/15));
}
```

## Running it

Any static file server will do. With Ruby:

```bash
ruby -run -ehttpd . -p8000
```

Then open `http://localhost:8000/html/core.html` and move the camera with `a`/`d` and `w`/`s`. The first tile of the 10×10 map is textured.

## Porting the math to the ESP8266

The ESP8266 is a Wi-Fi chip with a 32-bit Tensilica L106 core, 80 KB of data RAM and a clock of 80 or 160 MHz. With the NodeMCU firmware it runs Lua, so I took the projection part of `generic` (rotation matrices, camera, perspective division) and rewrote it in Lua (`3d.lua`). There is no display: the chip computes projected vertices and the result goes to the serial console, which is enough to measure throughput.

Things worth knowing before you start:

-   Build the firmware with only the modules you need using the *NodeMCU custom builds* service. You can choose an integer or a float build; the port uses integers with a fixed scaling factor, so it works on both.
-   Flash it with `nodemcu-flasher`: firmware at `0x00000` and `esp_init_data_default.bin` at `0xfc000`, 115200 baud. The first boot formats the file system.
-   Upload Lua files with ESPlorer. If a script blocks the chip, erase the flash and start over:

```bash
python esptool.py -p COM3 -b 115200 erase_flash
```

`init.lua` measures free heap before and after the run:

```lua
print("START HEAP "..node.heap());
dofile("3d.lua")
print("FINISH HEAP "..node.heap());
```

The program starts with 41 424 bytes of free heap and ends with 24 792.

## Numbers

| Platform | Throughput |
| --- | --- |
| JavaScript, Opera 53, Intel Atom Z3736F tablet, textured tile on screen | 5 000–7 000 vertices/s |
| JavaScript, same tablet, no textured tile on screen | over 10 000 vertices/s |
| Lua on ESP8266 at 80 MHz | 300 vertices/s |
| Lua on ESP8266 at 160 MHz | 600 vertices/s |

On the ESP8266 the speed scales exactly with the clock, which says the work is purely CPU-bound: no memory or I/O bottleneck at this size. On the tablet, a single textured tile in view cuts throughput by a third or more: texturing on a 2D canvas, without a GPU, is by far the most expensive thing the engine does.

## What I took from it

-   A small engine you understand completely is a better reference than a big one you do not. Porting `generic` to four very different machines was possible only because all of it fits in my head.
-   Caching (matrices, trig, colours) gave more than any micro-optimisation in the drawing code.
-   Measuring vertices per second on every platform gave me one number to compare a browser, a microcontroller and an 8-bit computer. I used the same approach later with OpenCL in [Simple High Performance Computing](https://michalasobczak.pl/posts/simple-high-performance-computing.html).

Source: [github.com/michalasobczak/generic](https://github.com/michalasobczak/generic) and [github.com/michalasobczak/esp8266](https://github.com/michalasobczak/esp8266). See also the [overview of my hands-on projects](https://michalasobczak.pl/posts/hands-on-projects-on-github.html).
