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 designed as 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

  • Smooth interpolation, cascading reveals, collapse/expand mutations
  • Per-entity targeted animation with configurable behaviors

Performance

  • Background mesh generation on a dedicated 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-rs)
        │  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 (Optional)

InputProcessor is a convenience layer that translates raw input events into VisoCommand values. You can use it or wire commands directly:

#![allow(unused)]
fn main() {
use viso::{InputProcessor, InputEvent};

let mut input = InputProcessor::new();

// In your event handler:
let event = InputEvent::CursorMoved { x, y };
if let Some(cmd) = input.handle_event(event, engine.hovered_target()) {
    let _ = engine.execute(cmd);
}
}

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

This chapter provides a high-level view of viso’s architecture: how subsystems relate to each other, how data flows from file to screen, and how threading is organized.

System Diagram

┌─────────────────────────────────────────────────────────────────┐
│                      Application Layer                          │
│  (your application — e.g. foldit-rs)                            │
│                                                                 │
│  Owns the authoritative `molex::Assembly`. All structural       │
│  mutations push a new Arc<Assembly> via engine.set_assembly.    │
│                                                                 │
│  winit events ──► InputProcessor ──► VisoCommand ──► engine     │
└──────────────────────────────┬──────────────────────────────────┘
                               │ 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                                          │  │
│  │  ├─ PullRenderer             ShaderComposer:              │  │
│  │  ├─ BallAndStickRenderer     └─ naga_oil composition      │  │
│  │  ├─ 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 three threads 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 background thread (non-blocking)
  • Picking up completed meshes from the triple buffer (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 Mesh Thread

Owns the per-entity mesh cache and performs CPU-intensive work:

  • Receiving scene requests via mpsc::Receiver (blocks when idle)
  • Generating backbone, sidechain, ball-and-stick, and nucleic acid meshes
  • Maintaining a per-entity cache keyed on mesh_version
  • Writing results to a triple buffer (non-blocking)

Background Surface Thread

A short-lived worker spun up to regenerate isosurface meshes (Gaussian / SES / cavity surfaces) when surface options change. Sends results back through an mpsc channel that the main thread polls.

Lock-Free Bridges

MechanismDirectionSemantics
triple_buffer (asm)Host → MainLatest Arc<Assembly> (non-blocking read)
mpsc::channelMain → MeshSubmit scene requests (non-blocking send)
triple_buffer (rebuild)Mesh → MainLatest PreparedRebuild (non-blocking read)
triple_buffer (anim)Mesh → MainLatest PreparedAnimationFrame (non-blocking read)
mpsc::channelSurface → MainDensity isosurface meshes (non-blocking poll)

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      # VisoCommand + payload types (BandInfo, 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/              # Raw events → VisoCommand
├── 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,
│   │   │               # PreparedAnimationFrame
│   │   ├── 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-rs — 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 live separately on InputProcessor (KeyBindings); they are an input-layer concern, not a rendering option.

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,

    // Legacy (prefer `overrides.color_scheme`)
    pub backbone_color_mode: BackboneColorMode,

    // 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 one of two systems:

  • The legacy BackboneColorMode enum on DisplayOptions, retained for backward compatibility with existing presets.
  • The newer ColorScheme enum on DisplayOverrides (the recommended API), which decouples what data drives color from which palette is used.
#![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.

BackboneColorMode (legacy)

#![allow(unused)]
fn main() {
pub enum BackboneColorMode {
    Score,              // Per-residue energy score
    ScoreRelative,      // Relative scoring within the structure
    SecondaryStructure, // Helix/sheet/coil coloring
    Chain,              // Each chain gets a distinct color (default)
}
}

A From<&BackboneColorMode> impl maps each variant to its ColorScheme equivalent (Chain → Entity, Score → Score, ScoreRelative → ScoreRelative, SecondaryStructure → SecondaryStructure).

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 are smooth — residues don’t suddenly flash to new colors but transition over the animation duration.

How Colors Flow Through the Pipeline

  1. Scene syncDisplayOptions, 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 based on 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 manages smooth visual transitions when protein structures change. It is fully 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 metadata flags (size-change permission, sidechain suppression). Each phase has an easing function, duration, lerp range, and sidechain visibility flag.
  2. AnimationRunner — executes a single animation from start to target states, advancing through phases sequentially.

There are no trait objects or behavior types. The consumer constructs Transition values using preset constructors, and the runner evaluates the phase sequence directly.

Transition

Transition is the only animation type in the public API. Construct it via preset constructors and tune with builder methods:

#![allow(unused)]
fn main() {
pub struct Transition {
    pub allows_size_change: bool,
    pub suppress_initial_sidechains: bool,
    // (phases + name are internal)
}

// Preset constructors
Transition::snap()       // Instant, allows resize
Transition::smooth()     // 300ms cubic hermite ease-out (also Default)
Transition::collapse_expand(collapse_dur, expand_dur)
Transition::backbone_then_expand(backbone_dur, expand_dur)
Transition::cascade(base_dur, delay_per_residue)

// Total duration helper (sum across phases)
let d: Duration = transition.total_duration();

// Builder methods
Transition::collapse_expand(
    Duration::from_millis(200),
    Duration::from_millis(300),
)
    .allowing_size_change()
    .suppressing_initial_sidechains()
}

AnimationPhase (internal)

Each phase in a transition 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. 0.4
    pub(crate) include_sidechains: bool,
}
}

