voon
HE explosion · RTX 5070 Ti · Ryzen 9 9950X · 3840×2160

Engine crates for the plus3 games.

Rust and Vulkan. A data-driven VFX system with 86 stock effects, deterministic water, rigid bodies and ragdolls, a typed console, a profiler, and the plumbing in between. Every image below is rendered headless by the demo on one machine, with the frame timings measured on that same run.

RTX 5070 Ti · Ryzen 9 9950X · rendered at 8K, shown at 4K

Cargo.toml
[dependencies]
voon-fx = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
voon-fx-gpu = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
voon-gpumem = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0", features = ["fx"] }
Run the demo
just demo                 # the interactive demo
just shot all             # headless PNGs of every scenario
just shot 2_explosion --size 3840x2160

Crates

Pin a tag: v0.1.0. Each crate is its own dependency.

Effects

voon-fx

wasm32 (no default features)rayon86 stock effects

The CPU side of the VFX system. Effects are RON files; a World simulates particles, ribbons, beams, smoke, fog, fire, fluids, weather, decals, shells, meshes, lights and camera shake, then extracts one FramePacket per frame. No ash.

  • Every random number is a hash of (seed, element, index), so Rust and the shaders agree bit for bit.
  • Meters, Y up, right-handed, reverse-Z, premultiplied linear HDR.
Cargo.toml
[dependencies]
voon-fx = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Spawn and step
use std::sync::Arc;
use glam::{Vec2, Vec3};
use voon_fx::view::ViewDesc;
use voon_fx::{FramePacket, Library, Settings, SpawnParams, Tier, View, World};

let mut lib = Library::new();
lib.load_dir(std::path::Path::new(voon_fx::EFFECTS)); // stock effects; load your own dir too
lib.finish();
let mut world = World::new(Arc::new(lib), Settings::tier(Tier::High));

world.spawn_by_name("combo_explosion", SpawnParams::at(Vec3::ZERO));

let mut packet = FramePacket::default();
// Each frame:
world.update(dt, &scene, camera_pos); // scene: impl voon_fx::SceneQuery (voon-phys has one)
world.extract(&View::new(&ViewDesc {
    view, proj, prev_view_proj: None, near: 0.05, size: [w, h],
    time, dt, frame, jitter: Vec2::ZERO, cut: false,
}), &mut packet);
// packet.lights -> your lighting, packet.shake -> your camera, packet -> voon-fx-gpu
An effect is data
// effects/combo_missile.ron: effects compose other effects
(
    bounds: (min: (-2.0, -2.0, -2.0), max: (2.0, 2.0, 2.0)),
    duration: Loop,
    priority: 3,
    elements: [
        (kind: Sub((effect: "ribbon_missile", inherit_velocity: 0.0))),
        (kind: Sub((effect: "combo_explosion", at: Stop))), // boom when it stops
    ],
)

voon-fx-gpu

nativeVulkan 1.3prebuilt SPIR-V

The Vulkan side: Fx records each Phase into your frame, one renderer per family, self-contained Slang shaders shipped as prebuilt SPIR-V. It never allocates: you hand it a GpuMemory.

Cargo.toml
[dependencies]
voon-fx-gpu = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
The frame loop
use voon_fx_gpu::{Fx, GpuConfig, Phase, Targets};

// mem: anything implementing voon_fx_gpu::GpuMemory (voon-gpumem does, feature "fx")
let mut fx = Fx::new(&device, &mut mem, &GpuConfig {
    hdr_format, depth_format, frames: 2, cache, tier: Tier::High,
})?;
fx.resize(&device, &mut mem, &Targets {
    extent, hdr: hdr_image, hdr_view, depth: depth_image, depth_view, scene_copy: None,
})?;

// Each frame, after waiting on this slot's fence:
for e in fx.take_events() {
    world.spawn(e.effect, SpawnParams::facing(e.pos, e.normal)); // GPU collisions spawn effects
}
world.update(dt, &scene, camera_pos);
world.extract(&view, &mut packet);
fx.prepare(&device, &mut mem, slot, &lights, &packet)?;

unsafe {
    fx.record(&device, cb, slot, Phase::Compute);
    draw_opaque_scene(cb);
    for phase in Phase::ORDER.into_iter().skip(1) {
        fx.record(&device, cb, slot, phase); // decals, refraction, volumes, overlays...
    }
}
fx.finish();

voon-gpumem

nativefeature fx = GpuMemory for voon-fx-gpu

A Vulkan memory allocator: TLSF blocks per memory type, types scored per Usage with ReBAR detection, dedicated allocations when the driver prefers them, memory budget and priority, owned handles freed on drop, a per-label ledger and leak listing.

