DEV Community

AutoJanitor
AutoJanitor

Posted on

Real-Time Raytracing on a Nintendo 64, With Every Frame Rate a Measured Number

The Feverdream Engine measured that POV-Ray's per-frame cost was process lifecycle, not tracing: about 11 ms of real work hiding inside a 700 ms relaunch. Made resident, it traces live at 77+ fps on a modern CPU.

This project asks the same question honestly on 1996 silicon. A 93.75 MHz VR4300, a Reality Signal Processor, 4 MB of RDRAM. Not a port of the POV-Ray codebase — a native implementation of the thesis: a resident, in-memory, per-frame raytracer of simple scenes, with every frame rate a measured number and never a promised one.

Two lanes were developed independently and merged on evidence.

House rule first

A frame counter derived from loop iterations reduces to a constant and lies. That bug shipped once in a sibling project — a denominator of iterations × a hardcoded 60.0f printed the literal 60.00 on every build at every speed, and reached four published surfaces before anyone caught it. So in this repo, cycle counts come from the CP0 COUNT register, frame rates come from wall vblanks cross-checked against CP0, or they don't get quoted.

Nothing here has run on real hardware yet. Everything is under the ares emulator in interpreter mode, and the findings files say so at the top.

The CPU lane: the "no FPU" premise is measured false

feverdream-cpu.z64 traces the whole scene from scratch every frame on the VR4300: three spheres (one a mirror, with a reflection bounce), an infinite checkered plane, a point light with hard shadows, Blinn specular, a graduated sky, into an 80×60 RGBA5551 buffer pixel-doubled to 320×240. The camera orbits, 128 frames to the turn, so nothing can be baked. No RDP command is ever issued. The CPU writes pixels and the VI scans them out.

13.79 fps at 80×60, 3.80 fps at 160×120, from vblank counts over 256 frames, cross-checked against CP0 COUNT to better than 1%. Those are the honest slow numbers, and they're the ones we quote.

The A/B that motivated the lane: the premise "in the wild" is that the N64 lacks an FPU and a raytracer must therefore be written in fixed point. The machine has a full IEEE COP1. Same scene, same loop, same shading, only the arithmetic differing:

Lane Cycles per primary ray vs float
float (single precision, COP1) 1,268
fixed 16.16, 64-bit storage 1,637 +29.1%
fixed 16.16, 32-bit storage 1,864 +47.0%

Float beats fixed point by 29%, and it wins in all eight link layouts swept. The fixed lane doesn't avoid the square root; it open-codes it — a range reduction plus three Newton passes, roughly twenty integer ops, where the float lane issues one sqrt.s and one div.s, each 29 cycles and pipelined against surrounding work. The tracer normalizes four times per primary ray before anything else happens. Holding 16.16 in int32_t instead of int64_t costs a further 13.8%, because libdragon's ABI is o64 and the registers are already 64 bits wide.

The folklore is wrong, and it's wrong by about a third.

The largest single effect we measured wasn't arithmetic at all. Link layout — where the code happens to land relative to I-cache lines — moved byte-identical output by 54%. That invalidated our first cross-binary A/B and forced every comparison into a single binary with a padding knob. Later, across ten separately linked ROMs, two builds of the same tracer differed by −3.9% to +9.9% on identical work. So any cross-build effect under ~10% is reported as not established, which is exactly what happened to one of this phase's two candidate optimizations.

Receipt: the ROM prints an FNV-1a hash of its own framebuffer, and the same sources compiled natively reproduce it. 1,098 of 1,098 frames bit-identical between host and ROM across ten linked builds. Getting there caught a real bug in the harness rather than the tracer: the host reference was compiled without the ROM's -ffast-math, and exactly one scene in twelve had enough near-tangent geometry to notice.

The RSP lane: the vector unit wins intersection everywhere

The sibling transformer port had measured the RSP losing to the CPU on ternary-weight workloads. Whether it wins on intersection arithmetic is exactly the kind of thing this lab measures instead of assumes.

Verdict: the RSP wins batched ray-sphere intersection in every measured cell. 4.45× over the VR4300's own FPU at 4,096 rays × 16 spheres amortized, 6.2× over CPU 16.16 fixed, and still 2.25× at the worst-case smallest batch (64 rays × 4 spheres) including DMA in, rspq dispatch, and DMA out.

Rays Spheres CPU float (CP0) RSP queued (CP0) Speedup
64 4 12,568 5,597 2.25×
512 4 107,297 25,220 4.25×
4096 4 878,756 184,831 4.75×
64 16 43,325 12,631 3.43×
512 16 355,749 81,168 4.38×
4096 16 2,815,356 633,005 4.45×

