Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

Understand how Foldit uses viso:

Dig into viso internals:

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

InputAction
Left dragRotate camera
Shift + left dragPan camera
Scroll wheelZoom
Click residueSelect residue
Shift + clickAdd/remove from selection
Double-clickSelect secondary structure segment
Triple-clickSelect entire chain
Click backgroundClear selection
QRecenter camera on focus
TabCycle focus through entities
RToggle turntable auto-rotation
TToggle trajectory playback
IToggle ion visibility
UToggle water visibility
OToggle solvent visibility
LCycle lipid display mode
`Reset focus to session
EscapeClear 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 Assembly slot 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

MechanismDirectionSemantics
mpsc::channelMain to workerSubmit scene requests (non-blocking send)
triple_buffer (rebuild)Worker to mainLatest PreparedRebuild (non-blocking read)
triple_buffer (anim)Worker to mainLatest PreparedRebuild (non-blocking read)
triple_buffer (surface)Worker to mainLatest 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

  1. Scene sync: DisplayOptions, DisplayOverrides, and ColorOptions are sent to the background processor as part of the FullRebuild request.
  2. Background thread: during mesh generation, colors are computed per-residue from the resolved color scheme and palette and baked into vertex / instance buffers.
  3. GPU upload: color buffers are uploaded to the GPU as part of the prepared rebuild.
  4. Rendering: shaders read per-residue colors directly, with selection highlighting applied as an overlay in the fragment shader via the SelectionBuffer bit-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)
  1. Transition: a struct holding a Vec<AnimationPhase> plus an allows_size_change flag. Each phase carries an easing function, duration, lerp range, and a sidechain-visibility flag.
  2. 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

  1. The host mutates its Assembly and pushes the new snapshot via engine.set_assembly; pending per-entity transitions are stored on the engine’s AnimationState.
  2. 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.
  3. Each frame the runner advances and interpolated positions are written into EntityPositions. Sidechain positions are interpolated with the same eased t as the backbone.
  4. 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:

FunctionDescription
LinearNo easing
QuadraticInSlow start, fast end
QuadraticOutFast 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); None if 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 of world_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:

  1. 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.
  2. Sidechain capsules (picking_capsule.wgsl) with a storage buffer of capsule instances.
  3. Ball-and-stick spheres (picking_sphere.wgsl). Atom indices are mapped through the per-rebuild PickMap.
  4. 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:

  • 0None
  • 1..=residue_countResidue(idx)
  • residue_count+1..=residue_count+atom_countAtom { entity, atom }

Non-Blocking Readback

Reading data back from the GPU is expensive if done synchronously. Viso uses a two-frame pipeline:

Frame N:

  1. The picking pass renders to the offscreen texture.
  2. A single pixel at the mouse position is copied to a staging buffer (256 bytes minimum, aligned for wgpu).
  3. start_readback() initiates an async buffer map without blocking.

Frame N+1:

  1. poll_and_resolve polls the wgpu device without blocking.
  2. If the map callback has fired (signaled via AtomicBool), the mapped data is read: 4 bytes as u32, resolved through the active PickMap to a PickTarget.
  3. The staging buffer is unmapped.
  4. The result is cached in hovered_target on 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.

PatternExpansion
Singlethe clicked residue
Doubleevery residue in the clicked residue’s SS segment
Tripleevery residue in the clicked residue’s chain
Emptyempty (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:

TargetFormatContents
ColorRgba16FloatScene color with alpha blending
NormalRgba16FloatView-space normals / metadata (no blending)
DepthDepth32FloatDepth 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 by cartoon_style preset unless Custom).
  • 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, or Stippled per bond type (BondOptions).
  • Source: Auto (geometry-detected), Manual (caller-provided), or Both.

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 LightningInstance per clash, spanning the two clashing atoms; the jagged bolt is drawn procedurally in the fragment shader.
  • Animation: the centerline jag scrolls with camera.time and 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) or BallAndStick (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_start and fog_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

ChannelTypeDirectionPurpose
Requestmpsc::Sender<SceneRequest>Main to workerSubmit work
Rebuild resulttriple_bufferWorker to mainOption<PreparedRebuild>
Animation resulttriple_bufferWorker to mainOption<PreparedRebuild>
Surface resulttriple_bufferWorker to mainOption<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:

  1. Same version: reuse the cached mesh (skip generation).
  2. Different version: regenerate and update the cache.
  3. 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:

  1. Backbone mesh: cubic Hermite splines with rotation-minimizing frames, with separate index ranges for the tube and ribbon passes.
  2. Sidechain capsule instances: packed capsule structs for the storage buffer.
  3. Ball-and-stick instances: sphere and capsule instances for non-protein entities (and proteins drawn in BallAndStick mode).
  4. 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:

  1. generation bumps on every FullRebuild. The worker skips a queued animation frame whose generation is behind the latest rebuild before it spends time generating; the main thread discards a stale result before GPU upload.
  2. topology_generation advances only when the entity-id set changes. A coordinate-only rebuild that bumped generation does 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

ThreadOwnsDoes
Main threadGPU resources, engine, sceneInput, render, GPU upload
Scene-processor workerPer-entity mesh cacheCPU mesh, animation, and isosurface generation
BridgeTriple buffers + mpsc channelLock-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 (features viewer / gui / web), it uses an internal helper called VisoApp to play the host role for itself. Library users never go through VisoApp: own your Assembly and call set_assembly directly. VisoApp is not part of the library’s public surface with default-features = false.

What Happens During Init

  1. GPU setup: RenderContext is configured with a surface (or an externally-owned device), adapter, device, and queue.
  2. Shader compilation: ShaderComposer loads and composes all WGSL modules using naga_oil.
  3. Camera: CameraController is created with default orbital parameters (FOV 45 degrees, fit to origin).
  4. Renderers: backbone, sidechain, bond, band, clash arc, grease bead, pull, ball-and-stick, nucleic-acid, and isosurface.
  5. Post-processing: SSAO, bloom, composite, and FXAA passes.
  6. Picking: GPU picking system with an offscreen R32Uint target and staging buffer.
  7. Scene processor: the background worker thread is spawned for mesh generation.
  8. Assembly slot: Scene starts with an empty current Assembly and pending: None. The first set_assembly fills pending; the next update(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):

FieldTypePurpose
gpuGpuPipelinewgpu context, all renderers, picking, post-process, lighting, culling state
camera_controllerCameraControllerCamera matrices, animation, frustum
constraintsConstraintSpecsStored band / pull / clash / exposed-hydrophobic specs
animationAnimationStateStructural animator, trajectory player, pending transitions
optionsVisoOptionsDisplay, lighting, post-processing, geometry, etc.
active_presetOption<String>Name of the currently-applied options preset
frame_timingFrameTimingFPS smoothing, frame pacing
densityDensityStoreLoaded electron density maps
external_void_fieldOption<ExternalVoidField>Host-supplied void distance field, meshed as a blob
sceneScenePending/current Assembly + derived per-entity state
annotationsEntityAnnotationsPer-entity overrides: focus, visibility, behaviors, appearance, scores, SS, surfaces
surface_regenSurfaceRegenSubmit 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:

  1. Camera animation tick: interpolates animated focus, distance, and bounding radius; advances turntable auto-rotation.
  2. Drain the pending Assembly snapshot. If a new Assembly snapshot is waiting (because the host or VisoApp called engine.set_assembly), the engine rederives its per-entity state and submits a FullRebuild request to the background mesh processor.
  3. Apply any pending scene. If the background processor has a completed PreparedRebuild ready, 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:

  1. Apply pending animation frame. If an interpolated animation frame is ready from the background thread, upload it to the GPU.
  2. Tick animation. Advance trajectory and structural animation; submit a new animation-frame request to the background thread if anything changed.
  3. Update camera and lighting uniforms. Write camera matrices, hovered-residue id, derived fog parameters, and headlamp lighting.
  4. Frustum cull sidechains.
  5. Resolve constraints. Translate stored band/pull specs into world-space using current interpolated atom positions.
  6. Geometry pass. Render all molecular geometry to HDR render targets (Rgba16Float color + normals, Depth32Float depth).
  7. Picking pass. Render to the offscreen R32Uint target and copy the pixel under the cursor to a staging buffer.
  8. Post-processing. SSAO, bloom, composite (outlines, fog, tone mapping), FXAA.
  9. Present to the swapchain surface.
  10. 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. Call resize() 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:

  1. 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.
  2. Frame N+1: poll_and_resolve polls the device without blocking (it wraps complete_readback). If the map is complete, it reads the residue ID and resolves it to a PickTarget via the engine’s PickMap. 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 calls engine.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 VisoApp entirely. VisoApp is the standalone-app helper that viso uses to be its own host when run via cargo run -p viso (or the viewer / gui / web features). Library consumers own their own Assembly and 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:

  1. Rederive per-entity state. For each entity, the engine builds render-ready derived data (backbone chains, sidechain topology, SS types, residue color metadata).
  2. Submit a FullRebuild. The background processor receives a Vec<FullRebuildEntity> plus the active display, color, and geometry options. Per-entity mesh caching means only entities whose mesh_version changed are regenerated.
  3. Triple-buffer the result. When the processor finishes, the resulting PreparedRebuild is written to a triple buffer.
  4. Apply on the next frame. engine.update(dt) calls apply_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, and feed_modifiers on VisoEngine (src/engine/intake.rs). The button feed returns a classified ClickEvent on release, which the host turns into a selection change.
  • Keyboard: a KeyBindings table mapping physical-key strings to engine methods, dispatched with KeyBindings::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

KeyEngine method
KeyQrecenter_camera
KeyTtoggle_trajectory
Tabcycle_focus (steps through visible focusable entities)
KeyRtoggle_auto_rotate
Backquotereset_focus (back to all-entities)
Escapeclear_selection
KeyItoggle_type_visibility(Ion)
KeyUtoggle_type_visibility(Water)
KeyOtoggle_type_visibility(Solvent)
KeyLcycle_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.