Cargo.toml
[dependencies]
voon-gpumem = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0", features = ["fx"] }
Allocate, write, report
use voon_gpumem::{Allocator, AllocatorDesc, Request, Usage};

let mem = Allocator::new(&AllocatorDesc {
    budget: true,   // VK_EXT_memory_budget
    priority: true, // VK_EXT_memory_priority
    ..AllocatorDesc::new(&instance, physical, &device)
});

// Freed on drop; the label shows up in report() and leak listings.
let mut boxes = mem.buffer(
    Request::new("boxes", Usage::Upload),
    1 << 20,
    vk::BufferUsageFlags::STORAGE_BUFFER,
)?;
boxes.write(0, bytemuck::cast_slice(&data))?;

for line in mem.report().lines() {
    log::info!("{line}"); // per-pool usage, fragmentation, per-label totals
}
Just the TLSF
use voon_gpumem::Tlsf;

// The sub-allocator on its own, for any range you manage (one big buffer, an atlas...).
let mut heap = Tlsf::new(64 << 20);
let r = heap.alloc(4096, 256).unwrap(); // r.offset, r.size, r.id
heap.free(r.id);

Water & physics

voon-fluid

wasm32deterministic

Deterministic water for gameplay: shallow-water heightfield Surfaces with swell Waves, queries for height, normal and flow, and water-bending Bend forms (orb, whip, stream, wall, ring) with hits and splashes.

Cargo.toml
[dependencies]
voon-fluid = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
A pool
use glam::{Vec2, Vec3};
use voon_fluid::{Disturb, Surface, SurfaceDesc, Wave, STEP};

let mut pool = Surface::new(
    SurfaceDesc {
        origin: Vec2::splat(-2.0), size: Vec2::splat(4.0), level: 0.6, cell: 0.1,
        waves: vec![Wave { dir: Vec2::new(1.0, 0.3), amplitude: 0.02, length: 2.0, ..Wave::default() }],
        ..SurfaceDesc::default()
    },
    |_x, _z| Some(0.0), // floor height under each cell
);

pool.disturb(Disturb { pos: Vec3::new(1.0, 0.6, 1.0), radius: 0.4, amount: 0.08 }); // a splash
pool.step(STEP); // fixed step, same result on every machine

// Gameplay reads the same water the renderer draws:
let s = pool.sample(0.9, -0.4).unwrap(); // height, normal, velocity, depth

voon-phys

nativebox3d

Rigid bodies over box3d, a Sync collider Mirror that answers voon-fx scene queries, and buoyancy Floaters with wakes and entry splashes.

Cargo.toml
[dependencies]
voon-phys = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Bodies that effects collide with
use glam::{Quat, Vec3};
use voon_phys::{Layer, Matter, Phys, Shape};

let mut phys = Phys::new(Vec3::new(0.0, -9.81, 0.0)).unwrap();
phys.add_static(&Shape::Box(Vec3::new(20.0, 0.5, 20.0)), Vec3::new(0.0, -0.5, 0.0),
                Quat::IDENTITY, 0.6, Layer::default());
let crate_body = phys.add_dynamic(7, &[Shape::Box(Vec3::splat(0.25))], Vec3::new(0.0, 3.0, 0.0),
                                  Quat::from_rotation_z(0.3), Matter::default(), Layer::default()).unwrap();

// Each frame:
phys.step(dt, 4);
for m in phys.moves() {
    entities[m.key as usize].set(m.pos, m.rot); // only bodies that moved
}
// The collider mirror answers voon-fx scene queries: sparks bounce off crates.
world.update(dt, phys.scene(), camera_pos);

voon-ragdoll

glam onlydeterministic

A deterministic, position-based twelve-capsule humanoid ragdoll with hits, blasts, catch-up ticks and settling. Only depends on glam.

Cargo.toml
[dependencies]
voon-ragdoll = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Hit, simulate, skin
use std::sync::Arc;
use glam::{Mat4, Vec3};
use voon_ragdoll::{Archetype, Boxes, Hit, Impact, Ragdoll, RagdollDef, Tuning, MAX_CATCHUP_TICKS};

let tuning = Tuning::default();
// Bones map to the twelve capsules by name.
let def = Arc::new(RagdollDef::build(&skeleton, &Archetype::humanoid(), &tuning)?);
let ground = Boxes { floor: Some(0.0), boxes: Vec::new() };

