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 108 stock effects, a procedural open-world nature renderer, sky and clouds, a painterly post process, deterministic water, weather, cloth, fracture, rigid bodies and ragdolls, a typed console, a profiler, and the plumbing in between. Every image below is rendered headless on one machine by the demo or the nature viewer, with the frame timings measured on that same machine.

RTX 5070 Ti · Ryzen 9 9950X · supersampled, 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
just nature-shots         # headless nature renders

Crates

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

Effects

voon-fx

wasm32 (no default features)rayon108 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. New since the last tag: GPU sub-emitters, mesh debris, global haze, fireballs, sand and ash weather, the open ocean with wakes, gusts in a shared wind field, and views that render voon-fluid fire and floods. 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, clustered lights, temporal volumes, effect sun shadows, haze froxels, a wind field the host can sample, optional clustered decals and a reactive mask for upscalers, all self-contained Slang 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);

Simulation

voon-fluid

wasm32deterministic

Deterministic water and fire for gameplay: shallow-water heightfield Surfaces with swell Waves, an open Gerstner Ocean with Kelvin Wakes, water-bending Bend forms (orb, whip, stream, wall, ring) with hits and splashes, voxel fire spread (Burn) and tile flooding (Flood).

See it in the gallery →
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
Sea, fire and flood
use glam::{UVec2, UVec3, Vec2, Vec3};
use voon_fluid::{Burn, BurnDesc, Flood, FloodDesc, Material, Ocean, OceanDef, Sea, Wake, WakeDef, WaterQuery};

// Open water: a seeded Gerstner ocean, with a shallow-water harbour Surface blended in over 8 m.
let mut sea = Sea::new(Ocean::new(OceanDef { wind_speed: 12.0, seed: 3, ..OceanDef::default() }));
sea.add(harbour, 8.0);
let mut wake = Wake::new(WakeDef::default());
wake.record(boat_xz, heading, speed, 1.0, t); // Kelvin wake stamps behind the hull
sea.step(dt);
let s = sea.sample(x, z).unwrap(); // the same query as a pool; add wake.height_at(xz, t)

// Voxel fire: fuel burns, spreads with the wind, dries out and gets doused.
let mut fire = Burn::new(BurnDesc { dims: UVec3::new(64, 16, 64), ..BurnDesc::default() });
let wood = fire.add_material(Material::default());
fire.set_box(Vec3::ZERO, Vec3::new(4.0, 3.0, 4.0), wood);
fire.ignite(Vec3::new(1.0, 0.5, 1.0), 0.5, 600.0);
fire.step(dt); // fixed 20 Hz ticks; fire.burning() lists the flames

// Flooding: tile water through walls, doors, drains and breaches.
let mut flood = Flood::new(FloodDesc { dims: UVec2::new(64, 64), ..FloodDesc::default() });
flood.breach(Vec2::new(3.0, 10.0), 0.5); // m³/s
flood.step(dt);

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.

See it in the gallery →
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.

See it in the gallery →
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
}

voon-weather

wasm32deterministicglam + serde

Deterministic weather: game-named WeatherDef kinds blended over a seeded Schedule or moving storm cells, sampled by place and time with no stepped history, so every client agrees. Forced overrides script it; samples give cloud, rain, snow, wind and gusts, fog, temperature and wetness, plus lightning Strikes with warning leads, forecasts and plain effect parameters for a renderer.

See it in the gallery →
Cargo.toml
[dependencies]
voon-weather = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Sample, force, forecast
use glam::Vec2;
use voon_weather::{CellSpawn, EffectScale, Forced, Rect, Schedule, Weather, WeatherDef, WeatherTable};

// The game names its kinds; presets fill in cloud, precip, wind, fog and lightning.
let mut table = WeatherTable::new(vec![WeatherDef::clear("clear"), WeatherDef::rain("rain")]);
let storm = table.push(WeatherDef::storm("storm"));

// A seeded schedule plus moving cells: no stepped history, any time can be sampled.
let mut weather = Weather::spatial(seed, table, Schedule::weighted(vec![6.0, 3.0, 1.0]), CellSpawn::default());
weather.force(Forced::new(storm, t).until(t + 600.0)); // a scripted storm, ramped in and out

