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-group mesh caching – only changed groups 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::parse ───▶ MoleculeEntity◄─┘
        │
        ▼
     Scene (live renderable state, dirty-flagged)
        │
        ├───▶ SceneProcessor (background thread)
        │         mesh generation + triple buffer
        │
        ▼
     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.

Using Viso as a Library

Add viso to your Cargo.toml with no extra features:

[dependencies]
viso = { path = "../viso" }  # or git/registry
pollster = "0.4"             # for blocking on async GPU init

The minimal integration has three steps: create an engine, load structure data, and run a render loop.

1. Create the Engine

RenderContext initializes the wgpu device and surface. Pass it to VisoEngine::new_empty to get an engine with no entities loaded:

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

let context = pollster::block_on(
    RenderContext::new(window.clone(), (width, height))
)?;
let mut engine = VisoEngine::new_empty(context)?;
}

Or load a structure file directly:

#![allow(unused)]
fn main() {
let mut engine = pollster::block_on(
    VisoEngine::new_with_path(window.clone(), (width, height), scale_factor, "path/to/structure.cif")
)?;
}

new_with_path accepts .cif, .pdb, and .bcif files. It parses the file, populates the scene, and kicks off background mesh generation.

2. Load Entities

If you used new_empty, load entities yourself:

#![allow(unused)]
fn main() {
// entities: Vec<MoleculeEntity> from molex or your own pipeline
let entity_ids = engine.load_entities(entities, true); // true = fit camera
engine.sync_scene_to_renderers(None);
}

sync_scene_to_renderers submits the scene to the background thread for mesh generation. Pass None to snap to the current state with no animation.

3. Render Loop

Each frame, call update then render:

#![allow(unused)]
fn main() {
engine.update(dt);       // advance animation, apply pending meshes
match engine.render() {  // full GPU pipeline → 2D texture
    Ok(()) => {}
    Err(wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost) => {
        engine.resize(width, height);
    }
    Err(e) => log::error!("render error: {e:?}"),
}
}

That’s it. 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

For quick prototyping, enable the viewer feature:

[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()?;
}

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

What Foldit Adds

The standalone viewer is intentionally minimal. Foldit adds:

  • Multiple entity groups with independent visibility and focus cycling
  • Backend integration (Rosetta minimization, ML structure prediction) that streams coordinate updates
  • Animated transitions between poses using the animation system
  • A webview UI for controls, panels, and sequence display
  • Band and pull visualization for constraint-based manipulation

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) 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::scene::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
EscapeClear selection
WToggle water visibility

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                          │
│  (foldit-rs window.rs / viso main.rs)                           │
│                                                                 │
│  winit events ──► InputProcessor ──► VisoCommand                │
│  IPC messages ──► backend handler ──► engine API calls          │
└──────────────────────────────┬──────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│                         VisoEngine                              │
│                                                                 │
│  ┌────────────┐  ┌────────────┐  ┌────────────┐  ┌────────────┐ │
│  │ Scene      │  │ Animator   │  │ Camera     │  │ Picking    │ │
│  │            │  │            │  │ Controller │  │ System     │ │
│  │ Groups     │  │ Backbone   │  │ Arcball    │  │ GPU read   │ │
│  │ Entities   │  │ Sidechain  │  │ Animation  │  │ Selection  │ │
│  │ Focus      │  │ Per-entity │  │ Frustum    │  │ Bit-array  │ │
│  └─────┬──────┘  └─────┬──────┘  └─────┬──────┘  └─────┬──────┘ │
│        │               │               │               │        │
│        ▼               ▼               │               │        │
│  ┌───────────────────────────┐         │               │        │
│  │  SceneProcessor           │         │               │        │
│  │  (background thread)      │         │               │        │
│  │                           │         │               │        │
│  │  Per-group mesh cache     │         │               │        │
│  │  Tube/ribbon/sidechain    │         │               │        │
│  │  Ball-and-stick/NA gen    │         │               │        │
│  └─────────────┬─────────────┘         │               │        │
│                │ triple buffer         │               │        │
│                ▼                       ▼               ▼        │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │                      Renderers                            │  │
│  │                                                           │  │
│  │  Molecular:                   Post-Processing:            │  │
│  │  ├─ BackboneRenderer          ├─ SSAO                     │  │
│  │  │  (tubes + ribbons)         ├─ Bloom                    │  │
│  │  ├─ SidechainRenderer         ├─ Composite                │  │
│  │  ├─ BallAndStickRenderer      └─ FXAA                     │  │
│  │  ├─ BandRenderer                                          │  │
│  │  ├─ PullRenderer              ShaderComposer:             │  │
│  │  └─ NucleicAcidRenderer       └─ naga_oil composition     │  │
│  └───────────────────────────────────────────────────────────┘  │
│                            │                                    │
│                            ▼                                    │
│                    ┌──────────────┐                             │
│                    │ RenderContext│                             │
│                    │ wgpu device  │                             │
│                    │ queue        │                             │
│                    │ surface      │                             │
│                    └──────────────┘                             │
└─────────────────────────────────────────────────────────────────┘

High-Level Data Flow

┌───────────────────────────────────────────────────────────┐
│                     INITIALIZATION                        │
│                                                           │
│  File path (.cif/.pdb/.bcif)  ──or──  Vec<MoleculeEntity> │
│         │                                    │            │
│         ▼                                    │            │
│  molex::parse ──► Vec<MoleculeEntity> ◄┘            │
│                                │                          │
│                                ▼                          │
│                     Engine stores as                      │
│                    SOURCE OF TRUTH                        │
└────────────────────────────┬──────────────────────────────┘
                             │
                             ▼
┌───────────────────────────────────────────────────────────┐
│                         SCENE                             │
│                                                           │
│  The "live" renderable state of the world.                │
│  Positions, SS types, colors, sidechain topology —        │
│  everything needed to produce geometry.                   │
│                                                           │
│  Dirty-flagged: only rebuilds geometry when changed.      │
│  During animation: reflects interpolated state.           │
│  When animation completes: matches source of truth.       │
└────────────────────────────┬──────────────────────────────┘
                             │
                             ▼
┌───────────────────────────────────────────────────────────┐
│                       RENDERER                            │
│                                                           │
│  Consumes Scene read-only, produces GPU data.             │
│                                                           │
│  ┌─────────────────────────────────────────────────────┐  │
│  │ renderer::geometry                                  │  │
│  │                                                     │  │
│  │ Scene data ──► meshes + impostor instances          │  │
│  │ (tubes, ribbons, capsules, ball-and-stick, NA)      │  │
│  └──────────────────────┬──────────────────────────────┘  │
│                         │                                 │
│                         ▼                                 │
│  ┌─────────────────────────────────────────────────────┐  │
│  │ GPU Passes                                          │  │
│  │                                                     │  │
│  │ 1. Geometry pass (color + normals + depth)          │  │
│  │ 2. Picking pass (object ID readback)                │  │
│  │ 3. Post-process (SSAO, bloom, fog, FXAA)            │  │
│  │           │                                         │  │
│  │           ▼                                         │  │
│  │ Final 2D screen-space texture                       │  │
│  └─────────────────────────────────────────────────────┘  │
└────────────────────────────┬──────────────────────────────┘
                             │
                             ▼
