-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
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.
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.
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.
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 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.
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.
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.
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'sps_4_0default. 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.
Reference
Guides
History