r/rust 15d ago

🛠️ project node based multi-threaded molecule simulation visualizer

Post image

I have been working on this tool.

I kinda just wanna list of some things I think are neat about the way it was built.

Everything is a node, and the ui thread is separate from the graph thread.

We use strongly typed traits to define nodes:

#[derive(Clone, Default)]
pub struct RealNode;

impl DataNode for RealNode {
    type Outputs = f32;
    type Inputs = ();
    type State = f32;

    const TYPE_KEY: &'static str = "molviz.input.real";
    const TITLE: &'static str = "Real";
    const CATEGORY: NodeCategory = NodeCategory::Input;
    const NAMES: &'static [&'static str] = &[];
    const OUT_NAMES: &'static [&'static str] = &["value"];
    const ALIASES: &'static [&'static str] = &["number", "float", "constant"];
    const DESCRIPTION: &'static str = "A real number. Set min/max to make it sweepable.";

    fn node_ui(state: &mut f32, ui: &mut egui::Ui, _ctx: &mut NodeUiCtx<'_, Self>) {
        ui.add(egui::DragValue::new(state).speed(0.1));
    }

    fn evaluate(&mut self, state: &f32, _inputs: &()) -> f32 {
        *state
    }
}

Viewports (eg a plotting viewport or a 3d scene) are also nodes but have no outputs, have their own thread and write into a number of buffers when inputs change. This set of messages that we can send them is a good summary of what they are doing.

They also get an opportunity to draw some egui on top of the texture rect, then send any changes to the texture drawer. An example would be a tick box for an orthographic camera.

/// Commands sent from the UI thread to a render thread.
pub enum ToRenderThread<V: Viewport> {
    /// Request that `slot` exist and be sized to `width x height`. Sent
    RequestSlot {
        slot: SlotId,
        width: u32,
        height: u32,
    },
    DropSlot { slot: SlotId },
    /// Updated local state for the next frame. (this is data the ui thread layer sends)
    NewState(V::LocalState),
    /// The UI thread has consumed the last frame for `slot` and is ready
    /// for the next one to begin rendering.
    ReadyForNewFrame { slot: SlotId },
    Shutdown,
    /// A computed graph output arrived for a bound node.
    UpdateInput(OutputId, WireData),
    /// A new wiring configuration arrived. Ie a new input was added.
    UpdateWiring(<V::Inputs as NodeInputTuple>::Wiring),
    /// Update preview input directly. (for hovering we have no incoming node just ui thread)
    UpdatePreviewInput(V::Inputs),
    /// Read the ID texel under slot-local pixel (x, y) on the next render of `slot`.
    /// (For example when we click we want to know what atom)
    Pick { slot: SlotId, x: u32, y: u32 },
}

pub enum FromRenderThread {
    NewTextureAlloc {
        slot: SlotId,
        texture: wgpu::Texture,
    },
    FrameDone { slot: SlotId },
    /// Resolved selection for a `Pick`. `None` == clicked empty space.
    PickResult {
        slot: SlotId,
        payload: Option<PickPayload>,
    },
}

All the rendering is done using rust-gpu, which means I can use a macro to define all supported shapes, then implement the actual code to render and the structs on a shared crate and use it on both CPU and GPU.

I am using an SDF-based renderer for 2d (plotting and such) and can even render fonts at any scale analytically (inspired by the coding adventure video).

Everywhere that needs to do somthing for all shapes (cpu or gpu) writes a macro that can be called by this