let s = weather.sample(Vec2::new(120.0, -40.0), t); // same answer on every client
let fx = s.effects(&EffectScale::default()); // rain, snow, sand, haze, flash rate, darkening
let wet = weather.wetness_at(player, t, 10.0, 3600.0); // settled from the last hour

// Strikes hash per tile and time bucket, each with a warning lead for telegraph rings.
let strikes = weather.strikes(Rect::around(player, 500.0), t, t + 2.0);
let outlook = weather.forecast(player, t, 3600.0, 60.0); // spells of dominant kinds

voon-cloth

wasm32deterministicglam only

Deterministic XPBD Rope and Cloth in metres: fixed ticks with catch-up and render interpolation, pins and moving anchors, reeling and tension in newtons, aerodynamic drag and lift from gusty Wind, tearing, and ground, sphere, capsule and box Colliders with friction.

See it in the gallery →
Cargo.toml
[dependencies]
voon-cloth = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
A flag and a mooring line
use glam::{Vec2, Vec3};
use voon_cloth::{Cloth, ClothDef, Colliders, Pose, Rope, RopeDef, Wind};

let mut flag = Cloth::new(
    &ClothDef {
        width: 24, height: 16, spacing: Vec2::splat(0.05),
        tear: 1.6, // stretch links snap past 160% of rest length
        wind: Wind::new(Vec3::new(6.0, 0.0, 1.5)), // gusts hashed from the seed
        ..ClothDef::default()
    },
    Pose::at(Vec3::new(0.0, 3.0, 0.0)));
flag.pin_column(0); // the edge on the pole

let mut world = Colliders::with_ground(0.0);
let deck = world.cuboid(Pose::at(Vec3::new(2.0, 0.5, 0.0)), Vec3::new(2.0, 0.1, 1.0), 0.8);
let mut line = Rope::between(&RopeDef::default(), Vec3::new(0.0, 2.0, 0.0), Vec3::new(2.0, 0.7, 0.0));
line.pin(0, Vec3::new(0.0, 2.0, 0.0));

world.move_to(deck, deck_pose); // a moving deck carries the rope with friction
flag.step(frame_dt, &world); // fixed XPBD ticks, bit-identical on every machine
line.step(frame_dt, &world);
let pull = line.tension(0); // newtons at the pinned end

voon-fracture

wasm32deterministicglam + serde

Seeded pre-fracture into convex pieces: fracture cuts boxes and hulls into Voronoi cells biased toward an Impact, shatter_glass breaks panes into radial and ring shards, and every triangle knows whether it is a fresh break face. voon-phys spawns the pieces as a capped Debris pool.

See it in the gallery →
Cargo.toml
[dependencies]
voon-fracture = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Break a crate, shatter a pane
use glam::{Vec2, Vec3};
use voon_fracture::{fracture, shatter_glass, Fracture, Glass, Impact, Solid};
use voon_phys::{Blast, Debris, DebrisDef};

// Pre-fracture at load time: the same seed gives bit-identical pieces everywhere.
let pieces = fracture(&Fracture {
    solid: Solid::Box(Vec3::splat(0.5)), seed: 42, pieces: 24,
    impact: Some(Impact { point: Vec3::new(0.5, 0.2, 0.0), radius: 0.3 }), // smaller pieces near the hit
    inner: false,
});
let faces: Vec<_> = pieces[0].triangles().collect(); // (corners, normal, is a fresh break face)

let shards = shatter_glass(&Glass {
    half_extents: Vec2::new(0.8, 1.2), thickness: 0.01, impact: Vec2::new(0.1, 0.3),
    seed: 7, radials: 12, rings: 4,
});

// voon-phys spawns pieces as dynamic hull bodies, evicting the oldest batch at the cap:
let mut debris = Debris::new(DebrisDef { cap: 128, ..DebrisDef::default() });
debris.spawn(&mut phys, &pieces, crate_pos, crate_rot, Vec3::ZERO, Vec3::ZERO,
             Some(&Blast { point: hit, direction: Vec3::NEG_Z, strength: 6.0, radius: 1.5, spread: 0.5 }));

