The grey disc at the user's along-tube pose is empty space: with no step limit the centre ray hits after 12,088 steps and the disc draws as sky. The cache is not the cause. Along the ray it reads what the fractal's own estimate reads, about 1.5 pixels at every distance, where the surface is really 7.5 pixels away. A tighter estimator through the orbit's full Jacobian reads 4x here, but over-reads elsewhere by up to 21x, so it is kept only as an ignored survey test. PLAN.md records what does and does not help. --probe now prints the fractal's estimate at each step and, every 128 steps, how far a search finds the surface really is. The user's poses go in poses/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fractal-3d — realtime 3D fractals at any zoom depth
Explores the Mandelbulb and Mandelbox by distance estimation, at depths far below what a float can resolve. The estimator runs in whatever arithmetic the depth needs: f32 near unit scale, then multi-limb fixed point that widens as the camera descends, so zoom depth is limited by patience rather than by the size of a float.
The display never evaluates the fractal. It traces a world-space cache of
distance values in f32, which costs the same at every depth; the fractal is
evaluated only to fill that cache, in bounded slices of GPU time. Moving never
lowers the quality of what is already drawn, and a place once seen stays cached.
PLAN.md has the design, where it stands, and the measurements behind it.
Rust + wgpu + WGSL, native desktop only.
Building and running
cargo run --release
Build with --release. The distance at the camera is computed on the CPU in
multi-limb fixed point each frame the camera moves, and without full
optimisation that alone can cost more than a frame; a debug build says so on
startup.
Requires a GPU exposing wgpu's SHADER_INT64 (Vulkan, DX12, or Metal 2.3+) —
the fixed-point tiers form 64-bit widening products out of 32-bit limbs, which
standard WGSL cannot express. This is checked at startup with a clear message.
Controls
| Input | Action |
|---|---|
W A S D |
Move forward, left, back, right |
Space / Left Shift |
Move up / down |
| Click, then move mouse | Look around (click captures the pointer) |
| Hold middle button, move mouse | Move the light |
Q / E |
Roll the view left / right |
Esc |
Release the pointer, or take it back |
| Scroll wheel | Multiply movement speed (×1.15 per notch) |
1 2 3 |
Sphere, Mandelbulb, Mandelbox |
V |
Cycle what each pixel shows (see --show) |
P |
Copy the exact pose to the clipboard as --fractal … --pose …, and print it |
Looking around is free in all directions: the camera holds an orientation rather than yaw and pitch angles, so there is no world "up" to be re-levelled against, no pole to clamp short of, and rolling is a first-class turn rather than a correction. Pitch straight up and the view carries over the top.
Movement speed is set by the scroll wheel alone and does not fall off as you approach a surface. That is deliberate — tying speed to the distance makes an approach decelerate geometrically, which is pleasant but means a surface can never be reached or crossed. The trade is that at too high a speed the camera passes through geometry, which is why the range spans thirty decades: exploring at 1e-25 needs a speed to match.
The window title reports the frame time, the zoom depth, how many bricks the cache holds and in how many pages, how many requested bricks are still waiting to be filled, any arithmetic tier still compiling, and the movement speed.
Command line
The scripted options exist so a capture or a regression run can put the camera
somewhere exact, or fly a path, without a human at the keyboard.
fractal-3d --help describes each in full.
| Option | Meaning |
|---|---|
--fractal <name> |
sphere, mandelbulb-8, or mandelbox |
--pos <x,y,z> |
Starting position, overriding the fractal's default |
--pose <pose> |
Start at an exact pose as P copies it, at any depth |
--look <yaw,pitch> |
Starting orientation in radians (default: face the origin) |
--dive <digits> |
Descend until the surface is 1e-digits away, before the first frame |
--frames <n> |
Quit after n frames |
--fill-ms <ms> |
GPU time a frame's fills aim for (default 10); higher sharpens new views sooner |
--cache-gib <gib> |
Most GPU memory the brick cache may use (default 12) |
--quality <q> |
Voxels per pixel footprint the render asks for (default 1) |
--refine <levels> |
Levels past --quality a still view goes on refining to (default 1) |
--show <view> |
image, or a diagnostic: steps (steps per ray, magenta where they ran out), levels (every other cache level darkened), shortfall (red where the cache had only coarser data than the pixel wants), shading (diffuse light in red, occlusion in green), normals |
--no-cone |
Trace every ray from the camera, skipping the pass that first proves how far each 8×8 tile's rays can start; for measuring what it saves |
--moving |
Draw every frame as a moving camera would: one centred sample, never averaged or refined |
--spin <rad> |
Yaw every frame, per frame so a capture lands on a reproducible pose |
--tilt <rad> |
Roll every frame |
--fly <fraction> |
Fly forward every frame by this fraction of the distance to the surface |
--size <w,h> |
Window size, where the compositor honours it |
--tour <digits,...> |
Fly in through these depths, screenshotting each on arrival and once settled, then quit |
--tour-dir, --tour-speed, --settle-frames |
Where the tour writes, how fast it flies, how long it waits at a stop |
--trace |
Print one line per frame, and a brick census with every capture (below) |
--shot <path> |
Write the final frame as a binary PPM and quit (ffmpeg -i out.ppm out.png converts it) |
--probe <x,y> |
With --frames, print every step of the ray through that pixel: distance along it, footprint, distance read, the fractal's own estimate there, level read and wanted, and every 128 steps how far the surface really is; then its normal and occlusion taps |
--reference <path> |
With --frames, also trace the final pose on the CPU from the fractal itself, with no cache; --reference-iterations adds orbit iterations |
--walk |
Walk from the starting pose down the estimator's gradient to the surface, print how far it really was against the estimate, and quit |
How it works
The short version; PLAN.md has the reasoning and what was tried instead.
- Levels and bricks. Space is tiled at every power-of-two scale. A brick is
one tile of one level: 8³ voxels of distance and orbit trap, stored as the
9³ samples at their corners in
rg16floatin a 3D page texture, so filtered reads never leave it. Distances are stored in units of the brick's own voxel, which is what makes 16 bits enough at any depth. - Sparse. A brick exists only near the surface, and only if its parent does, so the cache is a tree of thin shells. Brick keys are exact at any depth (256-bit tile coordinates on the CPU); the GPU's hash table is keyed on the low 32 bits, which is unambiguous because it only ever holds bricks near the camera.
- Render. One f32 ray per pixel, sphere traced through the finest brick present at each point and shaded from the cached field. Where the finest brick found is coarser than the pixel wants and cannot resolve the distance it reports, the ray asks for the next level down. All geometry is camera-relative in a scaled frame, so f32 is enough however deep the camera is. Detail not yet filled is drawn from a coarser level: softer in world space, never blurred or lowered in resolution on screen. Before the rays, one cone per 8×8 tile is traced the same way with the tile's width taken off every distance, and the tile's rays start where it stopped, so empty space the tile's rays share is crossed once rather than 64 times.
- Fills. Each brick is filled at its own exact corner, in the arithmetic
of its tier, so a fill that lasts while the camera moves on still samples
where it began. Requests are read back a frame or two late and admitted most
urgent first: urgency is how many octaves coarser than its pixel wants the
data a ray found was, so a surface close to the camera that is far from
resolved goes before one in the distance that nearly is. Each render's
requests replace the backlog, so a place the view has left stops being
filled. Each fill is resumable: every frame advances each brick in flight by
a planned number of orbit iterations and saves where it got to, so no single
dispatch holds the GPU for long however deep the tier. The step count and
the number of bricks in flight are planned from recent timed passes, priced
per thread launched and per iteration, to keep each pass near what the frame
has left after the render pass, at most
--fill-ms. - Still views keep sharpening. The jittered samples a still view averages
read up to
--refinelevels finer than the pixel, and ask for them at low urgency, so detail below a pixel keeps resolving while the camera rests. A moving camera reads at the pixel's own level, where finer data would only alias. - Precision tiers. Coarse levels fill in f32; a level too fine for f32 fills in fixed point with as many 32-bit limbs as its depth needs. Each tier's fill pipeline compiles in the background the first time it is wanted (a few seconds), and the view keeps refining in coarser tiers meanwhile.
- Memory. Pages of 64³ bricks (about 0.7 GiB each) are allocated as the pool
fills, up to
--cache-gib. Past that, leaves the render has not sampled for a while are evicted; a brick with children never is.
Diagnostics
--trace prints one line per frame. A line's frame time and every figure on
it describe the same frame:
frame time step R render fill f32+fixed flight f32+fixed steps f32+fixed
done requests pending bricks pages levels cpu a+b+c+d
stepis the CPU time in the camera update, which includes the distance at the camera in fixed point.Rmeans the render pass ran;-means nothing it depends on changed.renderandfillare GPU times of the last timed passes.flightandstepsare bricks being filled per tier and the iterations each advances per frame;doneis bricks finished this frame.requestsis the size of the last request set read back,pendingthe requests still waiting for admission.cpusplits the frame's CPU time into hearing back from the GPU, deciding what to fill, drawing and presenting (which includes waiting for the surface), and encoding the fills.
With --trace, every capture also prints a census: for each level,
present/needed/sampled — bricks in the pool, bricks the current view needs
(the ones it sampled and their ancestors), and bricks the last few renders
sampled. Present far above needed means fills went to places the view does
not use.
--tour 1,3,5,6 --tour-dir DIR flies in through those depths in one run,
writing depth-NN-arrived.ppm and depth-NN-settled.ppm at each stop and
printing the worst frame and how many frames went over 20 ms per leg.
scripts/contact_sheet.py DIR out.png lays a tour out as one image.
--dive 10 --tour 10 settles a single deep view from a cold cache, which is
the reproducible benchmark for fill work.
scripts/headless.sh --mode 640x360 -- <options> runs any of this under a
private headless sway, off the desktop. Its output size is the render size;
--size has no effect under a tiling compositor.
Tests
cargo test
cargo clippy --all-targets
The test profile is the dev profile, which optimises this crate at level 1 and its dependencies at level 2. The fixed-point descent tests take ten seconds unoptimised and a second at level 1, and the tests keep overflow checks and debug assertions, which is what a fixed-point library wants them run with.
The suite is mostly properties rather than golden values — that a distance estimate lets sphere tracing converge, that widening the camera does not move it, that the fixed-point format still records a step f64 cannot see, that the GPU's estimator and fixed-point arithmetic agree with the CPU's, that the brick table and index agree with a plain map through random inserts and removals, and that admitting a deep brick admits its ancestors first.
A cluster of them exist because of a category of bug this project kept producing: a sign or a scale factor that looks perfectly fine in a screenshot and is wrong the moment anyone touches the controls. Looking right must move the view right, looking up must raise it, rolling right must tilt it right, and pitch must not also yaw. Each was wrong at some point, and each test was checked by reverting the fix to confirm it fails.
Tests that need a GPU skip themselves when none is available.