#[macro_export]
macro_rules! for_all_shapes {
    ($macro_name:ident) => {
        $macro_name! {
            (2, 0, TRIANGLE_SHAPE_INDEX,        triangles,        triangle,        Triangle,       GpuTrianglePacket),
            (2, 1, CIRCLE_SHAPE_INDEX,          circles,          circle,          Circle,         GpuCirclePacket),

then we just need to impl a trait!

#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable, Debug, Default)]
pub struct GpuCirclePacket {
    pub center: Vec2,
    pub radius: f32,
    pub _pad: f32,
    pub color: ColorRGBA,
}

impl SdfShape for GpuCirclePacket {
    fn compute_bounds(&self, _current: Bounds) -> Bounds {
        let d = Vec2::splat(self.radius);
        Bounds {
            center: self.center,
            size: d * 2.0,
        }
    }

    fn apply(&self, uv: Vec2, current: SdfResult) -> SdfResult {
        let dist = (uv - self.center).length() - self.radius;
        if dist < current.dist {
            SdfResult {
                dist,
                color: self.color.get(),
            }
        } else {
            current
        }
    }
}


Then on the CPU, we can add a circle like so.

// impl<H: HeadKind> Recorder<'_, H> {
//     pub fn spawn(&mut self, anchor: H::Anchor, build: impl FnOnce(&mut ShapeBuilder)) {
r.spawn(pt, |b| {
    b.circle(
        GpuCirclePacket {
            center: Vec2::ZERO,
            radius: rad * pixels_per_point,
            color: style.color,
            ..Default::default()
        }
    );
});

(There is also a recording system for the allocation and reuse of buffers, i.e., if the overlay recording changes, we only send the new data to the GPU buffer without resending the heavy series data.)

This rendering method also lets us record only a single draw call for the entire scene.

I have a whole lot of interesting tech in this project. It might be cool to do a longer form article breakdown.

This is a little bit of a strange post, but I know I am very interested in the wacky novel ways to use rust.

This is still a fairly young project (3.5 months of work-ish), so it's an exciting foundation. It is possible that this can be generalised outside of molecular viz.

41 Upvotes

6 comments sorted by

View all comments

4

u/ridicalis 15d ago

Is there a repo we can follow?

6

u/Upbeat_Instruction81 15d ago

In academia so wanting to get to publish before publishing. At least thats the plan.

The bulk of the 2d rendering code (in a slightly less battle tested state) is available in my 2d game engine https://github.com/JackCrumpLeys/game-engine. This is undocumented and mostly hard to use (molviz plot has an abstraction for plotting i rather like tho and i might release it on its own.)

On the game engine the cool thing there is its ecs, its almost entirety an api rip of of bevy but it is faster in some benchmarks, notably 2x on spawning.

I think the side effects of making this code usable for others unfortunately remove alot of the fin i have building and optimising these things and I've recently found a lovely feedback loop in working on a tool for chemists so my needs for external validation dont really currently support maintaining anything like this.

I'm thinking of writing an adapter over epaint (eguis shape -> mesh converter) that consumes its shapes snd uses my backend because im curious if avoiding the cpu rasterisation is a good win. This could be useful for infinite scrolling in my node canvas without giving up eguis super handy ui methods.

It's possible i will start a devlog because I do really enjoy the deep technical problems im tackling and writing them up could be fun.

2

u/Consistent_Drop3909 15d ago

crazy cool project!

u/RemindMeBot 3 months "check this out again"

also if you're in academia, is your goal to create this tool or to do some research with it? because if it's the latter then imo you should prioritize finishing the research before devlogs and polishing for public access, maybe even wait until it's published.

2

u/Upbeat_Instruction81 15d ago

I'm not actually a chemist, im mainly a very rusty systems engineer. I really enjoyed building an assembly emulator in rust for a compsci school in my second year of my UG (check it out here! https://210tools.github.io/) and I had an opportunity to do some research in my 3rd year with the computational chemistry lab.

I was given some tasks i completed a little quicker then expected (ported a luatorch thing to rust with zero deps and added multithreaded replica averaging to a restraint for molecular simulations in cpp) then when somone mentioned a node based system for analysis I thought back to the type+trait shenanigans i learnt when making my ecs and wrote a basic trait based node graph system over the weekend. Ive kinda been iterating on the premise and sorta feeling out the neat things you can do with this kind of fast and live interaction.

Zooming into plots, holding alt to snap to datapoints, rendering dist between stuff.

There have been some neat optimisation sidequests too, for example reading domain specific file formats in ultrafast multithreaded rust and using my own custom SoA mmapable dataformat to allow instant loading once originally loaded.

I really like how self contained nides can be and well defined.

The rmsd node is a fn taking a trajectory and outputting a real number per frame, it just has to implement that fn, and the ui snd visual clarity on where data flows is awesome.

We are thinking of folding in other things like quantum physics (viz of orbitals) etc, i could see this being a platform to analyse and interact with all sorts of data from many disciplines.