Pleochroma GitHub
Networking

Distributed rendering

Offloading sample tracing to a coordinator and its joined workers over mutual TLS.

1Preview-then-handoff

Preview-then-handoff: the desktop renders a local preview while the camera moves, then hands sample tracing to a coordinator, which traces sample-index ranges itself (with --render) and across the render workers that have joined it; every range is summed into the desktop's one accumulator. DESKTOP · pleochroma-cut camera moving local CPU / GPU preview, few samples, immediate camera settled hand sample tracing off to the coordinator COORDINATOR · worker serve splits the image into sample-index ranges, not screen tiles own lane with --render a lost worker's ranges go to the other lanes WORKERS · worker join (×N) GPU / CPU path tracer claims chunks sized to its measured rate dials out to coordinator no inbound port needed, works behind NAT scene samples chunks ranges :7878 · mTLS 1.3 :7880 · worker dials in ACCUMULATOR · ON THE DESKTOP local and remote samples are summed per pixel; the ranges are disjoint, so arrival order never matters
Fig. 1 — Preview-then-handoff: local preview while the camera moves; once it settles, the coordinator traces sample-index ranges itself and across its joined workers, and every range lands in the desktop's one accumulator.

Spectral path tracing through a high-index gemstone with dozens of internal bounces is expensive, and streaming every interactive frame to a remote machine would add visible input latency. Pleochroma instead renders locally while the camera moves, and hands off to its one configured remote endpoint once the camera settles: the client sends a compact SceneState (serialized with postcard) and the remote streams traced radiance back, which is folded into the client's own accumulation buffer. That endpoint is always a coordinator, pleochroma-worker serve, which renders the request itself, splits it across render workers that have joined it, or both (section 3); the desktop app never talks to a worker directly.

The settings dialog's Live Compute choice decides what happens at the settle: Local Only, Remote Only, or Local + Remote. With Local + Remote both sides trace the same image against one shared sample budget — the live view's single target sample count, there is no separate remote setting — through one sample cursor per settle (LiveEpoch, apps/pleochroma-cut/src/bridge/sample_cursor/live.rs), so their samples are combined, never duplicated. The remote side claims short chunks sized to about 1.5 s of its measured rate (8 samples for the first chunk, before a rate is known; LIVE_CHUNK_TARGET_SECS, bridge/remote/live_lane), so a camera drag wastes at most a second or two of remote time. A chunk that fails keeps the prefix the remote already traced and returns the untraced remainder to the local tracer; after two consecutive failures the remote sits out the rest of that settle. Each settle is stamped with the scene it was dispatched for, so a scene change during the handoff can never merge samples of the old scene into the new image.

2Sample-range additivity

Splitting a frame into screen-space tiles load-balances poorly for a gemstone render: background tiles finish almost instantly while tiles covering the stone itself carry the full bounce budget. Because each sample's RNG seed is a deterministic function of pixel index and sample number, sample contributions are additive and order-independent, so Pleochroma instead partitions work by sample-index range across the whole frame — one node traces samples [0, 64), another [64, 128), and so on — with no tile-seam stitching and linear load balancing across heterogeneous machines.

Disjointness is the whole correctness condition, so it is enforced in one place: a SampleCursor in the GUI-free crates/pleochroma-dispatch hands out disjoint absolute sample ranges to every contributor of one image — the export's local and remote lanes, the live view's local tracer and remote chunks, and the coordinator's own lane and joined workers alike. Every backend also applies the same non-finite rule (add_finite_sample, crates/pleochroma/src/optics/raytracer/sampling.rs, with its twin in the GPU reduction): a sample with a NaN or infinite component is dropped but still counted, so one bad sample can never poison a pixel's sum and the divisor of a merge is always the number of samples actually traced. A statistical test renders one range on the CPU and the next on the GPU, merges them the production way, and checks the result against an independent CPU reference (renderer/gpu/merge_tests.rs).

3The coordinator and joining workers

pleochroma-worker serve is the coordinator (apps/pleochroma-worker/src/serve, src/coordinator). It always serves the read-only design library to viewers. On a build with the worker feature it also opens a separate worker port that render workers dial into, and it traces samples with its own CPU/GPU only when started with --render (--only-gpu/--only-cpu imply it); without that flag it acquires no GPU and traces nothing itself. Its WELCOME advertises exactly that: no render capability for a bare coordinator with no joined workers (the desktop app then sees a library-only remote and renders locally), the plain CPU or GPU backend for --render alone, and a Coordinator { workers, threads, gpus } backend as soon as a worker has joined. A capability change between requests is announced to the viewer with CAPABILITY_CHANGED.