let hit = Impact::Hit(Hit { point: Vec3::new(0.0, 1.3, 0.1), dir: Vec3::NEG_Z, impulse: 90.0, role: None });
let mut body = Ragdoll::spawn(def, &prev_pose, &pose, pose_dt, model, velocity, Some(hit), seed, &tuning);

// Fixed ticks, deterministic per seed; catches up if a frame was slow.
while !body.settled() {
    since += dt as f64;
    body.advance(&ground, &tuning, since / tuning.dt as f64, MAX_CATCHUP_TICKS);
    body.bone_matrices(&mut bones); // -> your skinning
}

Rendering

voon-render

nativeash

Vulkan plumbing for toon renderers: barriers and dynamic rendering, a parallel pipeline builder, the pipeline cache, frame-block writer, swapchain and device helpers, SSAO and bloom, screen UI fonts, shot readback, a GPU timer and frustum culling. Never allocates GPU memory.

Cargo.toml
[dependencies]
voon-render = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
A pipeline and a screenshot
use voon_render::pipeline::{Blend, Depth, Desc};
use voon_render::spv;

let pipe = voon_render::pipeline::graphics(&device, cache, layout, &Desc {
    vert: spv!("scene.vert"), // SPIR-V compiled by voon-slang in build.rs
    frag: Some(spv!("scene.frag")),
    input: vertex_input,
    colors: vec![(vk::Format::R16G16B16A16_SFLOAT, true)],
    depth: Depth::Write,
    depth_format: vk::Format::D32_SFLOAT,
    cull: vk::CullModeFlags::BACK,
    bias: false,
    clamp: false,
    topology: vk::PrimitiveTopology::TRIANGLE_LIST,
    blend: Blend::None,
})?;

// Screenshots: read back the LDR target, then
let rgba = voon_render::shot::opaque_rgba(bytes, extent, bgra);
voon_render::shot::save_png(&rgba, extent.width, extent.height, path)?;

voon-slang

build-dependencyslangc 2026.18.2

The shared Slang library and the build-script helper that compiles it, or any self-contained shader set, with slangc 2026.18.2. Falls back to prebuilt SPIR-V and reflects the frame block into Rust.

Cargo.toml
[build-dependencies]
voon-slang = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
build.rs
// build.rs: compile a self-contained Slang shader set with slangc 2026.18.2
use std::path::PathBuf;

fn main() {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    voon_slang::compile(&voon_slang::Compile {
        roots: vec![root.join("shaders")],
        includes: vec![root.join("shaders")],
        extra: Vec::new(),
        prebuilt: Some(root.join("prebuilt")), // used when slangc is missing
        env: "GAME",
        variants: &[("smoke.frag", "smoke_mrt.frag", &["-DMRT=1"])],
    });
}

// Then, in the crate: voon_render::spv!("smoke.frag")

voon-splash

nativeno slangc needed

The plus3 studio intro: a self-contained Vulkan pass for the animated logo, its jingle, the Splash timeline and the loading Bar that paces itself toward real progress.

Cargo.toml
[dependencies]
voon-splash = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Intro, then loading bar
use voon_splash::{Bar, Pass, Splash};

let pass = Pass::new(&device, swapchain_format, pipeline_cache)?; // prebuilt SPIR-V, no slangc
let mut splash = Splash::new(true);
let mut bar: Option<Bar> = None;

// Each frame:
if splash.playing() {
    if let Some(j) = splash.step(dt) {
        play(voon_splash::jingle(48_000), j.delay_s); // mono f32 PCM, your audio backend
    }
    pass.record(&device, cb, extent, splash.t(), 1.0);
} else {
    let bar = bar.get_or_insert_with(|| Bar::new(false));
    bar.report(loaded_fraction, next_ceiling); // eases toward progress, never backwards
    if loading_done {
        bar.ready();
    }
    bar.step(dt);
}

Runtime & console

voon-vars

wasm32

Namespaced typed variables with ranges, units and flags, commands, binds, config scripts and the console core. Your code reads plain struct fields; the console reads and writes them by name.

Cargo.toml
[dependencies]
voon-vars = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Declare and use
use voon_vars::{var_enum, vars, Build, VarStore};

var_enum! { pub Mode { Windowed, Borderless "Borderless window", Fullscreen } }

// Each line: name: type = default, optional range/step/unit/flags, "help text".
vars! {
    pub struct GameVars;
    video: Video "Display" {
        mode: Mode = Windowed, "Window mode";
        fov: f32 = 90.0, range 60.0..=120.0, step 1.0, unit "deg", "Field of view";
        vsync: bool = true, "Wait for vblank";
    }
    dev: Dev "Developer" {
        lines: bool = false, flags [dev, ephemeral], "Debug lines, never saved";
    }
}