AnimationPhase is pub(crate) — consumers don’t construct it directly; they use the preset constructors above. The runner maps raw progress (0→1 over total duration) through the phase sequence, and each phase applies its own easing within its lerp range.

Preset Behaviors

Snap

Instant transition. Duration is zero. Used for initial loads where animation would delay the first meaningful frame. Also used internally when trajectory frames are fed through the animation pipeline.

Smooth (Default)

Standard eased lerp between start and target. 300ms with cubic hermite ease-out (CubicHermite { c1: 0.33, c2: 1.0 }). Good for incremental changes where start and target are close.

Collapse/Expand

Two-phase animation for mutations:

  1. Collapse phase — sidechain atoms collapse toward the backbone CA position (QuadraticIn easing)
  2. Expand phase — new sidechain atoms expand outward from CA to their final positions (QuadraticOut easing)
#![allow(unused)]
fn main() {
Transition::collapse_expand(
    Duration::from_millis(300),  // Collapse duration
    Duration::from_millis(300),  // Expand duration
)
}

Collapse-to-CA is handled at animation setup time — when allows_size_change is true, the runner’s start sidechain positions are written as CA coordinates so the lerp expands them outward into their target positions.

Backbone Then Expand

Two-phase animation for transitions where sidechains should appear after backbone settles:

  1. Backbone phase — backbone atoms lerp to final positions while sidechains are hidden
  2. Expand phase — sidechain atoms expand from collapsed (at CA) to final positions
#![allow(unused)]
fn main() {
Transition::backbone_then_expand(
    Duration::from_millis(400),  // Backbone lerp duration
    Duration::from_millis(600),  // Sidechain expand duration
)
}

Uses include_sidechains: false on the first phase to hide sidechains during backbone movement, preventing visual artifacts when new atoms appear before the backbone has settled.

Cascade

Staggered per-residue wave animation (QuadraticOut easing):

#![allow(unused)]
fn main() {
Transition::cascade(
    Duration::from_millis(500),  // Base duration per residue
    Duration::from_millis(5),    // Delay between residues
)
}

Note: per-residue staggering is not yet integrated into the runner — currently animates all residues with the same timing.

Per-Entity Animation

Each entity gets its own animation runner with independent timing. StructureAnimator (private) manages a HashMap<EntityId, …> of per-entity runners and writes interpolated atom positions into the engine’s EntityPositions each frame.

The mutation surface lives on VisoApp (update_entity_coords, update_entity, update_entities, sync_entities) — each call sets the 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 the 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 rederives per-entity state from the new snapshot. For each entity that has a pending transition, an AnimationRunner is created with the start/target backbone positions and the transition’s phases.
  3. Each frame, the runner advances; interpolated positions are written into EntityPositions. Sidechain positions are interpolated with the same eased t as backbone.
  4. When a runner completes (progress ≥ 1.0), the entity snaps to target and the runner is removed.

Preemption