RELEASE NOTE

serve no longer renders by default. A single-machine setup that should keep rendering must add --render (or --only-gpu/--only-cpu, which imply it).

A worker is pleochroma-worker join <coordinator-host:port> (apps/pleochroma-worker/src/join): it connects out to the coordinator's worker port over mutual TLS with a worker certificate, reports its render capability in its HELLO, and then serves the coordinator's requests on that connection. No inbound port is needed on the worker, so a machine behind NAT or a cloud VM can join. --slots K opens K parallel connections (default 2, at most 64), each serving one request stream at a time; every slot reconnects forever with jittered backoff, 1 s doubling to 60 s. The second slot is there for fast workers: it keeps a chunk in flight while the first one's transfer and decode would otherwise leave the GPU idle.

Default portListenerWho connectsAllowlist
7878viewer port (--bind)pleochroma-cutallowlist-viewers.txt
7879viewer enrollment (--enroll-bind)a viewer redeeming a token—
7880worker port (--worker-bind)pleochroma-worker joinallowlist-workers.txt
7881worker enrollment (--worker-enroll-bind)join --token—

The worker port sits two ports above --bind and each enrollment listener one above its own port, on --bind's host unless overridden (WORKER_PORT_OFFSET, apps/pleochroma-worker/src/cli/mod.rs). Every listener binds loopback only unless --allow-remote is passed. --no-workers skips the worker port and its enrollment listener; --no-enroll skips both enrollment listeners.

