Introduction
Viso is a GPU-accelerated 3D protein visualization engine built in Rust on top of wgpu. It powers the molecular graphics in Foldit, rendering proteins, ligands, nucleic acids, and constraint visualizations at interactive frame rates.
Viso is an embeddable library: you give it a window or surface, feed it structure data, and it produces a 2D texture. The host decides what to do with that texture: display it in a winit window, paint it onto an HTML canvas, write it to a PNG, or drop it into a dioxus/egui texture slot.
#![allow(unused)]
fn main() {
use viso::Viewer;
Viewer::builder()
.with_path("1ubq") // PDB code or local .cif/.pdb/.bcif path
.with_title("My Viewer")
.build()
.run()?;
}
Features
Rendering
- Ray-marched impostors for pixel-perfect spheres and capsules at any zoom
- Post-processing pipeline: SSAO, bloom, FXAA, depth-based outlines, fog, tone mapping
Interaction
- Arcball camera with animated transitions, panning, zoom, and auto-rotate
- GPU picking: click to select residues, double-click for SS segments, triple-click for chains, shift-click for multi-select
Animation
- Snap, smooth (cubic-hermite), and cascade transitions
- Per-entity targeted animation with behavior overrides
Performance
- Background mesh generation on a worker thread with triple-buffered results
- Per-entity mesh caching: only changed entities are regenerated
- Lock-free communication between main and background threads
Configuration
- TOML-serializable options for display, lighting, color, geometry, and camera
- Load/save presets, per-section diffing on update
How It Works
File (.cif/.pdb/.bcif) ──or── Vec<MoleculeEntity>
│ │
▼ │
molex::adapters ──▶ Vec<MoleculeEntity>◄─┘
│
▼
molex::Assembly (owned by your application, e.g. foldit)
│ engine.set_assembly(Arc::new(assembly.clone()))
▼
Scene + EntityAnnotations (engine-side derived state,
│ rederived on generation change)
│
├───▶ SceneProcessor (background thread)
│ per-entity mesh cache, triple-buffered output
│
▼
Renderer (geometry → picking → post-process)
│
▼
2D texture ──▶ winit / canvas / PNG / embed
For the full architecture, see Architecture Overview.
Where to Start
Embed viso in your application:
- Quick Start: standalone viewer walkthrough
- Engine Lifecycle: creation, initialization, shutdown
- The Render Loop: per-frame sequence
- Handling Input: mouse and keyboard wiring
Understand how Foldit uses viso:
- Scene Management: Assembly, entities, focus
- Dynamic Structure Updates: Rosetta and ML integration
- Options and Presets: TOML configuration
Dig into viso internals:
- Architecture Overview: system diagram and data flow
- Rendering Pipeline: geometry pass and post-processing
- Background Scene Processing: threading model
- Animation System: transitions, behaviors, interpolation
Quick Start
Viso is a library first. With no feature flags enabled, it gives you
VisoEngine, a self-contained rendering engine you embed in your own
event loop. The optional viewer feature adds a standalone winit window
for quick prototyping; gui adds an embedded webview options panel;
binary (default) builds the CLI.
Using Viso as a Library
Add viso to your Cargo.toml:
[dependencies]
viso = { path = "../viso", default-features = false }
pollster = "0.4" # for blocking on async GPU init
The minimal integration has three parts: build a VisoEngine, push a
molex::Assembly snapshot to it, and run a render loop. You own the
Assembly directly using molex’s APIs.
1. Build the Engine and Push an Assembly
#![allow(unused)]
fn main() {
use std::sync::Arc;
use viso::{RenderContext, VisoEngine};
use viso::options::VisoOptions;
use molex::{Assembly, MoleculeEntity};
let context = pollster::block_on(
RenderContext::new(window.clone(), (width, height))
)?;
let mut engine = VisoEngine::new(context, VisoOptions::default())?;
// You own the Assembly. After every mutation, push the latest
// snapshot via engine.set_assembly. The engine drains it on the
// next update tick.
let mut assembly = Assembly::new(entities);
engine.set_assembly(Arc::new(assembly.clone()));
}
2. Mutate and Re-publish
Mutate your Assembly through molex’s APIs (add_entity,
remove_entity, update_positions, etc.), then push the new snapshot
to viso:
#![allow(unused)]
fn main() {
assembly.add_entity(new_entity);
assembly.update_positions(eid, &new_coords);
engine.set_assembly(Arc::new(assembly.clone()));
}
The engine generation-checks each push, so re-publishing without an actual change is a no-op.
3. Render Loop
Each frame, call update then render:
#![allow(unused)]
fn main() {
engine.update(dt); // poll assembly snapshots, advance animation,
// apply pending background mesh data
match engine.render() {
Ok(()) => {}
Err(wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost) => {
engine.resize(width, height);
}
Err(e) => log::error!("render error: {e:?}"),
}
}
The engine handles background mesh generation, animation, and the full post-processing pipeline internally. You own the event loop and the window.
Input
The host decodes platform events and calls typed engine methods. Pointer
and scroll feed in directly; the button feed returns a classified
ClickEvent you turn into a selection change:
#![allow(unused)]
fn main() {
engine.feed_pointer_motion(x, y);
engine.feed_scroll(delta);
if let Some(click) = engine.feed_pointer_button(button, pressed) {
match viso::classify_click_for_selection(&click) {
viso::ClickSelectionAction::Clear => store.clear(),
viso::ClickSelectionAction::Replace(r) => store.replace(r),
viso::ClickSelectionAction::Toggle(r) => store.toggle(r),
}
engine.set_selection(&store.as_btreemap());
}
}
Keyboard goes through a KeyBindings table dispatched with
bindings.dispatch(key_str, &mut engine). See
Handling Input for the full wiring.
Standalone Viewer (separate use case)
If you want to run viso as a standalone application (not embed it in
your own library), there’s a built-in Viewer for quick prototyping.
This is a separate use case from library embedding; library users should
not enable these features.
[dependencies]
viso = { path = "../viso", features = ["viewer"] }
This pulls in winit and pollster and gives you Viewer, which
handles window creation, the event loop, input wiring, and the render
loop:
#![allow(unused)]
fn main() {
use viso::Viewer;
Viewer::builder()
.with_path("assets/models/4pnk.cif")
.build()
.run()?;
}
Internally, the standalone viewer uses a helper called VisoApp to own
its own Assembly. VisoApp is not part of the library API; it
exists solely so viso can be its own host when run standalone. Library
consumers own their own molex::Assembly and call engine.set_assembly
directly, never going through VisoApp.
Running the CLI
The binary feature (enabled by default) builds a standalone CLI that
can download structures from RCSB by PDB code:
cargo run -p viso -- 1ubq
This downloads the CIF file, caches it in assets/models/, and opens
a viewer window. You can also pass a local file path:
cargo run -p viso -- path/to/structure.cif
Building and Running
Prerequisites
- Rust (stable, 1.80+)
- A GPU with WebGPU support: Metal (macOS), Vulkan (Linux/Windows), or DX12 (Windows)
- Internet access (optional, for RCSB downloads)
GUI panel (viso-ui)
The default build embeds a WASM-based options panel. On first cargo build, the
build script runs Trunk automatically to compile it. Two
extra tools are required:
# WASM compilation target
rustup target add wasm32-unknown-unknown
# Trunk (WASM bundler)
cargo install trunk
If Trunk or the WASM target is missing, the build still succeeds but the panel
will be non-functional. To skip the GUI entirely, build with
--no-default-features --features viewer.
Building
From the repository root:
# Build the standalone viewer
cargo build -p viso
# Build with optimizations (recommended for real use)
cargo build -p viso --release
Running
With a PDB ID
Pass a 4-character PDB code to auto-download from RCSB:
cargo run -p viso --release -- 1ubq
The file is downloaded as mmCIF and cached in assets/models/1ubq.cif. Subsequent runs with the same ID load from cache.
With a Local File
cargo run -p viso --release -- path/to/structure.cif
Viso supports mmCIF (.cif), PDB (.pdb), and BinaryCIF (.bcif) files.
Logging
Viso uses env_logger. Control verbosity with RUST_LOG:
# Errors only (default)
cargo run -p viso -- 1ubq
# Info-level (see download progress, frame counts, etc.)
RUST_LOG=info cargo run -p viso -- 1ubq
# Debug-level (animation frames, picking results, mesh timing)
RUST_LOG=debug cargo run -p viso -- 1ubq
# Module-specific filtering
RUST_LOG=viso::renderer::pipeline::processor=debug cargo run -p viso -- 1ubq
Platform Notes
macOS (Metal)
Metal is the default backend. No extra setup needed. Ensure your macOS version is 10.15+ (Catalina) or later.
Linux (Vulkan)
Requires Vulkan drivers. Install:
# Ubuntu/Debian
sudo apt install libvulkan-dev vulkan-tools
# Fedora
sudo dnf install vulkan-loader-devel vulkan-tools
Windows (DX12 / Vulkan)
DX12 is the default backend on Windows 10+. Vulkan is also supported if drivers are installed.
Controls
| Input | Action |
|---|---|
| Left drag | Rotate camera |
| Shift + left drag | Pan camera |
| Scroll wheel | Zoom |
| Click residue | Select residue |
| Shift + click | Add/remove from selection |
| Double-click | Select secondary structure segment |
| Triple-click | Select entire chain |
| Click background | Clear selection |
| Q | Recenter camera on focus |
| Tab | Cycle focus through entities |
| R | Toggle turntable auto-rotation |
| T | Toggle trajectory playback |
| I | Toggle ion visibility |
| U | Toggle water visibility |
| O | Toggle solvent visibility |
| L | Cycle lipid display mode |
| ` | Reset focus to session |
| Escape | Clear selection |
| \ | Toggle the GUI options panel (when built with gui) |
Architecture Overview
How viso’s subsystems fit together: the engine’s components, the path data takes from file to screen, and the threading model.
System Diagram
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ (your application, e.g. foldit) │
│ │
│ Owns the authoritative `molex::Assembly`. All structural │
│ mutations push a new Arc<Assembly> via engine.set_assembly. │
│ │
│ winit events ──► engine.feed_* / KeyBindings::dispatch │
└──────────────────────────────┬──────────────────────────────────┘
│ engine.set_assembly(Arc<Assembly>)
▼
┌─────────────────────────────────────────────────────────────────┐
│ VisoEngine │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Scene │ │ Animation │ │ Camera │ │ GpuPipeline│ │
│ │ + Annot. │ │ State │ │ Controller │ │ │ │
│ │ │ │ │ │ │ │ Renderers │ │
│ │ Per-entity │ │ Animator │ │ Arcball │ │ Picking │ │
│ │ derived │ │ Trajectory │ │ Animation │ │ Post-proc │ │
│ │ state + │ │ Pending │ │ Frustum │ │ Lighting │ │
│ │ overrides │ │ trans. │ │ │ │ Density │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ ▼ ▼ │ │ │
│ ┌───────────────────────────┐ │ │ │
│ │ Background scene │ │ │ │
│ │ processor (worker thread) │ │ │ │
│ │ │ │ │ │
│ │ Per-entity mesh cache │ │ │ │
│ │ Backbone / sidechain / │ │ │ │
│ │ ball-and-stick / NA │ │ │ │
│ └─────────────┬─────────────┘ │ │ │
│ │ triple buffer │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Renderers │ │
│ │ │ │
│ │ Molecular: Post-processing: │ │
│ │ ├─ BackboneRenderer ├─ SSAO │ │
│ │ │ (tubes + ribbons) ├─ Bloom │ │
│ │ ├─ SidechainRenderer ├─ Composite │ │
│ │ ├─ BondRenderer └─ FXAA │ │
│ │ ├─ BandRenderer │ │
│ │ ├─ ClashArcRenderer ShaderComposer: │ │
│ │ ├─ GreaseBeadRenderer └─ naga_oil composition │ │
│ │ ├─ PullRenderer │ │
│ │ ├─ BallAndStickRenderer │ │
│ │ ├─ NucleicAcidRenderer │ │
│ │ └─ IsosurfaceRenderer │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ RenderContext│ │
│ │ wgpu device │ │
│ │ queue │ │
│ │ surface │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
High-Level Data Flow
┌───────────────────────────────────────────────────────────┐
│ INITIALIZATION │
│ │
│ File path (.cif/.pdb/.bcif) ──or── Vec<MoleculeEntity> │
│ │ │ │
│ ▼ │ │
│ molex::adapters parse ──► Vec<MoleculeEntity> ◄┘ │
│ │ │
│ ▼ │
│ molex::Assembly (owned by host) │
│ │ │
│ ▼ engine.set_assembly(...) │
│ pending: Option<Arc<Assembly>> │
│ │ (engine-internal slot) │
│ ▼ │
│ Scene (in VisoEngine) │
└────────────────────────────┬──────────────────────────────┘
│ engine.update() drains,
│ rederives on generation bump
▼
┌───────────────────────────────────────────────────────────┐
│ SCENE │
│ │
│ Scene + EntityAnnotations: per-entity derived render │
│ state + user-authored overrides (focus, visibility, │
│ appearance, behaviors, scores, SS overrides, surfaces). │
│ │
│ Driven by `mesh_version` per-entity for cache │
│ invalidation. During animation, `EntityPositions` holds │
│ interpolated atom positions read by the renderers. │
└────────────────────────────┬──────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ RENDERER │
│ │
│ Consumes Scene + annotations read-only. │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Background mesh processor │ │
│ │ per-entity FullRebuildEntity → cached meshes → │ │
│ │ PreparedRebuild (raw byte buffers) │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ triple buffer │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ GPU passes │ │
│ │ │ │
│ │ 1. Geometry pass (color + normals + depth) │ │
│ │ 2. Picking pass (residue ID readback, async) │ │
│ │ 3. Post-process (SSAO, bloom, fog, FXAA) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Final 2D screen-space texture │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────┬──────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ OUTPUT / EMBEDDING │
│ │
│ The final texture is consumed by the host: │
│ • winit window (standalone viewer) │
│ • HTML canvas (wasm / web embed) │
│ • PNG snapshot (headless) │
│ • dioxus / egui / any framework with a texture slot │
│ │
│ Use `engine.render()` for swapchain present, or │
│ `engine.render_to_texture(view)` to render into a │
│ caller-owned texture view. │
└───────────────────────────────────────────────────────────┘
Data Flow: File to Screen
1. Parsing
PDB / CIF / BCIF file → molex::adapters → Vec<MoleculeEntity>
molex parses structure files into MoleculeEntity values (atomic
coordinates, names, chains, residue info, molecule type, computed
H-bonds, DSSP-classified SS).
2. Assembly Construction
Vec<MoleculeEntity> → molex::Assembly (owned by your application)
The Assembly is the authoritative structural state. Your
application owns it and pushes the latest snapshot to the engine via
engine.set_assembly(Arc::new(assembly.clone())) whenever it
changes.
3. Scene Rederivation
Each engine.update(dt) drains the engine’s pending Assembly slot;
if a new snapshot is ready, the engine rebuilds its derived per-entity
state (chains, sidechain topology, SS arrays, color metadata) from the
new assembly.
4. Background Mesh Generation
Scene → per-entity FullRebuildEntity → SceneProcessor → PreparedRebuild
The sync layer collects per-entity render data and submits a
SceneRequest::FullRebuild to the background thread. The processor
generates (or retrieves cached) meshes per entity, concatenates them
into a single PreparedRebuild, and writes it to the result triple
buffer.
5. GPU Upload
PreparedRebuild → queue.write_buffer() → GPU buffers
The main thread picks up the prepared rebuild and writes raw byte arrays directly to GPU buffers. This is a memcpy-level operation, typically under 1ms.
6. Rendering
GPU buffers → Geometry Pass → Post-Processing → Swapchain
All molecular renderers draw to HDR render targets. Post-processing applies SSAO, bloom, compositing (outlines, fog, tone mapping), and FXAA before presenting to the swapchain (or writing into the caller-owned texture view).
Threading Model
Viso uses one background worker alongside the main thread, with lock-free communication:
Main Thread
Owns all GPU resources and runs the render loop:
- Processing input events (mouse, keyboard, IPC)
- Draining the pending
Assemblyslot for new snapshots - Running animation per frame
- Submitting scene requests to the worker (non-blocking)
- Picking up completed meshes from the triple buffers (non-blocking)
- Uploading data to the GPU
- Executing the render pipeline
- Initiating GPU picking readback and resolving completed reads
The main thread never blocks. If meshes aren’t ready, it renders the previous frame’s data.
Background Scene-Processor Worker
A single worker thread owns the per-entity mesh cache and performs all CPU-intensive generation:
- Receiving scene requests via
mpsc::Receiver(blocks when idle) - Generating backbone, sidechain, ball-and-stick, and nucleic-acid meshes
- Regenerating isosurface meshes (Gaussian / SES / cavity / density /
void field) on the same worker via
SceneRequest::SurfaceRebuild - Maintaining a per-entity cache keyed on
mesh_version - Writing each result kind to its own triple buffer (non-blocking)
There is no separate surface thread; surface regeneration shares this
worker and returns a PreparedSurface over a triple buffer.
Lock-Free Bridges
| Mechanism | Direction | Semantics |
|---|---|---|
mpsc::channel | Main to worker | Submit scene requests (non-blocking send) |
triple_buffer (rebuild) | Worker to main | Latest PreparedRebuild (non-blocking read) |
triple_buffer (anim) | Worker to main | Latest PreparedRebuild (non-blocking read) |
triple_buffer (surface) | Worker to main | Latest PreparedSurface (non-blocking read) |
Triple buffers guarantee:
- The writer always has a buffer to write to (never blocks)
- The reader always gets the latest completed result
- No data races or mutex contention
Module Structure
viso/src/
├── lib.rs # Public API (flat re-exports only)
├── main.rs # Standalone CLI entry point (binary feature)
├── animation/ # Structural animation
│ ├── animator.rs # StructureAnimator + per-entity runners
│ ├── runner.rs # AnimationRunner phase evaluation
│ ├── state.rs # AnimationState (animator + trajectory + pending)
│ └── transition.rs # AnimationPhase, Transition presets (public API)
├── app/ # Standalone-app layer (feature-gated)
│ ├── viewer.rs # winit Viewer + ViewerBuilder (feature = "viewer")
│ ├── gui/ # wry-webview options panel (feature = "gui")
│ ├── web/ # WASM entry (feature = "web")
│ └── mod.rs # VisoApp (host of Assembly in standalone),
│ # publish helper that calls engine.set_assembly
├── bridge/ # GUI / IPC action types (feature = "gui")
├── camera/ # Orbital camera controller, animation, frustum
├── engine/ # Core engine struct + frame loop
│ ├── mod.rs # VisoEngine (thin dispatcher)
│ ├── annotations.rs # EntityAnnotations: focus, visibility, behaviors,
│ │ # appearance, scores, SS, surfaces
│ ├── bootstrap.rs # GPU init + VisoEngine::new + FrameTiming
│ ├── command.rs # Constraint payload types (AtomRef, BandInfo,
│ │ # ClashInfo, ExposedHydrophobicInfo, PullInfo)
│ ├── constraint.rs # Band/pull resolution
│ ├── culling.rs # Frustum culling
│ ├── density.rs # Density map loading + isosurface integration
│ ├── density_store.rs# DensityStore (loaded electron density maps)
│ ├── entity_view.rs # Per-entity render-ready derived data
│ ├── focus.rs # Focus enum
│ ├── options_apply.rs# set_options / set_surface_scale / etc.
│ ├── positions.rs # EntityPositions: interpolated atom positions
│ ├── scene.rs # Scene: pending Assembly + last_seen_generation + state
│ ├── scene_state.rs # SceneRenderState: per-entity render aggregations
│ ├── surface.rs # Surface options resolution
│ ├── surface_regen.rs# Background isosurface regeneration
│ ├── sync/ # Scene → renderer pipeline
│ └── trajectory.rs # TrajectoryPlayer (DCD frame sequencer)
├── error.rs # VisoError
├── gpu/ # wgpu device init, dynamic buffers, lighting,
│ # shader composition, residue color buffer
├── input/ # Pointer/key intake types: ClickEvent, KeyBindings
├── options/ # TOML-serializable runtime options + score color
├── renderer/ # GPU rendering pipeline
│ ├── mod.rs # PipelineLayouts, Renderers, GeometryPassInput
│ ├── gpu_pipeline.rs # GpuPipeline (rendering entry point)
│ ├── draw_context.rs # DrawBindGroups
│ ├── entity_topology.rs # Per-entity topology metadata for renderers
│ ├── geometry/ # Mesh + impostor generation (backbone, sidechain,
│ │ # ball-and-stick, NA, isosurface, band, pull, bond)
│ ├── impostor/ # Impostor primitives (sphere, capsule, cone, polygon)
│ ├── mesh.rs # Generic mesh helpers
│ ├── picking/ # GPU picking + PickingSystem + PickTarget + PickMap
│ ├── pipeline/ # Background mesh-gen pipeline
│ │ ├── prepared.rs # SceneRequest, PreparedRebuild,
│ │ │ # SurfaceRebuildBody, PreparedSurface
│ │ ├── mesh_gen.rs # Per-entity / per-frame mesh generation
│ │ ├── mesh_concat.rs # Merge per-entity meshes
│ │ └── processor.rs # Background thread + cache
│ ├── pipeline_util.rs# Helper utilities
│ └── postprocess/ # SSAO, bloom, composite, FXAA, screen passes
├── shaders/ # WGSL sources organized by role
│ ├── modules/ # Shared modules: camera, lighting, ray, sdf, ...
│ ├── raster/ # Mesh + impostor rasterization shaders
│ ├── screen/ # Full-screen passes (composite, FXAA, SSAO, bloom)
│ └── utility/ # Picking shaders
└── util/ # Helpers (easing.rs, hash.rs)
Key Design Decisions
Why Background Mesh Generation?
Mesh generation for complex proteins (>1000 residues) can take 20-40ms. At 60fps, that’s most of the frame budget. By offloading to a background thread:
- The main thread maintains smooth rendering
- GPU upload is <1ms (raw buffer writes)
- The background thread can take as long as it needs without dropping frames
Why Triple Buffers?
Triple buffers provide lock-free communication:
- The writer always has a buffer to write to
- The reader always reads the latest result
- No mutexes, no contention, no blocking on either side
The cost is memory (3× the buffer size), but mesh data is typically 1–10MB, so this is negligible.
Why Per-Entity Mesh Caching?
Molecular scenes often have multiple entities where only some change
at a time (e.g. Rosetta updates one entity while others stay static).
Per-entity caching with mesh_version-based invalidation means only
changed entities are regenerated. For a 3-entity scene where one
changes, this saves 60–80% of generation time.
Why Capsule Impostors?
Sidechains and ball-and-stick atoms use ray-marched impostor rendering instead of mesh-based spheres and cylinders:
- Memory: a capsule is 48 bytes vs hundreds of bytes for a mesh sphere
- Quality: impostors are pixel-perfect at any zoom level
- Performance: GPU ray-marching is efficient for the simple SDF shapes (spheres, capsules, cones)
Why a Host-Owned Assembly?
molex::Assembly belongs to molex; viso just renders it. The host
application (typically foldit) owns the authoritative
Assembly because it also drives Rosetta and ML backends and needs
to mutate the assembly in response to their results. Viso never
mutates the structural state itself; the host pushes the latest
Arc<Assembly> snapshot via engine.set_assembly, and the engine
drains it on the next sync tick. The library API stays narrow: a
single setter and a generation check inside update. No viso-flavored
channels or publishers leak out of the engine.
When viso runs as a standalone application (cargo run -p viso,
feature = "viewer" / "gui" / "web"), the in-tree helper VisoApp
plays the host role for viso itself. VisoApp is purely an internal
standalone-deployment helper: it is feature-gated and is not part
of the library’s public surface. Library consumers own their own
Assembly and call engine.set_assembly directly.
Options and Presets
Viso’s visual appearance is controlled by the VisoOptions struct,
which can be loaded from and saved to TOML files. This enables
presets for different visualization styles.
Options Structure
#![allow(unused)]
fn main() {
pub struct VisoOptions {
pub display: DisplayOptions,
pub lighting: LightingOptions,
pub post_processing: PostProcessingOptions,
pub camera: CameraOptions,
pub colors: ColorOptions,
pub geometry: GeometryOptions,
pub debug: DebugOptions,
}
}
All sub-structs use #[serde(default)], so TOML files can be partial:
only the fields you want to override need to be specified. Key bindings
are not part of VisoOptions. They live in the standalone KeyBindings
table (src/input/key_bindings.rs), which holds closures and is not
serializable; build it with KeyBindings::default() and edit it in code,
not via TOML.
Display Options
#![allow(unused)]
fn main() {
pub struct DisplayOptions {
// Ambient visibility (type-level toggles)
pub show_waters: bool,
pub show_ions: bool,
pub show_solvent: bool,
// Surface presentation mode (VSync, immediate, mailbox)
pub present_mode: PresentMode,
// Structural bond display (H-bonds, disulfides)
pub bonds: BondOptions,
// Per-entity overridable fields (flattened for TOML compat).
// These are also used at per-entity scope via `EntityAnnotations`;
// `None` at either scope falls through to the next layer
// (entity → global → built-in defaults).
#[serde(flatten)]
pub overrides: DisplayOverrides,
}
}
DisplayOverrides carries 14 per-entity overridable fields:
drawing_mode, color_scheme, helix_style, sheet_style,
show_sidechains, show_hydrogens, surface_kind, surface_opacity,
show_cavities, sidechain_color_mode, na_color_mode, lipid_mode,
palette_preset, palette_mode. Any field set to Some(...) at the
global scope acts as the default for entities that don’t override it.
Resolved getters (display.drawing_mode(), display.show_sidechains(),
etc.) walk the override chain to produce a final value.
Lighting Options
#![allow(unused)]
fn main() {
pub struct LightingOptions {
pub light1_intensity: f32, // default: 2.0 (key light)
pub light2_intensity: f32, // default: 1.1 (fill light)
pub ambient: f32, // default: 0.45
pub specular_intensity: f32, // default: 0.35
pub shininess: f32, // default: 38.0
pub rim_power: f32, // default: 5.0
pub rim_intensity: f32, // default: 0.3
pub rim_directionality: f32, // default: 0.3
pub rim_color: [f32; 3], // default: [1.0, 0.85, 0.7]
pub ibl_strength: f32, // default: 0.6
pub roughness: f32, // default: 0.35
pub metalness: f32, // default: 0.15
}
}
Light directions are derived per-frame from the camera (“headlamp” lighting) rather than configured statically.
Post-Processing Options
#![allow(unused)]
fn main() {
pub struct PostProcessingOptions {
pub outline_thickness: f32, // default: 1.0
pub outline_strength: f32, // default: 0.7
pub ao_strength: f32, // default: 0.85
pub ao_radius: f32, // default: 0.5
pub ao_bias: f32, // default: 0.025
pub ao_power: f32, // default: 2.0
pub fog_start: f32, // default: 100.0
pub fog_density: f32, // default: 0.005
pub exposure: f32, // default: 1.0
pub normal_outline_strength: f32, // default: 0.5
pub bloom_intensity: f32, // default: 0.0 (disabled)
pub bloom_threshold: f32, // default: 1.0
}
}
Camera Options
#![allow(unused)]
fn main() {
pub struct CameraOptions {
pub fovy: f32, // Field of view in degrees, default: 45.0
pub znear: f32, // Near clip plane, default: 5.0
pub zfar: f32, // Far clip plane, default: 2000.0
pub rotate_speed: f32, // Mouse rotation sensitivity, default: 0.5
pub pan_speed: f32, // Mouse pan sensitivity, default: 0.5
pub zoom_speed: f32, // Scroll zoom sensitivity, default: 0.1
}
}
Color Options
#![allow(unused)]
fn main() {
pub struct ColorOptions {
pub lipid_carbon_tint: [f32; 3], // Warm beige/tan for lipid carbons
pub hydrophobic_sidechain: [f32; 3], // Blue for hydrophobic sidechains
pub hydrophilic_sidechain: [f32; 3], // Orange for hydrophilic sidechains
pub nucleic_acid: [f32; 3], // Light blue-violet for DNA/RNA
pub band_default: [f32; 3], // Purple
pub band_backbone: [f32; 3], // Yellow-orange
pub band_disulfide: [f32; 3], // Yellow-green
pub band_hbond: [f32; 3], // Cyan
pub solvent_color: [f32; 3],
pub cofactor_tints: HashMap<String, [f32; 3]>,
}
}
The default cofactor_tints includes greens for chlorophylls (CLA,
CHL), oranges for carotenoids (BCR, BCB), reds for hemes (HEM, HEC,
HEA, HEB), and others.
Geometry Options
Geometry options control cartoon rendering detail. Per-SS parameters
(width, thickness, roundness) can be set directly or driven by a
cartoon_style preset:
#![allow(unused)]
fn main() {
pub struct GeometryOptions {
pub cartoon_style: CartoonStyle, // Ribbon | Tube | Cylindrical | Custom
pub sheet_arrows: bool, // default: true
// Per-SS appearance (in Ångström)
pub helix_width: f32, // default: 1.4
pub helix_thickness: f32, // default: 0.25
pub helix_roundness: f32, // default: 0.0
pub sheet_width: f32, // default: 1.6
pub sheet_thickness: f32, // default: 0.25
pub sheet_roundness: f32, // default: 0.0
pub coil_width: f32, // default: 0.4
pub coil_thickness: f32, // default: 0.4
pub coil_roundness: f32, // default: 1.0
// Nucleic acid backbone
pub na_width: f32, // default: 1.2
pub na_thickness: f32, // default: 0.25
pub na_roundness: f32, // default: 0.0
// Mesh detail
pub segments_per_residue: usize, // default: 32
pub cross_section_verts: usize, // default: 16
// Small-molecule rendering
pub solvent_radius: f32, // default: 0.15
pub ligand_sphere_radius: f32, // default: 0.3
pub ligand_bond_radius: f32, // default: 0.12
}
}
CartoonStyle::Custom keeps the per-SS fields as-is; the other
presets overwrite them at resolve time.
Debug Options
DebugOptions controls debug-only visualizations (frustum overlays,
LOD heatmaps, etc.). See options/debug.rs for the current field set.
Loading and Saving
#![allow(unused)]
fn main() {
// Load from TOML file (partial files supported)
let options = VisoOptions::load(Path::new("presets/dark.toml"))?;
// Save to TOML file
options.save(Path::new("presets/my_preset.toml"))?;
// List available presets in a directory
let presets = VisoOptions::list_presets(Path::new("presets/"));
// Returns: ["dark", "publication", "presentation", ...]
}
VisoOptions::json_schema() returns a Schemars schema describing the
UI-exposed subset of options (used by the embedded webview panel).
Example TOML Preset
[lighting]
light1_intensity = 2.5
ambient = 0.5
specular_intensity = 0.4
shininess = 50.0
[post_processing]
outline_thickness = 1.5
outline_strength = 0.8
ao_strength = 1.0
bloom_intensity = 0.15
bloom_threshold = 0.8
[camera]
fovy = 35.0
rotate_speed = 0.4
[colors]
hydrophobic_sidechain = [0.2, 0.4, 0.85]
hydrophilic_sidechain = [0.9, 0.55, 0.15]
Applying Options at Runtime
engine.set_options(new_options) is the canonical entry point. It
diffs the new options against the current ones and dispatches the
right invalidations:
- Display/color/geometry changes that affect mesh content trigger a full scene resync via the background processor.
- Lighting changes are pushed directly to GPU lighting uniforms.
- Post-processing changes update GPU uniforms and SSAO/bloom render targets without touching geometry.
- Camera changes (FOV, znear/zfar, sensitivity) are applied to the controller in place.
Color Modes
Viso supports several coloring schemes for backbones, sidechains, and nucleic acids. Colors are computed during background scene processing and uploaded to GPU buffers for zero-cost rendering.
The active color scheme is driven by the ColorScheme enum on
DisplayOverrides, which decouples what data drives color from
which palette is used.
ColorScheme
#![allow(unused)]
fn main() {
pub enum ColorScheme {
Entity, // Each entity gets a distinct palette color
SecondaryStructure, // Helix / sheet / coil
ResidueIndex, // N-to-C gradient per chain
BFactor, // Crystallographic B-factor gradient
Hydrophobicity, // Kyte-Doolittle hydrophobicity gradient
Score, // Absolute Rosetta energy score
ScoreRelative, // Score normalized to the 5th/95th percentiles
Solid, // Single uniform color (first palette stop)
}
}
ColorScheme chooses what data maps to color. The companion
Palette (selected via palette_preset and palette_mode on
DisplayOverrides) chooses which colors. Any scheme can be combined
with any palette.
Color modes in detail
Chain / Entity (Default)
Each entity gets a distinct color from the active palette. Single-chain proteins use a gradient along the chain. This is the most common mode for general visualization.
Secondary Structure
Colors residues by their computed secondary structure type:
- Alpha helix: distinct helix color
- Beta sheet: distinct sheet color
- Coil/Loop: neutral color
Secondary structure is computed by molex (DSSP) by default. Per-entity
overrides via engine.set_ss_override(id, ss_types).
Score / ScoreRelative
Colors residues by per-residue energy values (e.g. from Rosetta).
Score uses absolute values; ScoreRelative normalizes to the
5th/95th percentiles within the structure. Scores are set via
engine.set_per_residue_scores(id, Some(scores)).
ResidueIndex
N-to-C gradient per chain; useful for sequence-position visualization.
BFactor / Hydrophobicity
Gradient by crystallographic B-factor or Kyte-Doolittle hydrophobicity.
Solid
Single uniform color drawn from the first stop of the active palette.
Sidechain Color Modes
#![allow(unused)]
fn main() {
pub enum SidechainColorMode {
Hydrophobicity,
Backbone, // default: match the backbone color of the residue
}
}
Backbone (Default)
Sidechain atoms inherit the backbone color of their residue. This is the default because it makes sidechains read as part of their residue visually rather than as an independent layer.
Hydrophobicity
Hydrophobic / hydrophilic dichotomy:
- Hydrophobic: blue (default:
[0.3, 0.5, 0.9]) - Hydrophilic: orange (default:
[0.95, 0.6, 0.2])
Configurable via ColorOptions::hydrophobic_sidechain /
hydrophilic_sidechain.
Nucleic Acid Color Modes
#![allow(unused)]
fn main() {
pub enum NaColorMode {
Uniform,
BaseColor, // default: color each backbone segment by its base
}
}
BaseColor (Default)
Each residue’s backbone segment is colored to match its nucleobase (A/T/G/C/U).
Uniform
All nucleic acid backbone uses a single color (default light
blue-violet [0.45, 0.55, 0.85]), configurable via
ColorOptions::nucleic_acid.
Non-Protein Coloring
Ligands, ions, and waters use element-based CPK coloring in the ball-and-stick renderer:
- Standard CPK colors for common elements (C, N, O, S, P, etc.)
- Lipid carbons use a warm beige/tan tint (configurable via
ColorOptions::lipid_carbon_tint) - Cofactors can have per-residue-name carbon tints via
ColorOptions::cofactor_tints
Color Transitions During Animation
When backbone colors change between poses (e.g. score coloring updates after minimization), the background processor caches per-residue colors in the prepared scene. During animation, the renderers interpolate between the old and new colors using the same easing function as the backbone position interpolation.
Color changes interpolate over the animation duration rather than snapping to the new colors in a single frame.
How Colors Flow Through the Pipeline
- Scene sync:
DisplayOptions,DisplayOverrides, andColorOptionsare sent to the background processor as part of theFullRebuildrequest. - Background thread: during mesh generation, colors are computed per-residue from the resolved color scheme and palette and baked into vertex / instance buffers.
- GPU upload: color buffers are uploaded to the GPU as part of the prepared rebuild.
- Rendering: shaders read per-residue colors directly, with
selection highlighting applied as an overlay in the fragment shader
via the
SelectionBufferbit-array.
Animation System
Viso’s animation system handles smooth visual transitions when protein
structures change. It is data-driven: a Transition describes the
animation as a sequence of phases, and an AnimationRunner evaluates
those phases each frame.
Data-Driven Architecture
Transition -> AnimationRunner
(phases + flags) (evaluates phases per frame)
- Transition: a struct holding a
Vec<AnimationPhase>plus anallows_size_changeflag. Each phase carries an easing function, duration, lerp range, and a sidechain-visibility flag. - AnimationRunner: evaluates a single animation from start to target, advancing through phases sequentially.
There are no trait objects or behavior types. The consumer constructs a
Transition from a preset constructor, and the runner evaluates the
phase sequence directly.
Transition
Transition is the only animation type in the public API. Construct it
from a preset and tune it with the builder method:
#![allow(unused)]
fn main() {
pub struct Transition {
pub allows_size_change: bool,
// phases + name are crate-internal
}
// Preset constructors
Transition::snap() // Instant; allows resize (also used for trajectory frames)
Transition::smooth() // 300ms cubic-hermite ease-out (also Default)
Transition::cascade(base_dur, delay_per_residue)
// Total duration across phases
let d: Duration = transition.total_duration();
// Builder method
Transition::smooth().allowing_size_change()
}
AnimationPhase (internal)
Each phase defines a segment of the animation:
#![allow(unused)]
fn main() {
pub(crate) struct AnimationPhase {
pub(crate) easing: EasingFunction,
pub(crate) duration: Duration,
pub(crate) lerp_start: f32, // e.g. 0.0
pub(crate) lerp_end: f32, // e.g. 1.0
pub(crate) include_sidechains: bool,
}
}
AnimationPhase is pub(crate): consumers don’t construct it; the
preset constructors build it. The runner maps raw progress (0 to 1 over
the total duration) through the phase sequence, and each phase applies
its own easing within its lerp range.
Presets
Snap
Instant transition, zero duration. Used for initial loads (where animation would delay the first meaningful frame) and for trajectory frames fed through the animation pipeline.
Smooth (Default)
Standard eased lerp from start to target: 300ms with cubic-hermite
ease-out (CubicHermite { c1: 0.33, c2: 1.0 }). Good for incremental
changes where start and target are close.
Cascade
Single-phase quadratic-out lerp intended for a staggered per-residue wave. Per-residue staggering is not yet integrated into the runner, so it currently animates all residues with the same timing.
Per-Entity Animation
The engine drives one in-flight AnimationPlayer (built by
build_animation from the current and pending snapshots) and a
StructureAnimator that holds per-entity runner state keyed on
EntityId. The animator writes interpolated atom positions into the
engine’s EntityPositions each frame.
The mutation surface lives on VisoApp (update_entities,
update_entity, sync_entities). Each call sets new target coordinates
and queues a per-entity Transition for the engine’s next sync.
Per-entity behavior overrides (engine.set_entity_behavior) take
precedence over the supplied default transition.
How It Works
- The host mutates its
Assemblyand pushes the new snapshot viaengine.set_assembly; pending per-entity transitions are stored on the engine’sAnimationState. - On the next
engine.update(), the engine builds an animation from the new snapshot. A same-topology change adopts the target up front and eases the kept positions toward it; a topology-changing mutation defers adoption to a waypoint and stays on the old conformation until then. - Each frame the runner advances and interpolated positions are written
into
EntityPositions. Sidechain positions are interpolated with the same easedtas the backbone. - When a runner completes (progress reaches 1.0), the entity snaps to target and the runner is removed.
Preemption
When a newer snapshot arrives mid-animation, the engine coalesces to the latest target: it re-aims while still easing (or drops the in-flight player to rebuild toward the latest), letting an in-flight expand finish first. The result is responsive feedback during rapid update cycles such as a Rosetta wiggle.
Sidechain Animation
Sidechain positions are stored alongside the backbone start/target arrays
and lerped with the same eased t, so renderers and constraint
resolution read interpolated sidechains without recomputing them. When
allows_size_change is set (a residue mutation), the start sidechain
positions are written as CA coordinates at setup so the lerp grows them
outward into their target positions. A phase with
include_sidechains: false hides sidechains for that segment of the
animation.
Trajectory Playback
DCD trajectory frames feed through the same animation pipeline.
TrajectoryPlayer (src/engine/trajectory.rs) is a frame sequencer with
no animation dependencies. Each frame it produces is applied through the
same path as Transition::snap(), so trajectory and structural animation
share one code path in the engine’s tick_animation.
#![allow(unused)]
fn main() {
engine.load_trajectory(Path::new("path/to/traj.dcd"));
engine.toggle_trajectory(); // play / pause
let has = engine.has_trajectory();
}
Easing Functions
Defined in src/util/easing.rs:
| Function | Description |
|---|---|
Linear | No easing |
QuadraticIn | Slow start, fast end |
QuadraticOut | Fast start, slow end |
CubicHermite { c1, c2 } | Configurable control points (default: ease-out) |
All functions clamp input to [0, 1] and evaluate in well under 100ns.
Disabling Animation
Use Transition::snap() per update, or set a snap behavior on an
entity so every subsequent update is instantaneous:
#![allow(unused)]
fn main() {
let eid = engine.entity_id(raw_id).expect("known entity");
engine.set_entity_behavior(eid, Transition::snap());
}
Camera System
Viso uses an arcball camera that orbits a focus point. It supports animated transitions between viewpoints, turntable auto-rotation, frustum culling for sidechains, and screen/world coordinate conversion.
Arcball Model
The camera is defined by four parameters:
- Focus point: the world-space point the camera orbits.
- Distance: how far the camera sits from the focus point.
- Orientation: a quaternion for the camera’s rotation.
- Bounding radius: the radius of the structure being viewed, used to drive fog and culling.
All manipulation (rotate, pan, zoom) operates on these parameters, not on a view matrix directly.
Camera Controller
CameraController (src/camera/controller.rs) wraps the camera and owns
input, GPU uniforms, and animation. It is pub(crate): consumers drive
the camera through VisoEngine methods, not through the controller.
The tunables come from CameraOptions:
rotate_speed(default 0.5)pan_speed(default 0.5)zoom_speed(default 0.1)fovy(default 45.0 degrees)znear(default 5.0)zfar(default 2000.0)
Pointer-Driven Manipulation
Rotate, pan, and zoom are applied by the pointer intake, not by a command
type. While the primary button is held over a non-pickable area,
feed_pointer_motion rotates the camera (or pans when shift is held);
feed_scroll zooms.
- Rotate: horizontal pointer movement rotates around the up vector,
vertical movement around the right vector, scaled by
rotate_speed. - Pan: translates the focus point along the camera’s right and up
vectors, cancelling any in-progress focus animation, scaled by
pan_speed. - Zoom: adjusts the orbital distance (clamped to a sensible range),
scaled by
zoom_speed.
See Handling Input for the intake
wiring. The underlying rotate / pan / zoom methods on the
controller are crate-internal.
Camera Animation
The camera animates between states for smooth viewpoint changes when loading structures or switching focus.
Fitting to a Bounding Sphere
The engine computes a bounding sphere over the relevant entities and calls one of the controller’s fit methods:
fit_to_sphere(centroid, radius): instant fit (initial load).fit_to_sphere_animated(centroid, radius): animated fit (focus cycle, scene replacement).
The fit accounts for both horizontal and vertical FOV so the structure fits the viewport. Public entry points on the engine:
#![allow(unused)]
fn main() {
engine.fit_camera_to_focus(); // animated fit to the current focus
engine.recenter_camera(); // alias used by the default Q binding
engine.snap_camera_to_focus(); // instant fit (no animation)
}
focus_centroid() returns the atom-count-weighted centroid of the
visible scene, and set_camera_pose(center, eye, up) positions the
camera explicitly from a saved viewpoint.
Per-Frame Update
engine.update(dt) ticks the controller’s update_animation,
interpolating focus, distance, and bounding radius toward their targets.
Auto-Rotation
#![allow(unused)]
fn main() {
engine.toggle_auto_rotate();
engine.set_auto_rotate(true);
}
When enabled, the camera spins around the up vector at a fixed turntable speed (~29 degrees/sec). The spin axis is captured from the current up vector at the moment auto-rotation is enabled.
Frustum Culling
The camera produces a frustum used to cull sidechains: sidechains outside the view frustum (with a small angstrom margin) are skipped during rendering. The engine reuploads the frustum-filtered sidechain instance buffer when the camera moves enough to invalidate the previous cull.
Coordinate Conversion
Two conversions are exposed on the engine for input and constraint math:
world_to_screen(world) -> Option<Vec2>: project a world point to pixels (origin top-left);Noneif the point is at or behind the camera.screen_to_world_at_depth(screen_pos, world_point) -> Vec3: unproject a screen pixel onto a plane parallel to the camera at the depth ofworld_point. Used for drag operations so the drag stays at the atom’s depth.
Fog Derivation
Fog parameters are derived from the camera each frame in pre_render:
- Fog start: the current orbital distance.
- Fog density:
2.0 / max(bounding_radius, 10.0).
The composite post-pass applies depth-based fog, fading distant geometry to the background color.
GPU Uniform
The camera uniform is uploaded each frame during the render pass. It carries the projection and view matrices, inverse projection, camera position, hovered residue id, screen dimensions, and an elapsed-time field. All renderers bind it for vertex transformation and view-dependent effects.
GPU Picking and Selection
Viso uses GPU-based picking to determine what is under the mouse cursor. This is faster and more accurate than CPU ray-casting, especially with complex molecular geometry.
How Picking Works
Offscreen Render Pass
The picking system renders all molecular geometry to an offscreen
texture with format R32Uint. Instead of colors, each fragment
writes a pick ID (an entity-and-element-specific 1-based index;
0 means “no hit”).
Main render: geometry → HDR color + normals + depth
Picking render: same geometry → R32Uint pick IDs + depth
The picking pass uses depth testing (Less compare with depth
writes) so only the closest geometry’s pick ID survives.
Geometry Types in Picking
The picking pass renders the following geometry, each with its own shader:
- Backbone tube + ribbon (
picking_mesh.wgsl). In Cartoon mode the renderer issues separate index ranges for tube (coil) segments and ribbon (helix/sheet) segments; both write their residue’s pick ID. - Sidechain capsules (
picking_capsule.wgsl) with a storage buffer of capsule instances. - Ball-and-stick spheres (
picking_sphere.wgsl). Atom indices are mapped through the per-rebuildPickMap. - Ball-and-stick capsules (
picking_capsule.wgsl) for bond capsules in BallAndStick mode.
PickTarget and PickMap
A typed pick target:
#![allow(unused)]
fn main() {
pub enum PickTarget {
None,
Residue(u32), // residue index
Atom { entity_id: u32, atom_idx: u32 }, // small-molecule atom
}
}
A PickMap (built per-rebuild, embedded in PreparedRebuild) maps
raw GPU pick IDs to typed targets:
0→None1..=residue_count→Residue(idx)residue_count+1..=residue_count+atom_count→Atom { entity, atom }
Non-Blocking Readback
Reading data back from the GPU is expensive if done synchronously. Viso uses a two-frame pipeline:
Frame N:
- The picking pass renders to the offscreen texture.
- A single pixel at the mouse position is copied to a staging buffer (256 bytes minimum, aligned for wgpu).
start_readback()initiates an async buffer map without blocking.
Frame N+1:
poll_and_resolvepolls the wgpu device without blocking.- If the map callback has fired (signaled via
AtomicBool), the mapped data is read: 4 bytes asu32, resolved through the activePickMapto aPickTarget. - The staging buffer is unmapped.
- The result is cached in
hovered_targeton the picking system.
If the readback isn’t ready yet, the previous frame’s cached value is used, so hover is at most one frame behind the cursor.
The flow is wired up inside engine.render():
#![allow(unused)]
fn main() {
self.gpu.pick.picking.start_readback(); // after queue.submit()
self.gpu.pick.poll_and_resolve(&device); // wraps complete_readback()
}
poll_and_resolve (src/renderer/picking/mod.rs) is the call the engine
makes; it internally calls complete_readback on the picking pipeline
and maps the raw id through the PickMap.
Public Hover API
Consumers query the resolved hover target through the engine:
#![allow(unused)]
fn main() {
let target: PickTarget = engine.hovered_target();
match target {
PickTarget::None => { /* mouse on background */ }
PickTarget::Residue(idx) => { /* hovering residue */ }
PickTarget::Atom { entity_id, atom_idx } => { /* hovering ligand atom */ }
}
}
hovered_target is read from the cached pick resolved on the previous
frame, so it is current as of the last completed readback.
Selection Buffer
The SelectionBuffer is a GPU storage buffer containing a bit-array
of selected residues. It’s bound to all molecular renderers so
shaders can highlight selected residues.
Bit Packing
Selection is stored as u32 words with one bit per residue:
Word 0: residues 0-31 (bit 0 = residue 0, bit 1 = residue 1, …)
Word 1: residues 32-63
Word 2: residues 64-95
…
Updating Selection
The engine pushes the latest selection to the GPU each frame inside
pre_render. Consumers don’t need to call this directly.
Dynamic Capacity
The buffer grows as needed when entity counts change. The engine’s
ensure_residue_capacity rebuilds the buffer and bind group when
the total residue count exceeds the current capacity.
Click Handling
The engine classifies clicks; the host applies them. feed_pointer_button
returns a ClickEvent whose expansion field already lists the residues
the click selects (computed against the current scene). The host runs
classify_click_for_selection to get a ClickSelectionAction, applies
it to its own selection store, then pushes the result with
engine.set_selection. See
Handling Input for the full loop.
| Pattern | Expansion |
|---|---|
Single | the clicked residue |
Double | every residue in the clicked residue’s SS segment |
Triple | every residue in the clicked residue’s chain |
Empty | empty (background click; classifies as Clear) |
A plain click maps to Replace(expansion), a shift-held click to
Toggle(expansion), and an Empty click to Clear.
Double Click (Secondary Structure Segment)
The double-click expansion walks the engine’s concatenated cartoon SS
array backward and forward from the clicked residue until the SS type
changes, then returns every residue in the resulting range
(residues_in_segment).
Triple Click (Chain)
The triple-click expansion finds the chain containing the clicked
residue and returns every residue in that chain (residues_in_chain).
Click Type Detection
The engine’s multi-click state machine (src/input/click_state.rs)
tracks timing between presses. Clicks within a threshold on the same
target increment the click counter (single, double, triple). If the
pointer moved between press and release, the gesture is classified as a
drag (which drives the camera) and feed_pointer_button returns None.
Selection in Shaders
All molecular renderers receive the selection bind group. In the fragment shader:
let word_idx = residue_idx / 32u;
let bit_idx = residue_idx % 32u;
let is_selected = (selection_data[word_idx] >> bit_idx) & 1u;
if is_selected == 1u {
// Apply selection highlight (e.g. brighten color)
}
The hover effect uses the camera uniform’s hovered_residue field: the
shader checks whether the fragment’s residue index matches the hovered
residue and applies a highlight.
Querying and Mutating Selection State
Selection is host-owned. Push the authoritative per-entity selection to the engine and read back the hover target:
#![allow(unused)]
fn main() {
// Replace the selection. The engine stores this per-entity map as the
// source of truth and re-derives the flat GPU bitset from it.
engine.set_selection(&selection); // &BTreeMap<EntityId, BTreeSet<u32>>
// Clear the selection.
engine.clear_selection();
// Currently hovered target (resolved from the previous frame's pick).
let hovered: PickTarget = engine.hovered_target();
}
The engine keeps no public list of selected residues; the host’s store
is authoritative, and the engine derives its GPU bitset from whatever
set_selection was last given.
Rendering Pipeline
Viso’s rendering pipeline has two main stages: a geometry pass that renders molecular structures to HDR render targets, and a post-processing stack that applies screen-space effects.
Overview
Geometry Pass (10 molecular renderers)
↓ Color (Rgba16Float) + Normals (Rgba16Float) + Depth (Depth32Float)
↓
Post-Processing Stack:
1. SSAO: depth + normals → ambient occlusion texture
2. Bloom: color → threshold → blur → half-res bloom texture
3. Composite: color + SSAO + depth + normals + bloom → tone-mapped result
4. FXAA: anti-aliased final output → swapchain
Geometry Pass
Render Targets
All molecular renderers write to two HDR render targets plus a depth buffer:
| Target | Format | Contents |
|---|---|---|
| Color | Rgba16Float | Scene color with alpha blending |
| Normal | Rgba16Float | View-space normals / metadata (no blending) |
| Depth | Depth32Float | Depth buffer (Less compare, writes enabled) |
Rgba16Float enables HDR lighting and bloom without banding
artifacts.
Molecular Renderers
The Renderers struct holds ten renderers, drawn in the geometry pass:
1. BackboneRenderer
Renders protein backbones as a single mesh with two index ranges: tube indices (drawn first for coil segments and fully in tube mode) and ribbon indices (drawn for helices and sheets in ribbon mode).
- Geometry: cubic Hermite splines with rotation-minimizing frames.
- Per-SS appearance: helix / sheet / coil width, thickness, and
roundness from
GeometryOptions(driven bycartoon_stylepreset unlessCustom). - Detail:
segments_per_residue×cross_section_verts(defaults 32 × 16, scalable per LOD tier). - Vertex data: position, normal, color, residue idx, center pos.
2. SidechainRenderer
Renders sidechain atoms as ray-marched capsule impostors.
- Technique: storage buffer of capsule instances rendered as ray-marched impostors.
- Capsule radius: 0.3 Å.
- Color: from the active sidechain color mode (Backbone or Hydrophobicity).
- Frustum culling: instances outside the view frustum are skipped on upload.
3. BondRenderer
Renders structural bonds (H-bonds, disulfides) as configurable capsules.
- Style:
Solid,Dashed, orStippledper bond type (BondOptions). - Source:
Auto(geometry-detected),Manual(caller-provided), orBoth.
4. BandRenderer
Renders constraint bands (e.g. for Rosetta minimization).
- Visual: capsule impostors with variable radius (0.1–0.4 Å,
scaled by
strength). - Colors by type: default (purple), backbone (yellow-orange), disulfide (yellow-green), H-bond (cyan), disabled (gray).
- Anchor spheres: small spheres at band endpoints.
5. ClashArcRenderer
Renders steric clashes as glowing deep-red electric bolts, one camera-facing billboard ribbon per clashing atom pair.
- Geometry: one
LightningInstanceper clash, spanning the two clashing atoms; the jagged bolt is drawn procedurally in the fragment shader. - Animation: the centerline jag scrolls with
camera.timeand a fast brightness flicker reads as electric energy; severity drives the jag amplitude. - Source: clash specs supplied via
engine.update_clashes, resolved per-entity to world-space each frame.
6. GreaseBeadRenderer
Renders flagged exposed-hydrophobic residues as ray-cast SDF “grease bead” impostors, one billboard quad per residue.
- Geometry: a central sphere smooth-min’d with a few slowly-boiling satellites, anchored at the residue’s sidechain (CB if present, else the sidechain centroid, else CA).
- Shading: writes real depth and an SDF-gradient normal, lit with a warm-yellow greasy PBR and an animated highlight.
- Source: bead specs supplied via
engine.update_exposed_hydrophobics, resolved to a world-space anchor each frame.
7. PullRenderer
Renders the active drag constraint.
- Cylinder: capsule from atom to mouse target (purple).
- Arrow: cone impostor at the target end pointing toward the drag direction.
8. BallAndStickRenderer
Renders ligands, ions, waters, and (in BallAndStick drawing mode) proteins.
- Atoms: ray-cast sphere impostors with vdW-scaled radii.
- Bonds: capsule impostors (cylinders with hemispherical caps).
- Lipid modes:
Coarse(P-only spheres + thin tail bonds) orBallAndStick(full detail).
9. NucleicAcidRenderer
Renders DNA/RNA backbones and base rings.
- Stems: capsule instances tracing the phosphate backbone.
- Rings: polygon instances for the nucleobase rings.
- Color: per-base (default) or uniform.
10. IsosurfaceRenderer
Renders molecular surfaces (Gaussian, SES, or cavity) and
electron-density isosurfaces, generated on the background scene-processor
worker via SceneRequest::SurfaceRebuild.
- Backface depth pre-pass is rendered separately so the composite pass can apply correct depth-aware blending for translucent surfaces.
Shared Bind Groups
All renderers receive common bind groups via DrawBindGroups:
#![allow(unused)]
fn main() {
pub(crate) struct DrawBindGroups<'a> {
pub camera: &'a wgpu::BindGroup, // Projection / view matrices
pub lighting: &'a wgpu::BindGroup, // Light directions, intensities
pub selection: &'a wgpu::BindGroup, // Selection bit-array
pub color: Option<&'a wgpu::BindGroup>, // Per-residue color override
}
}
Level of Detail
Backbone tessellation scales with camera distance to keep distant chains
cheap. Each frame check_and_submit_lod computes a per-chain LOD tier
from the chain’s bounding center and the camera eye
(select_chain_lod_tier). When the per-chain tiers change, it submits an
animation-frame remesh whose per_chain_lod: Option<Vec<ChainLod>>
overrides the global segments_per_residue and cross_section_verts for
each chain. Tier 0 is full detail; higher tiers scale the segment and
cross-section counts down (lod_scaled). LOD is skipped while a full
rebuild is pending, since the backbone’s cached chains are stale until it
applies.
Post-Processing Stack
1. SSAO (Screen-Space Ambient Occlusion)
Computes local ambient occlusion from the depth and normal buffers.
- Kernel: hemisphere samples in view-space.
- Noise: 4×4 rotation noise texture to reduce banding.
- Parameters:
ao_radius(0.5),ao_bias(0.025),ao_power(2.0). - Output: single-channel AO texture.
- Blur pass: separable blur to smooth noise patterns.
2. Bloom
Extracts and blurs bright areas of the image.
- Threshold: extracts pixels above
bloom_threshold(1.0) to a half-resolution texture. - Blur: separable Gaussian blur (horizontal then vertical, ping-pong textures).
- Mip chain: progressive downsampling.
- Upsample: additive accumulation back to half-resolution.
- Output: half-resolution bloom texture.
- Default
bloom_intensity:0.0(disabled).
3. Composite
Combines all post-processing inputs into the final image.
Inputs:
- Scene color texture
- SSAO texture
- Depth texture
- Normal G-buffer
- Bloom texture
- Composite params uniform
Effects applied:
- SSAO as a darkening multiplier on base color.
- Depth-based fog (configurable
fog_startandfog_density). - Depth-based outlines (edge detection on depth discontinuities).
- Normal-based outlines (edge detection on normal discontinuities).
- Bloom additive blend.
- HDR tone mapping with
exposure. - Gamma correction.
4. FXAA
Fast Approximate Anti-Aliasing as the final pass.
- Smooths jagged edges on mesh-based geometry that supersampling alone doesn’t fully resolve.
- Reads from the composite output, writes to the swapchain surface
(or to the caller-owned texture view in
render_to_texture).
ShaderComposer
Viso uses naga_oil for shader composition, enabling modular WGSL
with imports:
#import viso::camera
#import viso::lighting
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let light = calculate_lighting(in.normal, in.position);
// …
}
Shaders live under src/shaders/:
shaders/modules/: shared modules (camera.wgsl,lighting.wgsl,pbr.wgsl,ray.wgsl,volume.wgsl,selection.wgsl,highlight.wgsl,shade.wgsl,depth.wgsl,constants.wgsl,fullscreen.wgsl,impostor_types.wgsl).shaders/raster/mesh/: mesh rasterization (backbone, NA).shaders/raster/impostor/: impostor shaders (sphere, capsule, cone, polygon).shaders/screen/: full-screen passes (composite.wgsl,fxaa.wgsl,ssao.wgsl,ssao_blur.wgsl,bloom_*.wgsl).shaders/utility/: picking shaders (picking_mesh.wgsl,picking_capsule.wgsl,picking_sphere.wgsl).
The composer produces naga::Module IR directly (skipping WGSL
re-parse at runtime for performance).
Render-Scale Supersampling
The rendering resolution can differ from the display resolution via
engine.set_surface_scale(scale). All internal textures (color,
depth, normal, SSAO, bloom) are sized to the render resolution. FXAA
downsamples to the display resolution as the final step.
Background Scene Processing
Mesh generation for molecular structures is CPU-intensive; generating backbone splines, ribbon surfaces, and sidechain capsule instances can take 20 to 40ms for complex structures. Viso runs it on a background worker thread so the main thread keeps rendering at full frame rate.
Architecture
Main Thread Background Worker
├─ submit(SceneRequest) ├─ blocks on mpsc::Receiver
│ → mpsc::Sender ├─ processes the request
│ ├─ generates / caches per-entity meshes
├─ try_recv_rebuild() ├─ concatenates into PreparedRebuild
│ ← triple_buffer::Output └─ writes to the matching triple buffer
├─ GPU upload (<1ms)
└─ Render
There is a single background worker (the scene-processor thread). Full
rebuilds, per-frame animation meshes, and isosurface regeneration all run
on it; each result kind returns over its own triple buffer.
Communication Channels
| Channel | Type | Direction | Purpose |
|---|---|---|---|
| Request | mpsc::Sender<SceneRequest> | Main to worker | Submit work |
| Rebuild result | triple_buffer | Worker to main | Option<PreparedRebuild> |
| Animation result | triple_buffer | Worker to main | Option<PreparedRebuild> |
| Surface result | triple_buffer | Worker to main | Option<PreparedSurface> |
Triple buffers are lock-free: the writer always has a buffer to write to, and the reader always gets the latest completed result. Neither side blocks.
SceneProcessor
#![allow(unused)]
fn main() {
let processor = SceneProcessor::new()?; // spawns the background worker
// Submit work (non-blocking).
processor.submit(SceneRequest::FullRebuild(Box::new(body)));
// Check for results (non-blocking).
if let Some(prepared) = processor.try_recv_rebuild() { /* upload */ }
if let Some(frame) = processor.try_recv_animation() { /* upload */ }
if let Some(surface) = processor.try_recv_surface() { /* upload */ }
// Shutdown: sends Shutdown, joins the thread.
processor.shutdown();
}
request_sender() hands out a clone of the request channel so other
subsystems (the surface-regen holder) can submit work without a
&mut borrow of the processor.
Request Types
#![allow(unused)]
fn main() {
pub(crate) enum SceneRequest {
FullRebuild(Box<FullRebuildBody>),
AnimationFrame(Box<AnimationFrameBody>),
SurfaceRebuild(Box<SurfaceRebuildBody>),
Shutdown,
}
}
FullRebuild
A complete scene rebuild with per-entity render-ready snapshots:
#![allow(unused)]
fn main() {
pub(crate) struct FullRebuildBody {
pub entities: Vec<FullRebuildEntity>,
pub display: DisplayOptions,
pub colors: ColorOptions,
pub geometry: GeometryOptions,
pub entity_options:
FxHashMap<u32, (DisplayOptions, GeometryOptions)>,
pub generation: u64,
pub topology_generation: u64,
}
pub(crate) struct FullRebuildEntity {
pub id: EntityId,
pub mesh_version: u64,
pub drawing_mode: DrawingMode,
pub topology: Arc<EntityTopology>,
pub positions: Vec<Vec3>,
pub ss_override: Option<Vec<SSType>>,
pub per_residue_colors: Option<Vec<[f32; 3]>>,
}
}
FullRebuild is submitted when a new Assembly snapshot is consumed,
when display/color/geometry options change, or when a scoped reset clears
local state. Per-entity mesh_version is the cache key: an entity whose
version is unchanged since the previous rebuild reuses its cached mesh.
generation bumps on every submit. topology_generation advances only
when the visible entity-id set changes (an entity added or removed); it
lets the consumer keep a rebuild whose topology still matches the current
scene even after newer same-topology submits bumped generation.
AnimationFrame
Per-frame mesh regeneration during animation:
#![allow(unused)]
fn main() {
pub(crate) struct AnimationFrameBody {
pub positions: EntityPositions, // interpolated
pub geometry: GeometryOptions,
pub per_chain_lod: Option<Vec<ChainLod>>, // per-chain detail override
pub include_sidechains: bool,
pub generation: u64,
pub topology_generation: u64,
}
}
Submitted while animation is in progress. It regenerates backbone meshes
(and optionally sidechains) from interpolated positions, reusing topology
and other state cached from the last FullRebuild. The result is a
PreparedRebuild delivered over the animation triple buffer.
SurfaceRebuild
Regenerates all isosurface meshes (density maps, entity surfaces, cavities, and a host-supplied void field) from job lists gathered on the main thread:
#![allow(unused)]
fn main() {
pub(crate) struct SurfaceRebuildBody {
pub density_jobs: Vec<(Density, f32, [f32; 4])>,
pub surface_jobs: Vec<SurfaceJob>,
pub cavity_jobs: Vec<(Vec<Vec3>, Vec<f32>)>,
pub void_field_job: Option<VoidFieldJob>,
pub surface_generation: u64,
}
}
A SurfaceJob carries the flattened scalar parameters the isosurface
generators consume (atom positions and radii, surface kind, grid
resolution, probe radius, level, color), so the worker never needs the
engine-side surface type. A VoidFieldJob carries a host-supplied
distance field (grid dims, origin, spacing, the flat phi grid, and an
iso-threshold) meshed as a smooth blob into the cavity stream. The
worker runs the generators, concatenates the meshes, and returns a
PreparedSurface { surface_generation, vertices, indices }. The main
thread polls it with try_recv_surface.
Shutdown
Terminates the background worker.
Per-Entity Mesh Caching
The worker keeps a per-entity mesh cache keyed on EntityId:
FxHashMap<EntityId, CachedEntityMesh>
CachedEntityMesh stores GPU-ready byte buffers (backbone vertices and
indices, sidechain instances, ball-and-stick spheres and capsules,
nucleic-acid stems and rings) plus typed intermediates needed for index
concatenation.
Cache Invalidation
On a FullRebuild, the worker checks each entity’s mesh_version
against the cached version:
- Same version: reuse the cached mesh (skip generation).
- Different version: regenerate and update the cache.
- Entity removed: evict from the cache.
Version-based invalidation is a u64 comparison. For a 3-entity scene
where only 1 changed, this skips regenerating the two unchanged entities.
Global vs Per-Entity Settings
A bumped mesh_version is the universal “regenerate me” signal.
Option-change paths in the engine bump the affected entities’ versions
before submitting the rebuild; color-only changes update color buffers
without forcing a full geometry regenerate.
Mesh Generation
For each entity, the worker generates whichever of these apply to its
drawing_mode:
- Backbone mesh: cubic Hermite splines with rotation-minimizing frames, with separate index ranges for the tube and ribbon passes.
- Sidechain capsule instances: packed capsule structs for the storage buffer.
- Ball-and-stick instances: sphere and capsule instances for non-protein entities (and proteins drawn in BallAndStick mode).
- Nucleic-acid instances: stem capsules and ring polygons.
Mesh Concatenation
After generating (or retrieving from cache) all entity meshes, they are
concatenated into one PreparedRebuild: vertex buffers appended, index
buffers appended with per-entity offset adjustment, instance buffers
concatenated, and a single PickMap built from raw GPU pick IDs to typed
targets.
PreparedRebuild
The output of a FullRebuild (and of an AnimationFrame), ready for GPU
upload:
#![allow(unused)]
fn main() {
pub(crate) struct PreparedRebuild {
pub generation: u64,
pub topology_generation: u64,
pub backbone: BackboneMeshData, // verts + tube/ribbon idx
pub sidechain_instances: Vec<u8>,
pub sidechain_instance_count: u32,
pub sidechains_omitted: bool, // backbone-only anim frame
pub bns: BallAndStickInstances, // sphere + capsule instances
pub na: NucleicAcidInstances, // stem + ring instances
pub pick_map: PickMap,
pub entity_residue_offsets: Vec<(EntityId, u32)>,
pub displayed_positions: Vec<(EntityId, Vec<Vec3>)>,
}
}
All byte arrays are raw GPU buffer data (bytemuck::cast_slice), ready
for queue.write_buffer() with no further processing.
sidechains_omitted marks a backbone-only animation frame: because
level-of-detail does not change sidechain positions, the apply side
leaves the previously uploaded sidechains in place rather than clobbering
them with an empty set. entity_residue_offsets records each entity’s
first global residue index in the GPU selection / color space.
displayed_positions records the atom positions the mesh was built from,
so overlay resolvers read the same frame the displayed mesh and its
anchors came from.
Stale Frame Discarding
When a scene is replaced, in-flight animation frames from the old scene become stale. Two counters guard against applying them:
generationbumps on everyFullRebuild. The worker skips a queued animation frame whosegenerationis behind the latest rebuild before it spends time generating; the main thread discards a stale result before GPU upload.topology_generationadvances only when the entity-id set changes. A coordinate-only rebuild that bumpedgenerationdoes not invalidate a frame whose topology still matches, so same-topology frames are not dropped needlessly.
Surface Rest-Detection
Marching-cubes isosurfaces are expensive, so they re-mesh only when the conformation comes to rest rather than on every animation frame.
Each consumed publish restarts a quiet-window clock (last_publish_at).
Once per update tick, maybe_settle_surface asks
should_settle_surface whether to regenerate. It regenerates only when:
- a surface could currently be shown (
surface_active), - the displayed generation differs from the generation the surface was
last built against (
surface_built_for_generation), and - the publish stream has stayed quiet for at least
SURFACE_SETTLE_WINDOW(180ms).
A continuous edit (wiggle, drag) keeps pushing the quiet window out, so the re-mesh runs at most once per rest, never per motion frame. At rest the scene’s reference coordinates equal the displayed coordinates, so the re-meshed surface matches what is on screen.
Threading Model Summary
| Thread | Owns | Does |
|---|---|---|
| Main thread | GPU resources, engine, scene | Input, render, GPU upload |
| Scene-processor worker | Per-entity mesh cache | CPU mesh, animation, and isosurface generation |
| Bridge | Triple buffers + mpsc channel | Lock-free data transfer |
The main thread never blocks on the worker. If meshes aren’t ready, the previous frame’s meshes keep rendering, so frame rates stay consistent even during expensive regeneration.
Engine Lifecycle
VisoEngine is the central rendering, animation, and picking coordinator.
It is read-only with respect to structural state: your application
owns a molex::Assembly and pushes the latest snapshot via
[VisoEngine::set_assembly]. This chapter covers how to create the
engine, what happens during initialization, and how to manage its
lifetime.
Construction
You own your molex::Assembly and hand viso the latest snapshot. There
is no viso-defined channel, publisher, or consumer in the public API; the
structural ingest contract is one setter on the engine.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use viso::{RenderContext, VisoEngine};
use viso::options::VisoOptions;
use molex::Assembly;
// 1. Build a wgpu RenderContext (async; use pollster or your runtime).
let context = pollster::block_on(
RenderContext::new(window.clone(), (width, height))
)?;
// 2. Build the engine.
let mut engine = VisoEngine::new(context, VisoOptions::default())?;
// 3. Push your Assembly to the engine.
let assembly: Assembly = /* your owned Assembly */;
engine.set_assembly(Arc::new(assembly.clone()));
}
After every Assembly mutation, re-publish by calling set_assembly
again. The engine stages the snapshot in its pending slot and drains it
on the next update(dt) tick; a generation check skips work if nothing
changed.
For an embedded host that owns a wgpu device but no window surface, build
the context with RenderContext::from_device(device, queue, format, width, height) instead of RenderContext::new, and present with
render_to_texture (see The Render Loop).
Note for standalone deployments only. When viso is built as its own standalone app via
cargo run -p viso(featuresviewer/gui/web), it uses an internal helper calledVisoAppto play the host role for itself. Library users never go throughVisoApp: own yourAssemblyand callset_assemblydirectly.VisoAppis not part of the library’s public surface withdefault-features = false.
What Happens During Init
- GPU setup:
RenderContextis configured with a surface (or an externally-owned device), adapter, device, and queue. - Shader compilation:
ShaderComposerloads and composes all WGSL modules usingnaga_oil. - Camera:
CameraControlleris created with default orbital parameters (FOV 45 degrees, fit to origin). - Renderers: backbone, sidechain, bond, band, clash arc, grease bead, pull, ball-and-stick, nucleic-acid, and isosurface.
- Post-processing: SSAO, bloom, composite, and FXAA passes.
- Picking: GPU picking system with an offscreen
R32Uinttarget and staging buffer. - Scene processor: the background worker thread is spawned for mesh generation.
- Assembly slot:
Scenestarts with an emptycurrentAssembly andpending: None. The firstset_assemblyfillspending; the nextupdate(dt)consumes it.
Initial Scene Sync
The first set_assembly after construction pushes your initial snapshot.
The next update(dt) drains it, rederives the scene, and submits a full
mesh rebuild to the background thread. On the following frame, the
prepared meshes are uploaded to the GPU.
Reloading or Swapping Topology
set_assembly plus update is the steady-state path. For a puzzle or
file reload, where the entire topology changes, use replace_assembly:
#![allow(unused)]
fn main() {
engine.replace_assembly(Arc::new(new_assembly));
}
replace_assembly tears down scene-local state (animation, surfaces,
derived per-entity views, annotations), stages the new snapshot, and
forces a synchronous sync so that follow-up calls (camera pose, SS
overrides, and so on) operate against synced state. set_assembly alone
leaves stale state from the previous topology around until the next
update, which is why reloads should go through replace_assembly.
Resize and Scale Factor
Forward window resize events to the engine:
#![allow(unused)]
fn main() {
engine.resize(new_width, new_height);
}
This resizes the wgpu surface, all post-processing textures, the picking render target, and the camera projection. For DPI changes:
#![allow(unused)]
fn main() {
engine.set_surface_scale(scale_factor);
let inner = window.inner_size();
engine.resize(inner.width, inner.height);
}
Shutdown
The background scene processor is joined automatically on drop. To force shutdown earlier:
#![allow(unused)]
fn main() {
engine.shutdown();
}
This sends a Shutdown request to the processor thread.
Ownership Model
VisoEngine owns these subsystems (plus a few scalar state fields):
| Field | Type | Purpose |
|---|---|---|
gpu | GpuPipeline | wgpu context, all renderers, picking, post-process, lighting, culling state |
camera_controller | CameraController | Camera matrices, animation, frustum |
constraints | ConstraintSpecs | Stored band / pull / clash / exposed-hydrophobic specs |
animation | AnimationState | Structural animator, trajectory player, pending transitions |
options | VisoOptions | Display, lighting, post-processing, geometry, etc. |
active_preset | Option<String> | Name of the currently-applied options preset |
frame_timing | FrameTiming | FPS smoothing, frame pacing |
density | DensityStore | Loaded electron density maps |
external_void_field | Option<ExternalVoidField> | Host-supplied void distance field, meshed as a blob |
scene | Scene | Pending/current Assembly + derived per-entity state |
annotations | EntityAnnotations | Per-entity overrides: focus, visibility, behaviors, appearance, scores, SS, surfaces |
surface_regen | SurfaceRegen | Submit handle for background isosurface regeneration |
Alongside these it holds scalar state: surface_built_for_generation and
last_publish_at (surface rest-detection; see
Background Scene Processing),
and input_state / mouse_pressed / shift_pressed (pointer intake).
The engine is not thread-safe (!Send, !Sync) because it holds
wgpu GPU resources. All engine access happens on the main thread. The
background scene processor communicates via channels and triple buffers.
The Render Loop
Every frame follows a specific sequence, and the order matters. Polling the assembly snapshot before applying pending mesh data ensures newly generated meshes appear on the same frame their owning rebuild finishes, and updating the camera before rendering keeps animation smooth.
Minimal Render Loop (Standalone)
From viso’s standalone viewer:
#![allow(unused)]
fn main() {
WindowEvent::RedrawRequested => {
let now = Instant::now();
let dt = now.duration_since(last_frame_time).as_secs_f32();
last_frame_time = now;
engine.update(dt);
match engine.render() {
Ok(()) => {}
Err(wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost) => {
engine.resize(width, height);
}
Err(e) => log::error!("render error: {e:?}"),
}
window.request_redraw();
}
}
What engine.update(dt) Does
engine.update(dt) handles all per-frame coordination work:
- Camera animation tick: interpolates animated focus, distance, and bounding radius; advances turntable auto-rotation.
- Drain the pending Assembly snapshot. If a new
Assemblysnapshot is waiting (because the host orVisoAppcalledengine.set_assembly), the engine rederives its per-entity state and submits aFullRebuildrequest to the background mesh processor. - Apply any pending scene. If the background processor has a
completed
PreparedRebuildready, it is uploaded to the GPU (vertex/index/instance buffers, picking bind groups). GPU upload is typically <1ms.
The main thread never blocks on the background thread. If meshes aren’t ready, the previous frame’s data continues to render until they are.
What engine.render() Does
The render method executes the full pipeline:
- Apply pending animation frame. If an interpolated animation frame is ready from the background thread, upload it to the GPU.
- Tick animation. Advance trajectory and structural animation; submit a new animation-frame request to the background thread if anything changed.
- Update camera and lighting uniforms. Write camera matrices, hovered-residue id, derived fog parameters, and headlamp lighting.
- Frustum cull sidechains.
- Resolve constraints. Translate stored band/pull specs into world-space using current interpolated atom positions.
- Geometry pass. Render all molecular geometry to HDR render
targets (
Rgba16Floatcolor + normals,Depth32Floatdepth). - Picking pass. Render to the offscreen
R32Uinttarget and copy the pixel under the cursor to a staging buffer. - Post-processing. SSAO, bloom, composite (outlines, fog, tone mapping), FXAA.
- Present to the swapchain surface.
- Initiate non-blocking picking readback for next frame.
Frame timing is throttled to a 300 fps target by default
(FrameTiming::should_render short-circuits if the previous frame’s
elapsed time hasn’t met the minimum frame duration).
Error Handling
Surface errors are expected during resize or focus changes:
SurfaceError::Outdated/SurfaceError::Lost: the surface needs reconfiguration. Callresize()with the current window dimensions.- Other errors are logged but non-fatal; the next frame retries.
Rendering to a Texture (Embedding)
If you’re embedding viso in a host that gives you a target texture
view (e.g. a dioxus or egui texture slot), use render_to_texture
instead of render:
#![allow(unused)]
fn main() {
engine.render_to_texture(&texture_view);
}
This runs the same pipeline but writes the final composite to the provided texture view instead of acquiring a swapchain frame, so the caller owns presentation.
Non-Blocking Picking Readback
GPU picking uses a two-frame pipeline to avoid stalling:
- Frame N: The picking pass renders to an offscreen texture and
copies the pixel under the mouse to a staging buffer.
start_readback()initiates an async buffer map. - Frame N+1:
poll_and_resolvepolls the device without blocking (it wrapscomplete_readback). If the map is complete, it reads the residue ID and resolves it to aPickTargetvia the engine’sPickMap. Otherwise it keeps the cached value from the previous successful read.
Hover is one frame behind the cursor, which avoids GPU pipeline stalls.
Scene Management
Viso’s structural state lives in molex::Assembly, owned by your
application, not by viso. Viso is a pure consumer: you push the latest
Arc<Assembly> to the engine via [VisoEngine::set_assembly], and the
engine drains the snapshot on the next sync tick and rederives its render
state.
There is no “group” abstraction in viso. Every entity lives directly in
the Assembly and is identified by an opaque EntityId.
Pushing Assembly Snapshots
┌──────────────────────┐ ┌─────────────────┐
│ your application │ Arc<Assembly> │ VisoEngine │
│ │ ─engine.set_assembly──► │ │
│ molex::Assembly │ │ pending slot │
│ (mutated freely) │ │ → Scene+derived│
└──────────────────────┘ └─────────────────┘
- Your application mutates its
molex::Assembly(using molex’s APIs) and callsengine.set_assembly(Arc::new(self.assembly.clone())). VisoEngine::update(dt)drains the pending snapshot; if its generation differs from the last applied one, the engine rederives its per-entity state and submits a full-rebuild request to the background mesh processor.
That’s the entire structural ingest contract for library users. There is no viso-defined channel, publisher, or consumer in the public API.
Mutating the Scene
You mutate molex::Assembly through molex’s own APIs and re-publish
to viso after each batch of changes:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use molex::Assembly;
use viso::{Transition, VisoEngine};
// 1. Mutate the assembly however you like.
let mut assembly = /* your owned Assembly */;
assembly.add_entity(new_entity);
assembly.update_positions(eid, &new_coords);
// ... add/remove/update as needed ...
// 2. Push the new snapshot. Cheap: Arc<Assembly> is shared
// by reference.
engine.set_assembly(Arc::new(assembly.clone()));
// 3. (Optional) For entities whose positions changed, queue a
// per-entity transition so the next sync animates instead of
// snapping. Without this, the engine snaps to the new state.
engine.set_entity_behavior(entity_id, Transition::smooth());
}
The next engine.update(dt) drains the pending snapshot, rederives
the scene, and submits a full-rebuild to the background mesh
processor. Mesh generation happens off-thread, so the main thread is
not blocked.
Note. If you’re embedding viso as a library, ignore
VisoAppentirely.VisoAppis the standalone-app helper that viso uses to be its own host when run viacargo run -p viso(or theviewer/gui/webfeatures). Library consumers own their ownAssemblyand don’t need or want the convenience wrapper.
Engine-Side Annotations
Some per-entity state is purely a viso concern (it doesn’t belong on
the molecular structure itself). Those live on
EntityAnnotations, mutated through engine methods:
#![allow(unused)]
fn main() {
// Animation behavior overrides (keyed by EntityId, not raw u32).
let eid = engine.entity_id(raw_id).expect("known entity");
engine.set_entity_behavior(eid, Transition::smooth());
engine.clear_entity_behavior(eid);
// Per-entity appearance overrides (drawing mode, color scheme,
// helix/sheet style, surface kind, palette, etc.).
let mut overrides = DisplayOverrides::default();
overrides.color_scheme = Some(ColorScheme::SecondaryStructure);
engine.set_entity_appearance(eid, overrides);
engine.clear_entity_appearance(eid);
}
set_entity_appearance diffs against the previous overrides and
dispatches only the invalidations that matter: a surface_kind change
triggers surface regeneration, a color_scheme change triggers color
recomputation, and so on.
Looking Up Entities
The engine exposes a small read-only surface for looking entities up:
#![allow(unused)]
fn main() {
// Translate a raw u32 (from IPC, TOML, CLI) to an opaque EntityId.
let eid: Option<EntityId> = engine.entity_id(raw_id);
// Walk the current Assembly snapshot directly.
for entity in engine.assembly().entities() {
println!("{:?}: {}", entity.id(), entity.molecule_type());
}
// Total entity count.
let n = engine.entity_count();
}
entity_id is the canonical “boundary translator”: wire formats carry
raw u32 ids; viso-internal APIs use EntityId. Translate once at the
boundary and pass EntityId through.
Focus
Focus determines what the camera follows and what the user is “working
on”. It cycles through visible, focusable entities with Tab:
#![allow(unused)]
fn main() {
pub enum Focus {
All, // All entities (default)
Entity(EntityId), // A specific entity
}
}
#![allow(unused)]
fn main() {
// Cycle: All -> entity 1 -> ... -> entity N -> All
engine.cycle_focus();
// Focus a specific entity (by EntityId).
engine.focus_entity(eid);
// Reset to the all-entities view.
engine.reset_focus();
// Set focus without reframing the camera (host owns the reframe cadence).
engine.set_focus(Focus::Entity(eid));
// Read current focus state.
let focus: Focus = engine.focus();
let focused_entity: Option<EntityId> = engine.focused_entity();
// The entities the Tab cycle steps through, without mutating focus.
let focusable: Vec<EntityId> = engine.focusable_entities();
}
cycle_focus, focus_entity, and reset_focus refit the camera;
set_focus only records the value, leaving the reframe to the host.
Host Query API
Foldit drives selection, pulls, and a residue-info panel through a set of read-only query methods on the engine. They translate between the GPU’s flat residue space, structural references, and screen coordinates:
#![allow(unused)]
fn main() {
// Pick resolution: owning entity + entity-local residue + closest atom.
let pick: Option<PickedResidueAtom> =
engine.picked_residue_atom(flat_residue, (x, y));
// The heavy atom in a residue projecting closest to a screen point.
let atom: Option<String> = engine.closest_atom_in_residue(residue, (x, y));
// Flat residue index -> (raw entity id, entity-local residue).
let owner: Option<(u32, u32)> = engine.flat_to_entity_residue(flat);
// Resolve a structural atom reference to its current (interpolated) world
// position.
let pos: Option<Vec3> = engine.resolve_atom_position(residue, "CA");
// Screen/world conversions (also see the Camera System chapter).
let screen: Option<Vec2> = engine.world_to_screen(world);
let world: Vec3 = engine.screen_to_world_at_depth(screen_pos, world_point);
// Camera framing.
let centroid: Option<Vec3> = engine.focus_centroid();
engine.snap_camera_to_focus();
engine.set_camera_pose(center, eye, up);
}
resolve_atom_position, picked_residue_atom, and closest_atom_in_residue
read interpolated visual positions during animation, so they match what
is on screen rather than the live target coordinates.
What Happens During Sync
When a new Assembly snapshot arrives:
- Rederive per-entity state. For each entity, the engine builds render-ready derived data (backbone chains, sidechain topology, SS types, residue color metadata).
- Submit a
FullRebuild. The background processor receives aVec<FullRebuildEntity>plus the active display, color, and geometry options. Per-entity mesh caching means only entities whosemesh_versionchanged are regenerated. - Triple-buffer the result. When the processor finishes, the
resulting
PreparedRebuildis written to a triple buffer. - Apply on the next frame.
engine.update(dt)callsapply_pending_scene, which uploads the GPU buffers in a memcpy and rebuilds picking bind groups.
The main thread never blocks. If the new meshes aren’t ready by the next frame, the previous frame’s data continues to render until they are.
Handling Input
Viso does not own an input event loop or a command vocabulary. The host
owns its windowing layer, decodes platform events, and calls typed
methods on VisoEngine. There is no InputProcessor, no VisoCommand,
and no engine.execute(...).
Two surfaces handle input:
- Pointer and scroll:
feed_pointer_motion,feed_pointer_button,feed_scroll, andfeed_modifiersonVisoEngine(src/engine/intake.rs). The button feed returns a classifiedClickEventon release, which the host turns into a selection change. - Keyboard: a
KeyBindingstable mapping physical-key strings to engine methods, dispatched withKeyBindings::dispatch(key, engine)(src/input/key_bindings.rs).
Pointer Intake
#![allow(unused)]
fn main() {
impl VisoEngine {
pub fn feed_pointer_motion(&mut self, x: f32, y: f32);
pub fn feed_pointer_button(
&mut self,
button: MouseButton,
pressed: bool,
) -> Option<ClickEvent>;
pub fn feed_scroll(&mut self, delta: f32);
pub fn feed_modifiers(&mut self, shift: bool);
}
}
feed_pointer_motion updates the cursor position used by GPU picking.
While the primary button is held over a non-pickable area, it also drives
the camera: shift held pans, otherwise it rotates.
feed_pointer_button records the target under the cursor on press and
runs the multi-click classifier on release. It returns None on press,
on non-left buttons, and on releases that classify as the end of a drag.
It returns Some(ClickEvent) when a release resolves to a single,
double, triple, or empty-area click.
feed_scroll zooms (positive in, negative out). feed_modifiers
records the shift state used by both the drag branch and click
classification.
Wiring (winit example)
#![allow(unused)]
fn main() {
WindowEvent::CursorMoved { position, .. } => {
engine.feed_pointer_motion(position.x as f32, position.y as f32);
}
WindowEvent::MouseWheel { delta, .. } => {
let scroll = match delta {
MouseScrollDelta::LineDelta(_, y) => y,
MouseScrollDelta::PixelDelta(p) => p.y as f32 * 0.01,
};
engine.feed_scroll(scroll);
}
WindowEvent::ModifiersChanged(mods) => {
engine.feed_modifiers(mods.state().shift_key());
}
WindowEvent::MouseInput { button, state, .. } => {
let pressed = state == ElementState::Pressed;
if let Some(click) = engine.feed_pointer_button(button.into(), pressed) {
apply_click(&mut engine, &click);
}
}
}
ClickEvent
feed_pointer_button returns a ClickEvent (src/input/click.rs):
#![allow(unused)]
fn main() {
pub struct ClickEvent {
pub pattern: ClickPattern, // Single | Double | Triple | Empty
pub target: PickTarget, // hit under the cursor at release
pub modifiers: Modifiers, // { shift: bool }
pub expansion: Vec<(EntityId, u32)>, // residues this click selects
}
}
The engine computes expansion for you against the current scene: a
single click expands to the clicked residue, a double click to its
secondary-structure segment, a triple click to its chain. Each entry is
(entity, entity_local_residue_index), the same shape a host-side
selection store holds. Empty clicks (background) carry an empty
expansion.
Applying a Click to Selection
The engine no longer mutates selection in response to clicks; it reports
what happened and lets the host decide. classify_click_for_selection
maps a ClickEvent to an abstract action without consulting the current
selection:
#![allow(unused)]
fn main() {
use viso::{classify_click_for_selection, ClickSelectionAction};
fn apply_click(engine: &mut VisoEngine, click: &ClickEvent) {
match classify_click_for_selection(click) {
ClickSelectionAction::Clear => store.clear(),
ClickSelectionAction::Replace(residues) => store.replace(residues),
ClickSelectionAction::Toggle(residues) => store.toggle(residues),
}
engine.set_selection(&store.as_btreemap());
}
}
Empty clears. Shift-held clicks toggle the expansion against the
store; plain clicks replace it. After mutating its own store the host
pushes the new selection to the engine with
engine.set_selection(&BTreeMap<EntityId, BTreeSet<u32>>), which the
engine flattens into its GPU residue space. See
GPU Picking and Selection for
how the selection reaches the shaders.
Keyboard Input
Keyboard handling goes through KeyBindings, a table from physical-key
strings to engine actions. Forward the key as winit’s KeyCode debug
string (or the DOM KeyboardEvent.code) and call dispatch:
#![allow(unused)]
fn main() {
WindowEvent::KeyboardInput { event, .. } => {
if event.state == ElementState::Pressed {
if let PhysicalKey::Code(code) = event.physical_key {
bindings.dispatch(&format!("{code:?}"), &mut engine);
}
}
}
}
dispatch looks up the key, runs the bound closure against the engine,
and returns whether anything matched. Each binding calls an engine
method directly; there is no intermediate command type.
KeyBindings is a standalone type, not attached to any input processor.
Build the default table with KeyBindings::default(), an empty one with
KeyBindings::empty(), and add or replace entries with
insert(key, action). The table holds Box<dyn Fn(&mut VisoEngine)>
closures, so it is not serde-serializable and cannot be loaded from TOML.
Default bindings
| Key | Engine method |
|---|---|
KeyQ | recenter_camera |
KeyT | toggle_trajectory |
Tab | cycle_focus (steps through visible focusable entities) |
KeyR | toggle_auto_rotate |
Backquote | reset_focus (back to all-entities) |
Escape | clear_selection |
KeyI | toggle_type_visibility(Ion) |
KeyU | toggle_type_visibility(Water) |
KeyO | toggle_type_visibility(Solvent) |
KeyL | cycle_lipid_mode |
Driving the Engine Directly
The pointer and keyboard surfaces are conveniences. A host can call the underlying engine methods itself; for example to recenter from a button in its own UI:
#![allow(unused)]
fn main() {
engine.recenter_camera();
engine.toggle_auto_rotate();
engine.set_selection(&selection);
}
Dynamic Structure Updates
Viso supports live manipulation: structures can be updated mid-session by computational backends (Rosetta energy minimization, ML structure prediction) or user actions (mutations, drag operations).
All structural mutations happen on your molex::Assembly. After
each batch of changes, push the new snapshot to the engine via
engine.set_assembly(Arc::new(assembly.clone())). The engine itself
is read-only with respect to structural state.
Per-Entity Coordinate Updates
To stream new atom positions for an existing entity, mutate the
relevant entity’s coordinates on your Assembly (using molex’s
update_protein_entities codec helper, or
assembly.update_positions(eid, &coords) for direct position updates),
then re-publish:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use molex::ops::codec::update_protein_entities;
// Apply caller-provided Coords through molex's shared codec so the
// path matches the byte-format pipeline.
let mut entities = vec![assembly.entity(eid).unwrap().clone()];
update_protein_entities(&mut entities, &new_coords);
if let Some(updated) = entities.into_iter().next() {
assembly.remove_entity(eid);
assembly.add_entity(updated);
}
engine.set_assembly(Arc::new(assembly.clone()));
}
To make the next sync animate (instead of snapping), queue a per-entity behavior override before re-publishing:
#![allow(unused)]
fn main() {
engine.set_entity_behavior(entity_id, Transition::smooth());
engine.set_assembly(Arc::new(assembly.clone()));
}
The engine queues the transition for the affected entity on its next
sync, regardless of whether the override was set before or after the
set_assembly call (transitions are picked up in update).
Per-Entity Behavior Overrides
Override the default transition for a specific entity. Once set, every subsequent sync involving that entity uses the override (until cleared):
#![allow(unused)]
fn main() {
let eid = engine.entity_id(raw_id).expect("known entity");
engine.set_entity_behavior(eid, Transition::smooth());
// Subsequent re-publishes that touch this entity will use the
// override instead of the default transition.
engine.set_assembly(Arc::new(assembly.clone()));
// Revert to default:
engine.clear_entity_behavior(eid);
}
Transitions
Every update can specify a Transition controlling the visual
animation:
#![allow(unused)]
fn main() {
// Instant snap (no animation; used internally for initial loads
// and trajectory frames).
Transition::snap()
// Standard smooth interpolation (300ms cubic-hermite ease-out).
Transition::smooth()
// Staggered per-residue wave (quadratic-out).
Transition::cascade(
Duration::from_millis(500),
Duration::from_millis(5),
)
// Allow backbone size changes (residue mutations).
Transition::smooth().allowing_size_change()
}
See Animation System for details on the data-driven phase model.
Preemption
If a new update arrives while an animation is playing, the current visual position becomes the new animation’s start state and the timer resets. This provides responsive feedback during rapid update cycles (e.g. Rosetta wiggle).
Constraint Visualization (Bands and Pulls)
Bands and pulls are not commands; they are stored constraint specs that the engine resolves to world-space positions every frame so they auto-track animated atoms. Steric clash arcs and exposed-hydrophobic markers work the same way.
Bands
A BandInfo references atoms structurally rather than by world-space
position:
#![allow(unused)]
fn main() {
use viso::{AtomRef, BandInfo, BandTarget, BandType};
let band = BandInfo {
anchor_a: AtomRef { residue: 42, atom_name: "CA".into() },
anchor_b: BandTarget::Atom(AtomRef {
residue: 87,
atom_name: "CA".into(),
}),
strength: 1.0,
target_length: 3.5,
band_type: Some(BandType::Disulfide),
is_pull: false,
is_push: false,
is_disabled: false,
from_script: false,
};
}
BandTarget::Position(Vec3) anchors one end to a fixed world-space
point (used for “space pulls”). band_type set to None lets the
engine auto-detect the type from target_length.
Visual properties:
- Radius scales with
strength(0.1 to 0.4 Å) - Color depends on
band_type: default (purple), backbone (yellow-orange), disulfide (yellow-green), H-bond (cyan) - Disabled bands are gray
- Script-authored bands (
from_script: true) render dimmer
Pulls
A PullInfo is a single active drag constraint. The atom is
referenced structurally; the target is given in screen-space (physical
pixels) and unprojected at the atom’s depth each frame so the drag
stays parallel to the camera plane:
#![allow(unused)]
fn main() {
use viso::{AtomRef, PullInfo};
let pull = PullInfo {
atom: AtomRef { residue: 42, atom_name: "CA".into() },
screen_target: (mouse_x, mouse_y),
};
}
Pulls render as a purple cylinder from the atom to the target with a cone arrow head at the target end.
Update band and pull specs through the engine. Both methods replace the previous specs and re-resolve immediately:
#![allow(unused)]
fn main() {
engine.update_bands(vec![band1, band2]);
engine.update_pull(Some(pull));
engine.update_pull(None); // clear when drag ends
}
The engine resolves stored specs to world-space positions every frame, so bands and pulls track animated atoms automatically.
Clash Arcs
Steric clashes render as an electric arc between two atoms. A ClashInfo
names each atom per-entity (an entity id plus an entity-local residue and
PDB atom name) rather than by flat residue index:
#![allow(unused)]
fn main() {
use viso::{ClashEndpoint, ClashInfo};
let clash = ClashInfo {
a: ClashEndpoint { entity: eid_a, residue: 12, atom_name: "CB".into() },
b: ClashEndpoint { entity: eid_b, residue: 40, atom_name: "CG".into() },
severity: 0.8, // drives emissive intensity and pulse brightness
};
engine.update_clashes(vec![clash]); // replaces the previous clash set
}
Exposed Hydrophobics
Flagged exposed-hydrophobic residues render as a “grease bead” at the
sidechain anchor (CB if present, else the sidechain centroid, else CA).
An ExposedHydrophobicInfo names the residue per-entity:
#![allow(unused)]
fn main() {
use viso::ExposedHydrophobicInfo;
engine.update_exposed_hydrophobics(vec![ExposedHydrophobicInfo {
entity: eid,
residue: 23,
}]);
}
Both update_clashes and update_exposed_hydrophobics replace the
previous set and re-resolve immediately; the engine re-resolves their
anchors every frame so the markers track animated atoms.
Host-Supplied Void Field
A host can push a precomputed void distance field to be meshed as a smooth blob alongside detected cavities:
#![allow(unused)]
fn main() {
engine.set_external_void_field(dims, origin, spacing, phi, threshold);
}
phi is a flat row-major scalar grid (phi[x*ny*nz + y*nz + z]) in
marching-cubes polarity: high at void centers, near zero at atom walls
and exterior. It is meshed at the positive threshold into a blob that
carries the same cavity tint and breathing as a detected cavity. An empty
phi (or any zero dimension) clears the field. Meshing runs on the
background worker, so the result appears on the next frame after it
completes.