When a new target arrives while an entity is mid-animation:

  • The current interpolated position becomes the new animation’s start state.
  • The previous animation’s sidechain positions are captured for smooth handoff (when atom counts match).
  • A new runner replaces the old one with the new target.

This provides responsive feedback during rapid update cycles (e.g. Rosetta wiggle).

Sidechain Animation

Sidechain positions are stored alongside backbone start/target arrays and lerped with the same eased t. The animator writes interpolated sidechain positions each frame so renderers and constraint resolution can read them without recomputing.

Specialized sidechain behaviors:

  • Standard lerp — for smooth transitions, sidechains lerp alongside backbone.
  • Collapse toward CA — for mutations, start positions are set to the CA position at setup time; the runner’s normal lerp handles the expansion.
  • Hidden during backbone phase — multi-phase transitions use include_sidechains: false on early phases.

Trajectory Playback

DCD trajectory frames are fed through the standard animation pipeline. TrajectoryPlayer (in engine/trajectory.rs) is a frame sequencer with no animation dependencies. Each frame it produces is applied through the same path used for Transition::snap(), so trajectory and structural animation share a single code path in the engine’s tick_animation.

Load a trajectory bound to the first visible protein entity:

#![allow(unused)]
fn main() {
engine.load_trajectory(Path::new("path/to/traj.dcd"));
engine.execute(VisoCommand::ToggleTrajectory); // play/pause
let has = engine.has_trajectory();
}

Easing Functions

Available in util/easing.rs:

FunctionDescription
LinearNo easing
QuadraticInSlow start, fast end
QuadraticOutFast start, slow end
SqrtOutFast start, gradual slow
CubicHermite { c1, c2 }Configurable control points (default: ease-out)

All functions evaluate in <100ns and clamp input to [0, 1].

Disabling Animation

Use Transition::snap() per-update, or set a snap per-entity behavior 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 around a focus point. The camera supports animated transitions, auto-rotation, frustum culling, and coordinate conversion utilities.

Arcball Model

The camera is defined by:

  • Focus point — the world-space point the camera orbits around
  • Distance — how far the camera is from the focus point
  • Orientation — a quaternion defining the camera’s rotation
  • Bounding radius — the radius of the protein being viewed (used for fog and culling)

All camera manipulation (rotation, pan, zoom) operates on these parameters rather than directly on a view matrix.

Camera Controller

CameraController (in camera/controller.rs) wraps the camera and manages input, GPU uniforms, and animation. The type is pub(crate) — consumers interact with the camera through engine methods or VisoCommands, not through the controller directly.

The controller’s tunables come from CameraOptions:

  • rotate_speed (default 0.5)
  • pan_speed (default 0.5)
  • zoom_speed (default 0.1)
  • fovy (default 45.0°)
  • znear (default 5.0)
  • zfar (default 2000.0)

Rotation

Rotation uses the arcball model — horizontal mouse movement rotates around the up vector, vertical movement rotates around the right vector:

#![allow(unused)]
fn main() {
engine.execute(VisoCommand::RotateCamera { delta });
}

The sensitivity is controlled by rotate_speed.

Panning

Panning translates the focus point along the camera’s right and up vectors, cancelling any in-progress focus animation:

#![allow(unused)]
fn main() {
engine.execute(VisoCommand::PanCamera { delta });
}

Zooming

Zoom adjusts the orbital distance, clamped to a sensible range:

#![allow(unused)]
fn main() {
engine.execute(VisoCommand::Zoom { delta });
}

Camera Animation

The camera animates between states for smooth transitions when loading structures or changing focus.

Fitting to a Bounding Sphere

Internally, the engine computes a bounding sphere over the relevant entities and calls one of:

  • 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 protein fits in the viewport.

Public entry points:

#![allow(unused)]
fn main() {
engine.fit_camera_to_focus();   // fits to current focus target
engine.execute(VisoCommand::RecenterCamera);
}

Per-Frame Update

Inside engine.update(dt), the controller’s update_animation is ticked, interpolating focus, distance, and bounding radius toward their targets.

Auto-Rotation

Toggle turntable-style auto-rotation:

#![allow(unused)]
fn main() {
engine.execute(VisoCommand::ToggleAutoRotate);
}

