Skip to content

Blender Cycles Holographic Output

Module: quiltwright.cycles Script: scripts/render_dna_helix_hologram.py Source: src/quiltwright/cycles.py

POV-Ray will never see a ray-tracing core. Meshes don't have to care.

Renders Blender scenes and mesh files as Looking Glass quilts with Cycles, Blender's production path tracer. Companion to povray.md, which covers the POV-Ray backend the same way; everything downstream of quilt assembly -- filenames, casting, playback -- is shared with the rest of quiltwright.


Concept

The POV-Ray backend exists for scenes that are POV-Ray: CSG, isosurfaces, thirty-year-old archives. Those stay where they are. But most of what a scientific pipeline produces today is meshes -- PyVista/VTK geometry, glTF exports, USD, molecular surfaces -- and meshes are exactly what hardware ray tracing eats.

That is what this backend buys:

  • Apple's ray-tracing engine, where it exists. Cycles' Metal device runs ray/triangle intersection on the dedicated ray-tracing hardware of M3-class and later Apple GPUs (earlier Apple Silicon runs the same Metal path in GPU software). The equivalent applies elsewhere: OptiX on NVIDIA RT cores, HIP on AMD, oneAPI on Intel. Device selection is automatic, in that order, with CPU as the always-works fallback.
  • One process per quilt, not one parse per view. POV-Ray re-reads the scene and rebuilds its structures for every view -- 48 times for a Portrait quilt. Here the scene loads once, Cycles builds its BVH once (use_persistent_data), and only the camera moves between views. For mesh-heavy scenes the per-view cost collapses to actual ray tracing.
  • Production shading for free. Denoising, area lights, HDRI worlds, subsurface scattering -- whatever the scene or its materials ask for, Cycles already does.

The off-axis geometry is identical to every other quiltwright backend: the eye translates along the camera's right vector and the frustum shears back so the look-at point stays pinned -- here via Blender's camera shift, with

shift_x = -offset / (2 * Z * tan(fov/2) * aspect)

which is the same quantity VTK's SetWindowCenter receives in the PyVista backend, in fractions of the frame width instead of half-widths. The end-to-end tests render emissive markers at known depths and assert the focal-plane marker does not move across the sweep -- the property that distinguishes a correct off-axis shear from toe-in rotation.

Mechanism

render_cycles_quilt() writes a job description (JSON) and a driver script, then runs one headless Blender:

blender --background --factory-startup --python driver.py -- job.json

The driver -- generated from quiltwright/cycles.py, dependency-free beyond bpy -- loads the scene, configures Cycles, builds the camera, and renders view000.png ... viewNNN.png in a single process. The parent streams progress from QW_-prefixed stdout lines and tiles the frames with the same assemble_quilt() every backend feeds. render_cycles_views() is the sweep variant for hologram printers and lenticular interlacers, matching render_pov_views().

Scene sources

Input Route
.blend Opened natively: its materials, lights, world and (optionally) camera are used as-is
.gltf / .glb, .obj, .stl, .ply, .usd*, .fbx, .abc Imported into an empty scene

Imported meshes usually arrive without lights, and an unlit scene renders black in a path tracer, so an import with no lights of its own gets a lighting rig chosen by the lighting parameter. A .blend is never touched, and a rig always defers to any light the import carries.

lighting= Rig
"soft" (default) Neutral gray world plus a sun -- the studio-clay look
"studio" Camera-relative three-point rig (key/fill/rim area lights) over a near-black world -- the product-shot look
"sky" Blender's physical (Nishita) sky with the sun over the camera's left shoulder -- outdoor daylight
a .hdr/.exr path Equirectangular HDRI environment world
None Nothing is added

Every rig is expressed relative to the camera and scaled by the focal distance -- area-light wattage grows with distance squared -- so the same preset lights an angstrom-radius molecule and a room. The presets were tuned against rendered output; treat them as good starting points, and reach for an HDRI (or author lights in a .blend) when a shot needs a specific look.

PyVista, directly

A composed pv.Plotter needs no manual export step:

quilt = render_cycles_quilt_from_plotter(plotter, spec)  # render_quilt, ray-traced

is the hardware-ray-traced sibling of render_quilt() -- same plotter in, same quilt out, same FOV/dolly convention. Behind it, export_plotter_gltf() writes the scene to glTF and cycles_camera_from_plotter() translates the plotter's camera; both are public for when you want the intermediate pieces (pass gltf= to keep the exported scene for reuse).

