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:
- Quick Start – standalone viewer walkthrough
- Engine Lifecycle – creation, initialization, shutdown
- The Render Loop – per-frame sequence
- Handling Input – mouse and keyboard wiring
Understand how Foldit uses viso:
- Scene Management – groups, entities, focus
- Dynamic Structure Updates – Rosetta and ML integration
- Options and Presets – TOML configuration
Dig into viso internals:
- Architecture Overview – system diagram and data flow
- Rendering Pipeline – geometry pass and post-processing
- Background Scene Processing – threading model
- Animation System – transitions, behaviors, and interpolation
Quick Start
Viso is a library first. With no feature flags enabled, it gives you VisoEngine — a self-contained rendering engine you embed in your own event loop. The optional viewer feature adds a standalone winit window for quick prototyping.
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
| Input | Action |
|---|---|
| Left drag | Rotate camera |
| Shift + left drag | Pan camera |
| Scroll wheel | Zoom |
| Click residue | Select residue |
| Shift + click | Add/remove from selection |
| Double-click | Select secondary structure segment |
| Triple-click | Select entire chain |
| Click background | Clear selection |
| Escape | Clear selection |
| W | Toggle 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
| Mechanism | Direction | Semantics |
|---|---|---|
mpsc::channel | Main → Background | Submit requests (non-blocking send) |
triple_buffer (scene) | Background → Main | Latest PreparedScene (non-blocking read) |
triple_buffer (anim) | Background → Main | Latest 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:
| Action | Key |
|---|---|
recenter_camera | Q |
toggle_trajectory | T |
toggle_ions | I |
toggle_waters | U |
toggle_solvent | O |
toggle_lipids | L |
cycle_focus | Tab |
toggle_auto_rotate | R |
reset_focus | ` |
cancel | Escape |
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
- Scene sync –
DisplayOptionsandColorOptionsare sent to the background processor as part ofSceneRequest::FullRebuild - Background thread – during mesh generation, colors are computed per-vertex based on the active color mode and baked into vertex data
- GPU upload – vertex buffers with embedded colors are uploaded to the GPU
- Rendering – shaders read per-vertex colors directly, with selection highlighting applied as an overlay in the fragment shader via the
SelectionBufferbit-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)
- 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. - 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:
- Collapse phase — sidechain atoms collapse toward the backbone CA position (QuadraticIn easing)
- 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:
- Backbone phase — backbone atoms lerp to final positions while sidechains are hidden
- 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
animate_entity()builds per-residueResidueAnimationData(start/target backbone positions) for the entity’s residue range- An
AnimationRunneris created with those residues, the transition’s phases, and optional sidechain positions - Each frame,
update()callsinterpolate_residues()on each runner, which returns an iterator of(residue_idx, lerped_visual)pairs - Sidechain positions are interpolated with the same
eased_tas backbone - 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: falseon 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:
| Function | Description |
|---|---|
Linear | No easing |
QuadraticIn | Slow start, fast end |
QuadraticOut | Fast start, slow end |
SqrtOut | Fast 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:
- Calculate the centroid of all positions
- Compute a bounding sphere radius
- 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:
- Maps screen coordinates to NDC [-1, 1] (with Y-flip since screen origin is top-left)
- Accounts for both horizontal and vertical FOV
- 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:
- Tubes – backbone coils (in ribbon mode, only coil segments; in tube mode, everything). Uses the
picking_mesh.wgslshader. - Ribbons – helices and sheets (ribbon mode only). Uses the same mesh pipeline as tubes.
- Capsule sidechains – sidechain capsule impostors. Uses the
picking_capsule.wgslshader with a storage buffer of capsule instances. - 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:
- The picking pass renders to the offscreen texture
- A single pixel at the mouse position is copied to a staging buffer (256 bytes minimum, aligned for wgpu)
start_readback()initiates an async buffer map without blocking
Frame N+1:
complete_readback()polls the wgpu device without blocking- 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
- Read 4 bytes as
- The staging buffer is unmapped
- 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:
- Look up the SS type of the clicked residue
- Walk backward through
cached_ss_typesuntil the type changes → segment start - Walk forward until the type changes → segment end
- Select all residues in [start, end]
- If shift held, add to existing selection; otherwise replace
Triple Click (Chain)
A triple-click selects all residues in the same chain:
- Walk through
backbone_chainsto find which chain contains the residue - Calculate the global residue range for that chain
- Select all residues in the range
- 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:
| Target | Format | Contents |
|---|---|---|
| Target 0 | Rgba16Float | Scene color with alpha blending |
| Target 1 | Rgba16Float | View-space normals / metadata (no blending) |
| Depth | Depth32Float | Depth 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
CapsuleInstancestructs, 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 transformationslighting.wgsl– Blinn-Phong lighting, directional lightssdf.wgsl– signed distance field utilitiesraymarch.wgsl– ray marching for implicit surfacesvolume.wgsl– volume texture samplingfullscreen.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
| Channel | Type | Direction | Purpose |
|---|---|---|---|
| Request | mpsc::Sender<SceneRequest> | Main → Background | Submit work |
| Scene result | triple_buffer | Background → Main | Completed PreparedScene |
| Animation result | triple_buffer | Background → Main | Completed 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:
- Same version – reuse cached mesh (skip generation entirely)
- Different version – regenerate and update cache
- 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:
- Tube mesh – cubic Hermite splines with rotation-minimizing frames, filtered by SS type
- Ribbon mesh – B-spline interpolation for helices and sheets, with sheet offsets
- Sidechain capsule instances – packed
CapsuleInstancestructs for the storage buffer - Ball-and-stick instances – sphere and capsule instances for non-protein entities
- 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:
- Each
FullRebuildcarries a new generation fromSceneProcessor::next_generation() - Each
AnimationFramecarries the generation of the scene it was produced for - Background thread: frames with
generation < last_rebuild_generationare skipped before processing - Main thread:
try_recv_animation()discards frames withgeneration < 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
| Thread | Owns | Does |
|---|---|---|
| Main thread | GPU resources, engine, scene | Input, render, GPU upload |
| Background thread | Mesh cache | CPU mesh generation |
| Bridge | Triple buffers, mpsc channel | Lock-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
- GPU setup – wgpu instance, adapter, device, queue, and surface are configured via
RenderContext - Shader compilation –
ShaderComposerloads and composes all WGSL shaders using naga_oil - Camera –
CameraControlleris created with default orbital parameters (distance 150, FOV 45) - Renderers – all molecular renderers are created (tube, ribbon, sidechain, ball-and-stick, band, pull, nucleic acid)
- Post-processing – SSAO, bloom, composite, and FXAA passes are initialized
- Picking – GPU picking system with offscreen render target and staging buffer
- Scene processor – background thread is spawned for mesh generation
- 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:
| Component | Type | Purpose |
|---|---|---|
context | RenderContext | wgpu device, queue, surface |
scene | Scene | Entity groups, focus state |
camera_controller | CameraController | Camera matrices, animation |
animator | StructureAnimator | Backbone/sidechain animation |
picking | Picking | GPU picking system |
scene_processor | SceneProcessor | Background mesh thread |
tube_renderer | TubeRenderer | Backbone tubes/coils |
ribbon_renderer | RibbonRenderer | Helices and sheets |
sidechain_renderer | CapsuleSidechainRenderer | Sidechain capsules |
bns_renderer | BallAndStickRenderer | Ligands, ions, waters |
band_renderer | BandRenderer | Constraint bands |
pull_renderer | PullRenderer | Active pull visualization |
na_renderer | NucleicAcidRenderer | DNA/RNA backbones |
post_process | PostProcessStack | SSAO, 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:
- Animation update – advances structure animation, generates interpolated meshes if animating
- GPU picking pass – renders to offscreen R32Uint texture, reads back hovered residue (non-blocking)
- Selection buffer update – uploads selected residue bit-array to GPU
- Geometry pass – renders all molecular geometry to HDR render targets
- Post-processing – SSAO, bloom, composite (outlines + fog + tone mapping), FXAA
- 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. Callresize()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:
- Frame N: The picking pass renders to an offscreen texture and copies the pixel under the mouse to a staging buffer.
start_readback()initiates an async buffer map. - Frame N+1:
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_versioncounter 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 Type | Resulting Command |
|---|---|
| Single click on residue | SelectResidue { index, extend: false } |
| Shift + click on residue | SelectResidue { index, extend: true } |
| Double click | SelectSegment { index, extend } |
| Triple click | SelectChain { index, extend } |
| Click on background | ClearSelection |
| 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:
| Key | Action |
|---|---|
| Q | Recenter camera |
| T | Toggle trajectory playback |
| Tab | Cycle focus |
| R | Toggle auto-rotate |
| ` | Reset focus to session |
| Escape | Clear 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)
}