Correctness is receipted, not claimed. The microcode dumps the hardware VRSQ reciprocal-square-root unit for calibration — 1,638 of 1,638 points bit-exact, all 512 ROM entries recovered from the machine — and an independent host model of the kernel's fixed-point arithmetic matches the ROM element-wise on both hit distance and hit ID. Of 65,536 ray-sphere pairs, the 16-bit kernel gets the hit/miss decision wrong on exactly 3, all razor grazes. Along the way we refuted an assumption: VRSQ is not S15.16, whatever the docs imply. Measured.

Phase three added a batched shade kernel and answered a ternary question on the machine. Ternary composite shading (compares and shifts, zero multiplies) beats conventional fixed-point Blinn on the RSP by 1.18× — the op-count saving and nothing more, because RSP multiplies are one cycle and what conventional really pays is the rescale after each product. The headline is elsewhere: either RSP shade path beats the VR4300's float shade 6–7×. Both kernels bit-exact against the host model on the first run. The look difference is quantified and committed as PNGs: 98.3% of pixels differ, mean delta 3.3 out of 31. The banding is the aesthetic.

Famous scenes, and a price list

Phase two of the CPU lane added ray/AABB boxes and scaled spheres (ellipsoids), and priced them by subtraction against a control that renders a byte-identical picture with 32 extra primitives parked off camera, so the delta can only be intersection cost:

Primitive Cycles per ray, per primitive in scene
box (slab) 218.1
sphere 267.4
ellipsoid 275.2 (+2.9% over a sphere)

Cross-checked on an unrelated scene: the sphereflake's cost slope is 275.2 cycles/ray/sphere against the control's 267.4 — 2.8% apart.

Then the homages, live, vblank-derived, 80×60 doubled to 320×240:

Scene Primitives fps
Whitted 1979 (mirror ball; no refraction) 2 spheres 12.40
Wada basin, 4 mutual mirrors, depth 4 4 spheres 9.22
Sphereflake depth 0 / 1 / 2 1 / 10 / 91 spheres 17.12 / 5.70 / 0.75
chess2.pov homage 44 spheres + 33 boxes 0.72

Bounce depth is nearly free, and the image converges at five. Six bounces cost 1.9% more than one, and depths five and six produce the identical framebuffer — the sixth bounce is measurably slower and provably invisible.

And the one I keep coming back to: chess2.pov's own 1991 header says it "takes over 40 hours to render by standard amiga" at 320×240. This ROM renders a 77-primitive homage of that shot, at that output size, in 1.39 seconds per frame — about 103,700× faster. The findings file lists what the homage does not have (430 primitives, CSG lathes, wood and marble textures, focal blur) right beside the number, because a number without its caveats is marketing.

The per-frame scene rebuild the Feverdream thesis demands costs 0.060% of the heaviest frame. There's no parse wall on this machine because there is no parser.

The game, ported and receipted

All eight worlds of CHUNKINS — the MIT platformer from the desktop engine — hand-ported to C and running on the N64. The simulation is bit-identical between host and VR4300 across nine hash-receipted runs, including a scripted autopilot that beats world one end to end (stomp at frame 591, the gate at 1,141, the star at 1,772). It costs about 1% of a 60 fps frame and hands the raytracer a per-frame scene through a small interface. Today it plays under a debug top-down view that says "placeholder" on screen, because it is one.

Even the music is receipted

A 56-second orchestral loop from Rossini's Eduardo e Cristina overture, chosen because the actual first choices — William Tell, Barber of Seville, Hungarian Rhapsody No. 2, Light Cavalry — have no verifiably public-domain MIDI anywhere. The composition being PD doesn't free the arrangement, and the copies on the usual MIDI sites carry no license at all. The Mutopia edition is marked Public Domain on its catalog page, quoted with URLs in the repo alongside the searches that came up empty. Rendered with fluidsynth, cut on allegro bar boundaries, 679 KB as a looping VADPCM asset, decoded on the VR4300 by an in-ROM port of the reference decoder: CRC 3857A35C, bit-exact against an independent host decoder. The mixer poll costs 1.9% of the frame.

What this does not establish

No real hardware yet. One scene family. No quality sweep against a reference renderer. Emulator CP0 counts are synthetic until a different clock agrees, which is why every headline number has a vblank cross-check next to it. The findings files in each lane end with this list, and they're longer than this paragraph.

Repo: github.com/Scottcjn/feverdream-n64. Reproduce the CPU lane with cd cpu && make all probes && make measure && make host verify; the RSP lane with cd rsp && make run && python3 tools/check_model.py bench_log.txt.


Scott Boudreaux runs Elyan Labs in Lake Charles, Louisiana. This is a sibling to the lab's transformer ports on the same console, where the RSP lost; both directions are now measured, not assumed.

Top comments (0)