Skip to content

Architecture

magmacrunchmedia edited this page Aug 28, 2026 · 2 revisions

Architecture

Two tracers, one truth

The engine contains two complete ray tracers:

  • source/cpu_trace.c, plain C, single threaded, allowed to be slow and obliged to be right. Every optical law lands here first, where it can be held by a host test to a closed-form answer.
  • shaders/trace.hlsl, its statement-for-statement twin, running as a fullscreen-quad fragment shader.

They are not layered; neither calls the other. They are independent implementations of the same walk, and the engine's central correctness mechanism is the assertion that they agree.

holo_oracle_diff(scene, camera, spectral, &stats)

reads the frame the GPU just presented (through a D3D11 staging texture), renders the same camera through cpu_trace.c, and compares. The bar is a mean error under 1/255 with under 0.75% of pixels off by more than 8/255. Every GPU example accepts --diff and exits non-zero on failure, so a divergence is a failing exit code rather than a mystery.

The outlier allowance exists because the two sides legitimately flip independent float coins at razor edges: silhouettes, and glass near the critical angle, where a grazing ray hits on one side and misses on the other. A dispersive ball traced at twelve wavelengths has twelve TIR rims of such pixels. The mean is the load-bearing bar, and the current suite sits between 0.02 and 0.12.

On failure the oracle dumps both frames to build\diff_gpu.ppm and build\diff_cpu.ppm. When the verdict is "the twins disagree", the first question is always where.

The walk: chain and fork

Both tracers walk light the same way, and the shape is not arbitrary.

Each ray popped from the stack walks as a chain: every surface continues the ray in place (the reflection off a mirror or glass, the transmission through a filter, the current order off a grating) and forks at most one side branch onto the stack:

Surface Chain (continues in place) Fork (pushed)
Mirror / matte The reflected ray none
Glass The reflected ray The transmitted ray
Polarizer / waveplate The filtered ray, direction unchanged none
Grating This visit's diffracted order A revisit of the surface for the next order

One push per interaction is a hard ceiling. fxc, D3D11's shader compiler, silently corrupts the walk's stack arrays when a single branch pushes twice; see Shader constraints. The CPU mirrors the structure exactly, because a diff between differently-shaped walks would certify nothing.

A grating's next-order index rides in the high bits of the depth field (depth | (order << 8)), which is one fewer stack array to tempt fate with.

Caps

HOLO_MAX_BOUNCE 16 Depth at which a path stops.
HOLO_MAX_RAYS 32 Total interactions per pixel across all branches.
HOLO_STACK 16 Pending forks.
HOLO_MIN_TP 0.002 Throughput below which a branch cannot move a pixel by half a level, and is dropped.

The caps are fixed and identical on both sides so that the CPU and GPU drop the same branches. Determinism is the contract; without it the diff would measure sampling noise instead of correctness.

The two pipelines

holo_trace_ray() is the RGB walk: three-channel throughput, glass at its D-line index, no wavelength. Fast, and correct for scenes with no dispersive or polarizing element.

holo_trace_lambda() is the spectral walk: one wavelength at a time, carrying a Stokes row and a reference frame. holo_trace_ray_spectral() runs it for all twelve samples and folds through the CIE weights.

The two agree to the float on neutral, achromatic scenes (there is a test for exactly that) and part ways at the first dispersive or polarizing surface, which is the point. In the RGB path a polarizer is approximated as a flat 50% absorber and a waveplate as clear glass; a grating shows only its zeroth order. Games choose per frame (holo_gpu_scene_fill(..., spectral)), and the interactive examples toggle with T.

The uniform block

The whole scene reaches the shader as one constant buffer, HoloGpuScene, whose layout is the same thing written in two languages: the struct in source/gpu_scene.h and the cbuffer at the top of shaders/trace.hlsl must change together. Both sides count in float4s, so every float3 is followed by a scalar that rides in its fourth lane.

Two consequences worth knowing:

  • The camera's aspect ratio is not carried. The shader derives it from the framebuffer size in its own uniforms, so the image stays correct when the window is resized.
  • Grating constants live in scalar slots, not arrays, matched by rect index. That is a workaround for an fxc defect, documented in Shader constraints, and it caps the GPU at two gratings per scene.

Frame flow

sokol frame
  └─ display.c
       ├─ before_frame()          the game simulates and writes its camera
       ├─ upload uniforms         the game's block, header filled in
       ├─ draw the fullscreen triangle   (the entire render)
       └─ after_frame()           readback, diffing, quitting

A game is three callbacks and a scene. display.c is the only file that talks to sokol; everything else is portable arithmetic.

Simulation runs on the fixed-step accumulator ported from magnolia: the same wall-clock time always buys the same number of logic steps, however the frames it arrived in were shaped, and a stall drops its backlog rather than teleporting.

Module layout

Pure arithmetic is split from platform calls so the arithmetic is host testable, which is magnolia's timestep.c/clock.c discipline applied throughout.

Layer Modules
Physics (pure) linalg, polar, spectrum, geometry
Rendering (pure) camera, cpu_trace, gpu_scene
Simulation (pure) collision, timestep
Platform (sokol) display, input, oracle

The pure set compiles and runs anywhere a C compiler does, which is why the test suite needs no GPU, no window, and no mocking.

Portability

The engine is Windows/D3D11 today. sokol itself abstracts D3D11, Metal, GL and WebGPU, and display.c is written against that abstraction, but the tracer is a single HLSL file, so a second backend needs a second shader dialect.

The intended route is sokol-shdc: one annotated GLSL source compiled to HLSL, MSL, GLSL and WGSL. That also lifts the constraint this engine is currently shaped around, since the fxc defects documented here belong to one compiler on one backend. A Metal or WGSL build would not inherit them, and having one would have found them in minutes rather than hours.

Two further notes for anyone porting:

  • The shader is compiled as ps_5_0, not sokol's ps_4_0 default. The tracer dynamically indexes both constant buffers and large local arrays, and SM4 has no native dynamic constant-buffer indexing; it emulates it by copying into indexable temps.
  • Frame readback (holo_display_read_frame) is D3D11-only. Without it the oracle cannot run, so a new backend should implement it early. It is the thing that tells you the port is correct.

Clone this wiki locally