Introduction
Viso is a GPU-accelerated 3D protein visualization engine built in Rust on top of wgpu. It powers the molecular graphics in Foldit, rendering proteins, ligands, nucleic acids, and constraint visualizations at interactive frame rates.
Viso is designed as an embeddable library – you give it a window or surface, feed it structure data, and it produces a 2D texture. The host decides what to do with that texture: display it in a winit window, paint it onto an HTML canvas, write it to a PNG, or drop it into a dioxus/egui texture slot.
#![allow(unused)]
fn main() {
use viso::Viewer;
Viewer::builder()
.with_path("1ubq") // PDB code or local .cif/.pdb/.bcif path
.with_title("My Viewer")
.build()
.run()?;
}
Features
Rendering
- Ray-marched impostors for pixel-perfect spheres and capsules at any zoom
- Post-processing pipeline – SSAO, bloom, FXAA, depth-based outlines, fog, tone mapping
Interaction
- Arcball camera with animated transitions, panning, zoom, and auto-rotate
- GPU picking – click to select residues, double-click for SS segments, triple-click for chains, shift-click for multi-select
Animation
- Smooth interpolation, cascading reveals, collapse/expand mutations
- Per-entity targeted animation with configurable behaviors
Performance
- Background mesh generation on a dedicated thread with triple-buffered results
- Per-entity mesh caching – only changed entities are regenerated
- Lock-free communication between main and background threads
Configuration
- TOML-serializable options for display, lighting, color, geometry, and camera
- Load/save presets, per-section diffing on update
How It Works
File (.cif/.pdb/.bcif) ──or── Vec<MoleculeEntity>
│ │
▼ │
molex::adapters ──▶ Vec<MoleculeEntity>◄─┘
│
▼
molex::Assembly (owned by your application — e.g. foldit-rs)
│ engine.set_assembly(Arc::new(assembly.clone()))
▼
Scene + EntityAnnotations (engine-side derived state,
│ rederived on generation change)
│
├───▶ SceneProcessor (background thread)
│ per-entity mesh cache, triple-buffered output
│
▼
Renderer (geometry → picking → post-process)
│
▼
2D texture ──▶ winit / canvas / PNG / embed
For the full architecture, see Architecture Overview.
Where to Start
Embed viso in your application:
- Quick Start – standalone viewer walkthrough
- Engine Lifecycle – creation, initialization, shutdown
- The Render Loop – per-frame sequence
- Handling Input – mouse and keyboard wiring
Understand how Foldit uses viso:
- Scene Management – Assembly, entities, focus
- Dynamic Structure Updates – Rosetta and ML integration
- Options and Presets – TOML configuration
Dig into viso internals:
- Architecture Overview – system diagram and data flow
- Rendering Pipeline – geometry pass and post-processing
- Background Scene Processing – threading model
- Animation System – transitions, behaviors, 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; gui adds an embedded webview options
panel; binary (default) builds the CLI.
Using Viso as a Library
Add viso to your Cargo.toml:
[dependencies]
viso = { path = "../viso", default-features = false }
pollster = "0.4" # for blocking on async GPU init
The minimal integration has three parts: build a VisoEngine, push a
molex::Assembly snapshot to it, and run a render loop. You own the
Assembly directly using molex’s APIs.
1. Build the Engine and Push an Assembly
#![allow(unused)]
fn main() {
use std::sync::Arc;
use viso::{RenderContext, VisoEngine};
use viso::options::VisoOptions;
use molex::{Assembly, MoleculeEntity};
let context = pollster::block_on(
RenderContext::new(window.clone(), (width, height))
)?;
let mut engine = VisoEngine::new(context, VisoOptions::default())?;
// You own the Assembly. After every mutation, push the latest
// snapshot via engine.set_assembly. The engine drains it on the
// next update tick.
let mut assembly = Assembly::new(entities);
engine.set_assembly(Arc::new(assembly.clone()));
}
2. Mutate and Re-publish
Mutate your Assembly through molex’s APIs (add_entity,
remove_entity, update_positions, etc.), then push the new snapshot
to viso:
#![allow(unused)]
fn main() {
assembly.add_entity(new_entity);
assembly.update_positions(eid, &new_coords);
engine.set_assembly(Arc::new(assembly.clone()));
}
The engine generation-checks each push, so re-publishing without an actual change is a no-op.
3. Render Loop
Each frame, call update then render:
#![allow(unused)]
fn main() {
engine.update(dt); // poll assembly snapshots, advance animation,
// apply pending background mesh data
match engine.render() {
Ok(()) => {}
Err(wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost) => {
engine.resize(width, height);
}
Err(e) => log::error!("render error: {e:?}"),
}
}
The engine handles background mesh generation, animation, and the full post-processing pipeline internally. You own the event loop and the window.
Input (Optional)
InputProcessor is a convenience layer that translates raw input
events into VisoCommand values. You can use it or wire commands
directly:
#![allow(unused)]
fn main() {
use viso::{InputProcessor, InputEvent};
let mut input = InputProcessor::new();
// In your event handler:
let event = InputEvent::CursorMoved { x, y };
if let Some(cmd) = input.handle_event(event, engine.hovered_target()) {
let _ = engine.execute(cmd);
}
}
Standalone Viewer (separate use case)
If you want to run viso as a standalone application — not embed it
in your own library — there’s a built-in Viewer for quick
prototyping. This is a separate use case from library embedding;
library users should not enable these features.
[dependencies]
viso = { path = "../viso", features = ["viewer"] }
This pulls in winit and pollster and gives you Viewer, which
handles window creation, the event loop, input wiring, and the render
loop:
#![allow(unused)]
fn main() {
use viso::Viewer;
Viewer::builder()
.with_path("assets/models/4pnk.cif")
.build()
.run()?;
}
Internally, the standalone viewer uses a helper called VisoApp to
own its own Assembly. VisoApp is not part of the library API
— it exists solely so viso can be its own host when run standalone.
Library consumers own their own molex::Assembly and call
engine.set_assembly directly, never going through VisoApp.
Running the CLI
The binary feature (enabled by default) builds a standalone CLI that
can download structures from RCSB by PDB code:
cargo run -p viso -- 1ubq
This downloads the CIF file, caches it in assets/models/, and opens
a viewer window. You can also pass a local file path:
cargo run -p viso -- path/to/structure.cif
Building and Running
Prerequisites
- Rust (stable, 1.80+)
- A GPU with WebGPU support – Metal (macOS), Vulkan (Linux/Windows), or DX12 (Windows)
- Internet access (optional, for RCSB downloads)
GUI panel (viso-ui)
The default build embeds a WASM-based options panel. On first cargo build, the
build script runs Trunk automatically to compile it. Two
extra tools are required:
# WASM compilation target
rustup target add wasm32-unknown-unknown
# Trunk (WASM bundler)
cargo install trunk
If Trunk or the WASM target is missing, the build still succeeds but the panel
will be non-functional. To skip the GUI entirely, build with
--no-default-features --features viewer.
Building
From the repository root:
# Build the standalone viewer
cargo build -p viso
# Build with optimizations (recommended for real use)
cargo build -p viso --release
Running
With a PDB ID
Pass a 4-character PDB code to auto-download from RCSB:
cargo run -p viso --release -- 1ubq
The file is downloaded as mmCIF and cached in assets/models/1ubq.cif. Subsequent runs with the same ID load from cache.
With a Local File
cargo run -p viso --release -- path/to/structure.cif
Viso supports mmCIF (.cif), PDB (.pdb), and BinaryCIF (.bcif) files.
Logging
Viso uses env_logger. Control verbosity with RUST_LOG:
# Errors only (default)
cargo run -p viso -- 1ubq
# Info-level (see download progress, frame counts, etc.)
RUST_LOG=info cargo run -p viso -- 1ubq
# Debug-level (animation frames, picking results, mesh timing)
RUST_LOG=debug cargo run -p viso -- 1ubq
# Module-specific filtering
RUST_LOG=viso::renderer::pipeline::processor=debug cargo run -p viso -- 1ubq
Platform Notes
macOS (Metal)
Metal is the default backend. No extra setup needed. Ensure your macOS version is 10.15+ (Catalina) or later.
Linux (Vulkan)
Requires Vulkan drivers. Install:
# Ubuntu/Debian
sudo apt install libvulkan-dev vulkan-tools
# Fedora
sudo dnf install vulkan-loader-devel vulkan-tools
Windows (DX12 / Vulkan)
DX12 is the default backend on Windows 10+. Vulkan is also supported if drivers are installed.
Controls
| Input | Action |
|---|---|
| Left drag | Rotate camera |
| Shift + left drag | Pan camera |
| Scroll wheel | Zoom |
| Click residue | Select residue |
| Shift + click | Add/remove from selection |
| Double-click | Select secondary structure segment |
| Triple-click | Select entire chain |
| Click background | Clear selection |
| Q | Recenter camera on focus |
| Tab | Cycle focus through entities |
| R | Toggle turntable auto-rotation |
| T | Toggle trajectory playback |
| I | Toggle ion visibility |
| U | Toggle water visibility |
| O | Toggle solvent visibility |
| L | Cycle lipid display mode |
| ` | Reset focus to session |
| Escape | Clear selection |
| \ | Toggle the GUI options panel (when built with gui) |
Architecture Overview
This chapter provides a high-level view of viso’s architecture: how subsystems relate to each other, how data flows from file to screen, and how threading is organized.
System Diagram
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ (your application — e.g. foldit-rs) │
│ │
│ Owns the authoritative `molex::Assembly`. All structural │
│ mutations push a new Arc<Assembly> via engine.set_assembly. │
│ │
│ winit events ──► InputProcessor ──► VisoCommand ──► engine │
└──────────────────────────────┬──────────────────────────────────┘
│ engine.set_assembly(Arc<Assembly>)
▼
┌─────────────────────────────────────────────────────────────────┐
│ VisoEngine │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Scene │ │ Animation │ │ Camera │ │ GpuPipeline│ │
│ │ + Annot. │ │ State │ │ Controller │ │ │ │
│ │ │ │ │ │ │ │ Renderers │ │
│ │ Per-entity │ │ Animator │ │ Arcball │ │ Picking │ │
│ │ derived │ │ Trajectory │ │ Animation │ │ Post-proc │ │
│ │ state + │ │ Pending │ │ Frustum │ │ Lighting │ │
│ │ overrides │ │ trans. │ │ │ │ Density │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ ▼ ▼ │ │ │
│ ┌───────────────────────────┐ │ │ │
│ │ Background scene │ │ │ │
│ │ processor (worker thread) │ │ │ │
│ │ │ │ │ │
│ │ Per-entity mesh cache │ │ │ │
│ │ Backbone / sidechain / │ │ │ │
│ │ ball-and-stick / NA │ │ │ │
│ └─────────────┬─────────────┘ │ │ │
│ │ triple buffer │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Renderers │ │
│ │ │ │
│ │ Molecular: Post-processing: │ │
│ │ ├─ BackboneRenderer ├─ SSAO │ │
│ │ │ (tubes + ribbons) ├─ Bloom │ │
│ │ ├─ SidechainRenderer ├─ Composite │ │
│ │ ├─ BondRenderer └─ FXAA │ │
│ │ ├─ BandRenderer │ │
│ │ ├─ PullRenderer ShaderComposer: │ │
│ │ ├─ BallAndStickRenderer └─ naga_oil composition │ │
│ │ ├─ NucleicAcidRenderer │ │
│ │ └─ IsosurfaceRenderer │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ RenderContext│ │
│ │ wgpu device │ │
│ │ queue │ │
│ │ surface │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
High-Level Data Flow
┌───────────────────────────────────────────────────────────┐
│ INITIALIZATION │
│ │
│ File path (.cif/.pdb/.bcif) ──or── Vec<MoleculeEntity> │
│ │ │ │
│ ▼ │ │
│ molex::adapters parse ──► Vec<MoleculeEntity> ◄┘ │
│ │ │
│ ▼ │
│ molex::Assembly (owned by host) │
│ │ │
│ ▼ engine.set_assembly(...) │
│ pending: Option<Arc<Assembly>> │
│ │ (engine-internal slot) │
│ ▼ │
│ Scene (in VisoEngine) │
└────────────────────────────┬──────────────────────────────┘
│ engine.update() drains,
│ rederives on generation bump
▼
┌───────────────────────────────────────────────────────────┐
│ SCENE │
│ │
│ Scene + EntityAnnotations: per-entity derived render │
│ state + user-authored overrides (focus, visibility, │
│ appearance, behaviors, scores, SS overrides, surfaces). │
│ │
│ Driven by `mesh_version` per-entity for cache │
│ invalidation. During animation, `EntityPositions` holds │
│ interpolated atom positions read by the renderers. │
└────────────────────────────┬──────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ RENDERER │
│ │
│ Consumes Scene + annotations read-only. │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Background mesh processor │ │
│ │ per-entity FullRebuildEntity → cached meshes → │ │
│ │ PreparedRebuild (raw byte buffers) │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ triple buffer │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ GPU passes │ │
│ │ │ │
│ │ 1. Geometry pass (color + normals + depth) │ │
│ │ 2. Picking pass (residue ID readback, async) │ │
│ │ 3. Post-process (SSAO, bloom, fog, FXAA) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Final 2D screen-space texture │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────┬──────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ OUTPUT / EMBEDDING │
│ │
│ The final texture is consumed by the host: │
│ • winit window (standalone viewer) │
│ • HTML canvas (wasm / web embed) │
│ • PNG snapshot (headless) │
│ • dioxus / egui / any framework with a texture slot │
│ │
│ Use `engine.render()` for swapchain present, or │
│ `engine.render_to_texture(view)` to render into a │
│ caller-owned texture view. │
└───────────────────────────────────────────────────────────┘
Data Flow: File to Screen
1. Parsing
PDB / CIF / BCIF file → molex::adapters → Vec<MoleculeEntity>
molex parses structure files into MoleculeEntity values (atomic
coordinates, names, chains, residue info, molecule type, computed
H-bonds, DSSP-classified SS).
2. Assembly Construction
Vec<MoleculeEntity> → molex::Assembly (owned by your application)
The Assembly is the authoritative structural state. Your
application owns it and pushes the latest snapshot to the engine via
engine.set_assembly(Arc::new(assembly.clone())) whenever it
changes.
3. Scene Rederivation
Each engine.update(dt) drains the engine’s pending Assembly slot;
if a new snapshot is ready, the engine rebuilds its derived per-entity
state (chains, sidechain topology, SS arrays, color metadata) from the
new assembly.
4. Background Mesh Generation
Scene → per-entity FullRebuildEntity → SceneProcessor → PreparedRebuild
The sync layer collects per-entity render data and submits a
SceneRequest::FullRebuild to the background thread. The processor
generates (or retrieves cached) meshes per entity, concatenates them
into a single PreparedRebuild, and writes it to the result triple
buffer.
5. GPU Upload
PreparedRebuild → queue.write_buffer() → GPU buffers
The main thread picks up the prepared rebuild and writes raw byte arrays directly to GPU buffers. This is a memcpy-level operation, typically under 1ms.
6. Rendering
GPU buffers → Geometry Pass → Post-Processing → Swapchain
All molecular renderers draw to HDR render targets. Post-processing applies SSAO, bloom, compositing (outlines, fog, tone mapping), and FXAA before presenting to the swapchain (or writing into the caller-owned texture view).
Threading Model
Viso uses three threads with lock-free communication:
Main Thread
Owns all GPU resources and runs the render loop:
- Processing input events (mouse, keyboard, IPC)
- Draining the pending
Assemblyslot for new snapshots - Running animation per frame
- Submitting scene requests to the background thread (non-blocking)
- Picking up completed meshes from the triple buffer (non-blocking)
- Uploading data to the GPU
- Executing the render pipeline
- Initiating GPU picking readback and resolving completed reads
The main thread never blocks. If meshes aren’t ready, it renders the previous frame’s data.
Background Mesh Thread
Owns the per-entity mesh cache and performs CPU-intensive work:
- Receiving scene requests via
mpsc::Receiver(blocks when idle) - Generating backbone, sidechain, ball-and-stick, and nucleic acid meshes
- Maintaining a per-entity cache keyed on
mesh_version - Writing results to a triple buffer (non-blocking)
Background Surface Thread
A short-lived worker spun up to regenerate isosurface meshes
(Gaussian / SES / cavity surfaces) when surface options change. Sends
results back through an mpsc channel that the main thread polls.
Lock-Free Bridges
| Mechanism | Direction | Semantics |
|---|---|---|
triple_buffer (asm) | Host → Main | Latest Arc<Assembly> (non-blocking read) |
mpsc::channel | Main → Mesh | Submit scene requests (non-blocking send) |
triple_buffer (rebuild) | Mesh → Main | Latest PreparedRebuild (non-blocking read) |
triple_buffer (anim) | Mesh → Main | Latest PreparedAnimationFrame (non-blocking read) |
mpsc::channel | Surface → Main | Density isosurface meshes (non-blocking poll) |
Triple buffers guarantee:
- The writer always has a buffer to write to (never blocks)
- The reader always gets the latest completed result
- No data races or mutex contention
Module Structure
viso/src/
├── lib.rs # Public API (flat re-exports only)
├── main.rs # Standalone CLI entry point (binary feature)
├── animation/ # Structural animation
│ ├── animator.rs # StructureAnimator + per-entity runners
│ ├── runner.rs # AnimationRunner phase evaluation
│ ├── state.rs # AnimationState (animator + trajectory + pending)
│ └── transition.rs # AnimationPhase, Transition presets (public API)
├── app/ # Standalone-app layer (feature-gated)
│ ├── viewer.rs # winit Viewer + ViewerBuilder (feature = "viewer")
│ ├── gui/ # wry-webview options panel (feature = "gui")
│ ├── web/ # WASM entry (feature = "web")
│ └── mod.rs # VisoApp (host of Assembly in standalone),
│ # publish helper that calls engine.set_assembly
├── bridge/ # GUI / IPC action types (feature = "gui")
├── camera/ # Orbital camera controller, animation, frustum
├── engine/ # Core engine struct + frame loop
│ ├── mod.rs # VisoEngine (thin dispatcher)
│ ├── annotations.rs # EntityAnnotations: focus, visibility, behaviors,
│ │ # appearance, scores, SS, surfaces
│ ├── bootstrap.rs # GPU init + VisoEngine::new + FrameTiming
│ ├── command.rs # VisoCommand + payload types (BandInfo, PullInfo, …)
│ ├── constraint.rs # Band/pull resolution
│ ├── culling.rs # Frustum culling
│ ├── density.rs # Density map loading + isosurface integration
│ ├── density_store.rs# DensityStore (loaded electron density maps)
│ ├── entity_view.rs # Per-entity render-ready derived data
│ ├── focus.rs # Focus enum
│ ├── options_apply.rs# set_options / set_surface_scale / etc.
│ ├── positions.rs # EntityPositions: interpolated atom positions
│ ├── scene.rs # Scene: pending Assembly + last_seen_generation + state
│ ├── scene_state.rs # SceneRenderState: per-entity render aggregations
│ ├── surface.rs # Surface options resolution
│ ├── surface_regen.rs# Background isosurface regeneration
│ ├── sync/ # Scene → renderer pipeline
│ └── trajectory.rs # TrajectoryPlayer (DCD frame sequencer)
├── error.rs # VisoError
├── gpu/ # wgpu device init, dynamic buffers, lighting,
│ # shader composition, residue color buffer
├── input/ # Raw events → VisoCommand
├── options/ # TOML-serializable runtime options + score color
├── renderer/ # GPU rendering pipeline
│ ├── mod.rs # PipelineLayouts, Renderers, GeometryPassInput
│ ├── gpu_pipeline.rs # GpuPipeline (rendering entry point)
│ ├── draw_context.rs # DrawBindGroups
│ ├── entity_topology.rs # Per-entity topology metadata for renderers
│ ├── geometry/ # Mesh + impostor generation (backbone, sidechain,
│ │ # ball-and-stick, NA, isosurface, band, pull, bond)
│ ├── impostor/ # Impostor primitives (sphere, capsule, cone, polygon)
│ ├── mesh.rs # Generic mesh helpers
│ ├── picking/ # GPU picking + PickingSystem + PickTarget + PickMap
│ ├── pipeline/ # Background mesh-gen pipeline
│ │ ├── prepared.rs # SceneRequest, PreparedRebuild,
│ │ │ # PreparedAnimationFrame
│ │ ├── mesh_gen.rs # Per-entity / per-frame mesh generation
│ │ ├── mesh_concat.rs # Merge per-entity meshes
│ │ └── processor.rs # Background thread + cache
│ ├── pipeline_util.rs# Helper utilities
│ └── postprocess/ # SSAO, bloom, composite, FXAA, screen passes
├── shaders/ # WGSL sources organized by role
│ ├── modules/ # Shared modules: camera, lighting, ray, sdf, ...
│ ├── raster/ # Mesh + impostor rasterization shaders
│ ├── screen/ # Full-screen passes (composite, FXAA, SSAO, bloom)
│ └── utility/ # Picking shaders
└── util/ # Helpers (easing.rs, hash.rs)
Key Design Decisions
Why Background Mesh Generation?
Mesh generation for complex proteins (>1000 residues) can take 20-40ms. At 60fps, that’s most of the frame budget. By offloading to a background thread:
- The main thread maintains smooth rendering
- GPU upload is <1ms (raw buffer writes)
- The background thread can take as long as it needs without dropping frames
Why Triple Buffers?
Triple buffers provide lock-free communication:
- The writer always has a buffer to write to
- The reader always reads the latest result
- No mutexes, no contention, no blocking on either side
The cost is memory (3× the buffer size), but mesh data is typically 1–10MB, so this is negligible.
Why Per-Entity Mesh Caching?
Molecular scenes often have multiple entities where only some change
at a time (e.g. Rosetta updates one entity while others stay static).
Per-entity caching with mesh_version-based invalidation means only
changed entities are regenerated. For a 3-entity scene where one
changes, this saves 60–80% of generation time.
Why Capsule Impostors?
Sidechains and ball-and-stick atoms use ray-marched impostor rendering instead of mesh-based spheres and cylinders:
- Memory: a capsule is 48 bytes vs hundreds of bytes for a mesh sphere
- Quality: impostors are pixel-perfect at any zoom level
- Performance: GPU ray-marching is efficient for the simple SDF shapes (spheres, capsules, cones)
Why a Host-Owned Assembly?
molex::Assembly belongs to molex; viso just renders it. The host
application — typically foldit-rs — owns the authoritative
Assembly because it also drives Rosetta and ML backends and needs
to mutate the assembly in response to their results. Viso never
mutates the structural state itself; the host pushes the latest
Arc<Assembly> snapshot via engine.set_assembly, and the engine
drains it on the next sync tick. The library API stays narrow: a
single setter and a generation check inside update. No viso-flavored
channels or publishers leak out of the engine.
When viso runs as a standalone application (cargo run -p viso,
feature = "viewer" / "gui" / "web"), the in-tree helper VisoApp
plays the host role for viso itself. VisoApp is purely an internal
standalone-deployment helper — it is feature-gated and is not part
of the library’s public surface. Library consumers own their own
Assembly and call engine.set_assembly directly.
Options and Presets
Viso’s visual appearance is controlled by the VisoOptions struct,
which can be loaded from and saved to TOML files. This enables
presets for different visualization styles.
Options Structure
#![allow(unused)]
fn main() {
pub struct VisoOptions {
pub display: DisplayOptions,
pub lighting: LightingOptions,
pub post_processing: PostProcessingOptions,
pub camera: CameraOptions,
pub colors: ColorOptions,
pub geometry: GeometryOptions,
pub debug: DebugOptions,
}
}
All sub-structs use #[serde(default)], so TOML files can be partial —
only the fields you want to override need to be specified. Key
bindings live separately on InputProcessor (KeyBindings); they are
an input-layer concern, not a rendering option.
Display Options
#![allow(unused)]
fn main() {
pub struct DisplayOptions {
// Ambient visibility (type-level toggles)
pub show_waters: bool,
pub show_ions: bool,
pub show_solvent: bool,
// Surface presentation mode (VSync, immediate, mailbox)
pub present_mode: PresentMode,
// Structural bond display (H-bonds, disulfides)
pub bonds: BondOptions,
// Legacy (prefer `overrides.color_scheme`)
pub backbone_color_mode: BackboneColorMode,
// Per-entity overridable fields (flattened for TOML compat).
// These are also used at per-entity scope via `EntityAnnotations`;
// `None` at either scope falls through to the next layer
// (entity → global → built-in defaults).
#[serde(flatten)]
pub overrides: DisplayOverrides,
}
}
DisplayOverrides carries 14 per-entity overridable fields:
drawing_mode, color_scheme, helix_style, sheet_style,
show_sidechains, show_hydrogens, surface_kind, surface_opacity,
show_cavities, sidechain_color_mode, na_color_mode, lipid_mode,
palette_preset, palette_mode. Any field set to Some(...) at the
global scope acts as the default for entities that don’t override it.
Resolved getters (display.drawing_mode(), display.show_sidechains(),
etc.) walk the override chain to produce a final value.
Lighting Options
#![allow(unused)]
fn main() {
pub struct LightingOptions {
pub light1_intensity: f32, // default: 2.0 (key light)
pub light2_intensity: f32, // default: 1.1 (fill light)
pub ambient: f32, // default: 0.45
pub specular_intensity: f32, // default: 0.35
pub shininess: f32, // default: 38.0
pub rim_power: f32, // default: 5.0
pub rim_intensity: f32, // default: 0.3
pub rim_directionality: f32, // default: 0.3
pub rim_color: [f32; 3], // default: [1.0, 0.85, 0.7]
pub ibl_strength: f32, // default: 0.6
pub roughness: f32, // default: 0.35
pub metalness: f32, // default: 0.15
}
}
Light directions are derived per-frame from the camera (“headlamp” lighting) rather than configured statically.
Post-Processing Options
#![allow(unused)]
fn main() {
pub struct PostProcessingOptions {
pub outline_thickness: f32, // default: 1.0
pub outline_strength: f32, // default: 0.7
pub ao_strength: f32, // default: 0.85
pub ao_radius: f32, // default: 0.5
pub ao_bias: f32, // default: 0.025
pub ao_power: f32, // default: 2.0
pub fog_start: f32, // default: 100.0
pub fog_density: f32, // default: 0.005
pub exposure: f32, // default: 1.0
pub normal_outline_strength: f32, // default: 0.5
pub bloom_intensity: f32, // default: 0.0 (disabled)
pub bloom_threshold: f32, // default: 1.0
}
}
Camera Options
#![allow(unused)]
fn main() {
pub struct CameraOptions {
pub fovy: f32, // Field of view in degrees, default: 45.0
pub znear: f32, // Near clip plane, default: 5.0
pub zfar: f32, // Far clip plane, default: 2000.0
pub rotate_speed: f32, // Mouse rotation sensitivity, default: 0.5
pub pan_speed: f32, // Mouse pan sensitivity, default: 0.5
pub zoom_speed: f32, // Scroll zoom sensitivity, default: 0.1
}
}
Color Options
#![allow(unused)]
fn main() {
pub struct ColorOptions {
pub lipid_carbon_tint: [f32; 3], // Warm beige/tan for lipid carbons
pub hydrophobic_sidechain: [f32; 3], // Blue for hydrophobic sidechains
pub hydrophilic_sidechain: [f32; 3], // Orange for hydrophilic sidechains
pub nucleic_acid: [f32; 3], // Light blue-violet for DNA/RNA
pub band_default: [f32; 3], // Purple
pub band_backbone: [f32; 3], // Yellow-orange
pub band_disulfide: [f32; 3], // Yellow-green
pub band_hbond: [f32; 3], // Cyan
pub solvent_color: [f32; 3],
pub cofactor_tints: HashMap<String, [f32; 3]>,
}
}
The default cofactor_tints includes greens for chlorophylls (CLA,
CHL), oranges for carotenoids (BCR, BCB), reds for hemes (HEM, HEC,
HEA, HEB), and others.
Geometry Options
Geometry options control cartoon rendering detail. Per-SS parameters
(width, thickness, roundness) can be set directly or driven by a
cartoon_style preset:
#![allow(unused)]
fn main() {
pub struct GeometryOptions {
pub cartoon_style: CartoonStyle, // Ribbon | Tube | Cylindrical | Custom
pub sheet_arrows: bool, // default: true
// Per-SS appearance (in Ångström)
pub helix_width: f32, // default: 1.4
pub helix_thickness: f32, // default: 0.25
pub helix_roundness: f32, // default: 0.0
pub sheet_width: f32, // default: 1.6
pub sheet_thickness: f32, // default: 0.25
pub sheet_roundness: f32, // default: 0.0
pub coil_width: f32, // default: 0.4
pub coil_thickness: f32, // default: 0.4
pub coil_roundness: f32, // default: 1.0
// Nucleic acid backbone
pub na_width: f32, // default: 1.2
pub na_thickness: f32, // default: 0.25
pub na_roundness: f32, // default: 0.0
// Mesh detail
pub segments_per_residue: usize, // default: 32
pub cross_section_verts: usize, // default: 16
// Small-molecule rendering
pub solvent_radius: f32, // default: 0.15
pub ligand_sphere_radius: f32, // default: 0.3
pub ligand_bond_radius: f32, // default: 0.12
}
}
CartoonStyle::Custom keeps the per-SS fields as-is; the other
presets overwrite them at resolve time.
Debug Options
DebugOptions controls debug-only visualizations (frustum overlays,
LOD heatmaps, etc.). See options/debug.rs for the current field set.
Loading and Saving
#![allow(unused)]
fn main() {
// Load from TOML file (partial files supported)
let options = VisoOptions::load(Path::new("presets/dark.toml"))?;
// Save to TOML file
options.save(Path::new("presets/my_preset.toml"))?;
// List available presets in a directory
let presets = VisoOptions::list_presets(Path::new("presets/"));
// Returns: ["dark", "publication", "presentation", ...]
}
VisoOptions::json_schema() returns a Schemars schema describing the
UI-exposed subset of options (used by the embedded webview panel).
Example TOML Preset
[lighting]
light1_intensity = 2.5
ambient = 0.5
specular_intensity = 0.4
shininess = 50.0
[post_processing]
outline_thickness = 1.5
outline_strength = 0.8
ao_strength = 1.0
bloom_intensity = 0.15
bloom_threshold = 0.8
[camera]
fovy = 35.0
rotate_speed = 0.4
[colors]
hydrophobic_sidechain = [0.2, 0.4, 0.85]
hydrophilic_sidechain = [0.9, 0.55, 0.15]
Applying Options at Runtime
engine.set_options(new_options) is the canonical entry point. It
diffs the new options against the current ones and dispatches the
right invalidations:
- Display/color/geometry changes that affect mesh content trigger a full scene resync via the background processor.
- Lighting changes are pushed directly to GPU lighting uniforms.
- Post-processing changes update GPU uniforms and SSAO/bloom render targets without touching geometry.
- Camera changes (FOV, znear/zfar, sensitivity) are applied to the controller in place.
Color Modes
Viso supports several coloring schemes for backbones, sidechains, and nucleic acids. Colors are computed during background scene processing and uploaded to GPU buffers for zero-cost rendering.
The active color scheme is one of two systems:
- The legacy
BackboneColorModeenum onDisplayOptions, retained for backward compatibility with existing presets. - The newer
ColorSchemeenum onDisplayOverrides(the recommended API), which decouples what data drives color from which palette is used.
ColorScheme (recommended)
#![allow(unused)]
fn main() {
pub enum ColorScheme {
Entity, // Each entity gets a distinct palette color
SecondaryStructure, // Helix / sheet / coil
ResidueIndex, // N-to-C gradient per chain
BFactor, // Crystallographic B-factor gradient
Hydrophobicity, // Kyte-Doolittle hydrophobicity gradient
Score, // Absolute Rosetta energy score
ScoreRelative, // Score normalized to the 5th/95th percentiles
Solid, // Single uniform color (first palette stop)
}
}
ColorScheme chooses what data maps to color. The companion
Palette (selected via palette_preset and palette_mode on
DisplayOverrides) chooses which colors. Any scheme can be combined
with any palette.
BackboneColorMode (legacy)
#![allow(unused)]
fn main() {
pub enum BackboneColorMode {
Score, // Per-residue energy score
ScoreRelative, // Relative scoring within the structure
SecondaryStructure, // Helix/sheet/coil coloring
Chain, // Each chain gets a distinct color (default)
}
}
A From<&BackboneColorMode> impl maps each variant to its
ColorScheme equivalent (Chain → Entity, Score → Score,
ScoreRelative → ScoreRelative, SecondaryStructure → SecondaryStructure).
Chain / Entity (Default)
Each entity gets a distinct color from the active palette. Single-chain proteins use a gradient along the chain. This is the most common mode for general visualization.
Secondary Structure
Colors residues by their computed secondary structure type:
- Alpha helix — distinct helix color
- Beta sheet — distinct sheet color
- Coil/Loop — neutral color
Secondary structure is computed by molex (DSSP) by default. Per-entity
overrides via engine.set_ss_override(id, ss_types).
Score / ScoreRelative
Colors residues by per-residue energy values (e.g. from Rosetta).
Score uses absolute values; ScoreRelative normalizes to the
5th/95th percentiles within the structure. Scores are set via
engine.set_per_residue_scores(id, Some(scores)).
ResidueIndex
N-to-C gradient per chain — useful for sequence-position visualization.
BFactor / Hydrophobicity
Gradient by crystallographic B-factor or Kyte-Doolittle hydrophobicity.
Solid
Single uniform color drawn from the first stop of the active palette.
Sidechain Color Modes
#![allow(unused)]
fn main() {
pub enum SidechainColorMode {
Hydrophobicity,
Backbone, // default — match the backbone color of the residue
}
}
Backbone (Default)
Sidechain atoms inherit the backbone color of their residue. This is the default because it makes sidechains read as part of their residue visually rather than as an independent layer.
Hydrophobicity
Hydrophobic / hydrophilic dichotomy:
- Hydrophobic — blue (default:
[0.3, 0.5, 0.9]) - Hydrophilic — orange (default:
[0.95, 0.6, 0.2])
Configurable via ColorOptions::hydrophobic_sidechain /
hydrophilic_sidechain.
Nucleic Acid Color Modes
#![allow(unused)]
fn main() {
pub enum NaColorMode {
Uniform,
BaseColor, // default — color each backbone segment by its base
}
}
BaseColor (Default)
Each residue’s backbone segment is colored to match its nucleobase (A/T/G/C/U).
Uniform
All nucleic acid backbone uses a single color (default light
blue-violet [0.45, 0.55, 0.85]), configurable via
ColorOptions::nucleic_acid.
Non-Protein Coloring
Ligands, ions, and waters use element-based CPK coloring in the ball-and-stick renderer:
- Standard CPK colors for common elements (C, N, O, S, P, etc.)
- Lipid carbons use a warm beige/tan tint (configurable via
ColorOptions::lipid_carbon_tint) - Cofactors can have per-residue-name carbon tints via
ColorOptions::cofactor_tints
Color Transitions During Animation
When backbone colors change between poses (e.g. score coloring updates after minimization), the background processor caches per-residue colors in the prepared scene. During animation, the renderers interpolate between the old and new colors using the same easing function as the backbone position interpolation.
Color changes are smooth — residues don’t suddenly flash to new colors but transition over the animation duration.
How Colors Flow Through the Pipeline
- Scene sync —
DisplayOptions,DisplayOverrides, andColorOptionsare sent to the background processor as part of theFullRebuildrequest. - Background thread — during mesh generation, colors are computed per-residue based on the resolved color scheme and palette and baked into vertex / instance buffers.
- GPU upload — color buffers are uploaded to the GPU as part of the prepared rebuild.
- Rendering — shaders read per-residue colors directly, with
selection highlighting applied as an overlay in the fragment shader
via the
SelectionBufferbit-array.
Animation System
Viso’s animation system 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
Transition is the only animation type in the public API. Construct
it via preset constructors and tune with builder methods:
#![allow(unused)]
fn main() {
pub struct Transition {
pub allows_size_change: bool,
pub suppress_initial_sidechains: bool,
// (phases + name are internal)
}
// Preset constructors
Transition::snap() // Instant, allows resize
Transition::smooth() // 300ms cubic hermite ease-out (also Default)
Transition::collapse_expand(collapse_dur, expand_dur)
Transition::backbone_then_expand(backbone_dur, expand_dur)
Transition::cascade(base_dur, delay_per_residue)
// Total duration helper (sum across phases)
let d: Duration = transition.total_duration();
// Builder methods
Transition::collapse_expand(
Duration::from_millis(200),
Duration::from_millis(300),
)
.allowing_size_change()
.suppressing_initial_sidechains()
}
AnimationPhase (internal)
Each phase in a transition defines a segment of the animation:
#![allow(unused)]
fn main() {
pub(crate) struct AnimationPhase {
pub(crate) easing: EasingFunction,
pub(crate) duration: Duration,
pub(crate) lerp_start: f32, // e.g. 0.0
pub(crate) lerp_end: f32, // e.g. 0.4
pub(crate) include_sidechains: bool,
}
}
AnimationPhase is pub(crate) — consumers don’t construct it
directly; they use the preset constructors above. The runner maps raw
progress (0→1 over total duration) through the phase sequence, and
each phase applies its own easing within its lerp range.
Preset Behaviors
Snap
Instant transition. Duration is zero. Used for initial loads where animation would delay the first meaningful frame. Also used internally when trajectory frames are fed through the animation pipeline.
Smooth (Default)
Standard eased lerp between start and target. 300ms with cubic hermite ease-out (CubicHermite { c1: 0.33, c2: 1.0 }). Good for incremental changes where start and target are close.
Collapse/Expand
Two-phase animation for mutations:
- 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 — when allows_size_change is true, the runner’s start sidechain positions are written as CA coordinates so the lerp expands them outward into their target positions.
Backbone Then Expand
Two-phase animation for transitions where sidechains should appear after backbone settles:
- 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 animation runner with independent timing.
StructureAnimator (private) manages a HashMap<EntityId, …> of
per-entity runners and writes interpolated atom positions into the
engine’s EntityPositions each frame.
The mutation surface lives on VisoApp (update_entity_coords,
update_entity, update_entities, sync_entities) — each call sets
the new target coordinates and queues a per-entity Transition for
the engine’s next sync. Per-entity behavior overrides
(engine.set_entity_behavior) take precedence over the supplied
default transition.
How It Works
- The host mutates the
Assemblyand pushes the new snapshot viaengine.set_assembly; pending per-entity transitions are stored on the engine’sAnimationState. - On the next
engine.update(), the engine rederives per-entity state from the new snapshot. For each entity that has a pending transition, anAnimationRunneris created with the start/target backbone positions and the transition’s phases. - Each frame, the runner advances; interpolated positions are
written into
EntityPositions. Sidechain positions are interpolated with the same easedtas backbone. - When a runner completes (progress ≥ 1.0), the entity snaps to target and the runner is removed.
Preemption
When a new target arrives while an entity is mid-animation:
- The current interpolated position becomes the new animation’s start state.
- The previous animation’s sidechain positions are captured for smooth handoff (when atom counts match).
- A new runner replaces the old one with the new target.
This provides responsive feedback during rapid update cycles (e.g. Rosetta wiggle).
Sidechain Animation
Sidechain positions are stored alongside backbone start/target arrays
and lerped with the same eased t. The animator writes interpolated
sidechain positions each frame so renderers and constraint resolution
can read them without recomputing.
Specialized sidechain behaviors:
- Standard lerp — for smooth transitions, sidechains lerp alongside backbone.
- Collapse toward CA — for mutations, start positions are set to the CA position at setup time; the runner’s normal lerp handles the expansion.
- Hidden during backbone phase — multi-phase transitions use
include_sidechains: falseon early phases.
Trajectory Playback
DCD trajectory frames are fed through the standard animation
pipeline. TrajectoryPlayer (in engine/trajectory.rs) is a frame
sequencer with no animation dependencies. Each frame it produces is
applied through the same path used for Transition::snap(), so
trajectory and structural animation share a single code path in the
engine’s tick_animation.
Load a trajectory bound to the first visible protein entity:
#![allow(unused)]
fn main() {
engine.load_trajectory(Path::new("path/to/traj.dcd"));
engine.execute(VisoCommand::ToggleTrajectory); // play/pause
let has = engine.has_trajectory();
}
Easing Functions
Available in util/easing.rs:
| 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
Use Transition::snap() per-update, or set a snap per-entity
behavior so every subsequent update is instantaneous:
#![allow(unused)]
fn main() {
let eid = engine.entity_id(raw_id).expect("known entity");
engine.set_entity_behavior(eid, Transition::snap());
}
Camera System
Viso uses an arcball camera that orbits around a focus point. The camera supports animated transitions, auto-rotation, frustum culling, and coordinate conversion utilities.
Arcball Model
The camera is defined by:
- Focus point — the world-space point the camera orbits around
- Distance — how far the camera is from the focus point
- Orientation — a quaternion defining the camera’s rotation
- Bounding radius — the radius of the protein being viewed (used for fog and culling)
All camera manipulation (rotation, pan, zoom) operates on these parameters rather than directly on a view matrix.
Camera Controller
CameraController (in camera/controller.rs) wraps the camera and
manages input, GPU uniforms, and animation. The type is pub(crate)
— consumers interact with the camera through engine methods or
VisoCommands, not through the controller directly.
The controller’s tunables come from CameraOptions:
rotate_speed(default 0.5)pan_speed(default 0.5)zoom_speed(default 0.1)fovy(default 45.0°)znear(default 5.0)zfar(default 2000.0)
Rotation
Rotation uses the arcball model — horizontal mouse movement rotates around the up vector, vertical movement rotates around the right vector:
#![allow(unused)]
fn main() {
engine.execute(VisoCommand::RotateCamera { delta });
}
The sensitivity is controlled by rotate_speed.
Panning
Panning translates the focus point along the camera’s right and up vectors, cancelling any in-progress focus animation:
#![allow(unused)]
fn main() {
engine.execute(VisoCommand::PanCamera { delta });
}
Zooming
Zoom adjusts the orbital distance, clamped to a sensible range:
#![allow(unused)]
fn main() {
engine.execute(VisoCommand::Zoom { delta });
}
Camera Animation
The camera animates between states for smooth transitions when loading structures or changing focus.
Fitting to a Bounding Sphere
Internally, the engine computes a bounding sphere over the relevant entities and calls one of:
fit_to_sphere(centroid, radius)— instant fit (initial load)fit_to_sphere_animated(centroid, radius)— animated fit (focus cycle, scene replacement)
The fit accounts for both horizontal and vertical FOV so the protein fits in the viewport.
Public entry points:
#![allow(unused)]
fn main() {
engine.fit_camera_to_focus(); // fits to current focus target
engine.execute(VisoCommand::RecenterCamera);
}
Per-Frame Update
Inside engine.update(dt), the controller’s update_animation is
ticked, interpolating focus, distance, and bounding radius toward
their targets.
Auto-Rotation
Toggle turntable-style auto-rotation:
#![allow(unused)]
fn main() {
engine.execute(VisoCommand::ToggleAutoRotate);
}
When enabled, the camera rotates around the up vector at a fixed turntable speed (~29°/s). The spin axis is captured from the current up vector when auto-rotation is enabled.
Frustum Culling
The camera provides a frustum used for sidechain culling — sidechains outside the view frustum (with a small Å margin) are skipped during rendering to improve performance. The engine reuploads the frustum-filtered sidechain instance buffer when the camera moves enough to invalidate the previous cull.
Coordinate Conversion
The controller exposes screen-to-world utilities for input handling:
screen_delta_to_world(delta_x, delta_y)— convert mouse pixel movement to a world-space displacement using the camera’s right and up vectors. The scale is proportional to the orbital distance, so movement feels consistent at any zoom level.screen_to_world_at_depth(...)— unproject a screen pixel onto a plane parallel to the camera at a reference world point’s depth. Used for pull operations so the drag stays at the atom’s depth.
These are crate-internal — they’re consumed by the constraint resolution path that produces the per-frame band/pull world-space positions.
Fog Derivation
Fog parameters are derived from the camera’s distance and bounding radius each frame:
- Fog start — based on the orbital distance.
- Fog density —
2.0 / max(bounding_radius, 10.0).
The composite post-pass uses these to apply depth-based fog, fading distant geometry to the background color.
GPU Uniform
The camera uniform is uploaded to the GPU each frame inside
engine.render(). It contains the projection matrix, view matrix,
inverse projection, camera position, hovered residue id, screen
dimensions, and an elapsed-time field. All renderers bind to this
uniform for vertex transformation and view-dependent effects.
GPU Picking and Selection
Viso uses GPU-based picking to determine what is under the mouse cursor. This is faster and more accurate than CPU ray-casting, especially with complex molecular geometry.
How Picking Works
Offscreen Render Pass
The picking system renders all molecular geometry to an offscreen
texture with format R32Uint. Instead of colors, each fragment
writes a pick ID (an entity-and-element-specific 1-based index;
0 means “no hit”).
Main render: geometry → HDR color + normals + depth
Picking render: same geometry → R32Uint pick IDs + depth
The picking pass uses depth testing (Less compare with depth
writes) so only the closest geometry’s pick ID survives.
Geometry Types in Picking
The picking pass renders the following geometry, each with its own shader:
- Backbone tube + ribbon — uses
picking_mesh.wgsl. In Cartoon mode the renderer issues separate index ranges for tube (coil) segments and ribbon (helix/sheet) segments; both write their residue’s pick ID. - Sidechain capsules — uses
picking_capsule.wgslwith a storage buffer of capsule instances. - Ball-and-stick spheres — uses
picking_sphere.wgsl. Atom indices are mapped through the per-rebuildPickMap. - Ball-and-stick capsules — uses
picking_capsule.wgslfor bond capsules in BallAndStick mode.
PickTarget and PickMap
A typed pick target:
#![allow(unused)]
fn main() {
pub enum PickTarget {
None,
Residue(u32), // residue index
Atom { entity_id: u32, atom_idx: u32 }, // small-molecule atom
}
}
A PickMap (built per-rebuild, embedded in PreparedRebuild) maps
raw GPU pick IDs to typed targets:
0→None1..=residue_count→Residue(idx)residue_count+1..=residue_count+atom_count→Atom { entity, atom }
Non-Blocking Readback
Reading data back from the GPU is expensive if done synchronously. Viso uses a two-frame pipeline:
Frame N:
- The picking pass renders to the offscreen texture.
- A single pixel at the mouse position is copied to a staging buffer (256 bytes minimum, aligned for wgpu).
start_readback()initiates an async buffer map without blocking.
Frame N+1:
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 - Resolve through the active
PickMapto aPickTarget
- Read 4 bytes as
- The staging buffer is unmapped.
- Result is cached in
hovered_targeton the picking system.
If the readback isn’t ready yet, the previous frame’s cached value is used. Hover feedback is at most one frame behind, which is imperceptible.
The flow is wired up inside engine.render():
#![allow(unused)]
fn main() {
self.gpu.pick.picking.start_readback(); // after queue.submit()
self.gpu.pick.poll_and_resolve(&device); // before next render
}
Public Hover API
Consumers query the resolved hover target through the engine:
#![allow(unused)]
fn main() {
let target: PickTarget = engine.hovered_target();
match target {
PickTarget::None => { /* mouse on background */ }
PickTarget::Residue(idx) => { /* hovering residue */ }
PickTarget::Atom { entity_id, atom_idx } => { /* hovering ligand atom */ }
}
}
InputProcessor::handle_event takes the current hover target so it
can attach the right residue index to selection commands.
Selection Buffer
The SelectionBuffer is a GPU storage buffer containing a bit-array
of selected residues. It’s bound to all molecular renderers so
shaders can highlight selected residues.
Bit Packing
Selection is stored as u32 words with one bit per residue:
Word 0: residues 0-31 (bit 0 = residue 0, bit 1 = residue 1, …)
Word 1: residues 32-63
Word 2: residues 64-95
…
Updating Selection
The engine pushes the latest selection to the GPU each frame inside
pre_render. Consumers don’t need to call this directly.
Dynamic Capacity
The buffer grows as needed when entity counts change. The engine’s
ensure_residue_capacity rebuilds the buffer and bind group when
the total residue count exceeds the current capacity.
Click Handling
Selection commands are produced by InputProcessor from click
events and dispatched through engine.execute(...):
| Command | Behavior |
|---|---|
SelectResidue { index, extend: false } | Replace selection with the clicked residue |
SelectResidue { index, extend: true } | Toggle the residue (shift-click) |
SelectSegment { index, extend } | Select all residues in the same SS segment |
SelectChain { index, extend } | Select all residues in the same chain |
ClearSelection | Clear everything |
Double Click (Secondary Structure Segment)
SelectSegment walks the engine’s concatenated cartoon SS array
backward and forward from the clicked residue until the SS type
changes, then selects every residue in the resulting range. Shift-held
clicks add to the existing selection.
Triple Click (Chain)
SelectChain finds the chain containing the clicked residue and
selects every residue in that chain.
Click Type Detection
InputProcessor’s mouse state machine tracks timing between clicks.
Clicks within a threshold on the same residue increment the click
counter (single → double → triple). If the mouse moved between press
and release, it’s classified as a drag and produces a camera command
instead of a selection.
Selection in Shaders
All molecular renderers receive the selection bind group. In the fragment shader:
let word_idx = residue_idx / 32u;
let bit_idx = residue_idx % 32u;
let is_selected = (selection_data[word_idx] >> bit_idx) & 1u;
if is_selected == 1u {
// Apply selection highlight (e.g. brighten color)
}
The hover effect uses the camera uniform’s hovered_residue field —
the shader checks if the fragment’s residue index matches the hovered
residue and applies a highlight.
Querying Selection State
#![allow(unused)]
fn main() {
// Currently selected residue indices
let selected: &[i32] = engine.selected_residues();
// Currently hovered target (one frame behind mouse)
let hovered: PickTarget = engine.hovered_target();
// Clear via command
engine.execute(VisoCommand::ClearSelection);
}
Rendering Pipeline
Viso’s rendering pipeline has two main stages: a geometry pass that renders molecular structures to HDR render targets, and a post-processing stack that applies screen-space effects.
Overview
Geometry Pass (8 molecular renderers)
↓ Color (Rgba16Float) + Normals (Rgba16Float) + Depth (Depth32Float)
↓
Post-Processing Stack:
1. SSAO: depth + normals → ambient occlusion texture
2. Bloom: color → threshold → blur → half-res bloom texture
3. Composite: color + SSAO + depth + normals + bloom → tone-mapped result
4. FXAA: anti-aliased final output → swapchain
Geometry Pass
Render Targets
All molecular renderers write to two HDR render targets plus a depth buffer:
| Target | Format | Contents |
|---|---|---|
| Color | Rgba16Float | Scene color with alpha blending |
| Normal | Rgba16Float | View-space normals / metadata (no blending) |
| Depth | Depth32Float | Depth buffer (Less compare, writes enabled) |
Rgba16Float enables HDR lighting and bloom without banding
artifacts.
Molecular Renderers
The Renderers struct holds eight renderers, drawn in the geometry
pass:
1. BackboneRenderer
Renders protein backbones as a single mesh with two index ranges — tube indices (drawn first for coil segments and fully in tube mode) and ribbon indices (drawn for helices and sheets in ribbon mode).
- Geometry: cubic Hermite splines with rotation-minimizing frames.
- Per-SS appearance: helix / sheet / coil width, thickness, and
roundness from
GeometryOptions(driven bycartoon_stylepreset unlessCustom). - Detail:
segments_per_residue×cross_section_verts(defaults 32 × 16, scalable per LOD tier). - Vertex data: position, normal, color, residue idx, center pos.
2. SidechainRenderer
Renders sidechain atoms as ray-marched capsule impostors.
- Technique: storage buffer of capsule instances rendered as ray-marched impostors.
- Capsule radius: 0.3 Å.
- Color: from the active sidechain color mode (Backbone or Hydrophobicity).
- Frustum culling: instances outside the view frustum are skipped on upload.
3. BondRenderer
Renders structural bonds (H-bonds, disulfides) as configurable capsules.
- Style:
Solid,Dashed, orStippledper bond type (BondOptions). - Source:
Auto(geometry-detected),Manual(caller-provided), orBoth.
4. BandRenderer
Renders constraint bands (e.g. for Rosetta minimization).
- Visual: capsule impostors with variable radius (0.1–0.4 Å,
scaled by
strength). - Colors by type: default (purple), backbone (yellow-orange), disulfide (yellow-green), H-bond (cyan), disabled (gray).
- Anchor spheres: small spheres at band endpoints.
5. PullRenderer
Renders the active drag constraint.
- Cylinder: capsule from atom to mouse target (purple).
- Arrow: cone impostor at the target end pointing toward the drag direction.
6. BallAndStickRenderer
Renders ligands, ions, waters, and (in BallAndStick drawing mode) proteins.
- Atoms: ray-cast sphere impostors with vdW-scaled radii.
- Bonds: capsule impostors (cylinders with hemispherical caps).
- Lipid modes:
Coarse(P-only spheres + thin tail bonds) orBallAndStick(full detail).
7. NucleicAcidRenderer
Renders DNA/RNA backbones and base rings.
- Stems: capsule instances tracing the phosphate backbone.
- Rings: polygon instances for the nucleobase rings.
- Color: per-base (default) or uniform.
8. IsosurfaceRenderer
Renders electron-density-derived molecular surfaces (Gaussian, SES,
or cavity) generated by the background surface_regen worker.
- Backface depth pre-pass is rendered separately so the composite pass can apply correct depth-aware blending for translucent surfaces.
Shared Bind Groups
All renderers receive common bind groups via DrawBindGroups:
#![allow(unused)]
fn main() {
pub(crate) struct DrawBindGroups<'a> {
pub camera: &'a wgpu::BindGroup, // Projection / view matrices
pub lighting: &'a wgpu::BindGroup, // Light directions, intensities
pub selection: &'a wgpu::BindGroup, // Selection bit-array
pub color: Option<&'a wgpu::BindGroup>, // Per-residue color override
}
}
Post-Processing Stack
1. SSAO (Screen-Space Ambient Occlusion)
Computes local ambient occlusion from the depth and normal buffers.
- Kernel: hemisphere samples in view-space.
- Noise: 4×4 rotation noise texture to reduce banding.
- Parameters:
ao_radius(0.5),ao_bias(0.025),ao_power(2.0). - Output: single-channel AO texture.
- Blur pass: separable blur to smooth noise patterns.
2. Bloom
Extracts and blurs bright areas of the image.
- Threshold: extracts pixels above
bloom_threshold(1.0) to a half-resolution texture. - Blur: separable Gaussian blur (horizontal then vertical, ping-pong textures).
- Mip chain: progressive downsampling.
- Upsample: additive accumulation back to half-resolution.
- Output: half-resolution bloom texture.
- Default
bloom_intensity:0.0(disabled).
3. Composite
Combines all post-processing inputs into the final image.
Inputs:
- Scene color texture
- SSAO texture
- Depth texture
- Normal G-buffer
- Bloom texture
- Composite params uniform
Effects applied:
- SSAO as a darkening multiplier on base color.
- Depth-based fog (configurable
fog_startandfog_density). - Depth-based outlines (edge detection on depth discontinuities).
- Normal-based outlines (edge detection on normal discontinuities).
- Bloom additive blend.
- HDR tone mapping with
exposure. - Gamma correction.
4. FXAA
Fast Approximate Anti-Aliasing as the final pass.
- Smooths jagged edges on mesh-based geometry that supersampling alone doesn’t fully resolve.
- Reads from the composite output, writes to the swapchain surface
(or to the caller-owned texture view in
render_to_texture).
ShaderComposer
Viso uses naga_oil for shader composition, enabling modular WGSL
with imports:
#import viso::camera
#import viso::lighting
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let light = calculate_lighting(in.normal, in.position);
// …
}
Shaders live under src/shaders/:
shaders/modules/— shared modules (camera.wgsl,lighting.wgsl,pbr.wgsl,ray.wgsl,volume.wgsl,selection.wgsl,highlight.wgsl,shade.wgsl,depth.wgsl,constants.wgsl,fullscreen.wgsl,impostor_types.wgsl).shaders/raster/mesh/— mesh rasterization (backbone, NA).shaders/raster/impostor/— impostor shaders (sphere, capsule, cone, polygon).shaders/screen/— full-screen passes (composite.wgsl,fxaa.wgsl,ssao.wgsl,ssao_blur.wgsl,bloom_*.wgsl).shaders/utility/— picking shaders (picking_mesh.wgsl,picking_capsule.wgsl,picking_sphere.wgsl).
The composer produces naga::Module IR directly (skipping WGSL
re-parse at runtime for performance).
Render-Scale Supersampling
The rendering resolution can differ from the display resolution via
engine.set_surface_scale(scale). All internal textures (color,
depth, normal, SSAO, bloom) are sized to the render resolution. FXAA
downsamples to the display resolution as the final step.
Background Scene Processing
Mesh generation for molecular structures is CPU-intensive — generating backbone splines, ribbon surfaces, and sidechain capsule instances can take 20–40ms for complex structures. Viso offloads this to a background thread so the main thread can continue rendering at full frame rate.
Architecture
Main Thread Background Thread
├─ SceneProcessor::submit() ├─ Blocks on mpsc::Receiver
│ → mpsc::Sender ├─ Processes request
│ ├─ Generates / caches per-entity meshes
│ ├─ Concatenates into PreparedRebuild
├─ try_recv_rebuild() └─ Writes to triple_buffer::Output
│ ← triple_buffer::Input
├─ GPU upload (<1ms)
└─ Render
Communication Channels
| Channel | Type | Direction | Purpose |
|---|---|---|---|
| Request | mpsc::Sender<SceneRequest> | Main → Background | Submit work |
| Rebuild result | triple_buffer | Background → Main | Completed PreparedRebuild |
| 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(Box::new(body)));
// Check for results (non-blocking)
if let Some(prepared) = processor.try_recv_rebuild() {
// Upload to GPU
}
// Shutdown
processor.shutdown(); // Sends Shutdown message, joins thread
}
Request Types
#![allow(unused)]
fn main() {
pub(crate) enum SceneRequest {
FullRebuild(Box<FullRebuildBody>),
AnimationFrame(Box<AnimationFrameBody>),
Shutdown,
}
}
FullRebuild
A complete scene rebuild with per-entity render-ready snapshots:
#![allow(unused)]
fn main() {
pub(crate) struct FullRebuildBody {
pub entities: Vec<FullRebuildEntity>, // per-entity snapshots
pub display: DisplayOptions,
pub colors: ColorOptions,
pub geometry: GeometryOptions,
pub entity_options:
FxHashMap<u32, (DisplayOptions, GeometryOptions)>, // per-entity overrides
pub generation: u64,
}
pub(crate) struct FullRebuildEntity {
pub id: EntityId,
pub mesh_version: u64,
pub drawing_mode: DrawingMode,
pub topology: Arc<EntityTopology>,
pub positions: Vec<Vec3>,
pub ss_override: Option<Vec<SSType>>,
pub per_residue_colors: Option<Vec<[f32; 3]>>,
pub sheet_plane_normals: Vec<(u32, Vec3)>,
}
}
FullRebuild is submitted when:
- A new
Assemblysnapshot is consumed (entities added / removed / modified, scores or SS overrides changed). - Display, color, or geometry options change.
- A scoped reset (e.g.
engine.reset_scene_local_state) clears local state.
mesh_version per-entity is the cache key — entities whose version
hasn’t changed since the previous rebuild reuse their cached mesh.
AnimationFrame
Per-frame mesh regeneration during animation:
#![allow(unused)]
fn main() {
pub(crate) struct AnimationFrameBody {
pub positions: EntityPositions, // interpolated
pub geometry: GeometryOptions,
pub per_chain_lod: Option<Vec<(usize, usize)>>, // per-chain detail override
pub include_sidechains: bool,
pub generation: u64,
}
}
This is submitted while animation is in progress. It regenerates
backbone meshes (and optionally sidechains) from interpolated
positions, reusing topology and other state cached from the last
FullRebuild.
Shutdown
Terminates the background thread.
Per-Entity Mesh Caching
The background thread maintains a cache of per-entity meshes, keyed on
EntityId:
FxHashMap<EntityId, CachedEntityMesh>
CachedEntityMesh stores GPU-ready byte buffers (backbone vertex /
index, sidechain instances, ball-and-stick spheres+capsules, nucleic
acid stems+rings) plus typed intermediates needed for index
concatenation.
Cache Invalidation
When a FullRebuild arrives, the processor checks each entity’s
mesh_version against the cached version:
- Same version — reuse cached mesh (skip generation entirely).
- Different version — regenerate and update cache.
- Entity removed — evict from cache.
Version-based invalidation is cheap (a u64 comparison) and avoids regenerating unchanged entities. For a scene with 3 entities where only 1 changed, this saves ~70% of mesh generation time.
Global vs Per-Entity Settings
Display, color, and geometry options affect mesh content. The
processor distinguishes geometry-affecting changes (which require
mesh regeneration) from color-only changes (which only update vertex
color buffers). A bumped mesh_version is the universal “regenerate
me” signal — option-change paths in the engine bump the affected
entities’ versions before submitting the rebuild.
Mesh Generation
For each entity, the processor generates whichever of the following
apply to its drawing_mode:
- Backbone mesh — cubic Hermite splines with rotation-minimizing frames, with separate index ranges for tube and ribbon passes.
- Sidechain capsule instances — packed capsule structs for the storage buffer.
- Ball-and-stick instances — sphere and capsule instances for non-protein entities (and proteins drawn in BallAndStick mode).
- Nucleic acid instances — stem capsules and ring polygons for DNA/RNA backbones.
Mesh Concatenation
After generating (or retrieving from cache) all entity meshes, they’re
concatenated into a single PreparedRebuild:
- Vertex buffers are appended.
- Index buffers are appended with per-entity index offset adjustment.
- Instance buffers are concatenated.
- A single
PickMapis built mapping raw GPU pick IDs to typed pick targets.
PreparedRebuild
The output of a FullRebuild, ready for GPU upload:
#![allow(unused)]
fn main() {
pub(crate) struct PreparedRebuild {
pub generation: u64,
pub backbone: BackboneMeshData, // verts + tube/ribbon idx
pub sidechain_instances: Vec<u8>,
pub sidechain_instance_count: u32,
pub bns: BallAndStickInstances, // sphere + capsule instances
pub na: NucleicAcidInstances, // stem + ring instances
pub pick_map: PickMap,
}
}
All byte arrays are raw GPU buffer data (bytemuck::cast_slice),
ready for queue.write_buffer() with no further processing.
PreparedAnimationFrame
The output of an AnimationFrame request, containing only the data
that changes during animation:
#![allow(unused)]
fn main() {
pub(crate) struct PreparedAnimationFrame {
pub backbone: BackboneMeshData,
pub sidechain_instances: Option<Vec<u8>>,
pub sidechain_instance_count: u32,
pub generation: u64,
}
}
Only backbone mesh and (optionally) sidechain instances are regenerated during animation — ball-and-stick, nucleic-acid, and isosurface meshes don’t change.
Stale Frame Discarding
When a scene is replaced (e.g. loading a new structure), in-flight animation frames from the old scene become stale — their per-chain LOD or topology assumptions may not match the new scene.
A monotonically increasing generation counter prevents this:
- Each
FullRebuildcarries a new 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: stale animation frames are discarded before GPU upload.
This two-level check ensures stale frames are dropped both before expensive mesh generation and before GPU upload, with no additional synchronization primitives.
Threading Model Summary
| Thread | Owns | Does |
|---|---|---|
| Main thread | GPU resources, engine, scene | Input, render, GPU upload |
| Mesh thread | Per-entity mesh cache | CPU mesh generation |
| Surface thread | (none — short-lived) | Isosurface mesh regeneration |
| Bridge | Triple buffers, mpsc channels | Lock-free data transfer |
The main thread never blocks on the background threads. If meshes aren’t ready yet, the previous frame’s meshes are rendered. This ensures consistent frame rates even during expensive mesh regeneration.
Engine Lifecycle
VisoEngine is the central rendering, animation, and picking
coordinator. It is read-only with respect to structural state —
your application owns a molex::Assembly and pushes the latest
snapshot to the engine via [VisoEngine::set_assembly]. This chapter
covers how to create the engine, what happens during initialization,
and how to manage its lifetime.
Construction
You own your own molex::Assembly and hand viso the latest snapshot.
There is no viso-defined channel, publisher, or consumer in the public
API — the entire structural ingest contract is one setter on the
engine.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use viso::{RenderContext, VisoEngine};
use viso::options::VisoOptions;
use molex::Assembly;
// 1. Build a wgpu RenderContext (async — use pollster or your runtime).
let context = pollster::block_on(
RenderContext::new(window.clone(), (width, height))
)?;
// 2. Build the engine.
let mut engine = VisoEngine::new(context, VisoOptions::default())?;
// 3. Push your Assembly to the engine.
let assembly: Assembly = /* your owned Assembly */;
engine.set_assembly(Arc::new(assembly.clone()));
}
After every Assembly mutation, re-publish by calling
engine.set_assembly(...) again. The engine stages the snapshot in
its internal pending slot and drains it on the next update(dt) (or
sync_now()) tick — a generation check skips work if nothing changed.
Note for standalone deployments only. When viso is built as its own standalone app via
cargo run -p viso(featuresviewer/gui/web), it uses an internal helper calledVisoAppto play the host role for itself. Library users never go throughVisoApp— own your ownAssemblyand callset_assemblydirectly.VisoAppis not part of the library’s public surface withdefault-features = false.
What Happens During Init
- GPU setup —
RenderContextis configured with a surface, adapter, device, and queue. - Shader compilation —
ShaderComposerloads and composes all WGSL modules usingnaga_oil. - Camera —
CameraControlleris created with default orbital parameters (FOV 45°, fit to origin). - Renderers — backbone, sidechain, ball-and-stick, bond, band, pull, nucleic-acid, and isosurface renderers.
- Post-processing — SSAO, bloom, composite, and FXAA passes.
- Picking — GPU picking system with offscreen
R32Uinttarget and staging buffer. - Scene processor — background thread spawned for mesh generation.
- Assembly slot —
Scenestarts with an emptycurrentAssembly andpending: None. The firstset_assemblycall fillspending; the nextupdate(dt)consumes it.
Initial Scene Sync
The first engine.set_assembly(...) call after construction pushes
your initial snapshot. The next engine.update(dt) drains the pending
snapshot, rederives the scene, and submits a full mesh rebuild to the
background thread. On the frame after, apply_pending_scene uploads
the meshes to the GPU.
If you want an explicit non-animating sync (rare — update does this
for you), call:
#![allow(unused)]
fn main() {
engine.sync_scene_to_renderers(std::collections::HashMap::new());
}
The HashMap<u32, Transition> argument lets you animate specific
entities; passing an empty map snaps everything.
Resize and Scale Factor
Forward window resize events to the engine:
#![allow(unused)]
fn main() {
engine.resize(new_width, new_height);
}
This resizes the wgpu surface, all post-processing textures, the picking render target, and the camera projection.
For DPI changes:
#![allow(unused)]
fn main() {
engine.set_surface_scale(scale_factor);
let inner = window.inner_size();
engine.resize(inner.width, inner.height);
}
Shutdown
The background scene processor is joined automatically on drop. To force shutdown earlier:
#![allow(unused)]
fn main() {
engine.shutdown();
}
This sends a Shutdown request to the processor thread.
Ownership Model
VisoEngine owns 11 sub-systems, each in its own field:
| Field | Type | Purpose |
|---|---|---|
gpu | GpuPipeline | wgpu context, all renderers, picking, post-process, lighting, shader composer, density mesh receiver |
camera_controller | CameraController | Camera matrices, animation, frustum |
constraints | ConstraintSpecs | Stored band/pull constraint specs |
animation | AnimationState | Structural animator, trajectory player, pending transitions |
options | VisoOptions | Display, lighting, post-processing, geometry, etc. |
active_preset | Option<String> | Name of the currently-applied options preset |
frame_timing | FrameTiming | FPS smoothing, frame pacing |
density | DensityStore | Loaded electron density maps |
scene | Scene | Pending/current Assembly + derived per-entity state |
annotations | EntityAnnotations | Per-entity overrides: focus, visibility, behaviors, appearance, scores, SS, surfaces |
surface_regen | SurfaceRegen | Background isosurface mesh regeneration |
The engine is not thread-safe (!Send, !Sync) because it holds
wgpu GPU resources. All engine access must happen on the main thread.
The background scene processor and surface regeneration thread
communicate via channels and triple buffers.
The Render Loop
Every frame follows a specific sequence. The order matters — polling the assembly snapshot before applying pending mesh data ensures newly generated meshes appear on the same frame their owning rebuild finishes, and updating the camera before rendering ensures smooth animation.
Minimal Render Loop (Standalone)
From viso’s standalone viewer:
#![allow(unused)]
fn main() {
WindowEvent::RedrawRequested => {
let now = Instant::now();
let dt = now.duration_since(last_frame_time).as_secs_f32();
last_frame_time = now;
engine.update(dt);
match engine.render() {
Ok(()) => {}
Err(wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost) => {
engine.resize(width, height);
}
Err(e) => log::error!("render error: {e:?}"),
}
window.request_redraw();
}
}
What engine.update(dt) Does
engine.update(dt) handles all per-frame coordination work:
- Camera animation tick — interpolates animated focus, distance, and bounding radius; advances turntable auto-rotation.
- Drain the pending Assembly snapshot. If a new
Assemblysnapshot is waiting (because the host orVisoAppcalledengine.set_assembly), the engine rederives its per-entity state and submits aFullRebuildrequest to the background mesh processor. - Apply any pending scene. If the background processor has a
completed
PreparedRebuildready, it is uploaded to the GPU (vertex/index/instance buffers, picking bind groups). GPU upload is typically <1ms.
The main thread never blocks on the background thread. If meshes aren’t ready, the previous frame’s data continues to render until they are.
What engine.render() Does
The render method executes the full pipeline:
- Apply pending animation frame. If an interpolated animation frame is ready from the background thread, upload it to the GPU.
- Tick animation. Advance trajectory and structural animation; submit a new animation-frame request to the background thread if anything changed.
- Update camera and lighting uniforms. Write camera matrices, hovered-residue id, derived fog parameters, and headlamp lighting.
- Frustum cull sidechains.
- Resolve constraints. Translate stored band/pull specs into world-space using current interpolated atom positions.
- Geometry pass. Render all molecular geometry to HDR render
targets (
Rgba16Floatcolor + normals,Depth32Floatdepth). - Picking pass. Render to the offscreen
R32Uinttarget and copy the pixel under the cursor to a staging buffer. - Post-processing. SSAO, bloom, composite (outlines, fog, tone mapping), FXAA.
- Present to the swapchain surface.
- Initiate non-blocking picking readback for next frame.
Frame timing is throttled to a 300 fps target by default
(FrameTiming::should_render short-circuits if the previous frame’s
elapsed time hasn’t met the minimum frame duration).
Error Handling
Surface errors are expected during resize or focus changes:
SurfaceError::Outdated/SurfaceError::Lost— the surface needs reconfiguration. Callresize()with the current window dimensions.- Other errors are logged but non-fatal — the next frame retries.
Rendering to a Texture (Embedding)
If you’re embedding viso in a host that gives you a target texture
view (e.g. a dioxus or egui texture slot), use render_to_texture
instead of render:
#![allow(unused)]
fn main() {
engine.render_to_texture(&texture_view);
}
This runs the same pipeline but writes the final composite to the provided texture view instead of acquiring a swapchain frame, so the caller owns presentation.
Non-Blocking Picking Readback
GPU picking uses a two-frame pipeline to avoid stalling:
- Frame N: The picking pass renders to an offscreen texture and
copies the pixel under the mouse to a staging buffer.
start_readback()initiates an async buffer map. - Frame N+1:
complete_readback()polls the device without blocking. If the map is complete, it reads the residue ID and resolves it to aPickTargetvia the engine’sPickMap. Otherwise it uses the cached value from the previous successful read.
Hover feedback is one frame behind mouse movement, which is imperceptible in practice but avoids GPU pipeline stalls.
Scene Management
Viso’s structural state lives in molex::Assembly, which is owned by
your application — not by viso. Viso is a pure consumer: you push
the latest Arc<Assembly> to the engine via
[VisoEngine::set_assembly], and the engine drains the snapshot on
the next sync tick and rederives its render state.
There is no “group” abstraction in viso — every entity lives directly
in the Assembly and is identified by an opaque EntityId.
Pushing Assembly Snapshots
┌──────────────────────┐ ┌─────────────────┐
│ your application │ Arc<Assembly> │ VisoEngine │
│ │ ─engine.set_assembly──► │ │
│ molex::Assembly │ │ pending slot │
│ (mutated freely) │ │ → Scene+derived│
└──────────────────────┘ └─────────────────┘
- Your application mutates its
molex::Assembly(using molex’s APIs) and callsengine.set_assembly(Arc::new(self.assembly.clone())). VisoEngine::update(dt)drains the pending snapshot; if its generation differs from the last applied one, the engine rederives its per-entity state and submits a full-rebuild request to the background mesh processor.
That’s the entire structural ingest contract for library users. There is no viso-defined channel, publisher, or consumer in the public API.
Mutating the Scene
You mutate molex::Assembly through molex’s own APIs and re-publish
to viso after each batch of changes:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use molex::Assembly;
use viso::{Transition, VisoEngine};
// 1. Mutate the assembly however you like.
let mut assembly = /* your owned Assembly */;
assembly.add_entity(new_entity);
assembly.update_positions(eid, &new_coords);
// ... add/remove/update as needed ...
// 2. Push the new snapshot. Cheap: Arc<Assembly> is shared
// by reference.
engine.set_assembly(Arc::new(assembly.clone()));
// 3. (Optional) For entities whose positions changed, queue a
// per-entity transition so the next sync animates instead of
// snapping. Without this, the engine snaps to the new state.
engine.set_entity_behavior(entity_id, Transition::smooth());
}
The next engine.update(dt) drains the pending snapshot, rederives
the scene, and submits a full-rebuild to the background mesh
processor. Mesh generation happens off-thread, so the main thread is
not blocked.
Note. If you’re embedding viso as a library, ignore
VisoAppentirely.VisoAppis the standalone-app helper that viso uses to be its own host when run viacargo run -p viso(or theviewer/gui/webfeatures). Library consumers own their ownAssemblyand don’t need or want the convenience wrapper.
Engine-Side Annotations
Some per-entity state is purely a viso concern (it doesn’t belong on
the molecular structure itself). Those live on
EntityAnnotations, mutated through engine methods:
#![allow(unused)]
fn main() {
// Animation behavior overrides (keyed by EntityId, not raw u32).
let eid = engine.entity_id(raw_id).expect("known entity");
engine.set_entity_behavior(eid, Transition::collapse_expand(/* ... */));
engine.clear_entity_behavior(eid);
// Per-entity appearance overrides (drawing mode, color scheme,
// helix/sheet style, surface kind, palette, etc.).
let mut overrides = DisplayOverrides::default();
overrides.color_scheme = Some(ColorScheme::SecondaryStructure);
engine.set_entity_appearance(eid, overrides);
engine.clear_entity_appearance(eid);
}
set_entity_appearance diffs against the previous overrides and
dispatches only the invalidations that matter — a surface_kind
change triggers surface regeneration, a color_scheme change triggers
color recomputation, and so on.
Looking Up Entities
The engine exposes a small read-only surface for looking entities up:
#![allow(unused)]
fn main() {
// Translate a raw u32 (from IPC, TOML, CLI) to an opaque EntityId.
let eid: Option<EntityId> = engine.entity_id(raw_id);
// Walk the current Assembly snapshot directly.
for entity in engine.assembly().entities() {
println!("{:?}: {}", entity.id(), entity.molecule_type());
}
// Total entity count.
let n = engine.entity_count();
}
entity_id is the canonical “boundary translator” — wire formats
carry raw u32 ids; viso-internal APIs use EntityId. Translate
once at the boundary and pass EntityId through.
Focus
Focus determines what the camera follows and what the user is
“working on”. It cycles through entities with Tab:
#![allow(unused)]
fn main() {
pub enum Focus {
Session, // All visible entities (default)
Entity(EntityId), // A specific entity
}
}
#![allow(unused)]
fn main() {
// Cycle: Session → Entity₁ → … → EntityN → Session
engine.execute(VisoCommand::CycleFocus);
// Focus a specific entity by raw id.
engine.execute(VisoCommand::FocusEntity { id });
// Reset to session-wide focus.
engine.execute(VisoCommand::ResetFocus);
// Read current focus state.
let focus: Focus = engine.focus();
let focused_entity: Option<EntityId> = engine.focused_entity();
}
What Happens During Sync
When a new Assembly snapshot arrives:
- Rederive per-entity state. For each entity, the engine builds render-ready derived data (backbone chains, sidechain topology, SS types, residue color metadata).
- Submit a
FullRebuild. The background processor receives aVec<FullRebuildEntity>plus the active display, color, and geometry options. Per-entity mesh caching means only entities whosemesh_versionchanged are regenerated. - Triple-buffer the result. When the processor finishes, the
resulting
PreparedRebuildis written to a triple buffer. - Apply on the next frame.
engine.update(dt)callsapply_pending_scene, which uploads the GPU buffers in a memcpy and rebuilds picking bind groups.
The main thread never blocks. If the new meshes aren’t ready by the next frame, the previous frame’s data continues to render until they are.
Handling Input
Viso provides an InputProcessor that translates raw mouse/keyboard
events into VisoCommand values. The engine executes commands via
engine.execute(cmd).
Architecture
Platform events (winit, web, etc.)
│
▼
InputEvent (platform-agnostic)
│
▼
InputProcessor
│ translates to
▼
VisoCommand
│
▼
engine.execute(cmd)
InputProcessor is optional convenience. Consumers who handle their
own input can construct VisoCommand values directly and skip
InputProcessor entirely.
InputEvent
The platform-agnostic input enum:
#![allow(unused)]
fn main() {
pub enum InputEvent {
CursorMoved { x: f32, y: f32 },
MouseButton { button: MouseButton, pressed: bool },
Scroll { delta: f32 },
ModifiersChanged { shift: bool },
}
}
Your windowing layer converts platform events into these variants.
Wiring Input (winit example)
From viso’s standalone viewer:
#![allow(unused)]
fn main() {
struct ViewerShell {
engine: Option<VisoEngine>,
input: InputProcessor,
// ...
}
impl ViewerShell {
fn dispatch_input(&mut self, event: InputEvent) {
let Some(engine) = &mut self.engine else { return };
// Update cursor for GPU picking
if let InputEvent::CursorMoved { x, y } = event {
engine.set_cursor_pos(x, y);
}
// Translate event to command and execute
if let Some(cmd) =
self.input.handle_event(event, engine.hovered_target())
{
let _ = engine.execute(cmd);
}
}
}
}
Mouse movement
#![allow(unused)]
fn main() {
WindowEvent::CursorMoved { position, .. } => {
dispatch_input(InputEvent::CursorMoved {
x: position.x as f32,
y: position.y as f32,
});
}
}
Scroll (zoom)
#![allow(unused)]
fn main() {
WindowEvent::MouseWheel { delta, .. } => {
let scroll_delta = match delta {
MouseScrollDelta::LineDelta(_, y) => y,
MouseScrollDelta::PixelDelta(pos) => pos.y as f32 * 0.01,
};
dispatch_input(InputEvent::Scroll { delta: scroll_delta });
}
}
Click (selection)
#![allow(unused)]
fn main() {
WindowEvent::MouseInput { button, state, .. } => {
dispatch_input(InputEvent::MouseButton {
button: MouseButton::from(button),
pressed: state == ElementState::Pressed,
});
}
}
Modifier keys
#![allow(unused)]
fn main() {
WindowEvent::ModifiersChanged(modifiers) => {
dispatch_input(InputEvent::ModifiersChanged {
shift: modifiers.state().shift_key(),
});
}
}
Keyboard Input
Keyboard events go through InputProcessor::handle_key_press, which
looks up the physical key in the configurable KeyBindings map:
#![allow(unused)]
fn main() {
WindowEvent::KeyboardInput { event, .. } => {
if event.state == ElementState::Pressed {
if let PhysicalKey::Code(code) = event.physical_key {
let key_str = format!("{code:?}");
if let Some(cmd) = input.handle_key_press(&key_str) {
let _ = engine.execute(cmd);
}
}
}
}
}
Display toggles for ambient types (water/ion/solvent) and lipid mode
go through VisoCommand::SetTypeVisibility and
VisoCommand::CycleLipidMode respectively. The default key bindings
already wire these up — pressing U, I, O, or L produces the
right command without any extra code in the viewer.
VisoCommand
The full action vocabulary:
#![allow(unused)]
fn main() {
pub enum VisoCommand {
// Camera
RecenterCamera,
ToggleAutoRotate,
RotateCamera { delta: Vec2 },
PanCamera { delta: Vec2 },
Zoom { delta: f32 },
// Focus
CycleFocus,
ResetFocus,
// Playback
ToggleTrajectory,
// Selection
ClearSelection,
SelectResidue { index: i32, extend: bool },
SelectSegment { index: i32, extend: bool },
SelectChain { index: i32, extend: bool },
// Entity management
FocusEntity { id: u32 },
ToggleEntityVisibility { id: u32 },
RemoveEntity { id: u32 },
// Display toggles
SetTypeVisibility { mol_type: MoleculeType, visible: Option<bool> },
CycleLipidMode,
}
}
engine.execute(cmd) returns a CommandOutcome describing what
changed (SelectionChanged, FocusChanged, VisibilityChanged,
NoEffect, or Unhandled). RemoveEntity is Unhandled when sent
to the engine directly — it must be routed through VisoApp (or the
host that owns the Assembly) so the assembly is mutated and the new
snapshot is pushed via engine.set_assembly.
Click Detection
InputProcessor tracks click timing internally to distinguish:
| Click 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. Keys use the
winit::keyboard::KeyCode debug format ("KeyQ", "Tab",
"Backquote", etc.):
#![allow(unused)]
fn main() {
let mut input = InputProcessor::new();
// Default bindings are pre-loaded.
// Or with a custom map:
let bindings: KeyBindings = toml::from_str(&toml_str)?;
let mut input = InputProcessor::with_key_bindings(bindings);
}
Default keybindings:
| Key | Action |
|---|---|
KeyQ | Recenter camera on focus |
KeyT | Toggle trajectory playback |
Tab | Cycle focus |
KeyR | Toggle auto-rotate |
Backquote | Reset focus to session |
Escape | Clear selection |
KeyI | Toggle ion visibility |
KeyU | Toggle water visibility |
KeyO | Toggle solvent visibility |
KeyL | Cycle lipid display mode |
Skipping InputProcessor
For web embeds or custom hosts, construct commands directly:
#![allow(unused)]
fn main() {
// Rotate camera by 5 degrees worth of pixel delta
engine.execute(VisoCommand::RotateCamera {
delta: Vec2::new(5.0, 0.0),
});
// Select residue 42
engine.execute(VisoCommand::SelectResidue {
index: 42,
extend: false,
});
}
Dynamic Structure Updates
Viso is designed for live manipulation — structures can be updated mid-session by computational backends (Rosetta energy minimization, ML structure prediction) or user actions (mutations, drag operations).
All structural mutations happen on your molex::Assembly. After
each batch of changes, push the new snapshot to the engine via
engine.set_assembly(Arc::new(assembly.clone())). The engine itself
is read-only with respect to structural state.
Per-Entity Coordinate Updates
To stream new atom positions for an existing entity, mutate the
relevant entity’s coordinates on your Assembly (using molex’s
update_protein_entities codec helper, or
assembly.update_positions(eid, &coords) for direct position updates),
then re-publish:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use molex::ops::codec::update_protein_entities;
// Apply caller-provided Coords through molex's shared codec so the
// path matches the byte-format pipeline.
let mut entities = vec![assembly.entity(eid).unwrap().clone()];
update_protein_entities(&mut entities, &new_coords);
if let Some(updated) = entities.into_iter().next() {
assembly.remove_entity(eid);
assembly.add_entity(updated);
}
engine.set_assembly(Arc::new(assembly.clone()));
}
To make the next sync animate (instead of snapping), queue a per-entity behavior override before re-publishing:
#![allow(unused)]
fn main() {
engine.set_entity_behavior(entity_id, Transition::smooth());
engine.set_assembly(Arc::new(assembly.clone()));
}
The engine queues the transition for the affected entity on its next
sync, regardless of whether the override was set before or after the
set_assembly call (transitions are picked up in update).
Per-Entity Behavior Overrides
Override the default transition for a specific entity. Once set, every subsequent sync involving that entity uses the override (until cleared):
#![allow(unused)]
fn main() {
let eid = engine.entity_id(raw_id).expect("known entity");
engine.set_entity_behavior(eid, Transition::collapse_expand(
Duration::from_millis(200),
Duration::from_millis(300),
));
// Subsequent re-publishes that touch this entity will use
// collapse_expand instead of the default snap.
engine.set_assembly(Arc::new(assembly.clone()));
// Revert to default:
engine.clear_entity_behavior(eid);
}
Transitions
Every update can specify a Transition controlling the visual
animation:
#![allow(unused)]
fn main() {
// Instant snap (no animation; used internally for initial loads
// and trajectory frames).
Transition::snap()
// Standard smooth interpolation (300ms cubic-hermite ease-out).
Transition::smooth()
// Two-phase: sidechains collapse to CA, then expand. For mutations.
Transition::collapse_expand(
Duration::from_millis(300),
Duration::from_millis(300),
)
// Two-phase: backbone moves first with sidechains hidden, then
// sidechains expand into place.
Transition::backbone_then_expand(
Duration::from_millis(400),
Duration::from_millis(600),
)
// Builder flags
Transition::collapse_expand(
Duration::from_millis(200),
Duration::from_millis(300),
)
.allowing_size_change()
.suppressing_initial_sidechains()
}
See Animation System for details on the data-driven phase model.
Preemption
If a new update arrives while an animation is playing, the current visual position becomes the new animation’s start state and the timer resets. This provides responsive feedback during rapid update cycles (e.g. Rosetta wiggle).
Constraint Visualization (Bands and Pulls)
Bands and pulls are not commands — they are stored constraint specs that the engine resolves to world-space positions every frame so they auto-track animated atoms.
Bands
A BandInfo references atoms structurally rather than by world-space
position:
#![allow(unused)]
fn main() {
use viso::{AtomRef, BandInfo, BandTarget, BandType};
let band = BandInfo {
anchor_a: AtomRef { residue: 42, atom_name: "CA".into() },
anchor_b: BandTarget::Atom(AtomRef {
residue: 87,
atom_name: "CA".into(),
}),
strength: 1.0,
target_length: 3.5,
band_type: Some(BandType::Disulfide),
is_pull: false,
is_push: false,
is_disabled: false,
from_script: false,
};
}
BandTarget::Position(Vec3) anchors one end to a fixed world-space
point (used for “space pulls”). band_type set to None lets the
engine auto-detect the type from target_length.
Visual properties:
- Radius scales with
strength(0.1 to 0.4 Å) - Color depends on
band_type: default (purple), backbone (yellow-orange), disulfide (yellow-green), H-bond (cyan) - Disabled bands are gray
- Script-authored bands (
from_script: true) render dimmer
Pulls
A PullInfo is a single active drag constraint. The atom is
referenced structurally; the target is given in screen-space (physical
pixels) and unprojected at the atom’s depth each frame so the drag
stays parallel to the camera plane:
#![allow(unused)]
fn main() {
use viso::{AtomRef, PullInfo};
let pull = PullInfo {
atom: AtomRef { residue: 42, atom_name: "CA".into() },
screen_target: (mouse_x, mouse_y),
};
}
Pulls render as a purple cylinder from the atom to the target with a cone arrow head at the target end.
Update band and pull specs through the engine. Both methods replace the previous specs and re-resolve immediately:
#![allow(unused)]
fn main() {
engine.update_bands(vec![band1, band2]);
engine.update_pull(Some(pull));
engine.update_pull(None); // clear when drag ends
}
The engine resolves stored specs to world-space positions every frame, so bands and pulls track animated atoms automatically.