When enabled, the camera rotates around the up vector at a fixed turntable speed (~29°/s). The spin axis is captured from the current up vector when auto-rotation is enabled.

Frustum Culling

The camera provides a frustum used for sidechain culling — sidechains outside the view frustum (with a small Å margin) are skipped during rendering to improve performance. The engine reuploads the frustum-filtered sidechain instance buffer when the camera moves enough to invalidate the previous cull.

Coordinate Conversion

The controller exposes screen-to-world utilities for input handling:

  • screen_delta_to_world(delta_x, delta_y) — convert mouse pixel movement to a world-space displacement using the camera’s right and up vectors. The scale is proportional to the orbital distance, so movement feels consistent at any zoom level.
  • screen_to_world_at_depth(...) — unproject a screen pixel onto a plane parallel to the camera at a reference world point’s depth. Used for pull operations so the drag stays at the atom’s depth.

These are crate-internal — they’re consumed by the constraint resolution path that produces the per-frame band/pull world-space positions.

Fog Derivation

Fog parameters are derived from the camera’s distance and bounding radius each frame:

  • Fog start — based on the orbital distance.
  • Fog density2.0 / max(bounding_radius, 10.0).

The composite post-pass uses these to apply depth-based fog, fading distant geometry to the background color.

GPU Uniform

The camera uniform is uploaded to the GPU each frame inside engine.render(). It contains the projection matrix, view matrix, inverse projection, camera position, hovered residue id, screen dimensions, and an elapsed-time field. All renderers bind to this uniform 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 — uses 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 — uses picking_capsule.wgsl with a storage buffer of capsule instances.
  3. Ball-and-stick spheres — uses picking_sphere.wgsl. Atom indices are mapped through the per-rebuild PickMap.
  4. Ball-and-stick capsules — uses 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. complete_readback() polls the wgpu device without blocking.
  2. If the map callback has fired (signaled via AtomicBool), the mapped data is read:
    • Read 4 bytes as u32
    • Resolve through the active PickMap to a PickTarget
  3. The staging buffer is unmapped.
  4. 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. Hover feedback is at most one frame behind, which is imperceptible.

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);     // before next render
}

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 */ }
}
}

InputProcessor::handle_event takes the current hover target so it can attach the right residue index to selection commands.

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

Selection commands are produced by InputProcessor from click events and dispatched through engine.execute(...):

CommandBehavior
SelectResidue { index, extend: false }Replace selection with the clicked residue
SelectResidue { index, extend: true }Toggle the residue (shift-click)
SelectSegment { index, extend }Select all residues in the same SS segment
SelectChain { index, extend }Select all residues in the same chain
ClearSelectionClear everything

Double Click (Secondary Structure Segment)

SelectSegment walks the engine’s concatenated cartoon SS array backward and forward from the clicked residue until the SS type changes, then selects every residue in the resulting range. Shift-held clicks add to the existing selection.

Triple Click (Chain)

SelectChain finds the chain containing the clicked residue and selects every residue in that chain.

Click Type Detection

InputProcessor’s mouse state machine tracks timing between clicks. Clicks within a threshold on the same residue increment the click counter (single → double → triple). If the mouse moved between press and release, it’s classified as a drag and produces a camera command instead of a selection.

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 if the fragment’s residue index matches the hovered residue and applies a highlight.

Querying Selection State

#![allow(unused)]
fn main() {
// Currently selected residue indices
let selected: &[i32] = engine.selected_residues();

// Currently hovered target (one frame behind mouse)
let hovered: PickTarget = engine.hovered_target();

// Clear via command
engine.execute(VisoCommand::ClearSelection);
}

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 (8 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 eight 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. 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.

6. 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).

7. 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.

8. IsosurfaceRenderer

Renders electron-density-derived molecular surfaces (Gaussian, SES, or cavity) generated by the background surface_regen worker.

  • 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
}
}

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–40ms for complex structures. Viso offloads this to a background thread so the main thread can continue rendering at full frame rate.

Architecture

Main Thread                          Background Thread
  ├─ SceneProcessor::submit()          ├─ Blocks on mpsc::Receiver
  │   → mpsc::Sender                   ├─ Processes request
  │                                    ├─ Generates / caches per-entity meshes
  │                                    ├─ Concatenates into PreparedRebuild
  ├─ try_recv_rebuild()                └─ Writes to triple_buffer::Output
  │   ← triple_buffer::Input
  ├─ GPU upload (<1ms)
  └─ Render

