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 demojust shot all # headless PNGs of every scenariojust shot 2_explosion --size 3840x2160
Gallery 65
4K60 · 9sHE explosionGPU 3.61 ms (≈277 fps)4K60 · 10sMissile strikeGPU 1.54 ms (≈648 fps)4K60 · 7sWater bending: ring to wallGPU 6.00 ms (≈167 fps)4K60 · 9sGlass, ice and shockwavesGPU 1.11 ms (≈898 fps)4K60 · 9sShieldsGPU 0.53 ms (≈1878 fps)4K60 · 9sMesh effectsGPU 0.56 ms (≈1794 fps)4K60 · 10sLightning stormGPU 0.39 ms (≈2586 fps)4K60 · 12sMolotovGPU 4.35 ms (≈230 fps)4K60 · 6sStudio introGPU 3.20 ms (≈313 fps)4KHE explosionGPU 3.61 ms (≈277 fps)4KHE explosionGPU 3.61 ms (≈277 fps)4KHE explosionGPU 3.61 ms (≈277 fps)4KHE explosionGPU 3.61 ms (≈277 fps)4KMissile strikeGPU 1.54 ms (≈648 fps)4KMolotovGPU 4.35 ms (≈230 fps)4KMolotovGPU 4.35 ms (≈230 fps)4KCampfireGPU 2.83 ms (≈353 fps)4KFluid plumeGPU 3.99 ms (≈251 fps)4KFluid plumeGPU 3.99 ms (≈251 fps)4KSmoke grenadeGPU 3.64 ms (≈275 fps)4KSmoke grenadeGPU 3.64 ms (≈275 fps)4KGrinder sparksGPU 0.81 ms (≈1234 fps)4KRifle impactsGPU 0.62 ms (≈1621 fps)4KEnergy orbGPU 0.44 ms (≈2272 fps)4KEnergy orbGPU 0.44 ms (≈2272 fps)4KGlass and iceGPU 1.11 ms (≈898 fps)4KGlass and iceGPU 1.11 ms (≈898 fps)4KShieldsGPU 0.53 ms (≈1878 fps)4KShieldsGPU 0.53 ms (≈1878 fps)4KMesh effectsGPU 0.56 ms (≈1794 fps)4KMesh effectsGPU 0.56 ms (≈1794 fps)4KGround slamGPU 1.97 ms (≈508 fps)4KGround slamGPU 1.97 ms (≈508 fps)4KGround zonesGPU 1.14 ms (≈877 fps)4KTextured spritesGPU 0.78 ms (≈1276 fps)4KKatana trailGPU 0.36 ms (≈2755 fps)4KTracers and swipeGPU 0.44 ms (≈2273 fps)4KScreen overlaysGPU 0.90 ms (≈1117 fps)4KScreen overlaysGPU 0.90 ms (≈1117 fps)4KDistortionGPU 0.96 ms (≈1046 fps)4KPoolGPU 2.51 ms (≈399 fps)4KRain on the poolGPU 3.32 ms (≈301 fps)4KLakeGPU 2.76 ms (≈362 fps)4KWater bending: whipGPU 4.32 ms (≈232 fps)4KWater bending: whipGPU 4.32 ms (≈232 fps)4KWater bending: wallGPU 6.00 ms (≈167 fps)4KWater bending: wallGPU 6.00 ms (≈167 fps)4KParticle liquidGPU 4.99 ms (≈200 fps)4KParticle liquidGPU 4.99 ms (≈200 fps)4KSplashesGPU 3.48 ms (≈288 fps)4KFloating cratesGPU 2.22 ms (≈450 fps)4KFloating cratesGPU 2.22 ms (≈450 fps)4KFalling cratesGPU 0.46 ms (≈2180 fps)4KRagdoll yardGPU 4.38 ms (≈228 fps)4KRagdoll yardGPU 4.38 ms (≈228 fps)4KLightning stormGPU 0.39 ms (≈2586 fps)4KLightning stormGPU 0.39 ms (≈2586 fps)4KRainGPU 3.68 ms (≈271 fps)4KSnowGPU 0.39 ms (≈2539 fps)4KLit fog volumesGPU 3.78 ms (≈264 fps)4KDecalsGPU 3.21 ms (≈312 fps)4KWater stressGPU 4.64 ms (≈215 fps)4KEverything at onceGPU 6.17 ms (≈162 fps)4KStudio introGPU 3.20 ms (≈313 fps)4KStudio introGPU 3.20 ms (≈313 fps)
Crates
Pin a tag: v0.1.0. Each crate is its own dependency.
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};
letmut lib = Library::new();
lib.load_dir(std::path::Path::new(voon_fx::EFFECTS)); // stock effects; load your own dir too
lib.finish();
letmut world = World::new(Arc::new(lib), Settings::tier(Tier::High));
world.spawn_by_name("combo_explosion", SpawnParams::at(Vec3::ZERO));
letmut 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
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" }
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.letmut 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...).letmut heap = Tlsf::new(64 << 20);
let r = heap.alloc(4096, 256).unwrap(); // r.offset, r.size, r.id
heap.free(r.id);
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};
letmut 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
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};
letmut 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);
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 });
letmut 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
}
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, thenlet rgba = voon_render::shot::opaque_rgba(bytes, extent, bgra);
voon_render::shot::save_png(&rgba, extent.width, extent.height, path)?;
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.2use 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")
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 slangcletmut splash = Splash::new(true);
letmut bar: Option<Bar> = None;
// Each frame:if splash.playing() {
ifletSome(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 backwardsif loading_done {
bar.ready();
}
bar.step(dt);
}
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! { pubMode { Windowed, Borderless"Borderless window", Fullscreen } }
// Each line: name: type = default, optional range/step/unit/flags, "help text".vars! {
pubstructGameVars;
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");
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};
pubconstCONFIG: ConfigPaths = ConfigPaths {
app: "game", file: "game.cfg", env: "GAME_CONFIG", dev: false,
};
constDEFAULT_BINDS: &str = r#"
bind ` toggleconsole
bind F5 "toggle video.vsync"
"#;
let build = Build { dev: true };
let defaults = voon_console::parse_default_binds(DEFAULT_BINDS);
letmut 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);
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 };
letmut host = UiHost::launch(dev, [width, height], window.scale_factor() as f32, cfg)?;
letmut 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);
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();
}
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()).ifletSome(cap) = voon_perf::frame_mark() {
std::fs::write("trace.json", voon_perf::chrome::to_vec(&cap))?; // open in Perfettobreak;
}
}
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.letSome(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:?}");
}
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
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 themjust shot all # headless PNGs into shots/just shot 2_explosion --size 3840x2160 --no-validation
just editor # the asset library over your model foldersjust editor-audit --json # index headless and report what each pack resolves