┌───────────────────────────────────────────────────────────┐
│                   OUTPUT / EMBEDDING                      │
│                                                           │
│  The final texture is consumed by the host:               │
│    • winit window (current viewer)                        │
│    • HTML canvas (wasm / web embed)                       │
│    • PNG snapshot (headless)                              │
│    • dioxus / egui / any framework with a texture slot    │
│                                                           │
│  The engine produces a texture; the consumer decides      │
│  what to do with it.                                      │
└───────────────────────────────────────────────────────────┘

Data Flow: File to Screen

1. Parsing

PDB/CIF file → molex → Coords → MoleculeEntity

The molex crate parses mmCIF files into Coords structs (atom positions, names, chains, residue info). These are wrapped in MoleculeEntity values with a molecule type classification.

2. Scene Organization

MoleculeEntity → EntityGroup → Scene

Entities are grouped into EntityGroup values. The scene maintains insertion order, visibility state, focus tracking, and a generation counter for dirty detection.

3. Background Mesh Generation

Scene → PerGroupData → SceneProcessor → PreparedScene

When the scene is dirty, per_group_data() collects render data for each visible group. This is submitted to the background thread via SceneRequest::FullRebuild. The processor generates (or retrieves cached) meshes for each group, concatenates them, and writes a PreparedScene to the triple buffer.

4. GPU Upload

PreparedScene → queue.write_buffer() → GPU buffers

The main thread picks up the PreparedScene and writes raw byte arrays directly to GPU buffers. This is a memcpy-level operation, typically under 1ms.

5. Rendering

GPU buffers → Geometry Pass → Post-Processing → Swapchain

All molecular renderers draw to HDR render targets. The post-processing stack applies SSAO, bloom, compositing (outlines, fog, tone mapping), and FXAA before presenting to the swapchain.

Threading Model

Viso uses two threads with lock-free communication:

Main Thread

Owns all GPU resources and runs the render loop. Responsibilities:

  • Processing input events (mouse, keyboard, IPC)
  • Managing the scene (add/remove groups, update entities)
  • Running animation (update each frame, get interpolated state)
  • 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
  • Handling GPU picking readback

The main thread never blocks on the background thread. If meshes aren’t ready, it renders the previous frame’s data.

Background Thread

Owns the mesh cache and performs CPU-intensive work:

  • Receiving scene requests via mpsc::Receiver (blocks when idle)
  • Generating tube, ribbon, sidechain, ball-and-stick, and nucleic acid meshes
  • Maintaining a per-group mesh cache with version-based invalidation
  • Coalescing queued requests to skip stale intermediates
  • Writing results to triple buffers (non-blocking)

Lock-Free Bridge

MechanismDirectionSemantics
mpsc::channelMain → BackgroundSubmit requests (non-blocking send)
triple_buffer (scene)Background → MainLatest PreparedScene (non-blocking read)
triple_buffer (anim)Background → MainLatest PreparedAnimationFrame (non-blocking read)

Triple buffers guarantee:

  • The writer always has a buffer to write to (never blocks)
  • The reader always gets the latest completed result
  • No data races or mutex contention

Module Structure

viso/src/
├── lib.rs                  # Public API (flat re-exports only)
├── main.rs                 # Standalone viewer binary
├── engine/                 # Core coordinator: frame loop, command dispatch, subsystem wiring
│   └── trajectory.rs       # TrajectoryPlayer (DCD frame sequencer)
├── scene/                  # Entity storage, groups, visibility, SS overrides, dirty flagging
├── animation/              # Structural animation system
│   ├── animator.rs         # StructureAnimator + StructureState (per-entity runner dispatch)
│   ├── runner.rs           # AnimationRunner + data types (phase evaluation)
│   ├── transition.rs       # AnimationPhase, Transition presets (public API)
│   └── easing.rs           # Easing curves
├── input/                  # Raw window events → VisoCommand conversion
├── options/                # TOML-serializable runtime options (lighting, camera, colors, display)
├── camera/                 # Orbital camera controller, animated transitions, frustum culling
├── gpu/                    # wgpu device/surface init, dynamic buffers, lighting, shader composition
├── renderer/               # GPU rendering pipeline
│   ├── geometry/           # Scene data → mesh/impostor generation
│   ├── picking/            # GPU-based object picking + readback
│   └── postprocess/        # SSAO, bloom, composite, FXAA
├── viewer.rs               # Standalone winit viewer (feature-gated)
├── gui/                    # Webview options panel (feature-gated)
└── util/                   # Frame timing, sheet adjust, bond topology, score color

Key Design Decisions

Why Background Processing?

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 background thread always has a buffer to write to
  • The main thread always reads the latest result
  • No mutexes, no contention, no blocking on either side

The cost is memory (3x the buffer size), but mesh data is typically 1-10MB, so this is negligible.

Why Per-Group Caching?

Molecular scenes often have multiple groups where only one changes at a time (e.g., Rosetta updates one group while others stay static). Per-group caching with version-based invalidation means only the changed group’s meshes are regenerated. For a 3-group scene, this can save 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)

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 keybindings: KeybindingOptions,
}
}

All fields use #[serde(default)], so TOML files can be partial – only the fields you want to override need to be specified.

Display Options

#![allow(unused)]
fn main() {
pub struct DisplayOptions {
    pub show_waters: bool,           // default: false
    pub show_ions: bool,             // default: true
    pub show_solvent: bool,          // default: false
    pub lipid_mode: LipidMode,       // default: Coarse
    pub show_sidechains: bool,       // default: true
    pub show_hydrogens: bool,        // default: false
    pub backbone_color_mode: BackboneColorMode,   // default: Chain
    pub sidechain_color_mode: SidechainColorMode, // default: Hydrophobicity
    pub na_color_mode: NaColorMode,  // default: Uniform
}
}

Lighting Options

#![allow(unused)]
fn main() {
pub struct LightingOptions {
    pub light1_dir: [f32; 3],       // Primary directional light
    pub light2_dir: [f32; 3],       // Fill directional light
    pub light1_intensity: f32,      // default: 2.0
    pub light2_intensity: f32,      // default: 1.1
    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],
    pub rim_dir: [f32; 3],
    pub ibl_strength: f32,          // default: 0.6
    pub roughness: f32,             // default: 0.35
    pub metalness: f32,             // default: 0.15
}
}

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
    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]>,
}
}

Geometry Options

#![allow(unused)]
fn main() {
pub struct GeometryOptions {
    pub tube_radius: f32,           // default: 0.3
    pub tube_radial_segments: u32,  // default: 8
    pub solvent_radius: f32,        // default: 0.15
    pub ligand_sphere_radius: f32,  // default: 0.3
    pub ligand_bond_radius: f32,    // default: 0.12
}
}

Keybinding Options

#![allow(unused)]
fn main() {
pub struct KeybindingOptions {
    pub bindings: HashMap<String, String>,  // action name -> key string
}
}

Default keybindings:

ActionKey
recenter_cameraQ
toggle_trajectoryT
toggle_ionsI
toggle_watersU
toggle_solventO
toggle_lipidsL
cycle_focusTab
toggle_auto_rotateR
reset_focus`
cancelEscape

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", ...]
}

Example TOML Preset

[display]
show_sidechains = true
backbone_color_mode = "Chain"

[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

Options are applied to the engine through dedicated methods. Display and color options trigger a scene re-sync (since the background processor needs them for mesh generation). Lighting and post-processing options are pushed directly to GPU uniforms.

#![allow(unused)]
fn main() {
// Apply post-processing options
engine.post_process.apply_options(&options, &queue);

// Apply lighting options
engine.update_lighting(&options.lighting);

// Display/color changes require a scene sync
engine.sync_scene_to_renderers(None);
}

Changes to display options (like backbone_color_mode or show_sidechains) invalidate the per-group mesh cache in the background processor, causing a full mesh regeneration on the next sync.

Color Modes

Viso supports several coloring schemes for backbones, sidechains, and nucleic acids. Colors are computed during background scene processing and baked into vertex data for zero-cost rendering.

Backbone Color Modes

Set via DisplayOptions::backbone_color_mode:

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

Chain (Default)

Each chain gets a distinct color interpolated from blue to red across the chain count. 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 from backbone geometry using dihedral angle analysis.

Score

Colors residues by per-residue energy scores from Rosetta. Uses a blue-white-red gradient:

  • Blue – favorable (low) energy
  • White – neutral
  • Red – unfavorable (high) energy

Scores are cached on each EntityGroup via set_per_residue_scores(). The background processor converts scores to RGB colors during mesh generation.

Score Relative

Similar to Score mode but normalizes within the structure, highlighting relative differences rather than absolute energy values.

Sidechain Color Modes

Set via DisplayOptions::sidechain_color_mode:

#![allow(unused)]
fn main() {
pub enum SidechainColorMode {
    Hydrophobicity,  // Default -- hydrophobic vs hydrophilic
}
}

Hydrophobicity (Default)

Sidechain atoms are colored by hydrophobicity:

  • Hydrophobic – blue (default: [0.3, 0.5, 0.9])
  • Hydrophilic – orange (default: [0.95, 0.6, 0.2])

These colors are configurable in ColorOptions.

Nucleic Acid Color Modes

Set via DisplayOptions::na_color_mode:

#![allow(unused)]
fn main() {
pub enum NaColorMode {
    Uniform,  // Default -- single color for all nucleic acid backbone
}
}

Uniform (Default)

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 special warm beige/tan tint (configurable via ColorOptions::lipid_carbon_tint)
  • Cofactors can have per-residue-name 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 PreparedScene. During animation, the tube and ribbon renderers interpolate between start and target colors using the same easing function as the backbone position interpolation.

This means 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 and ColorOptions are sent to the background processor as part of SceneRequest::FullRebuild
  2. Background thread – during mesh generation, colors are computed per-vertex based on the active color mode and baked into vertex data
  3. GPU upload – vertex buffers with embedded colors are uploaded to the GPU
  4. Rendering – shaders read per-vertex 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

#![allow(unused)]
fn main() {
pub struct Transition {
    pub phases: Vec<AnimationPhase>,
    pub allows_size_change: bool,
    pub suppress_initial_sidechains: bool,
    pub name: &'static str,
}

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

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

AnimationPhase

Each phase in a transition defines a segment of the animation:

#![allow(unused)]
fn main() {
pub struct AnimationPhase {
    pub easing: EasingFunction,
    pub duration: Duration,
    pub lerp_start: f32,    // e.g. 0.0
    pub lerp_end: f32,      // e.g. 0.4
    pub include_sidechains: bool,
}
}

The runner maps raw progress (0→1 over total duration) through the phase sequence. 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 — the SidechainAnimPositions start positions are set to CA coordinates when allows_size_change is true.

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 AnimationRunner with independent timing. The StructureAnimator manages a HashMap<u32, EntityAnimationState> of per-entity runners and aggregates their interpolated output each frame.

#![allow(unused)]
fn main() {
// The engine dispatches per-entity:
animator.animate_entity(
    &range,          // EntityResidueRange (includes entity_id)
    &backbone_chains,
    &transition,
    sidechain_data,  // Option<SidechainAnimPositions>
);

// Each frame:
let still_animating = animator.update(Instant::now());
let visual_backbone = animator.get_backbone();
}

How It Works

  1. animate_entity() builds per-residue ResidueAnimationData (start/target backbone positions) for the entity’s residue range
  2. An AnimationRunner is created with those residues, the transition’s phases, and optional sidechain positions
  3. Each frame, update() calls interpolate_residues() on each runner, which returns an iterator of (residue_idx, lerped_visual) pairs
  4. Sidechain positions are interpolated with the same eased_t as backbone
  5. When a runner completes (progress >= 1.0), the entity’s residues are snapped 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).

ResidueVisualState

Each residue’s visual state during animation:

#![allow(unused)]
fn main() {
pub struct ResidueVisualState {
    pub backbone: [Vec3; 3],  // N, CA, C positions
}
}

Interpolation lerps backbone positions linearly, with the easing applied via the phase’s easing function.

Sidechain Animation

Sidechain positions are stored as SidechainAnimPositions (start + target Vec<Vec3>) and lerped with the same eased_t as backbone. The animator pre-computes interpolated sidechain positions each frame so queries 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. The TrajectoryPlayer (in engine/trajectory.rs) is a frame sequencer with no animation dependencies. Each frame it produces is fed through animate_entity() with Transition::snap(), so trajectory and structural animation share a single code path in tick_animation().

StructureState

StructureState (in animator.rs) holds the current and target visual state for the entire structure. It converts between backbone chain format (Vec<Vec<Vec3>>) and per-residue ResidueVisualState arrays, preserving chain boundaries via chain_lengths.

The animator owns a single StructureState and per-entity runners write interpolated values into it each frame.

Easing Functions

Available in animation/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

#![allow(unused)]
fn main() {
// Disable all animation (instant snap)
animator.set_enabled(false);

// Or use Transition::snap() for individual updates
}

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 wraps the camera and manages input, GPU uniforms, and animation:

#![allow(unused)]
fn main() {
pub struct CameraController {
    pub camera: Camera,
    pub uniform: CameraUniform,
    pub buffer: wgpu::Buffer,
    pub layout: wgpu::BindGroupLayout,
    pub bind_group: wgpu::BindGroup,
    pub mouse_pressed: bool,
    pub shift_pressed: bool,
    pub rotate_speed: f32,  // default: 0.5
    pub pan_speed: f32,     // default: 0.5
    pub zoom_speed: f32,    // default: 0.1
}
}

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() {
controller.rotate(Vec2::new(delta_x, delta_y));
}

The sensitivity is controlled by rotate_speed.

Panning

Panning translates the focus point along the camera’s right and up vectors:

#![allow(unused)]
fn main() {
controller.pan(Vec2::new(delta_x, delta_y));
}

This cancels any animated focus point transition.

Zooming

Zoom adjusts the orbital distance, clamped to [1.0, 1000.0]:

#![allow(unused)]
fn main() {
controller.zoom(scroll_delta);
}

Camera Animation

The camera can animate between states for smooth transitions when loading structures or changing focus:

Fitting to Positions

#![allow(unused)]
fn main() {
// Instant fit (for initial load)
controller.fit_to_positions(&all_atom_positions);

// Animated fit (for adding new structures)
controller.fit_to_positions_animated(&all_atom_positions);
}

Both methods:

  1. Calculate the centroid of all positions
  2. Compute a bounding sphere radius
  3. Calculate the distance needed to fit the sphere in the viewport (accounting for both horizontal and vertical FOV)

The animated version sets target values that are interpolated each frame.

Per-Frame Update

#![allow(unused)]
fn main() {
let still_animating = controller.update_animation(dt);
}

This interpolates:

  • Focus point toward target focus
  • Distance toward target distance
  • Bounding radius toward target radius

The interpolation speed is controlled by CAMERA_ANIMATION_SPEED (default 3.0). Higher values mean faster convergence.

Auto-Rotation

Toggle turntable-style auto-rotation:

#![allow(unused)]
fn main() {
let is_rotating = controller.toggle_auto_rotate();
}

When enabled, the camera rotates around the up vector at TURNTABLE_SPEED (approximately 29 degrees per second). The spin axis is captured from the current up vector when auto-rotation is enabled.

#![allow(unused)]
fn main() {
if controller.is_auto_rotating() {
    // Camera is spinning
}
}

Frustum Culling

The camera provides a frustum for culling off-screen geometry:

#![allow(unused)]
fn main() {
let frustum = controller.frustum();
}

This is primarily used for sidechain culling – sidechains outside the view frustum are skipped during rendering to improve performance. Each sidechain is tested against the frustum using a 5.0 angstrom cull radius.

Coordinate Conversion

Screen Delta to World

Convert mouse movement to world-space displacement:

#![allow(unused)]
fn main() {
let world_offset = controller.screen_delta_to_world(delta_x, delta_y);
}

Uses the camera’s right and up vectors to map 2D screen movement to 3D space. The scale factor is proportional to the orbital distance, so movement feels consistent at any zoom level.

Screen to World at Depth

Unproject screen coordinates to a world-space point on a plane at a specific depth:

#![allow(unused)]
fn main() {
let world_point = controller.screen_to_world_at_depth(
    screen_x, screen_y,
    screen_width, screen_height,
    reference_world_point,
);
}

This is used for pull operations – the target position should be on a plane parallel to the camera at the residue’s depth, so the pull doesn’t move toward or away from the camera.

The conversion:

  1. Maps screen coordinates to NDC [-1, 1] (with Y-flip since screen origin is top-left)
  2. Accounts for both horizontal and vertical FOV
  3. Projects onto a plane at the depth of the reference point

Fog Derivation

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

  • Fog start – based on the distance and bounding radius
  • Fog density – increases with bounding radius for larger structures

The post-processing composite 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:

#![allow(unused)]
fn main() {
controller.update_gpu(&queue);
}

The uniform contains the projection matrix, view matrix, inverse projection, camera position, and screen dimensions. All renderers bind to this uniform for vertex transformation.

GPU Picking and Selection

Viso uses GPU-based picking to determine which residue 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 residue ID (the residue’s global index + 1, where 0 means “no hit”).

Main render: geometry → HDR color + normals + depth
Picking render: same geometry → R32Uint residue IDs + depth

The picking pass uses depth testing (Less compare with depth writes) so only the closest geometry’s residue ID is stored.

Geometry Types in Picking

The picking pass renders four types of geometry in order:

  1. Tubes – backbone coils (in ribbon mode, only coil segments; in tube mode, everything). Uses the picking_mesh.wgsl shader.
  2. Ribbons – helices and sheets (ribbon mode only). Uses the same mesh pipeline as tubes.
  3. Capsule sidechains – sidechain capsule impostors. Uses the picking_capsule.wgsl shader with a storage buffer of capsule instances.
  4. Ball-and-stick – ligand/ion sphere and bond proxies (degenerate capsules). Uses the same capsule pipeline.

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
    • If 0: no residue hit (mouse is on background)
    • Otherwise: residue index = value - 1
  3. The staging buffer is unmapped
  4. Result is cached in hovered_residue

If the readback isn’t ready yet, the previous frame’s cached value is used. This means hover feedback is at most one frame behind, which is imperceptible.

#![allow(unused)]
fn main() {
// In the render method:
self.picking.render(encoder, /* geometry buffers... */, mouse_x, mouse_y);
// After queue.submit():
self.picking.start_readback();
// Next frame, before render:
self.picking.complete_readback(&device);
}

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

#![allow(unused)]
fn main() {
// Upload current selection to GPU
selection_buffer.update(&queue, &selected_residues);
}

This clears the bit array and sets bits for each selected residue index.

Dynamic Capacity

The buffer grows as needed:

#![allow(unused)]
fn main() {
selection_buffer.ensure_capacity(&device, total_residue_count);
}

If the current buffer is too small, a new buffer and bind group are created.

Click Handling

Single Click

#![allow(unused)]
fn main() {
let selection_changed = picking.handle_click(shift_held);
}
  • Click on residue, no shift: clears selection, selects clicked residue
  • Click on residue, shift held: toggles the residue (adds if absent, removes if present)
  • Click on background: clears all selection

Double Click (Secondary Structure Segment)

A double-click selects all residues in the same contiguous secondary structure segment:

  1. Look up the SS type of the clicked residue
  2. Walk backward through cached_ss_types until the type changes → segment start
  3. Walk forward until the type changes → segment end
  4. Select all residues in [start, end]
  5. If shift held, add to existing selection; otherwise replace

Triple Click (Chain)

A triple-click selects all residues in the same chain:

  1. Walk through backbone_chains to find which chain contains the residue
  2. Calculate the global residue range for that chain
  3. Select all residues in the range
  4. If shift held, add to existing selection; otherwise replace

Click Type Detection

The input state machine tracks timing between clicks:

  • Clicks within a threshold on the same residue increment the click counter
  • The counter determines single (1), double (2), or triple (3) click
  • If the mouse moved between press and release, it’s classified as a drag (no selection)

Selection in Shaders

All molecular renderers receive the selection bind group. In the fragment shader:

// Check if this fragment's residue is selected
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 hovered_residue uniform – 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 hovered residue (-1 if none)
let hovered: i32 = engine.picking.hovered_residue;

// Currently selected residues
let selected: &Vec<i32> = &engine.picking.selected_residues;

// Clear everything
engine.clear_selection();
}

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 (7 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
Target 0Rgba16FloatScene color with alpha blending
Target 1Rgba16FloatView-space normals / metadata (no blending)
DepthDepth32FloatDepth buffer (Less compare, writes enabled)

Using Rgba16Float enables HDR lighting and bloom without banding artifacts.

Molecular Renderers

Seven renderers draw molecular geometry in order:

1. Tube Renderer

Renders protein backbone as smooth cylindrical tubes.

  • Geometry: cubic Hermite splines with rotation-minimizing frames (RMF)
  • Parameters: radius 0.3 angstroms, 8 radial segments, 4 axial segments per CA span
  • SS filtering: in ribbon mode, only renders coil/loop segments; tubes handle everything in tube mode
  • Vertex data: position, normal, color, residue_idx, center_pos

2. Ribbon Renderer

Renders helices and sheets as flat ribbons.

  • Helices: ribbon normal points radially outward from the helix axis
  • Sheets: constant width, smooth RMF-propagated normals (no pleating)
  • Parameters: helix width 1.4, sheet width 1.6, thickness 0.25 angstroms
  • Interpolation: B-spline with C2 continuity, 16 segments per residue
  • Sheet offsets: sheet residues are offset from the tube centerline to separate ribbon from tube in ribbon mode

3. Capsule Sidechain Renderer

Renders sidechain atoms as capsule impostors (ray-marched).

  • Technique: storage buffer of CapsuleInstance structs, rendered as ray-marched impostors
  • Capsule radius: 0.3 angstroms
  • Colors: hydrophobic (blue), hydrophilic (orange), configurable via ColorOptions
  • Frustum culling: sidechains outside the view frustum (with 5.0 angstrom margin) are skipped
  • Instance data: two endpoints + radius, color + entity_id for picking

4. Ball-and-Stick Renderer

Renders ligands, ions, waters, and non-protein entities.

  • Atoms: ray-cast sphere impostors
  • Bonds: capsule impostors (cylinders with hemispherical caps)
  • Atom radii: normal atoms 0.3x van der Waals radius, ions 0.5x, water oxygen 0.3 angstroms
  • Bond radius: 0.15 angstroms
  • Double bonds: two parallel capsules offset by 0.2 angstroms
  • Lipid modes: CoarseGrained (P spheres, head-group highlights, thin tail bonds) or BallAndStick (full detail)

5. Band Renderer

Renders constraint bands (for Rosetta minimization).

  • Visual: capsule impostors with variable radius (0.1 to 0.4 angstroms, scaled by constraint strength)
  • Colors by type: default (purple), backbone (yellow-orange), disulfide (yellow-green), H-bond (cyan), disabled (gray)
  • Anchor spheres: 0.5 angstrom radius spheres at band endpoints for pull indicators

6. Pull Renderer

Renders the active drag constraint.

  • Cylinder: capsule impostor from atom to near the mouse position (purple, 0.25 angstrom radius)
  • Arrow: cone impostor at the mouse end pointing toward the target (0.6 angstrom radius)

7. Nucleic Acid Renderer

Renders DNA/RNA backbones.

  • Geometry: flat ribbons tracing phosphorus (P) atoms with B-spline interpolation and RMF orientation
  • Parameters: width 1.2 angstroms (narrower than protein), thickness 0.25 angstroms, 16 segments per P-atom
  • Color: light blue-violet (configurable)

Shared Bind Groups

All renderers receive common bind groups via DrawBindGroups:

#![allow(unused)]
fn main() {
pub 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: 32 hemisphere samples in view-space
  • Noise: 4x4 rotation noise texture to reduce banding
  • Parameters: radius (0.5), bias (0.025), 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 the brightness threshold to a half-resolution texture
  • Blur: separable Gaussian blur (horizontal then vertical, ping-pong textures)
  • Mip chain: 4 levels of downsampling
  • Upsample: additive accumulation back to half-resolution
  • Output: half-resolution bloom texture
  • Parameters: threshold (1.0), intensity (0.0 by default – disabled)

3. Composite

Combines all post-processing inputs into the final image.

Inputs (8 bind group entries):

  • Scene color texture
  • SSAO texture
  • Depth texture
  • Color/SSAO sampler (linear)
  • Depth sampler (nearest)
  • Params uniform buffer
  • Normal G-buffer
  • Bloom texture

Effects applied:

  • SSAO as darkening multiplier on base color
  • Depth-based fog (configurable start and density)
  • Depth-based outlines (edge detection on depth discontinuities)
  • Normal-based outlines (edge detection on normal discontinuities)
  • Bloom additive blend
  • HDR tone mapping (exposure control)
  • Gamma correction

Parameters (CompositeParams uniform):

  • Screen size, outline thickness/strength, AO strength
  • Near/far planes, fog start/density
  • Normal outline strength, exposure, gamma, bloom intensity

4. FXAA

Fast Approximate Anti-Aliasing as the final pass.

  • Smooths jagged edges on mesh-based geometry (tubes, ribbons) that supersampling alone doesn’t fully resolve
  • Reads from the composite output, writes to the swapchain surface
  • Uses linear filtering for edge detection

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);
    // ...
}

Pre-loaded shared modules:

  • camera.wgsl – camera matrix uniforms and transformations
  • lighting.wgsl – Blinn-Phong lighting, directional lights
  • sdf.wgsl – signed distance field utilities
  • raymarch.wgsl – ray marching for implicit surfaces
  • volume.wgsl – volume texture sampling
  • fullscreen.wgsl – fullscreen triangle utilities

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 set_scale_factor(). 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 tube 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-group meshes
  │                                    ├─ Concatenates into PreparedScene
  ├─ try_recv_scene()                  └─ Writes to triple_buffer::Output
  │   ← triple_buffer::Input
  ├─ GPU upload (<1ms)
  └─ Render

Communication Channels

ChannelTypeDirectionPurpose
Requestmpsc::Sender<SceneRequest>Main → BackgroundSubmit work
Scene resulttriple_bufferBackground → MainCompleted PreparedScene
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 { /* ... */ });

// Check for results (non-blocking)
if let Some(prepared) = processor.try_recv_scene() {
    // Upload to GPU
}

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

Request Types

FullRebuild

A complete scene rebuild with all visible group data:

#![allow(unused)]
fn main() {
SceneRequest::FullRebuild {
    entities: Vec<PerEntityData>,
    entity_transitions: HashMap<u32, Transition>,
    display: DisplayOptions,
    colors: ColorOptions,
    geometry: GeometryOptions,
    generation: u64,
}
}

This is submitted when:

  • The scene is dirty (entities added/removed/modified)
  • Display or color options change
  • A targeted animation is requested

AnimationFrame

Per-frame mesh regeneration during animation:

#![allow(unused)]
fn main() {
SceneRequest::AnimationFrame {
    backbone_chains: Vec<Vec<Vec3>>,
    sidechains: Option<AnimationSidechainData>,
    ss_types: Option<Vec<SSType>>,
    per_residue_colors: Option<Vec<[f32; 3]>>,
    generation: u64,
}
}

This is submitted every frame while animation is in progress. It regenerates tube and ribbon meshes from interpolated backbone positions.

Shutdown

Terminates the background thread:

#![allow(unused)]
fn main() {
SceneRequest::Shutdown
}

Per-Group Mesh Caching

The background thread maintains a cache of per-group meshes:

HashMap<GroupId, (u64, CachedGroupMesh)>
         │         │        │
         │         │        └─ Tube, ribbon, sidechain, BNS meshes
         │         └─ mesh_version at time of generation
         └─ Group identifier

Cache Invalidation

When a FullRebuild arrives, the processor checks each group:

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

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

Global Settings Changes

Display and color options affect all meshes. The processor detects when these change and clears the entire cache, forcing full regeneration. This happens when:

  • Backbone color mode changes
  • Show/hide sidechains changes
  • Tube radius or segment count changes

Mesh Generation

For each group, the processor generates:

  1. Tube mesh – cubic Hermite splines with rotation-minimizing frames, filtered by SS type
  2. Ribbon mesh – B-spline interpolation for helices and sheets, with sheet offsets
  3. Sidechain capsule instances – packed CapsuleInstance structs for the storage buffer
  4. Ball-and-stick instances – sphere and capsule instances for non-protein entities
  5. Nucleic acid mesh – flat ribbon geometry for DNA/RNA backbones

Mesh Concatenation

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

  • Vertex buffers are appended
  • Index buffers are appended with index offset adjustment (each group’s indices are offset by the previous group’s vertex count)
  • Instance buffers are concatenated
  • Passthrough data (backbone chains, sidechain positions, etc.) is merged with global index remapping

PreparedScene

The output of a FullRebuild, ready for GPU upload:

#![allow(unused)]
fn main() {
pub struct PreparedScene {
    // Tube mesh
    pub tube_vertices: Vec<u8>,
    pub tube_indices: Vec<u8>,
    pub tube_index_count: u32,

    // Ribbon mesh
    pub ribbon_vertices: Vec<u8>,
    pub ribbon_indices: Vec<u8>,
    pub ribbon_index_count: u32,
    pub sheet_offsets: Vec<(u32, Vec3)>,

    // Sidechain capsule instances
    pub sidechain_instances: Vec<u8>,
    pub sidechain_instance_count: u32,

    // Ball-and-stick
    pub bns_sphere_instances: Vec<u8>,
    pub bns_sphere_count: u32,
    pub bns_capsule_instances: Vec<u8>,
    pub bns_capsule_count: u32,
    pub bns_picking_capsules: Vec<u8>,
    pub bns_picking_count: u32,

    // Nucleic acid mesh
    pub na_vertices: Vec<u8>,
    pub na_indices: Vec<u8>,
    pub na_index_count: u32,

    // Passthrough data for animation, camera, etc.
    pub backbone_chains: Vec<Vec<Vec3>>,
    pub sidechain_positions: Vec<Vec3>,
    pub ss_types: Option<Vec<SSType>>,
    pub per_residue_colors: Option<Vec<[f32; 3]>>,
    pub all_positions: Vec<Vec3>,
    pub entity_transitions: HashMap<u32, Transition>,
    pub entity_residue_ranges: Vec<(u32, u32, u32)>,
    // ... more passthrough fields
}
}

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 meshes that change during animation:

#![allow(unused)]
fn main() {
pub struct PreparedAnimationFrame {
    pub tube_vertices: Vec<u8>,
    pub tube_indices: Vec<u8>,
    pub tube_index_count: u32,
    pub ribbon_vertices: Vec<u8>,
    pub ribbon_indices: Vec<u8>,
    pub ribbon_index_count: u32,
    pub sheet_offsets: Vec<(u32, Vec3)>,
    pub sidechain_instances: Option<Vec<u8>>,
    pub sidechain_instance_count: u32,
    pub generation: u64,
}
}

Only tube, ribbon, and (optionally) sidechain meshes are regenerated during animation – ball-and-stick, bands, pulls, and nucleic acid meshes don’t change.

Stale Frame Discarding

When a scene is replaced (e.g. loading a new structure), any in-flight animation frames from the old scene become stale – their per_chain_lod array may be sized for the old scene while chain data comes from the new one, causing index-out-of-bounds panics.

A monotonically increasing generation counter prevents this:

  1. Each FullRebuild carries a new generation from SceneProcessor::next_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: try_recv_animation() discards frames with generation < scene_generation

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

Request Coalescing

If the background thread is busy and multiple requests queue up, it drains the channel and keeps only the latest request of each type. This prevents a backlog during rapid updates:

Queue: [FullRebuild, FullRebuild, AnimFrame, AnimFrame, AnimFrame]
                                          ↓ drain
Process: [FullRebuild (latest), AnimFrame (latest)]

This means the background thread always works on the most current data, never wasting time on stale intermediate states.

Threading Model Summary

ThreadOwnsDoes
Main threadGPU resources, engine, sceneInput, render, GPU upload
Background threadMesh cacheCPU mesh generation
BridgeTriple buffers, mpsc channelLock-free data transfer

The main thread never blocks on the background thread. 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 facade for all rendering, input handling, scene management, and animation in viso. This chapter covers how to create it, what happens during initialization, and how to manage its lifetime.

Creation

The engine is created asynchronously because wgpu adapter and device initialization are async:

#![allow(unused)]
fn main() {
// With a structure file (standalone use)
let mut engine = VisoEngine::new_with_path(
    window.clone(),   // impl Into<wgpu::SurfaceTarget<'static>>
    (width, height),  // Physical pixel dimensions
    scale_factor,     // DPI scale (e.g. 2.0 on Retina)
    &cif_path,        // Path to mmCIF file
).await;

// Without a pre-loaded structure (library use)
let mut engine = VisoEngine::new(
    window.clone(),
    (width, height),
    scale_factor,
).await;
}

What Happens During Init

  1. GPU setup – wgpu instance, adapter, device, queue, and surface are configured via RenderContext
  2. Shader compilationShaderComposer loads and composes all WGSL shaders using naga_oil
  3. CameraCameraController is created with default orbital parameters (distance 150, FOV 45)
  4. Renderers – all molecular renderers are created (tube, ribbon, sidechain, ball-and-stick, band, pull, nucleic acid)
  5. Post-processing – SSAO, bloom, composite, and FXAA passes are initialized
  6. Picking – GPU picking system with offscreen render target and staging buffer
  7. Scene processor – background thread is spawned for mesh generation
  8. Structure loading – if a path was provided, the file is parsed and entities are added to the scene

Initial Scene Sync

After creation, the scene has entities but no GPU meshes. You must sync:

#![allow(unused)]
fn main() {
engine.sync_scene_to_renderers(None);
}

This submits the scene to the background processor thread. The None argument means no animation action (the initial load uses Snap behavior by default). The main thread continues immediately – mesh generation happens in the background.

On the next frame, apply_pending_scene() will detect the completed meshes and upload them to the GPU.

Resize and Scale Factor

Handle window resize events by forwarding to the engine:

#![allow(unused)]
fn main() {
engine.resize(new_width, new_height);
}

This resizes:

  • The wgpu surface
  • All post-processing textures (SSAO, bloom, composite, FXAA)
  • The picking render target
  • The camera aspect ratio

For DPI changes:

#![allow(unused)]
fn main() {
engine.set_scale_factor(new_scale);
let inner = window.inner_size();
engine.resize(inner.width, inner.height);
}

Shutdown

The engine cleans up automatically on drop. The background scene processor thread is joined:

#![allow(unused)]
fn main() {
engine.shutdown_scene_processor();
}

This sends a SceneRequest::Shutdown message and waits for the thread to finish. It’s also called automatically in the Drop implementation, so explicit shutdown is only needed if you want to control timing.

Ownership Model

VisoEngine owns:

ComponentTypePurpose
contextRenderContextwgpu device, queue, surface
sceneSceneEntity groups, focus state
camera_controllerCameraControllerCamera matrices, animation
animatorStructureAnimatorBackbone/sidechain animation
pickingPickingGPU picking system
scene_processorSceneProcessorBackground mesh thread
tube_rendererTubeRendererBackbone tubes/coils
ribbon_rendererRibbonRendererHelices and sheets
sidechain_rendererCapsuleSidechainRendererSidechain capsules
bns_rendererBallAndStickRendererLigands, ions, waters
band_rendererBandRendererConstraint bands
pull_rendererPullRendererActive pull visualization
na_rendererNucleicAcidRendererDNA/RNA backbones
post_processPostProcessStackSSAO, bloom, composite, FXAA

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 communicates via channels and triple buffers.

The Render Loop

Every frame follows a specific sequence. Getting the order right matters – applying the pending scene before rendering ensures newly generated meshes appear, 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 dt = now.duration_since(last_frame_time).as_secs_f32();
    last_frame_time = now;

    engine.update(dt);   // Apply pending scene, advance animation

    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();
}
}

Full Render Loop (foldit-rs)

foldit-rs has a richer per-frame sequence:

1. Process IPC messages (webview input)
2. Ensure surface matches window size
3. Process backend updates (Rosetta/ML triple buffers)
4. Sync scene to renderers if dirty (submits to background thread)
5. engine.update(dt) — apply pending scene, advance camera + structure animation
6. Render
7. Push dirty UI state to webview
8. Request next frame

Step-by-Step

1. Apply Backend Updates

#![allow(unused)]
fn main() {
app.apply_backend_updates();
}

This drains updates from Rosetta and ML backends (delivered via triple buffers). Each update may modify entity coordinates, add groups, or change per-residue scores. See Dynamic Structure Updates.

2. Sync Scene if Dirty

#![allow(unused)]
fn main() {
app.sync_engine();
}

If the scene has changed since the last sync (tracked by a generation counter), this collects PerGroupData for all visible groups and submits a SceneRequest::FullRebuild to the background processor. This is non-blocking – the background thread generates meshes while the main thread continues rendering the previous frame’s data.

3. Update

#![allow(unused)]
fn main() {
engine.update(dt);
}

This single call handles all per-frame bookkeeping:

  • Apply pending scene – checks the triple buffer for completed meshes from the background thread. If available, uploads vertex/index buffers to the GPU, updates sidechain instance buffers, rebuilds picking bind groups, and fires animation if an action was specified. GPU upload is typically <1ms.
  • Advance camera animation – animated camera transitions (focus point, distance, bounding radius) and turntable auto-rotation.
  • Advance structure animation – interpolates backbone and sidechain positions when a transition is in progress.

4. Render

#![allow(unused)]
fn main() {
match engine.render() {
    Ok(()) => {}
    Err(wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost) => {
        engine.resize(width, height);
    }
    Err(e) => log::error!("render error: {:?}", e),
}
}

The render method executes the full pipeline:

  1. Animation update – advances structure animation, generates interpolated meshes if animating
  2. GPU picking pass – renders to offscreen R32Uint texture, reads back hovered residue (non-blocking)
  3. Selection buffer update – uploads selected residue bit-array to GPU
  4. Geometry pass – renders all molecular geometry to HDR render targets
  5. Post-processing – SSAO, bloom, composite (outlines + fog + tone mapping), FXAA
  6. Present – submits to the wgpu surface

Error Handling

Surface errors are expected during resize or focus changes:

  • SurfaceError::Outdated / SurfaceError::Lost – the surface needs to be reconfigured. Call resize() with the current window dimensions.
  • Other errors are logged but non-fatal – the next frame will attempt to render again.

Frame Timing

The render loop uses ControlFlow::Poll for continuous rendering. Frame timing is tracked internally for animation interpolation. The standalone viewer runs at the display’s refresh rate (vsync). foldit-rs targets 300fps with frame pacing.

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. Otherwise, it uses the cached value from the previous successful read.

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

Scene Management

The scene system organizes molecular data into groups, tracks visibility and focus, and provides the data structures that flow into the rendering pipeline.

Core Types

Scene

Scene is the authoritative container for all molecular data. It owns entity groups and tracks dirty state via a generation counter.

#![allow(unused)]
fn main() {
let mut scene = Scene::new();

// Add a group of entities
let group_id = scene.add_group(entities, "Loaded Structure");

// Check if scene changed since last render
if scene.is_dirty() {
    // Sync to renderers...
    scene.mark_rendered();
}
}

EntityGroup

An EntityGroup is a collection of MoleculeEntity values loaded together – typically from one file or one backend operation. Each group has:

  • A unique GroupId
  • A human-readable name
  • Visibility state
  • A mesh_version counter for cache invalidation
  • Optional per-residue scores and secondary structure overrides
#![allow(unused)]
fn main() {
// Read access
let group = engine.group(group_id).unwrap();
println!("{}: {} entities", group.name(), group.entities().len());

// Write access (bumps mesh_version, invalidates caches)
let group = engine.group_mut(group_id).unwrap();
group.set_entities(new_entities);
group.invalidate_render_cache();
}

MoleculeEntity

A MoleculeEntity represents a single molecular chain or component. It contains atomic coordinates (Coords), a molecule type, and an entity ID.

GroupId

A monotonically increasing u64 identifier. Each call to scene.add_group() or engine.load_entities() produces a new unique ID.

Group Management

Loading Entities

#![allow(unused)]
fn main() {
// Load entities into a new group, optionally fitting the camera
let group_id = engine.load_entities(entities, "My Structure", true);
}

The fit_camera parameter controls whether the camera animates to show the new group.

Visibility

#![allow(unused)]
fn main() {
engine.set_group_visible(group_id, false); // Hide
engine.set_group_visible(group_id, true);  // Show
}

Hidden groups are excluded from aggregated render data, picking, and camera fitting.

Removal

#![allow(unused)]
fn main() {
let removed = engine.remove_group(group_id); // Returns Option<EntityGroup>
engine.clear_scene(); // Remove all groups
}

Iteration

#![allow(unused)]
fn main() {
let ids = engine.group_ids(); // Ordered by insertion
let count = engine.group_count();

// Iterate over all groups (read-only)
for group in engine.scene.iter() {
    println!("{}: visible={}", group.name(), group.visible);
}
}

Focus System

Focus determines which entities are active for operations. It cycles through groups and focusable entities with tab:

#![allow(unused)]
fn main() {
pub enum Focus {
    Session,          // All groups (default)
    Group(GroupId),   // A specific group
    Entity(u32),      // A specific entity by entity_id
}
}

Cycling Focus

#![allow(unused)]
fn main() {
let new_focus = engine.cycle_focus();
// Session -> Group1 -> Group2 -> ... -> focusable entities -> Session
}

Querying Focus

#![allow(unused)]
fn main() {
let focus = engine.focus(); // Returns &Focus
}

Aggregated Render Data

When the scene is dirty, the engine collects data from all visible groups into AggregatedRenderData. This is computed lazily and cached:

#![allow(unused)]
fn main() {
let aggregated = scene.aggregated(); // Returns Arc<AggregatedRenderData>
}

The aggregated data contains:

  • Backbone chains – all visible backbone atom positions, concatenated across groups
  • Sidechain data – positions, bonds, hydrophobicity, residue indices (global)
  • Secondary structure types – per-residue SS classification
  • Non-protein entities – ligands, ions, waters, lipids
  • Nucleic acid chains – P-atom chains and nucleotide rings
  • All positions – for camera fitting

Global residue indices are remapped during aggregation so that the first group’s residues start at 0, the second group’s residues follow, etc.

Per-Group Data for Background Processing

When syncing to renderers, the scene produces PerGroupData for each visible group:

#![allow(unused)]
fn main() {
let per_group = scene.per_group_data(); // Vec<PerGroupData>
}

Each PerGroupData contains:

  • Group ID and mesh version (for cache invalidation)
  • Backbone chains and residue data
  • Sidechain atoms, bonds, and backbone-sidechain bonds
  • Secondary structure overrides
  • Per-residue scores
  • Non-protein entities and nucleic acid data

The background processor uses mesh_version to decide whether to regenerate or reuse cached meshes for each group.

Syncing Changes

After modifying the scene, sync to the rendering pipeline:

#![allow(unused)]
fn main() {
// Full sync with optional transition
engine.sync_scene_to_renderers(Some(Transition::smooth()));

// Per-entity behavior: set before updating coordinates
engine.set_entity_behavior(design_id, Transition::backbone_then_expand(
    Duration::from_millis(400),
    Duration::from_millis(600),
)
    .allowing_size_change()
    .suppressing_initial_sidechains()
);
}

The sync submits a SceneRequest::FullRebuild to the background thread. On the next frame, apply_pending_scene() picks up the results.

Backend Coordinate Updates

For Rosetta integration where multiple groups may be updated atomically:

#![allow(unused)]
fn main() {
// Combine coords from all visible groups
let result = scene.combined_coords_for_backend();
// result.bytes: ASSEM01 format for Rosetta
// result.chain_ids_per_group: chain ID mapping for splitting results

// Apply combined update from Rosetta
scene.apply_combined_update(&coords_bytes, &chain_ids_per_group)?;

// Update a single group's protein coordinates
scene.update_group_protein_coords(group_id, new_coords);
}

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 ViewerApp {
    engine: Option<VisoEngine>,
    input: InputProcessor,
    // ...
}

impl ViewerApp {
    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 key in the configurable KeyBindings map:

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

Display toggles (waters, ions, solvent, lipids) are VisoOptions mutations, not commands. The viewer handles them directly:

#![allow(unused)]
fn main() {
match code {
    KeyCode::KeyU => engine.toggle_waters(),
    KeyCode::KeyI => engine.toggle_ions(),
    KeyCode::KeyO => engine.toggle_solvent(),
    KeyCode::KeyL => engine.toggle_lipids(),
    _ => { /* check keybindings */ }
}
}

VisoCommand

The full action vocabulary:

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

    // Focus
    CycleFocus,
    ResetFocus,

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

    // Visualization
    UpdateBands { bands: Vec<BandInfo> },
    UpdatePull { pull: Option<PullInfo> },

    // Playback
    ToggleTrajectory,
}
}

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:

#![allow(unused)]
fn main() {
let mut input = InputProcessor::new();
// Default bindings are pre-loaded:
// Q → RecenterCamera, Tab → CycleFocus, R → ToggleAutoRotate, etc.
}

Default keybindings:

KeyAction
QRecenter camera
TToggle trajectory playback
TabCycle focus
RToggle auto-rotate
`Reset focus to session
EscapeClear selection

