Skip to main content

Module guide

Module guide 

Source
Expand description

Plugin authoring guide. Plugin authoring guide.

This module documents how to write a super::RuntimePlugin and register it with super::ViewportRuntime.

§The RuntimePlugin trait

A plugin is any type that implements super::RuntimePlugin:

pub trait RuntimePlugin: Send + 'static {
    fn priority(&self) -> i32;
    fn submit(&mut self, ctx: &RuntimeStepContext<'_>) {}
    fn collect(&mut self, ctx: &mut RuntimeStepContext<'_>) {}
    fn on_event(&mut self, event: &RuntimeEvent, ctx: &mut RuntimeStepContext<'_>) {}
    fn step(&mut self, ctx: &mut RuntimeStepContext<'_>);
}

step is the only required method. The others have default no-op implementations.

Hook call order each frame:

  1. on_event – called once per lifecycle event (node added or removed) before the step loop begins. Only fired when the scene’s node set changes.
  2. submit – called once per frame before the step loop, in priority order. Use this to kick off background work (e.g. spawning a compute task) that will be collected on the same or next frame. The context is &RuntimeStepContext (shared reference), so only get and contains on resources are available.
  3. step – called once per phase execution, in priority order. For simulate-range plugins with a fixed timestep this is called once per accumulated step (possibly multiple times per frame). For all other plugins it is called once per frame. This is where the main per-frame or per-step work happens.
  4. collect – called once per frame after the step loop, in priority order. Use this to read results from background work started in submit.

§RuntimePhase ordering

Phase constants are plain i32 values in the [super::plugin::phase] module. Plugins execute in ascending numeric order each frame:

PhaseValueWhen it runs
Prepare100First. Update time-dependent state before queries.
Pick200Ray-cast and object picking.
Select300Selection updates driven by pick results.
Manipulate400Gizmo drag and keyboard transform sessions.
Animate500Procedural or keyframe animation.
Simulate600Physics or simulation. Fixed timestep: runs N times per frame.
PostSimulate700After all Simulate iterations, before Writeback.
Writeback800Flush accumulated transform ops to the scene.

You can use values between bands to place a plugin at a specific sub-position. For example phase::ANIMATE + 50 runs after standard Animate plugins but before any plugin at phase::SIMULATE. Two plugins at the same priority run in registration order (stable sort).

The Simulate phase is special: with a fixed timestep configured, plugins in the [SIMULATE, POST_SIM) range may be called zero or more times per rendered frame depending on how much wall time accumulated since the last frame. All other phase ranges always run exactly once per frame.

§Hook registration

Build the runtime and add plugins with with_plugin:

use viewport_lib::runtime::{ViewportRuntime, FixedTimestep};

let mut runtime = ViewportRuntime::new()
    .with_fixed_timestep(FixedTimestep::new(60.0))
    .with_plugin(MyPhysicsPlugin::new())
    .with_plugin(MyAudioPlugin::new());

Multiple plugins can share the same priority. They run in registration order within the same band. Registration order is preserved by a stable sort on priority() at the start of each frame.

Call runtime.step(&mut scene, &mut selection, &frame_ctx) once per frame to drive all plugins. It returns a [super::output::RuntimeOutput] with selection ops, contact events, transform ops, and any application-defined events.

§Accessing scene data

Each hook receives a [super::context::RuntimeStepContext] with the following fields:

  • ctx.scene – read-only reference to the scene. Use this to query node transforms, materials, and spatial structure.
  • ctx.writeback – write transforms here. Call ctx.writeback.set(node_id, affine). The runtime flushes accumulated writes to the scene after the writeback phase.
  • ctx.output – accumulate selection operations, contact events, camera commands, and typed application events here.
  • ctx.resources – shared typed resource registry. Use this to pass data between plugins within the same frame or across frames.
  • ctx.pick_hit – forwarded from RuntimeFrameContext. The pick result under the cursor for this frame, if picking was done by the caller.
  • ctx.dt – delta time for this step. For simulate-range plugins with a fixed timestep this is the fixed step size, not wall dt.

§Resource sharing between plugins

[super::RuntimeResources] is a typed map keyed by TypeId. Any Send + 'static type can be stored as a resource. One value per type: inserting again overwrites.

Typical pattern: one plugin writes, another reads in the same frame.

// PhysicsPlugin at SIMULATE inserts contact data.
fn step(&mut self, ctx: &mut RuntimeStepContext) {
    let contacts = self.solver.solve(ctx.scene, ctx.dt);
    ctx.resources.insert(contacts);
}

// AudioPlugin at POST_SIM reads it.
fn step(&mut self, ctx: &mut RuntimeStepContext) {
    if let Some(contacts) = ctx.resources.get::<ContactList>() {
        for c in contacts.iter() {
            self.audio.play_impact(c.impulse);
        }
    }
}

Resources persist across frames until explicitly removed or the runtime is dropped. Use runtime.resources_mut().insert(...) before the first step to pre-populate resources that plugins expect to find on frame one.

Available methods on RuntimeResources:

  • insert(value) – store a value, replacing any existing value of that type
  • get::<T>() – shared reference, returns None if not present
  • get_mut::<T>() – mutable reference
  • remove::<T>() – take ownership and remove
  • contains::<T>() – presence check without borrowing the value

In submit (called with &RuntimeStepContext), only get and contains are accessible. In step, collect, and on_event (called with &mut RuntimeStepContext), full insert/get_mut/remove access is available.

§Debug visualization with DebugDraw

super::DebugDraw is a resource, not a renderer type. Plugins draw to it by calling ctx.resources.get_mut::<DebugDraw>() in their step implementations. After the frame, the host converts the accumulated primitives into render items.

Setup pattern:

use viewport_lib::runtime::debug_draw::DebugDraw;

// At startup: insert a DebugDraw resource.
runtime.resources_mut().insert(DebugDraw::new());

// Each frame, before runtime.step():
if let Some(dd) = runtime.resources_mut().get_mut::<DebugDraw>() {
    dd.begin_frame();  // clears transient (one-frame) primitives
}
let output = runtime.step(&mut scene, &mut selection, &frame_ctx);

// After step: convert accumulated primitives to render items.
if let Some(dd) = runtime.resources().get::<DebugDraw>() {
    frame_data.scene.polylines.extend(dd.to_polylines());
    if let Some(pc) = dd.to_point_cloud() {
        frame_data.scene.point_clouds.push(pc);
    }
}

Inside a plugin’s step:

fn step(&mut self, ctx: &mut RuntimeStepContext) {
    if let Some(dd) = ctx.resources.get_mut::<DebugDraw>() {
        dd.line(contact.point_a, contact.point_b, [1.0, 0.0, 0.0, 1.0]);
    }
}

Primitives submitted with line, point, aabb, sphere, and label are transient: begin_frame clears them. Persistent primitives are added with add_persistent(key, prim) keyed by a u64 and stay until remove_persistent(key) is called. See the [super::debug_draw] module for the full API.

§Fixed timestep and the Simulate phase

Configure a fixed timestep with ViewportRuntime::with_fixed_timestep:

let mut runtime = ViewportRuntime::new()
    .with_fixed_timestep(FixedTimestep::new(60.0));  // 60 Hz

The runtime accumulates wall time. Each call to runtime.step(...) advances the accumulator by frame_ctx.dt. If the accumulated time exceeds the step size, the runtime calls step() on simulate-range plugins once per step. If wall dt is less than the step size, step() may not be called at all for that frame.

Inside simulate-range plugins, ctx.dt is the fixed step size (e.g. 1.0/60.0), not wall dt. This makes physics integration deterministic regardless of frame rate.

For rendering, use runtime.alpha() to interpolate between the previous and current simulation state:

let alpha = runtime.alpha();
if let Some(t) = runtime.snapshots().interpolated(node_id, alpha) {
    // use t as the render transform instead of the scene node transform
}

alpha is in [0.0, 1.0]: the fractional position between the last completed step and the next. This eliminates the jitter that appears when frame rate and step rate are out of phase. When no fixed timestep is configured, alpha() returns 1.0.

§File layout for new plugins

New plugins live in a subdirectory under src/runtime/plugins/, not as a single file. The [super::plugins::skeleton_plugin] module is the reference template:

plugins/
  my_plugin/
    mod.rs        -- re-exports the public surface
    plugin.rs     -- the RuntimePlugin impl
    <feature>.rs  -- substrate types, math, helpers, internal state

Using a subdirectory from the start avoids a rename-and-rewire when the plugin grows. Re-export all public types from mod.rs and add the module to src/runtime/plugins/mod.rs.

§Minimal plugin example

A plugin that counts frames and logs every 60 steps:

use viewport_lib::runtime::{RuntimePlugin, RuntimeStepContext, ViewportRuntime};
use viewport_lib::runtime::plugin::phase;

struct FrameCounter {
    count: u64,
}

impl FrameCounter {
    fn new() -> Self {
        Self { count: 0 }
    }
}

impl RuntimePlugin for FrameCounter {
    fn priority(&self) -> i32 {
        phase::PREPARE
    }

    fn step(&mut self, _ctx: &mut RuntimeStepContext<'_>) {
        self.count += 1;
        if self.count % 60 == 0 {
            println!("frame {}", self.count);
        }
    }
}

// Registration:
let mut runtime = ViewportRuntime::new()
    .with_plugin(FrameCounter::new());

This plugin runs once per frame at the Prepare phase. It owns its state as a struct field. To share state with another plugin, insert it into ctx.resources instead of keeping it in the struct.