Everyone "knows" you can't raytrace a game on the CPU. POV-Ray, the raytracer that gave ReBoot and Donkey Kong Country their look, chugs along at 3–4 frames per second no matter how small you make the picture. So that look only ever shows up pre-rendered, baked overnight, never live.
In June 2026 someone on an Animation:Master Discord asked, half as a joke, whether a real-time POV-Ray game was possible. I had a hunch the wall was in the wrong place. So we measured it instead of arguing.
The finding
POV-Ray 3.7, a simple retro scene, 240×135, timed per frame:
| What | Time |
|---|---|
| Parse the scene | ~5 ms |
| Trace (the actual raytracing) | ~6 ms |
| Real work per frame | ~11 ms (~90 fps) |
Wall-clock per povray call |
~700 ms |
A 16×16 frame costs the same ~700 ms as a 480×480 one. That single fact tells you the cost is not the picture and not the raytracing. It's the program launching itself, loading config and fonts, encoding a PNG, writing it to disk, and shutting down — every single frame. Like quitting Maya and relaunching it for every frame of an animation.
POV-Ray was never slow. It's a batch tool being used as a renderer, and almost all of its time goes to being a batch tool.
Fix one: stop relaunching
POV-Ray is open source (AGPLv3), so we modified it rather than faked around it. daemon/resident.cpp creates the engine once via vfeUnixSession and renders every frame against the live session, capturing pixels straight into memory. No PNG, no disk, no process spawn.
That got us from 700 ms to ~360 ms a frame. Better, but still flat regardless of resolution — the tell-tale sign of a fixed wait somewhere.
Fix two: the 50 ms message poll
The frontend and backend in POV-Ray talk through a message queue. In vfe/vfepovms.cpp, every receive did a timed_wait of 50 ms. The source code literally contains the comment // TODO: have a shorter wait. Every phase change in a render ate up to 50 ms; about eight hops per frame is ~360 ms of pure sleep.
We cut it to 0.1 ms. Frame time fell to ~120 ms.
Fix three: more threads made it slower
The decisive test was varying thread count on a tiny scene:
threads=1: 81 ms threads=4: 81 ms threads=16: 118 ms
More threads, slower frames. And a stranger paradox: bigger resolutions got better fps. 240×135 took 128 ms; 960×540 took 84 ms. A fill-rate-bound renderer slows down with resolution. Ours sped up, because something per-thread was being paid every frame, and higher resolution finally gave those threads enough work to amortize it. (Part three of the story explains what that something actually was — it wasn't what we first thought.)
Fix four: the backend was sleeping too
With the message poll fixed, ~81 ms per frame remained even at one thread on an empty scene. Same bug, one layer down. The backend has two driver loops that poll their task queues with coarse sleeps:
-
source/backend/scene/scene.cpp, parse loop:Delay(10) -
source/backend/scene/view.cpp, render loop:Delay(50)
The render finishes in microseconds and then the driver sleeps 50 ms before noticing it's done. Cut both to 1 ms.
Frame time: ~13 ms. 77 fps, fully raytraced, on a reflective scene at 320×180, live in memory. From 1.4 fps, with three numbers changed in sleep calls and one process kept alive.
Phase two: now it's work, not waiting
With the sleeps gone the daemon pegged ~108% CPU at the frame floor, which meant the remaining overhead was real work. We stopped hunting Delay() calls and started reading perf.
The 33% bug. On an empty scene, pov::RandomDoubles was eating 33.5% of total daemon CPU. Every TraceTask constructor — one per thread, every frame — rebuilt the random-sequence tables used for pixel jitter and radiosity sampling, from a default-seeded Mersenne Twister. Identical values, regenerated from scratch, thousands of times a second. We memoized the sequences; a second perf pass showed the cache's 32 KB vector copy at 12%, so the sequence object now holds a shared_ptr to the immutable cached table. Zero generation, zero copy, and the output is bit-identical (the deterministic 180-frame game selftest's PPM hash didn't change).
This also explained the "more threads = slower" paradox at the root. It was never mostly thread-spawn. It was every extra thread regenerating the same tables.
We also added a persistent worker pool so tasks stop spawning a boost::thread each, and dropped the remaining control-loop polls to 200 µs nanosleeps.
The scoreboard
| Stage | Per frame | fps |
|---|---|---|
Stock povray (respawn + disk) |
~700 ms | 1.4 |
| Resident process + 50 ms→0.1 ms message poll | ~120 ms | ~8 |
| + low thread count | ~81 ms | ~12 |
| + backend driver delays 50 ms/10 ms → 1 ms | ~13 ms | 77 |
| + task pool, 0.2 ms polls, sequence cache | ~9 ms @ 320×180 | 107–141 |
Fixed-overhead floor at 64×36: 9.85 ms → 2.34 ms. Real scenes at 320×180: a spinning reflective scene at 107 fps, a bee meadow at 91 fps, the game's arena scene at 141 fps in the daemon and 138 fps end-to-end through the socket.
All numbers from one box (16 logical cores, ~8 physical), POV-Ray built from 3.7-stable, reproducible with bench.sh and the resident daemon in the repo.
There's a game in it
Once the renderer was real-time, it needed something to render. CHUNKINS: The Search for the Golden Acorn is a platformer traced live at ~100 fps — a squirrel working through a meadow, crate heights, an acorn mountain, and a thief's hollow, stomping glowing-eyed baddies and hunting the Golden Acorn. Every level is a plain Lua file. The engine has no idea what a squirrel is.
The licensing catch, handled honestly
POV-Ray is AGPLv3. Anything that links libpovray is a derivative work with source-disclosure and network obligations, and our first live drivers did exactly that — which meant the game logic was sitting inside the AGPL boundary.
The fix is architectural: a strict two-process split. fd-daemon (AGPL) is the resident raytracer and speaks a tiny Unix-socket protocol — scene text in, framebuffer out, with a whitelist on declared variable names to block option injection. The game (MIT) feeds it plain POV scene text plus name=float pairs and never links POV. That's the arm's-length separation the FSF's "intimate semantics" test asks for, and the socket costs about 0.14 ms per frame. Windows builds are code-signed through the SignPath Foundation so there's a verifiable link between the repo and the binary.
What transfers
The general lesson isn't about POV-Ray. It's that "X is too slow for real time" is a claim about a measurement, and the measurement is usually of the wrong thing. Nobody had timed parse and trace separately from process lifecycle, because nobody runs a batch renderer 60 times a second. The moment you do, three sleep calls and a redundant table rebuild are standing between you and a 50× speedup, and they've been there, with a TODO comment, for years.
Repo: github.com/Scottcjn/feverdream-engine — benchmark, patch, daemon, protocol, game. The follow-up asks the same question on 1996 hardware: Feverdream N64.
Scott Boudreaux runs Elyan Labs in Lake Charles, Louisiana. The engine's architecture was reviewed by three independent models before the first line of daemon code; the findings file lists what's measured and what isn't.
Top comments (0)