Skipping InputProcessor

For web embeds or custom hosts, construct commands directly:

#![allow(unused)]
fn main() {
// Rotate camera by 5 degrees
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). This chapter covers the APIs and patterns for dynamic updates.

Per-Entity Coordinate Updates

The primary API for updating structure coordinates is update_entity_coords(), which updates the engine’s source-of-truth entities first, then propagates to the scene and animation system:

#![allow(unused)]
fn main() {
// Update a specific entity's coordinates with animation
engine.update_entity_coords(entity_id, new_coords, Transition::smooth());
}

The engine looks up the entity’s assigned behavior (or uses the provided transition) and dispatches to the per-entity animation system.

Per-Entity Behavior Control

Before updating an entity, callers can assign a specific animation behavior:

#![allow(unused)]
fn main() {
// Set behavior BEFORE updating coordinates
engine.set_entity_behavior(entity_id, Transition::collapse_expand(
    Duration::from_millis(200),
    Duration::from_millis(300),
));

// Update coordinates — engine uses CollapseExpand for this entity
engine.update_entity_coords(entity_id, new_coords, Transition::smooth());

// After the animation completes, the behavior remains set.
// To revert to default:
engine.clear_entity_behavior(entity_id);
}

Transitions

Every update can specify a Transition that controls the visual animation:

#![allow(unused)]
fn main() {
// Instant snap (for loading, no animation)
Transition::snap()

// Standard smooth interpolation (300ms ease-out)
Transition::smooth()

// Two-phase: sidechains collapse to CA, backbone moves, sidechains expand
Transition::collapse_expand(
    Duration::from_millis(300),
    Duration::from_millis(300),
)

// Two-phase: backbone animates first, then sidechains expand
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 preset behaviors and phase evaluation.

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

Band and Pull Visualization

Bands (Constraints)

Bands visualize distance constraints between atoms:

#![allow(unused)]
fn main() {
engine.execute(VisoCommand::UpdateBands {
    bands: vec![
        BandInfo {
            endpoint_a: Vec3::new(10.0, 20.0, 30.0),
            endpoint_b: Vec3::new(15.0, 22.0, 28.0),
            strength: 1.0,
            target_length: 3.5,
            residue_idx: 42,
            band_type: BandType::Default,
            is_pull: false,
            is_push: false,
            is_disabled: false,
            is_space_pull: false,
            from_script: false,
        },
    ],
});
}

Band visual properties:

  • Radius scales with strength (0.1 to 0.4 angstroms)
  • Color depends on band type: default (purple), backbone (yellow-orange), disulfide (yellow-green), H-bond (cyan)
  • Disabled bands are gray

Pulls (Active Drag)

A pull is a temporary constraint while the user drags a residue:

#![allow(unused)]
fn main() {
engine.execute(VisoCommand::UpdatePull {
    pull: Some(PullInfo {
        atom_pos: atom_world_position,
        target_pos: mouse_world_position,
        residue_idx: 42,
    }),
});

// Clear when drag ends
engine.execute(VisoCommand::UpdatePull { pull: None });
}

Pulls render as a purple cylinder from atom to mouse position with a cone/arrow at the mouse end.

Animation Control

#![allow(unused)]
fn main() {
animator.skip();     // Jump to final state
animator.cancel();   // Stay at current visual state
animator.set_enabled(false); // Disable all animation (snap)
}