Communication Channels

ChannelTypeDirectionPurpose
Requestmpsc::Sender<SceneRequest>Main → BackgroundSubmit work
Rebuild resulttriple_bufferBackground → MainCompleted PreparedRebuild
Animation resulttriple_bufferBackground → MainCompleted PreparedAnimationFrame

Triple buffers are lock-free: the writer always has a buffer to write to, and the reader always gets the latest completed result. No blocking on either side.

SceneProcessor

#![allow(unused)]
fn main() {
let processor = SceneProcessor::new()?; // Spawns background thread

// 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 to GPU
}

// Shutdown
processor.shutdown(); // Sends Shutdown message, joins thread
}

Request Types

#![allow(unused)]
fn main() {
pub(crate) enum SceneRequest {
    FullRebuild(Box<FullRebuildBody>),
    AnimationFrame(Box<AnimationFrameBody>),
    Shutdown,
}
}

FullRebuild

A complete scene rebuild with per-entity render-ready snapshots:

#![allow(unused)]
fn main() {
pub(crate) struct FullRebuildBody {
    pub entities: Vec<FullRebuildEntity>,           // per-entity snapshots
    pub display: DisplayOptions,
    pub colors: ColorOptions,
    pub geometry: GeometryOptions,
    pub entity_options:
        FxHashMap<u32, (DisplayOptions, GeometryOptions)>, // per-entity overrides
    pub 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]>>,
    pub sheet_plane_normals: Vec<(u32, Vec3)>,
}
}

FullRebuild is submitted when:

  • A new Assembly snapshot is consumed (entities added / removed / modified, scores or SS overrides changed).
  • Display, color, or geometry options change.
  • A scoped reset (e.g. engine.reset_scene_local_state) clears local state.

mesh_version per-entity is the cache key — entities whose version hasn’t changed since the previous rebuild reuse their cached mesh.

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<(usize, usize)>>,  // per-chain detail override
    pub include_sidechains: bool,
    pub generation: u64,
}
}

This is 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.

Shutdown

Terminates the background thread.

Per-Entity Mesh Caching

The background thread maintains a cache of per-entity meshes, keyed on EntityId:

FxHashMap<EntityId, CachedEntityMesh>

CachedEntityMesh stores GPU-ready byte buffers (backbone vertex / index, sidechain instances, ball-and-stick spheres+capsules, nucleic acid stems+rings) plus typed intermediates needed for index concatenation.

Cache Invalidation

When a FullRebuild arrives, the processor checks each entity’s mesh_version against the cached version:

  1. Same version — reuse cached mesh (skip generation entirely).
  2. Different version — regenerate and update cache.
  3. Entity removed — evict from cache.

Version-based invalidation is cheap (a u64 comparison) and avoids regenerating unchanged entities. For a scene with 3 entities where only 1 changed, this saves ~70% of mesh generation time.

Global vs Per-Entity Settings

Display, color, and geometry options affect mesh content. The processor distinguishes geometry-affecting changes (which require mesh regeneration) from color-only changes (which only update vertex color buffers). 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.

Mesh Generation

For each entity, the processor generates whichever of the following apply to its drawing_mode:

  1. Backbone mesh — cubic Hermite splines with rotation-minimizing frames, with separate index ranges for 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 for DNA/RNA backbones.

Mesh Concatenation

After generating (or retrieving from cache) all entity meshes, they’re concatenated into a single PreparedRebuild:

  • Vertex buffers are appended.
  • Index buffers are appended with per-entity index offset adjustment.
  • Instance buffers are concatenated.
  • A single PickMap is built mapping raw GPU pick IDs to typed pick targets.

PreparedRebuild

The output of a FullRebuild, ready for GPU upload:

#![allow(unused)]
fn main() {
pub(crate) struct PreparedRebuild {
    pub generation: u64,
    pub backbone: BackboneMeshData,                 // verts + tube/ribbon idx
    pub sidechain_instances: Vec<u8>,
    pub sidechain_instance_count: u32,
    pub bns: BallAndStickInstances,                 // sphere + capsule instances
    pub na: NucleicAcidInstances,                   // stem + ring instances
    pub pick_map: PickMap,
}
}