Nature

voon-nature

nativeRTX 40/50ray querymesh shaders

A procedural open-world nature renderer: terrain, dense grass, virtual-geometry trees and rocks with wind, a physical sky, cached cascaded sun shadows, ray-traced AO and post, on voon-gpu and voon-graph. No motion vectors and no screen-space temporal techniques.

  • The host passes the world and asset cache dirs; voon-nature-view is the viewer, with headless shots, benches and the probe.
See it in the gallery →
Cargo.toml
[dependencies]
voon-nature = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Renderer and frame packet
use voon_nature::{CamState, Dirs, FramePacket, Renderer};

let gpu = voon_gpu::Gpu::new(GpuDesc {
    shaders: vec![voon_nature::SHADERS.into()],
    ..desc
})?;
let target = voon_gpu::Target::window(&gpu, display, window, extent, true, 2)?;
let mut r = Renderer::new(gpu, target, Dirs {
    world: ".cache/nature/world".into(),   // generated terrain, lz4
    assets: ".cache/nature/assets".into(), // tree and rock packs
})?;

// On the render thread, once per frame:
r.render(FramePacket {
    frame,
    time,
    dt,
    cam: CamState::from_yaw_pitch(pos, yaw, pitch, 60.0),
    extent: [w, h],
    debug_view: 0,
    vars: vars.clone(), // the voon-vars Cvars block: r.*, sky.*, post.*, fol.*, world.*
    shots: Vec::new(),
    reload: None,
    regen: false,
    dumps: Vec::new(),
})?;
let height = r.shared.world.read().as_ref().map(|w| w.height_at(x, z));

voon-nature-world

rayonlz4

Procedural terrain worlds: noise synthesis, fluvial and thermal erosion, rivers and wetness, biomes, tiles and the scatter of trees and rocks, with an lz4 cache on disk and CPU queries that match the GPU surface.

See it in the gallery →
Cargo.toml
[dependencies]
voon-nature-world = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Generate and query
use voon_nature_world::{World, WorldCfg};

let cfg = WorldCfg { seed: 7, ..Default::default() };
let (world, cached) = World::cached(&cfg, Path::new(".cache/nature/world"), false);

let y = world.height_at(x, z);
let biome = world.biome_at(x, z);
let s = world.sample([x as f64, 0.0, z as f64]); // height, normal, biome, density

voon-nature-gen

rayondeterministic

Procedural nature assets: trees as assemblies of trunk, branch and leaf parts, signed-distance rocks, grass blade presets per biome, far-foliage LOD meshes, and the cached GPU asset pack that bundles them as cluster meshes.

See it in the gallery →
Cargo.toml
[dependencies]
voon-nature-gen = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Trees, rocks and packs
use voon_nature_gen::{flatten, pack, rock, tree};

let oak = tree::generate(&tree::species(tree::Kind::Oak), 7);
let mesh = flatten(&oak.asm); // trunk, branches and leaf cards as one mesh

let boulder = rock::generate(&rock::params(rock::RockKind::Boulder), 3);

let pool = rayon::ThreadPoolBuilder::new().build()?;
let (pack, _) = pack::load_or_build(Path::new(".cache/nature/assets"), &pack::Cfg::default(), &pool)?;

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, histogram auto-exposure with an AgX tonemap, 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-env

nativeVulkanprebuilt SPIR-V

Shared environment rendering driven by one EnvSettings struct: the sky dome with sun halo and Earth-shadow belt, a procedural moon, stars, the Milky Way and deep-sky objects, volumetric clouds with cirrus and altocumulus, cloud shadows and mist, a time-of-day Palette and CPU ambient light. It never allocates: you hand it a GpuMemory.

See it in the gallery →
Cargo.toml
[dependencies]
voon-env = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Sky, clouds and stars
use voon_env::palette::{elevation, MoonLight, Palette};
use voon_env::{ambient, Env, EnvCamera, EnvConfig, EnvFrame, EnvSettings};