let store = VarStore::new(GameVars::default(), Build { dev: cfg!(debug_assertions) });
let fov = store.vars.video.fov; // plain typed fields, no lookups

// Your context owns the Console and the stores (impl voon_vars::Ctx), then:
voon_vars::run_line(&mut ctx, "set video.fov 100");
voon_vars::run_line(&mut ctx, "toggle video.vsync");

voon-console

nativewinit

The console's host side: winit key names, var and bind views, the con_* page actions, bind editing, the toggleconsole/vars/binds commands and config files. No UI dependency.

Cargo.toml
[dependencies]
voon-console = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Commands and default binds
use voon_console::{ConfigPaths, Shell};
use voon_vars::{Build, Console};

pub const CONFIG: ConfigPaths = ConfigPaths {
    app: "game", file: "game.cfg", env: "GAME_CONFIG", dev: false,
};

const DEFAULT_BINDS: &str = r#"
bind ` toggleconsole
bind F5 "toggle video.vsync"
"#;

let build = Build { dev: true };
let defaults = voon_console::parse_default_binds(DEFAULT_BINDS);
let mut console: Console<Game> = Console::new(build); // Game: your context type
console.register(&voon_console::commands()); // toggleconsole, vars, binds, bind, ...
console.binds = defaults.clone();
let shell = Shell::new(build, defaults); // your ctx implements ConsoleApp to hand it out

// Commands coming back from the console page:
voon_console::dispatch(&mut ctx, &cmd);

voon-gewy

nativegewy

The console page with its vars and binds windows (HTML/JS, embedded), and UiHost, which runs it and your own pages on gewy over the game's Vulkan frame.

Cargo.toml
[dependencies]
voon-gewy = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Host the console
use voon_gewy::gewy::vulkan::DeviceRef;
use voon_gewy::{ConsoleOptions, ConsoleSurface, UiConfig, UiHost};

mod pages {
    include!(concat!(env!("OUT_DIR"), "/bundle.rs")); // gewy_embed::emit_bundle("ui", ..) in build.rs
}

let cfg = UiConfig {
    app: "game",
    dev_dir_env: Some("GAME_UI_DEV"), // serve pages from disk while you edit them
    devtools_env: Some("GAME_DEVTOOLS"),
    runtime: None,
    pages: pages::BUNDLE,
    console_id: 1,
    console: ConsoleOptions { title: "game".into(), ..Default::default() },
    surfaces: vec![(0, "gewy://ui/hud/index.html".to_string())], // your own pages
    keep_env: Vec::new(),
    memory: Some(allocator.clone()), // a voon_gpumem::Allocator
};
let dev = DeviceRef { instance: &instance, physical, device: &device, queue_family: family, queue };
let mut host = UiHost::launch(dev, [width, height], window.scale_factor() as f32, cfg)?;
let mut console = ConsoleSurface::default();

// Each frame: run what the page asked for, then publish vars and binds back to it.
for cmd in host.console_cmds() {
    voon_console::dispatch(&mut ctx, &cmd);
}
console.publish(&mut host, &mut ctx);

voon-log

log

Verbosity flags, a runtime-adjustable log filter, and a file logger with rotation, a stderr tee, a panic hook and a warning mirror for in-game consoles.

Cargo.toml
[dependencies]
voon-log = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
main.rs
fn main() {
    // -v / -vv / --verbose on the command line raise the level; GAME_LOG overrides the filter.
    voon_log::init(voon_log::FileLog {
        app: "game",
        version: env!("CARGO_PKG_VERSION"),
        env: "GAME_LOG",
        file_name: "game.log",
        candidates: Vec::new(), // extra directories to try for the log file
        min_verbosity: 0,
        filter_for: |_level| None,
        mirror: true, // also feed warnings to the in-game console
    });
    log::info!("filter: {}", voon_log::spec());

    // Later, from a console command:
    voon_log::set_filter("info,game::net=debug").unwrap();
}

voon-perf

wasm32feature tracy

The built-in profiler's recorder: spans, counters and packet records on one monotonic clock, Chrome trace export and the EPVW viewer blob. Optional Tracy spans with feature tracy.

Cargo.toml
[dependencies]
voon-perf = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Capture a trace
use voon_perf::Limits;

voon_perf::set_app("game");
voon_perf::start("startup", Limits { max_secs: 30, ..Limits::default() });

loop {
    {
        let _z = voon_perf::zone!("update"); // a span until the end of scope
        voon_perf::plot!("enemies", enemies.len() as f64);
        update();
    }
    // Returns the capture once a limit is hit (or after voon_perf::stop()).
    if let Some(cap) = voon_perf::frame_mark() {
        std::fs::write("trace.json", voon_perf::chrome::to_vec(&cap))?; // open in Perfetto
        break;
    }
}

voon-build

build-dependency

A build-script helper that exports <PREFIX>_VERSION, _GIT_HASH, _BUILD_NUM, _GIT_BRANCH and _COMMIT_DATE.

Cargo.toml
[build-dependencies]
voon-build = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
build.rs + main.rs
// build.rs
fn main() {
    let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    voon_build::emit("GAME", &root, voon_build::Version::Pkg);
}

// main.rs: GAME_VERSION, GAME_GIT_HASH, GAME_BUILD_NUM, GAME_GIT_BRANCH, GAME_COMMIT_DATE
const BUILD: &str = concat!(env!("GAME_VERSION"), "+", env!("GAME_GIT_HASH"));

Net & platform

voon-blob

wasm32serde

Paced, selectively acknowledged blob transfers over any unreliable lane, in 1 KiB chunks. Bring your own sockets.

Cargo.toml
[dependencies]
voon-blob = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Send a blob over a lossy link
use voon_blob::{Msg, Receiver, Sender, Start, State, CHUNK, SPAN};

let mut tx = Sender::new();
tx.start(Start {
    peer: 3, id: 1, src: data.clone(), at: 0, // data: Arc<[u8]>
    rate: 64 * CHUNK as u32, rate_max: 8 << 20, window: SPAN, ping, // ping: Duration
}, now);
let mut rx = Receiver::new(1, data.len() as u32, 1 << 30, now).unwrap();

loop {
    for (_peer, d) in tx.pump(now, budget, |_| true) {
        send_unreliable(d); // paced 1 KiB chunks, any lossy lane
    }
    for d in received() {
        rx.on_data(now, &d);
    }
    if rx.state() == &State::Done {
        break;
    }
    for m in rx.service(now) {
        match m {
            Msg::Ack(a) => tx.ack(3, &a, ping, now),       // selective acks
            Msg::Get(g) => { tx.rewind(3, &g, ping, now); } // send Get reliably
        }
    }
}
let bytes = rx.finish().unwrap();

voon-discord

nativefeature sdk

Discord rich presence through the Social SDK, loaded at runtime. No SDK, no presence, no error. The Feed throttles, dedupes, latches start times and backs off.

Cargo.toml
[dependencies]
voon-discord = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0", features = ["sdk"] }
Rich presence
use std::time::Instant;
use voon_discord::{Config, Discord, Presence};

// No SDK on this machine? open() returns None and the game carries on.
let Some(mut discord) = Discord::open(&Config { app_id, sdk_env: None, search: vec![] }) else {
    return;
};

let presence = Presence {
    details: "Island 3, day 12".into(),
    state: "Fishing".into(),
    since: Some("session".into()), // start time latches while this key stays the same
    ..Presence::default()
};

// Call every frame; the feed throttles, dedupes and backs off for you.
let now = Instant::now();
discord.apply(presence.clone(), now);
discord.tick(now);
for event in discord.drain() {
    log::info!("discord: {event:?}");
}

Tools

voon-assets

nativeSQLite

Indexes folders and archives (zip, rar, tar) of models and textures in place, probes them and renders thumbnails, backed by a SQLite cache. Powers the editor.

Cargo.toml
[dependencies]
voon-assets = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Index a library
use std::time::Duration;
use voon_assets::{Config, Event, Library, Root};

let lib = Library::open(Config {
    roots: vec![Root { name: "synty".into(), path: "/home/me/Documents/synty".into() }],
    cache: "/home/me/.cache/assets".into(), // SQLite index + thumbnails
    threads: 4,
    thumbs: true,
}, |event| match event {
    Event::Status(s) => println!("{} {}/{}", s.stage, s.done, s.todo),
    Event::Changed(generation) => println!("index changed: {generation}"),
})?;
lib.start();                          // scan zips, rars, tars and folders in place
lib.watch(Duration::from_secs(3))?;   // rescan when files change

voon-editor + voon-demo

binaries

The asset library app, and the demo host behind every image on this page: one window, a scenario picker, and headless shots.

From the repo
just demo                       # every scenario, with a gewy overlay to pick them
just shot all                   # headless PNGs into shots/
just shot 2_explosion --size 3840x2160 --no-validation
just editor                     # the asset library over your model folders
just editor-audit --json        # index headless and report what each pack resolves