TV Hack
Log 03 / 06

Doom at 34 fps

18 fps → 34 fps. One function was eating 90% of every frame. Finding it is my favourite part of this whole project.

By prutxviBuilt with Claude~7 min read
Doom on the TV, played with the phone controller. The phone's address bar is blurred because it contains the private room link.

"Is it really running on the TV, or streaming from a PC?" It's really running on the TV. The game engine executes on the TV's own processor as WebAssembly, inside the TV's web engine. Once it's installed the laptop isn't involved; you can switch it off.

"Can it run Doom?"

Running Doom on unlikely hardware is an old internet tradition, so it had to happen. There's an excellent open-source project, doom.wasm, built on doomgeneric, that compiles Doom into a single WebAssembly module with the 1993 shareware episode embedded. The shareware episode is free to distribute.

We loaded it into the launcher as its own page, and got another black screen.

Black screen #2

This one was nastier than the first. The beacon said everything was fine: frames were being produced, there were no errors, and when we sampled Doom's internal picture it had colour in it. But the canvas on the TV stayed black.

A few experiments narrowed it down to something I still find wild: on this TV, writing raw pixels into a large canvas silently does nothing. putImageData works on small canvases and quietly draws nothing on large ones. A hidden canvas failed the same way, and so did a 32-bit view of the pixel data.

The fix was to skip the 2D canvas entirely and draw through WebGL: upload each frame to the graphics chip as a texture and draw one rectangle with it. Doom's pixels come out with red and blue swapped, so the shader swaps them back. It worked first time, and it's faster.

// launcher/doom.html: each frame, hand Doom's picture to the GPU
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, FW, FH, gl.RGBA, gl.UNSIGNED_BYTE, src);

// fragment shader: Doom's buffer is BGRA, so read it back as .bgr
gl_FragColor = vec4(texture2D(t, uv).bgr, 1.0);

The LED dot look

To match the rest of the OS, Doom has an optional LED dot mode. It's a CSS mask over the picture: a 9-pixel grid of transparent circles with black around them, so every cell reads as a round LED. Blue on the remote toggles it. Here's the same mask on a real frame from the TV:

  LED mode
A frame of Doom running on the TV: a brown and blue room, the status bar showing 43 ammo and 100% health.
.led::after {
  background: radial-gradient(circle, transparent 3.2px, #000 4.2px);
  background-size: 9px 9px;
}

It ran at 18 fps

Playable, but not smooth. I asked for it to be smooth, and that's when Claude stopped guessing and started measuring. Every measurement below was taken on the TV, reported back through the beacon.

Frames per secondone dot = 1 fps · Doom's cap is 35
Before
18
After
34

What the profiling found

CheckResultMeaning
Time per frame49–53 msWay over the 28 ms that 35 fps needs
GPU upload per frame~2 msGetting frames to the screen was cheap
JavaScript speed vs my Mac~9× slowerNormal for a TV chip
WebAssembly speed vs my Mac~2.6× slowerThe JIT works. The engine itself was the problem.

Then the profile of a single frame:

One Doom frame● cmap_to_fb · ○ everything else

90% of every frame was spent in one function: cmap_to_fb, the routine that copies Doom's pixels into the screen buffer.

The upstream build renders at 640×400, double Doom's original resolution. For every one of Doom's pixels, that function wrote 16 separate bytes one at a time, through struct fields (4 output pixels × 4 colour bytes each). And it was compiled with -Os, which optimises for size, not speed.

A detour that taught the best lesson

Before the profile, we noticed Doom busy-waits on the clock: on the Mac it read the time 422,460 times per frame. A virtual clock fixed that and looked 20× faster on the Mac. On the TV it changed nothing, still 18 fps. The TV is never early enough to wait, so there was nothing to save. Measure on the real device.

Rebuilding Doom

So we rebuilt the engine from C source with three changes:

  1. Render at the original 320×200 instead of 640×400. The graphics chip scales it up to the TV for free, and 4× fewer pixels go through the copy.
  2. Rewrite the pixel copy as a 256-entry palette lookup that writes one 32-bit value per pixel, instead of 16 single-byte writes.
  3. Compile with -O3 instead of -Os.
// doom-build/i_video.c, after: one lookup, one 32-bit write per pixel
static uint32_t fb_lut[256];   // rebuilt whenever Doom changes palette

void cmap_to_fb(uint8_t *out, uint8_t *in, int in_pixels) {
  uint32_t *o = (uint32_t *)out;
  int i;
  for (i = 0; i < in_pixels; i++) o[i] = fb_lut[in[i]];
}

No Docker needed: WASI-SDK as the compiler and Homebrew's binaryen for the WebAssembly tools. The shareware WAD's original download link is dead, so it was extracted from the prebuilt doom.wasm and checked against its known checksum.

18→34
fps on the TV
53→4.4
ms per frame on the TV
~10×
faster on the Mac (0.63 → 0.066 ms)
35
Doom's own frame cap

34 fps on a 35 fps engine. On a 2020 budget TV, running natively.

Controls

Opening Doom from the dock: a dot skull, "Loading Doom…", then the title screen.