All byte arrays are raw GPU buffer data (bytemuck::cast_slice), ready for queue.write_buffer() with no further processing.

PreparedAnimationFrame

The output of an AnimationFrame request, containing only the data that changes during animation:

#![allow(unused)]
fn main() {
pub(crate) struct PreparedAnimationFrame {
    pub backbone: BackboneMeshData,
    pub sidechain_instances: Option<Vec<u8>>,
    pub sidechain_instance_count: u32,
    pub generation: u64,
}
}

Only backbone mesh and (optionally) sidechain instances are regenerated during animation — ball-and-stick, nucleic-acid, and isosurface meshes don’t change.

Stale Frame Discarding

When a scene is replaced (e.g. loading a new structure), in-flight animation frames from the old scene become stale — their per-chain LOD or topology assumptions may not match the new scene.

A monotonically increasing generation counter prevents this:

  1. Each FullRebuild carries a new generation.
  2. Each AnimationFrame carries the generation of the scene it was produced for.
  3. Background thread: frames with generation < last_rebuild_generation are skipped before processing.
  4. Main thread: stale animation frames are discarded before GPU upload.

This two-level check ensures stale frames are dropped both before expensive mesh generation and before GPU upload, with no additional synchronization primitives.

Threading Model Summary

ThreadOwnsDoes
Main threadGPU resources, engine, sceneInput, render, GPU upload
Mesh threadPer-entity mesh cacheCPU mesh generation
Surface thread(none — short-lived)Isosurface mesh regeneration
BridgeTriple buffers, mpsc channelsLock-free data transfer

The main thread never blocks on the background threads. If meshes aren’t ready yet, the previous frame’s meshes are rendered. This ensures consistent frame rates even during expensive mesh 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 to the engine 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 own molex::Assembly and hand viso the latest snapshot. There is no viso-defined channel, publisher, or consumer in the public API — the entire 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 engine.set_assembly(...) again. The engine stages the snapshot in its internal pending slot and drains it on the next update(dt) (or sync_now()) tick — a generation check skips work if nothing changed.

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 own 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 setupRenderContext is configured with a surface, adapter, device, and queue.
  2. Shader compilationShaderComposer loads and composes all WGSL modules using naga_oil.
  3. CameraCameraController is created with default orbital parameters (FOV 45°, fit to origin).
  4. Renderers — backbone, sidechain, ball-and-stick, bond, band, pull, nucleic-acid, and isosurface renderers.
  5. Post-processing — SSAO, bloom, composite, and FXAA passes.
  6. Picking — GPU picking system with offscreen R32Uint target and staging buffer.
  7. Scene processor — background thread spawned for mesh generation.
  8. Assembly slotScene starts with an empty current Assembly and pending: None. The first set_assembly call fills pending; the next update(dt) consumes it.

Initial Scene Sync

The first engine.set_assembly(...) call after construction pushes your initial snapshot. The next engine.update(dt) drains the pending snapshot, rederives the scene, and submits a full mesh rebuild to the background thread. On the frame after, apply_pending_scene uploads the meshes to the GPU.

If you want an explicit non-animating sync (rare — update does this for you), call:

#![allow(unused)]
fn main() {
engine.sync_scene_to_renderers(std::collections::HashMap::new());
}

The HashMap<u32, Transition> argument lets you animate specific entities; passing an empty map snaps everything.

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 11 sub-systems, each in its own field:

FieldTypePurpose
gpuGpuPipelinewgpu context, all renderers, picking, post-process, lighting, shader composer, density mesh receiver
camera_controllerCameraControllerCamera matrices, animation, frustum
constraintsConstraintSpecsStored band/pull constraint 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
sceneScenePending/current Assembly + derived per-entity state
annotationsEntityAnnotationsPer-entity overrides: focus, visibility, behaviors, appearance, scores, SS, surfaces
surface_regenSurfaceRegenBackground isosurface mesh regeneration

The engine is not thread-safe (!Send, !Sync) because it holds wgpu GPU resources. All engine access must happen on the main thread. The background scene processor and surface regeneration thread communicate via channels and triple buffers.