The hop between VTK's world and Blender's is one deliberate contract: the scene is exported un-rotated (rotate_scene=False, since the rotation VTK otherwise bakes for glTF's Y-up convention has varied across versions), and Blender's importer then applies its fixed Y-up-to-Z-up rotation, landing a VTK point (x, y, z) at (x, -z, y). The camera goes through the same rotation, so scene and camera agree and the render matches what the plotter framed -- an invariant the end-to-end tests pin with depth markers, because a wrong hop renders perfectly plausible frames whose sweep is tilted.

Scalar-mapped colors survive: VTK bakes them into a glTF base-color texture that Blender wires into the material on import. Lights do not exist in the export, which is what the lighting rigs are for -- pass lighting="studio" through the bridge for the product-shot look. Notably, the export works with no OpenGL stack at all -- the plotter is read and exported, never rendered -- so this path runs on headless machines where render_quilt() itself cannot.

Cameras

Two modes:

Explicit -- a CyclesCamera, the right-handed Z-up twin of PovCamera: location, look_at (the focal plane), up, vertical fov. Both satisfy the QuiltCamera protocol and expose the same fov/focal_distance pair, so format_depth_budget() and the Clearance arithmetic apply unchanged -- run the depth budget before committing Cycles to a 48-view render, exactly as you would for POV-Ray. The off-axis shear itself is window_shear(...) / 2 in Blender shift_x units (quiltwright.quilt.window_shear).

The scene's own -- pass camera=None with a .blend, and the file's active camera becomes the center view. The focal plane is taken from the camera's depth-of-field focus distance (or focus object): that is Blender's native "this distance matters" annotation, and setting it blurs nothing unless DoF rendering is actually enabled. The camera's lens, sensor and existing shift are preserved; only shift_x moves during the sweep.

A subtlety worth recording: Blender's shift units and effective field of view both depend on the camera's sensor fit, and its AUTO fit sizes the sensor off sensor_width even when it resolves to a vertical fit. The driver mirrors BKE_camera_params_compute_viewplane exactly; the sensor-fit branches are each pinned by rendered-marker tests, because this is precisely the kind of arithmetic that looks right and ghosts on glass.

Usage

from quiltwright.quilt import QUILT_PRESETS, save_quilt
from quiltwright.cycles import CyclesCamera, render_cycles_quilt

camera = CyclesCamera(location=(0, -35, 8), look_at=(0, 0, 5), fov=14)
spec = QUILT_PRESETS["portrait"]
quilt = render_cycles_quilt("protein.glb", spec, camera, samples=128)
save_quilt(quilt, "protein", spec)  # -> protein_qs8x6a0.75.png

A .blend on its own camera:

quilt = render_cycles_quilt("scene.blend", spec, None)  # DoF focus = focal plane

Knobs that matter:

  • samples -- 64 previews cleanly with the denoiser on; 128-256 for finals.
  • view_transform -- "Standard" (default) is Blender's raw display-referred output and reads closest to POV-Ray's; Blender's own interactive default since 4.0 is "AgX", whose filmic highlight compression noticeably desaturates and flattens a render side by side with POV-Ray or a reference photo -- deliberately not the default here. Any OCIO transform name Blender recognizes works ("Filmic", "False Color", ...).
  • device -- "auto" (GPU first, Metal first), "gpu" (error if none), "cpu".
  • threads -- CPU renders get the same courtesy cap as the POV-Ray backend (cpu_count - 2, Blender's -t); 0 takes every core.
  • keep_views -- retain the per-view PNGs and the job JSON for inspection.

Requirements: a blender binary -- brew install --cask blender on macOS (the standard /Applications install is found automatically), or BLENDER_BINARY pointing anywhere else. Blender 4.x or later.

Worked example: one scene, both backends

scripts/render_dna_helix_hologram.py composes a B-DNA double helix -- sphere glyphs for the backbones, base-pair rungs colored A/T/G/C -- and renders it with either backend from the same generating code:

python scripts/render_dna_helix_hologram.py --still                                # Cycles, studio lighting
python scripts/render_dna_helix_hologram.py --backend povray --still               # POV-Ray, same camera
python scripts/render_dna_helix_hologram.py --lighting sky --device portrait --cast

One pv.Plotter builds the geometry and camera; the Cycles path renders it directly (via export_plotter_gltf), the POV-Ray path rebuilds the same points as analytic Sphere/Cylinder primitives and borrows the plotter's camera through pov_camera_from_plotter(), so both frame the subject identically. On a scene this size (161 primitives, no mesh data) POV-Ray's analytic intersectors are hard to beat -- seconds, not minutes, on a single core; this backend earns its keep on mesh-heavy scenes (Richardson cartoons, scanned surfaces) and on Apple Silicon, where the same call runs on the GPU's ray-tracing cores instead. See povray.md for that backend's own case study.

The mesh-heavy case: a real PyMOL cartoon

The helix above is deliberately the case POV-Ray wins -- a handful of analytic primitives. scripts/render_cartoon_hologram.py is the other end: a real Richardson cartoon (tens of thousands of triangles, the same shape quiltwright cartoon produces) rendered by both backends from the same PyMOL triangulation:

python scripts/render_cartoon_hologram.py 2omf.cif.gz --still                  # Cycles
python scripts/render_cartoon_hologram.py 2omf.cif.gz --backend povray --still # POV-Ray, same mesh

quiltwright.pymol.cartoon_obj() is the mesh twin of cartoon_inc(): the identical PyMOL export and coalescing, written as a plain OBJ instead of a POV-Ray include -- geometry only, no per-vertex color, since OBJ carries none reliably -- so a "which backend wins on a mesh this size" comparison starts from one triangulation, not two independently modeled scenes. It carries the same coordinate flip as everywhere else meshes cross from PyMOL's POV-Ray-native output into this package's right-handed convention: negate z, reverse each face's winding to compensate -- worth rereading its docstring before trusting a first render, since this path has not yet been exercised against a real PyMOL export in this codebase's own development environment (no PyMOL here) and the fix for a mesh that renders "inside out" is almost always exactly that flip.

What stays with POV-Ray

Scenes written in POV-Ray's scene language. CSG, isosurfaces and blobs do not map onto triangle acceleration structures, and the definitive answer to "how do I hardware-ray-trace museum.pov" remains: you don't -- that scene's value is that it renders unmodified, and povray.md is its path. The two backends produce interchangeable quilts on purpose; use whichever the scene's format dictates.

Testing

tests/test_cycles.py runs its geometry and orchestration tests on numpy alone (a stub stands in for Blender). The end-to-end tests need a real bpy and are found two ways: a blender binary, or QW_BPY_PYTHON naming a Python interpreter with the bpy wheel installed -- the CI/container case. With neither, they skip cleanly.