POV-Ray Holographic Output¶
Module: quiltwright.povray
Script: scripts/render_museum_hologram.py
Source: src/quiltwright/povray.py
"The scene was finished in 1997. The display it belongs on shipped in 2023."
Renders existing POV-Ray scenes as Looking Glass quilts, without porting the
scene to another renderer and without modifying a single line of it. Companion
to lfd.md, which covers the PyVista path and the Bridge/Studio setup,
and to cycles.md, which covers the Blender Cycles backend the
same way; everything downstream of quilt assembly -- filenames, casting,
playback -- is shared. For rendering molecular structures this way, see
pdb2pov.md. For a worked side-by-side of this backend against
Cycles on one composed scene, see
cycles.md's DNA helix example
(scripts/render_dna_helix_hologram.py --backend povray).
This page explains the mechanism. For the step-by-step procedure of getting a particular archive scene through it -- parsing, reading its camera, measuring its depths, and the traps at each step -- see pov-workflow.md.
Concept¶
A ray-tracer is an unusual thing to drive from a light-field pipeline, and the reason to bother is content. Decades of POV-Ray scenes exist that were composed for depth -- interiors, still lifes, molecular sets -- and they were built with global illumination, real refraction through glass, and correct shadows. That is exactly what a holographic display flatters, and it is expensive to reproduce in a rasterizer.
The driver does four things:
- Writes a wrapper scene per view that
#includes the original and appends onecamerastatement. POV-Ray uses the last camera it parses, so this overrides the scene's camera while leaving geometry, textures and lighting untouched. - Sweeps the eye across the view cone using an off-axis projection expressed directly in POV-Ray's camera vectors.
- Ray-traces each view at the quilt's tile resolution.
- Hands the views to
assemble_quilt()-- the same renderer-agnostic assembler the PyVista path uses.
from quiltwright.quilt import QUILT_PRESETS, save_quilt
from quiltwright.povray import PovCamera, render_pov_quilt
camera = PovCamera(location=(15, 20, 6), look_at=(44, 19.2, 45.1), fov=53.13)
spec = QUILT_PRESETS["16-landscape"]
quilt = render_pov_quilt("museum.pov", spec, camera, include_paths=["../myinclude"])
save_quilt(quilt, "museum", spec) # -> museum_qs8x6a1.77778.png
1. The off-axis camera, in POV-Ray¶
This is the load-bearing trick, and POV-Ray supports it natively -- which is not obvious, because the feature is a side effect of how its camera is specified rather than a documented capability.
POV-Ray builds the frustum from four vectors: location is the eye,
direction places the center of the image plane relative to it, and
right/up span that plane. Critically, POV-Ray does not
re-orthogonalize them. Tilting direction while holding right and up
fixed leaves the image plane parallel to itself and shears the frustum -- an
asymmetric-frustum projection, which is precisely what a light-field display
requires.
The obvious alternative -- pointing each view at the subject with look_at --
is "toe-in". It rotates the camera, which introduces vertical parallax and
keystone distortion, and the display cannot fuse the result. Toe-in produces
ghosting where off-axis produces depth.
For eye offset s along the unit right vector r, focal distance Z, and
image-plane distance D:
location = L + s·r
direction = D·f − (s·D/Z)·r
right = aspect · r
up = u
The subtracted term slides the image-plane center back onto the original view
axis. Deriving it takes one line: a ray from the shifted eye through the
image-plane center reaches the original axis at f-distance Z when
s + Z·c/D = 0, hence c = −s·D/Z.
Two rules follow, and both are enforced in code:
- Never emit
angle. POV-Ray'sanglekeyword overrides the length ofdirection, which silently destroys the shear while still rendering a plausible-looking image.camera_block()computes|direction|explicitly and a test asserts the keyword never appears. - Get the handedness right. POV-Ray is left-handed:
right = sky × forward. Inverting it mirrors the sweep and turns the hologram inside out -- near objects recede, far objects advance. A test pins the+y/+z->+xcase.
POV-Ray warns Camera vectors are not perpendicular on every view. That
warning is the shear; it is expected and benign.
Verification¶
A three-marker scene -- spheres at depths 6, 10 and 14, focal plane at 10 -- renders with the on-plane marker pinned to the center pixel across the whole sweep while the others separate in opposite directions. Measured against theory:
| Marker | Depth | Predicted shift | Measured |
|---|---|---|---|
| near | 6 | −127.4 px | −128.16 px |
| far | 14 | +54.6 px | +54.43 px |
Agreement is within 0.6%. tests/test_povray.py runs this as a live
ray-trace whenever a povray binary is present, asserting both the focal-plane
pin and the view ordering.
2. The depth budget¶
The number that decides whether a hologram fuses is the adjacent-view disparity: how far a feature moves between neighboring quilt views. The display blends neighbors optically, so a pixel or two reads as solid depth while larger shifts read as ghosting or a visible stack of copies. Scenes that look fine flat routinely blow this budget.
From the off-axis projection, for content at distance z with focal plane at
Z:
disparity = [tan(cone/2) / tan(fov/2)] · |1 − Z/z| · tile_height / (n_views − 1)
The aspect ratio cancels. view_disparity() implements it; the ray-traced
measurements above anchor it to within 2%.
Three consequences are worth internalizing:
- Content at the focal plane has zero disparity. It is welded to the glass.
- A narrower FOV increases disparity. This is the counterintuitive one. Narrowing the lens magnifies the scene and magnifies parallax with it. The "~14° FOV" advice that circulates for Looking Glass content is specific to object-centric scenes where the camera dollies in until the subject fills the frame. Applied to an architectural interior it makes ghosting worse. Keep the scene's own wide angle.
- The focal plane belongs at the harmonic mean of the depth range, not the
midpoint. Disparity grows with
|1 − Z/z|, which is asymmetric in depth, so the arithmetic midpoint leaves near content far worse off. Equalizing the two ends givesZ = 2/(1/near + 1/far)--focal_distance_for_range(). With the far plane at infinity it reduces to2 × near.
Roughly 4-5 px is the practical ceiling; past ~8 px expect visible ghosting on hard edges.
3. Sweep clearance -- the constraint peculiar to interiors¶
The quilt sweeps the eye laterally by Z · tan(cone/2). For an object on a
turntable that distance is empty space. Inside a room it is furniture and
walls.
At a 35° cone with the museum's focal plane, the sweep is ±14.8 units. The room's usable lateral corridor, measured by rendering at candidate offsets and watching for the frame to collapse to the unlit back face of a wall, is only −18 to +8. The first full render of this scene therefore had 11 of its 48 views showing the outside of a wall -- and the failure is quiet, because the center view (the one you preview) is perfect.
The fix is to probe the corridor, recenter the eye within it, and derive the cone from the clearance that remains:
cone = 2 · atan((corridor_half_width − margin) / focal_distance)
Clearance holds the measured corridor and does all three:
from dataclasses import replace
from quiltwright.quilt import QUILT_PRESETS, focal_distance_for_range
from quiltwright.povray import Clearance, PovCamera, format_depth_budget
room = Clearance(left=-18.0, right=8.0, margin=2.0) # measured, in scene units
camera = PovCamera.aimed(
(15.0, 20.0, 6.0), (58.0, 19.0, 53.0), # the scene's own eye and aim
fov=53.13, # the scene's own lens
focal_distance=focal_distance_for_range(31.0, 96.0), # measured, see section 4
lateral_shift=room.centre, # -5: middle of the corridor
)
spec = replace(QUILT_PRESETS["16-landscape"], view_cone=room.cone(camera.focal_distance))
For the museum that gives 26.4°, comfortably inside both the 16" Landscape's
50° native cone and the documented 35° standard. format_depth_budget(...,
clearance=room) prints the sweep extent against the measured walls and warns
when it exceeds them -- print it before committing to the render, since this is
the failure that costs an hour of ray-tracing to discover.
Worth noting: narrowing the cone to fit costs less than it appears. With the focal plane at the harmonic mean, the disparity at the depth extremes depends on the physical baseline and the scene's depth range, not on where the focal plane sits -- so trading cone for clearance trades look-around, not sharpness.
4. Case study -- the museum¶
"Eric's Science Museum", a POV-Ray scene begun in 1995: molecular exhibits under bell jars in a room borrowed from Michael "meek" Mittelstadt, with the models generated by pdb2pov in 1997. What is on display, and why the scene exists, is about-the-image.md -- this section is only about the numbers.
It is near-ideal light-field content: a foreground pedestal, mid-depth framed art, and an arched window onto terrain and sky at infinity.
Measured scene properties. The depth range comes from
quiltwright probe, which
slides an opaque plane along the view axis and scores how much of the frame
stays in front of it -- a cumulative depth histogram of the shot. Run it
through the camera you will render with, at the quality you will ship: POV-Ray
disables transparency below +Q8, and a cheap probe reports a room with no
windows and no sky at all.
| Property | Value | How |
|---|---|---|
| Nearest geometry | 31 units | first distance occupying >0.1% of frame -- the near pedestal's tabletop |
| Structured far content | 96 units | 95% of everything occludable is nearer than this |
| Sky through window | effective infinity, 6.1% of frame | never occludes at any finite distance |
| Lateral corridor | −18 to +8 units | eye offsets before the frame collapses to a wall |
| Scene's own vertical FOV | 53.13° | the_lens, unchanged |
Sky is deliberately left out of the near/far balance. A backdrop at infinity would drag the focal plane far back to serve content that is low-contrast and can afford the disparity anyway.
Derived camera:
| Parameter | Value | Source |
|---|---|---|
| Focal plane | 46.9 units | harmonic mean of 31 and 96 |
| Eye shift | −5 units along r |
centers the lateral corridor |
| View cone | 26.4° | clearance-limited, 2-unit margin |
| FOV | 53.13° | the scene's own lens, unchanged |
Resulting depth budget (16" Landscape, 960×720 tiles, 48 views):
| Content | Depth | Adjacent-view disparity |
|---|---|---|
| Nearest geometry | 31 | 3.68 px |
| Focal plane | 46.9 | 0.00 px |
| Far interior | 96 | 3.68 px |
| Sky | ∞ | 7.19 px (soft, low contrast) |

Center view (view 24) of the finished quilt, 960×720.
Verification on the finished quilt. Near and far features must shift in opposite directions about a stationary focal plane -- the signature of a correct off-axis render. Measured by 1-D cross-correlation of a feature crop between two views 11 gaps apart, scaled to the device's 720 px tile:
| Feature | Depth | Predicted | Measured |
|---|---|---|---|
| Bell-jar molecule (near) | 36.8 | +22 px | +24 px |
| Wall art (on the focal plane) | 46.2 | +1 px | +5 px |
| Window mullions (far) | 75.7 | −30 px | −31 px |
The middle row is the load-bearing one: the left-hand painting happens to sit within a unit of the focal plane, and it stays put while everything around it moves in opposite directions.
Pick crops with a single dominant depth. A wide, near-horizontal surface -- the pedestal's disc, say -- spans tens of units front to back, and correlating across it returns a number that belongs to no particular feature. Crops must also be chosen on a view between the two being compared, or the feature has already slid out of the box.
All 48 tiles are populated, with a brightness spread of 7.3 across the sweep -- no collapsed views.

Extreme views (0 and 47) of the same crop. The pedestal traverses most of its own width against the painting behind it.
Cost: 7.7 s per view, 368 s for the full 7680×4320 quilt, on an M-series
Mac at +A0.05 +AM2 +R4 +Q11. Quilts repay harder anti-aliasing than stills:
each view aliases differently and the display interpolates between them, so
edge noise reads as shimmer rather than grain.
python scripts/render_museum_hologram.py # full quality
python scripts/render_museum_hologram.py --preview # quarter size, ~40 s
python scripts/render_museum_hologram.py --cast # straight to the display
5. API¶
PovCamera¶
| Field | Meaning |
|---|---|
location |
Eye position in scene units |
look_at |
Aim point -- becomes the focal plane, so it lands on the glass |
sky |
Up-hint for the camera basis (default (0,1,0)) |
fov |
Vertical field of view in degrees |
Methods: focal_distance, basis() -> (forward, right, up),
image_plane_distance().
PovCamera.aimed(location, aim, *, fov, focal_distance=None, lateral_shift=0.0,
sky=(0,1,0)) adopts a scene's own viewpoint and adapts it for a sweep: the
focal plane moves to focal_distance along the original aim ray, and the eye
slides lateral_shift along the right vector with the look-at point riding
along, so the view direction and lens stay exactly as the scene's author
composed them.
Clearance(left, right, margin=0.0)¶
The measured lateral corridor of an enclosed scene, in scene units along the camera's right vector.
| Member | Meaning |
|---|---|
centre |
Offset that puts the eye in the middle of the corridor -- feed to lateral_shift |
half_width |
Usable travel either side of centre, net of margin |
cone(focal_distance) |
Widest view cone whose outermost eye still clears the walls |
fits(spec, focal_distance) |
Whether the sweep spec asks for stays inside the corridor |
Reporting¶
sweep_extent(spec, focal_distance)-- half-width of the lateral eye travel the sweep needs, i.e. the largestview_offsets()magnitude in closed form. Lives inquiltwright.quilt; re-exported here.depth_budget(spec, camera, depths)--(label, depth, disparity_px)per labeled depth;math.infis accepted for sky.camerais anyQuiltCamera(or anything withfovandfocal_distance), not only aPovCamera.format_depth_budget(spec, camera, depths, *, clearance=None, soft_px=5.5)-- the same as a printable report, flagging depths abovesoft_pxand warning when the sweep leavesclearance.
render_pov_quilt(scene, spec, camera, ...)¶
| Argument | Purpose |
|---|---|
include_paths |
Extra #include search directories. The scene's own directory is always searched. |
view_cone |
Override the spec's cone in degrees |
antialias |
POV-Ray +A threshold; lower is better. None disables |
quality |
POV-Ray +Q level, 0-11 |
jobs |
Concurrent POV-Ray processes (see below) |
binary |
Executable path; also settable via POVRAY_BINARY |
extra_args |
Raw POV-Ray flags, e.g. radiosity cache options |
keep_views |
Directory to retain per-view PNGs and wrapper scenes for inspection |
Returns a uint8 RGB array; pair with save_quilt() from
quiltwright.quilt and cast_quilt() from quiltwright.bridge
(re-exported from quiltwright.lfd as well).
render_pov_views(scene, spec, camera, out_dir, ...)¶
The same render as render_pov_quilt() with the assembly step removed. The
camera geometry is identical -- the same off-axis sheared frustum, the same
focal plane on look_at, the same view_offsets() -- and only the output
packing differs. Frames are written into out_dir as view000.png ...
viewNNN.png, view 0 leftmost, and the paths come back in view order.
It takes render_pov_quilt's arguments minus the quilt-assembly ones, plus:
| Argument | Purpose |
|---|---|
out_dir |
Directory for the frames; created if absent |
keep_wrappers |
Also write the generated per-view .pov wrappers alongside the frames, for inspection |
That is the form consumers other than a light-field panel ask for: a hologram
printer slicing views into hogels, or a lenticular interlacer. Pair it with
sweep_spec() / LITIHOLO_SWEEP from quiltwright.quilt when the view
count is not a convenient rectangle -- a quilt grid cannot express a prime
count, and a single-row sweep can.
from quiltwright import LITIHOLO_SWEEP, render_pov_views
paths = render_pov_views("risedronate.pov", LITIHOLO_SWEEP, camera, "sweep/")
# -> sweep/view000.png ... sweep/view022.png
format_depth_budget() applies unchanged and is worth running first. A sweep
whose parallax exceeds what the medium resolves ghosts on a lens sheet, and
there is no evidence that hogels are more forgiving. LITIHOLO_SWEEP puts 2.05°
between adjacent views against a Portrait quilt's 0.74°, so it has less margin
than a quilt -- see lfd.md for what that does and does not establish
about the printer.
Supporting helpers in quiltwright.quilt¶
assemble_quilt(views, spec)-- renderer-agnostic tiling; consumes views lazily and validates the count.view_offsets(spec, distance)/window_shear(...)-- the sweep and the dimensionless off-axis window shift every backend converts into its own units.view_disparity(spec, fov, focal_distance, depth)-- adjacent-view shift in px.focal_distance_for_range(near, far)-- harmonic-mean focal distance.sweep_extent(spec, focal_distance)-- closed-form half-width of the sweep.
from quiltwright.lfd import ... still works for these names: lfd re-exports
them. New code should import from quiltwright.quilt.
6. Gotchas¶
Legacy scenes need library paths. Scenes from the 1990s reference includes
by bare filename. POV-Ray searches its working directory and the +L library
paths, not the included file's directory, so pass include_paths for any
shared include tree. Missing includes fail with Cannot open include file and
a line number in the wrapper, not the scene.
.ini files carry intended quality settings. Many scenes ship one. Command
line options override ini values, so extra_args=["museum.ini"] composes.
Radiosity and photons should be cached, not recomputed. Lighting is
identical across a view sweep, so recomputing it 48 times is pure waste. Render
one view with the cache saved and the rest with it loaded, via extra_args.
The museum uses neither, which is why it renders as fast as it does.
jobs is usually best left at 1. POV-Ray already threads a single render
across all cores. Raise it only when per-render startup dominates -- very small
tiles or preview passes.
RGBD quilts are not a shortcut here. Bridge supports them and cast_quilt
carries the isRGBD flag, but POV-Ray 3.7 has no native depth output and
faking one requires overriding every object's texture -- invasive and
unreliable on a complex scene. Render the views.
Preview at quarter size. Disparity scales with tile height, so a preview quilt genuinely has lower disparity than the final -- the composition and view validity transfer, the ghosting margin does not.