How a request runs depends on its kind (src/coordinator/job). An export-type request — a still export, a tilt-video frame — becomes a job: a lane pool (pleochroma-dispatch's LanePool) over the request's sample range, with one lane for every idle joined worker whose pixel cap accepts the image plus the coordinator's own lane, each claiming chunks from the shared sample cursor sized to its measured rate. A live-view request takes the own lane plus every idle joined worker, fastest first (--interactive-workers all, the default); a coordinator without --render serves it from the joined workers alone. If a worker dies mid-job, the samples it had not finished go back to the cursor and the remaining lanes trace them, so the viewer still receives every sample of the request exactly once. Only when no lane at all is left does the viewer get ALL_WORKERS_LOST, and it then renders the unfinished part locally. The coordinator pings every idle joined connection after 10 s without traffic and drops it after 30 s of silence (LivenessConfig, src/coordinator/registry.rs); a joined worker that hears nothing for 45 s reconnects (JOIN_IDLE_TIMEOUT).

Two advanced flags change the live-view rule: --interactive-workers N caps the joined workers a live-view request may take (0 keeps it on the own lane alone, the lowest-latency choice on a slow link; the default is all), and --pin-interactive-worker <label> prefers one named worker while it is connected, idle and accepts the image. Throughput jobs — an export streamed final-only, or any final-picture request — wait in a per-viewer queue, one active job per viewer certificate. A small picture (its estimated time on the fastest worker under --whole-image-secs, default 2 s) goes whole to that one worker instead, and up to --jobs-per-viewer of them (default 8) run at once, so a catalogue batch keeps a fast worker busy. All in-flight jobs share one memory budget, --max-job-memory-mib (default 2048; a job is charged width × height × 48 bytes, plus width × height × 36 bytes for every lane that works on it, plus its HDR map and any share the viewer renders itself; a job whose lanes do not all fit runs on fewer, and one past the cap even with a single lane is refused, not queued).

4What travels on the wire

5Mutual TLS, roles and token enrollment

The wire protocol lives in crates/pleochroma-net, typed message framing with no networking of its own. pleochroma-worker's serve and join commands carry the actual sockets and TLS.

6The pleochroma-worker CLI

pleochroma-worker has four subcommands: render (one-shot scene-to-PNG, no networking), serve (the coordinator: the design-library protocol over TLS, the worker port, and with --render its own render lane), join (a render worker dialling a coordinator), and cert (manages the private CA: init, issue-server, issue-client, issue-token, claim). render, join and serve --render need a build with the worker feature (gpu implies it). Flags below are copied from the CLI's own --help text in apps/pleochroma-worker/src/cli/mod.rs.

terminal — render
pleochroma-worker render \
  --scene scene.json \
  --out render.png \
  --width 3840 \
  --height 2160 \
  --samples 8192 \
  --threads 16

--only-gpu / --only-cpu force a backend; the default is a hybrid split across both when it measures as worthwhile.

terminal — serve (coordinator)
pleochroma-worker serve \
  --bind 0.0.0.0:7878 --allow-remote \
  --ca pki/ca.pem --cert pki/server.pem --key pki/server.key \
  --render

--bind defaults to loopback only (127.0.0.1:7878); --allow-remote is required for any other address, on any of the four listeners. --db points at the design-library SQLite file (default facet_diagrams.sqlite in the working directory). Leave out --render for a coordinator that only distributes work to joined workers. --threads and --only-gpu/--only-cpu govern the own lane; --max-connections defaults to 64.

terminal — join (worker)
pleochroma-worker join coordinator.local:7880

The address is the coordinator's worker port (also accepted as --coordinator). --cert-dir names the worker certificate bundle (ca.pem, client.pem, client.key; default worker-cert in the working directory); --threads and --only-gpu/--only-cpu work as for render. --slots defaults to 2 (the second slot keeps a fast GPU worker busy between chunks); raise it further mainly on CPU-only machines that juggle many small chunks.

7Setting up a coordinator, its workers and the desktop app

terminal — on the coordinator machine
# 1. create a CA
pleochroma-worker cert init --dir pki/

# 2. issue the coordinator's own certificate (used on the viewer and worker ports)
pleochroma-worker cert issue-server --dir pki/ --host coordinator.local --ip 192.0.2.10

# 3. start the coordinator; drop --render if this machine should not trace samples itself
pleochroma-worker serve --bind 0.0.0.0:7878 --allow-remote \
  --ca pki/ca.pem --cert pki/server.pem --key pki/server.key --render

# 4. mint one-time, 180-second enrollment tokens (loopback only)
pleochroma-worker cert issue-token --ca pki/ca.pem --admin-addr 127.0.0.1:7879 --name my-laptop
pleochroma-worker cert issue-token --ca pki/ca.pem --admin-addr 127.0.0.1:7881 --name gpu-box --role worker
terminal — on each worker machine
# first run: claim the worker token into worker-cert/, then join
pleochroma-worker join --coordinator coordinator.local:7880 --token GW1-...

# every later run reuses the claimed bundle
pleochroma-worker join coordinator.local:7880

serve's viewer enrollment listener defaults to the same host as --bind, one port up, and the worker enrollment listener to one port above the worker port (--enroll-bind and --worker-enroll-bind override them), but cert issue-token only ever succeeds from a loopback peer regardless of that address — the operator runs it on the coordinator itself, not remotely. join --token claims one port above the worker port it was given (7881) unless --enroll-addr says otherwise; a token is single-use, so later runs leave it out. For an air-gapped machine, cert issue-client --role worker writes the same bundle for copying by hand.

In pleochroma-cut, open the Remote panel (Remote Coordinator) and choose Set Up Remote Coordinator: enter the coordinator's viewer address (coordinator.local:7878), paste the enrollment address (coordinator.local:7879) and the printed token, and press Redeem. The client redeems it (cert claim's wire equivalent), stores the returned certificate bundle, fills in the certificate folder, and completes the mutual-TLS handshake on the next Test Connection or render. The same form holds the live stream preferences and the default export Transfer; the app keeps exactly one such endpoint, and a settings file from an older build that listed several workers keeps only the first.

The Remote Coordinator dialog with one saved remote
Fig. 2 — The Remote Coordinator dialog: the Local and Remote switch for the library, Denoise Merged Image, the remote lanes kept in flight for batches and the pictures per preview request, and the saved remote with its live stream preferences (Live Progressive, 500 ms cadence), the export transfer (full data), Test Connection and Mirror Library to Local. The host name shown, render-box.local, is a placeholder.

Ray-Traced and Path-Traced

Protocol 28 appends EngineSpec as the final SceneState field; protocol 29, the current version, renders the backdrop as a lit tray. All viewers, coordinators and workers must update together. Ray-Traced sample ranges are fixed AA tap ranges, CPU/GPU lanes use the selected engine, and DisplayOnly skips denoising. Frozen Ray-Traced jobs use format 2; historical format-1 jobs remain Path-Traced. The CLI accepts --engine path|ray.

Ray-Traced is a deterministic approximation: one d-line index, or three fixed indices with Fire, RGB absorption tint and 1/4/8/16 AA taps. It finishes at the tap count without denoising. It omits polarization, birefringent doubling, scattering, zoning and edge rounding. Path-Traced retains the spectral model. Optical metrics, tilt sweeps and diagnostics keep their separate analytic tracer.