// mem: anything implementing voon_fx_gpu::GpuMemory
let mut env = Env::new(&device, &mut mem, EnvConfig::new(hdr_format, Some(depth_format), 2))?;
env.resize(&device, &mut mem, extent, 2)?; // clouds marched at half resolution

// Each frame: fill the settings from your time of day and weather.
let mut s = EnvSettings::default();
(s.sun.dir, s.moon.dir) = (sun_dir, moon_dir);
(s.clouds.cover, s.high.cirrus) = (0.35, 0.6);
let day = Palette::default().apply(&mut s, elevation(noon_dir), morning, &MoonLight::default(), 0.35);
let fill = ambient(&s).cube; // six-axis ambient for your lighting
env.prepare(&device, &mut mem, &s, &EnvFrame {
    slot, time, objects: &[], sights: &[],
    camera: EnvCamera { view, proj, prev_view_proj: Some(prev_vp), size: [w, h], main_view_proj: None },
})?;
unsafe {
    env.record_compute(&device, cmd); // noise, cloud shadow, sky probe, march and resolve
    env.record_sky(&device, cmd);     // inside your HDR pass: sky, sky objects, stars
}
let shadow = env.cloud_shadow(); // to your UBO; env_cloud_shadow(map, params, pos) in Slang

voon-paint

nativeVulkanprebuilt SPIR-V

A painterly post process that replaces the tonemap: grade, structure tensor, anisotropic Kuwahara underpainting and layered brush dabs sized by depth, clipped at silhouettes and anchored to world cells so they stay on surfaces as the camera moves. Look holds every knob; the host's allocator makes the work images.

See it in the gallery →
Cargo.toml
[dependencies]
voon-paint = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Paint the frame
use voon_paint::{Frame, Inputs, Look, Paint};

let paint = Paint::new(&device, pipeline_cache, &[swapchain_format])?;
// Four work images come from the host's allocator:
let targets = paint.targets(&device, extent,
    Inputs { hdr: hdr.view(), bloom: bloom_view, depth: depth.view() },
    &mut |want| gpu_mem.image(want), // ImageWant { label, format, extent, usage, mips } -> impl FxImage
)?;

let look = Look { stroke: 0.25, smear: 1.2, bristle: 0.6, boil: 8.0, ..Look::default() };
let frame = Frame {
    near: 0.05, proj_y: 1.0 / (fov_y * 0.5).tan(), time,
    eye: eye.to_array(), rotation: camera_to_world.to_array(),
};
unsafe {
    paint.record(&device, cmd, &targets, &look, frame); // grade, structure tensor, Kuwahara
    // ...then inside your render pass on the output image:
    paint.draw(&device, cmd, &targets, swapchain_format, &look, frame); // layered brush dabs
}

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.

See it in the gallery →
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);
}

voon-graph

nativeashrayon

A render graph. Declare passes and the images and buffers they read and write each frame; it compiles barriers, cross-queue timeline waits for async compute and transfer, and aliased transient memory, records batches in parallel and reads back per-pass timestamps. Plan::dump and dot show what it did.

Cargo.toml
[dependencies]
voon-graph = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Build, compile, execute
use voon_graph::{Access, BufferDesc, Cfg, Graph, ImageDesc, Queue, Runtime};

let mut rt = Runtime::new(2);

let mut g = Graph::new();
let depth = g.image("depth", ImageDesc::d2(D32_SFLOAT, w, h, DEPTH_STENCIL_ATTACHMENT | SAMPLED));
let cnt = g.buffer("counts", BufferDesc::new(4096, STORAGE_BUFFER | INDIRECT_BUFFER));
g.pass("cull", Queue::Compute)
    .write(cnt, Access::COMPUTE_WRITE)
    .record(move |c| { let addr = c.addr(cnt); /* c.device, c.cb */ });
g.pass("raster", Queue::Gfx)
    .read(cnt, Access::INDIRECT)
    .write(depth, Access::DEPTH_WRITE)
    .record(move |c| { let view = c.view(depth); });