The Render Loop

Every frame follows a specific sequence. 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 ensures smooth animation.

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: complete_readback() polls the device without blocking. If the map is complete, it reads the residue ID and resolves it to a PickTarget via the engine’s PickMap. Otherwise it uses the cached value from the previous successful read.

Hover feedback is one frame behind mouse movement, which is imperceptible in practice but avoids GPU pipeline stalls.

Scene Management

Viso’s structural state lives in molex::Assembly, which is 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::collapse_expand(/* ... */));
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 entities with Tab:

#![allow(unused)]
fn main() {
pub enum Focus {
    Session,             // All visible entities (default)
    Entity(EntityId),    // A specific entity
}
}
#![allow(unused)]
fn main() {
// Cycle: Session → Entity₁ → … → EntityN → Session
engine.execute(VisoCommand::CycleFocus);

// Focus a specific entity by raw id.
engine.execute(VisoCommand::FocusEntity { id });

// Reset to session-wide focus.
engine.execute(VisoCommand::ResetFocus);

// Read current focus state.
let focus: Focus = engine.focus();
let focused_entity: Option<EntityId> = engine.focused_entity();
}

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 provides an InputProcessor that translates raw mouse/keyboard events into VisoCommand values. The engine executes commands via engine.execute(cmd).

Architecture

Platform events (winit, web, etc.)
    │
    ▼
InputEvent (platform-agnostic)
    │
    ▼
InputProcessor
    │  translates to
    ▼
VisoCommand
    │
    ▼
engine.execute(cmd)

InputProcessor is optional convenience. Consumers who handle their own input can construct VisoCommand values directly and skip InputProcessor entirely.

InputEvent

The platform-agnostic input enum:

#![allow(unused)]
fn main() {
pub enum InputEvent {
    CursorMoved { x: f32, y: f32 },
    MouseButton { button: MouseButton, pressed: bool },
    Scroll { delta: f32 },
    ModifiersChanged { shift: bool },
}
}

Your windowing layer converts platform events into these variants.

Wiring Input (winit example)

From viso’s standalone viewer:

#![allow(unused)]
fn main() {
struct ViewerShell {
    engine: Option<VisoEngine>,
    input: InputProcessor,
    // ...
}

impl ViewerShell {
    fn dispatch_input(&mut self, event: InputEvent) {
        let Some(engine) = &mut self.engine else { return };

        // Update cursor for GPU picking
        if let InputEvent::CursorMoved { x, y } = event {
            engine.set_cursor_pos(x, y);
        }

        // Translate event to command and execute
        if let Some(cmd) =
            self.input.handle_event(event, engine.hovered_target())
        {
            let _ = engine.execute(cmd);
        }
    }
}
}

Mouse movement

#![allow(unused)]
fn main() {
WindowEvent::CursorMoved { position, .. } => {
    dispatch_input(InputEvent::CursorMoved {
        x: position.x as f32,
        y: position.y as f32,
    });
}
}

Scroll (zoom)

#![allow(unused)]
fn main() {
WindowEvent::MouseWheel { delta, .. } => {
    let scroll_delta = match delta {
        MouseScrollDelta::LineDelta(_, y) => y,
        MouseScrollDelta::PixelDelta(pos) => pos.y as f32 * 0.01,
    };
    dispatch_input(InputEvent::Scroll { delta: scroll_delta });
}
}

Click (selection)

#![allow(unused)]
fn main() {
WindowEvent::MouseInput { button, state, .. } => {
    dispatch_input(InputEvent::MouseButton {
        button: MouseButton::from(button),
        pressed: state == ElementState::Pressed,
    });
}
}

Modifier keys

#![allow(unused)]
fn main() {
WindowEvent::ModifiersChanged(modifiers) => {
    dispatch_input(InputEvent::ModifiersChanged {
        shift: modifiers.state().shift_key(),
    });
}
}

Keyboard Input

Keyboard events go through InputProcessor::handle_key_press, which looks up the physical key in the configurable KeyBindings map:

#![allow(unused)]
fn main() {
WindowEvent::KeyboardInput { event, .. } => {
    if event.state == ElementState::Pressed {
        if let PhysicalKey::Code(code) = event.physical_key {
            let key_str = format!("{code:?}");
            if let Some(cmd) = input.handle_key_press(&key_str) {
                let _ = engine.execute(cmd);
            }
        }
    }
}
}

Display toggles for ambient types (water/ion/solvent) and lipid mode go through VisoCommand::SetTypeVisibility and VisoCommand::CycleLipidMode respectively. The default key bindings already wire these up — pressing U, I, O, or L produces the right command without any extra code in the viewer.

VisoCommand

The full action vocabulary:

#![allow(unused)]
fn main() {
pub enum VisoCommand {
    // Camera
    RecenterCamera,
    ToggleAutoRotate,
    RotateCamera { delta: Vec2 },
    PanCamera { delta: Vec2 },
    Zoom { delta: f32 },

    // Focus
    CycleFocus,
    ResetFocus,

    // Playback
    ToggleTrajectory,

    // Selection
    ClearSelection,
    SelectResidue { index: i32, extend: bool },
    SelectSegment { index: i32, extend: bool },
    SelectChain { index: i32, extend: bool },

    // Entity management
    FocusEntity { id: u32 },
    ToggleEntityVisibility { id: u32 },
    RemoveEntity { id: u32 },

    // Display toggles
    SetTypeVisibility { mol_type: MoleculeType, visible: Option<bool> },
    CycleLipidMode,
}
}

engine.execute(cmd) returns a CommandOutcome describing what changed (SelectionChanged, FocusChanged, VisibilityChanged, NoEffect, or Unhandled). RemoveEntity is Unhandled when sent to the engine directly — it must be routed through VisoApp (or the host that owns the Assembly) so the assembly is mutated and the new snapshot is pushed via engine.set_assembly.

Click Detection

InputProcessor tracks click timing internally to distinguish:

Click TypeResulting Command
Single click on residueSelectResidue { index, extend: false }
Shift + click on residueSelectResidue { index, extend: true }
Double clickSelectSegment { index, extend }
Triple clickSelectChain { index, extend }
Click on backgroundClearSelection
Drag (moved after press)RotateCamera or PanCamera (no selection)

Shift + drag produces PanCamera instead of RotateCamera.

KeyBindings

Customizable key-to-command mapping, serde-serializable. Keys use the winit::keyboard::KeyCode debug format ("KeyQ", "Tab", "Backquote", etc.):

#![allow(unused)]
fn main() {
let mut input = InputProcessor::new();
// Default bindings are pre-loaded.

// Or with a custom map:
let bindings: KeyBindings = toml::from_str(&toml_str)?;
let mut input = InputProcessor::with_key_bindings(bindings);
}

Default keybindings:

KeyAction
KeyQRecenter camera on focus
KeyTToggle trajectory playback
TabCycle focus
KeyRToggle auto-rotate
BackquoteReset focus to session
EscapeClear selection
KeyIToggle ion visibility
KeyUToggle water visibility
KeyOToggle solvent visibility
KeyLCycle lipid display mode

Skipping InputProcessor

For web embeds or custom hosts, construct commands directly:

#![allow(unused)]
fn main() {
// Rotate camera by 5 degrees worth of pixel delta
engine.execute(VisoCommand::RotateCamera {
    delta: Vec2::new(5.0, 0.0),
});

// Select residue 42
engine.execute(VisoCommand::SelectResidue {
    index: 42,
    extend: false,
});
}

Dynamic Structure Updates

Viso is designed for 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::collapse_expand(
    Duration::from_millis(200),
    Duration::from_millis(300),
));

// Subsequent re-publishes that touch this entity will use
// collapse_expand instead of the default snap.
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()

// Two-phase: sidechains collapse to CA, then expand. For mutations.
Transition::collapse_expand(
    Duration::from_millis(300),
    Duration::from_millis(300),
)

// Two-phase: backbone moves first with sidechains hidden, then
// sidechains expand into place.
Transition::backbone_then_expand(
    Duration::from_millis(400),
    Duration::from_millis(600),
)

// Builder flags
Transition::collapse_expand(
    Duration::from_millis(200),
    Duration::from_millis(300),
)
    .allowing_size_change()
    .suppressing_initial_sidechains()
}

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.

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.