let plan = g.compile(&Cfg::default(), &alloc)?; // barriers, timeline waits, aliasing
rt.execute(plan, &mut alloc, &exec)?;           // batches recorded in parallel
for t in rt.timings() { log::info!("{} {:.3} ms", t.name, t.ms); }

voon-gpu

nativeVulkan 1.3+mesh shadersruntime slangc

A GPU-driven Vulkan context: capabilities, three queues, buffers by device address, one bindless set, Slang compiled at runtime with hot reload, mesh and compute pipelines, window and headless targets, asynchronous shot readback, and the glue that runs voon-graph.

  • The host names itself and passes its shader include roots and cache dir in GpuDesc.
Cargo.toml
[dependencies]
voon-gpu = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Context and pipelines
use voon_gpu::{Gpu, GpuDesc, PipeDesc, Pipelines, Raster, Target};

let gpu = Gpu::new(GpuDesc {
    app: "my-app".into(),
    validation: true,
    shaders: vec!["shaders".into()], // searched before voon_gpu::SHADERS (gpu.bindless)
    cache: ".cache/shaders".into(),
    ..Default::default()
})?;
let target = Target::headless(&gpu, extent)?;

let mut pipes = Pipelines::default();
let cull = pipes.add(&gpu, "cull", PipeDesc::Compute("cull/clusters.comp".into()))?;
let draw = pipes.add(&gpu, "draw", PipeDesc::Raster(
    Raster::mesh(Some("raster/draw.task"), "raster/draw.mesh", "raster/draw.frag").color(HDR),
))?;

gpu.shaders.set_watch(true)?;
pipes.update(&gpu, frame); // recompiles what changed on disk, swaps pipelines between frames

voon-lod

meshoptrayon

Cluster LOD for virtual geometry: meshes, a meshopt cluster DAG with monotonic error so each cluster picks its level on its own without cracks, its GPU encoding and a BVH over the groups.

Cargo.toml
[dependencies]
voon-lod = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Mesh to clusters
use voon_lod::{build, encode, Cfg, Mesh};

let mesh = Mesh { pos, nrm, uv, idx, ..Default::default() };
let dag = build(&mesh, &Cfg::default())?; // clusters, groups and a monotonic-error DAG
let gpu = encode(&dag, &mesh)?;           // header, nodes, groups, clusters, quantised verts
upload(&gpu.bytes());

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-probe + voon-ctl

unixschemars

A live debug probe: newline-framed JSON-RPC 2.0 over a Unix socket (or loopback TCP), with deferred replies, subscriptions and schema discovery, so a running app can be inspected and tweaked without rebuilding. voon-ctl is its command line.

Cargo.toml
[dependencies]
voon-probe + voon-ctl = { git = "https://scm.oiko.lol/plus3/voon.git", tag = "v0.1.0" }
Serve and call
use voon_probe::{api, code, Probe, ProbeCfg, RpcError};

let mut methods = api::catalog();
methods.ready(&["app.info", "camera.get", "cvar.set"]);
let probe = Probe::start(ProbeCfg::new("my-app", methods))?; // $XDG_RUNTIME_DIR/my-app.sock

// Each frame, on the main thread:
probe.set_frame(frame);
for req in probe.drain() {
    match req.method.as_str() {
        "camera.get" => req.respond(|_: api::Empty| Ok(camera_state())),
        m => req.reply.fail(RpcError::new(code::METHOD_NOT_FOUND, format!("no {m}"))),
    }
}
if probe.has_subscribers(api::topic::STATS) {
    probe.publish(api::topic::STATS, &stats);
}

// $ voon-ctl --app my-app camera.get

voon-editor, voon-demo + voon-nature-view

binaries

The asset library app, and the two hosts behind every image on this page: the demo (one window, a scenario picker, headless shots and recordings) and the nature viewer (fly-throughs, pose shots, benches and the probe).

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 nature                     # the nature viewer: fly with WASD and the mouse
just nature-shots               # headless pose renders into shots/nature/run
just nature-bench               # a 1440p fly-through with p50/p95 timings
just ctl --list                 # the probe methods of a running viewer
just editor                     # the asset library over your model folders
just editor-audit --json        # index headless and report what each pack resolves