Skip to main content

Crate unlit3dx

Crate unlit3dx 

Source
Expand description

The multi-world runtime: one unlit_ecs world per thread, jobs in, subscriptions back.

unlit_ecs’s world is !Send: components live behind RefCell and structural changes need &mut World, so a world cannot cross a thread. This crate turns that constraint into the shape of a program:

  • A threaded world runs its own world on a fresh thread, reached through the handle::WorldHandle its spawn returns. Jobs move in through a bounded channel and are drained between ticks.
  • A sub-world runs a nested world on the host’s thread, drained with host::drain_sub_world between frames.
  • A subscription is the way back, and it is opt-in: a world runs a collector for every subscription it carries and publishes what each returns, so nothing is collected until someone asks. A snapshot subscription keeps the newest T; a stream subscription keeps every one, folded into the reader’s tick. Neither blocks; dropping the reader unsubscribes. unlit3d::relay holds the two primitives, so a world that never ticks — a render sub-world publishing the size it draws with — publishes through the same handle a collector does.

The runtime layer is small and complete: job, channel, thread, host, world, and handle together are the whole low-level API, and a caller can build its own worlds on them. The feature-gated modules are the convenience layer: physics, audio, and synth wrap rapier, cpal, and a soundfont synthesizer in the same shape, while gpu and present hold the window-facing half of a multi-world app.

Nothing here holds a lock across a job: a world is owned by one thread, and every cross-world exchange is a channel. Reach for the low-level modules directly when a world needs a shape the convenience layer does not offer — unlit3d’s MeshSource is the generic pipeline registry a caller can build its own render bridge on, for instance. English | 简体中文

§unlit3dx

A multi-world runtime for games: rendering, physics, audio and gameplay each run in their own world, joined by jobs going out and snapshots coming back. The package is unlit3dx. It is at an early stage along with the crates it uses.

§Usage

The core is three moves: spawn a world, send it jobs, read its snapshots.

ⓘ
use std::time::Duration;

use unlit3dx::prelude::*;
use unlit_ecs::World;

let mut host = World::new();

// A world on its own thread: a `World` plus a thread, ticked by this closure.
// The spawn returns a `WorldHandle` — the entity the proxy lives on,
// the command queue and the thread's status in one value.
// A clock paces the loop at 60 Hz: each wait sleeps until the next step is
// due, keeping the schedule where a short tick does not shift it.
let mut clock = FixedClock::new(Duration::from_secs_f64(1.0 / 60.0));
let simulation = spawn_threaded_world(&mut host, "simulation", 16, move |world, control, _| {
    // ... advance the simulation inside `world` ...
    // Whoever drives the world publishes to its subscribers once the
    // tick has run, so a tick body never publishes by hand.
    clock.wait(control);
    !control.stop_requested()
});

// Jobs run on the world's thread between ticks and answer nothing. A job
// whose result is worth keeping publishes it, or asks for it.
simulation.sender.send(Box::new(|world: &mut World| world.root_scope_mut().spawn(bundle![0u8]))).expect("room in the queue");

// A snapshot is one of the two ways data comes back (the other is a stream,
// for events), and it is opt-in: the world runs the collector this
// subscription carries once per tick and publishes whatever it returns. The payload is the caller's type — the
// library defines none — so the collector is where a scene decides what it
// wants to see.
let reader = simulation.sender.watch(|world: &World| {
    let bodies: Vec<u64> = world.root_scope().query::<&u64>().map(|(_, id)| *id).collect();
    Some(bodies)
}).expect("room in the queue");
if let Some(latest) = reader.latest() {
    // ... apply `latest` ...
}
// Dropping the reader unsubscribes: the world stops collecting for it.

The feature-gated modules build scenes on top of that core. A sub-world keeps its owner on the main thread but takes jobs like a threaded world:

ⓘ
let render = spawn_sub_world(&mut host, "render", 64); // 64 queued jobs
let mut nested = host.root_scope_mut().get_mut::<World>(render.entity).expect("the nested world");
drain_sub_world(&mut nested); // run the jobs other threads posted

The physics bridge drains rapier’s contacts into a PhysicsContacts component each step, and the synth bridge turns note jobs and MIDI files into soundfont audio. A render bridge is a caller’s own build: the unlit3dx_examples crate writes one over unlit3d’s MeshSource registry and ties every bridge into one scene.

§Why separate worlds

A unlit_ecs world is not Send: its components live in UnsafeCells so a shared &World can reach them, and structural changes need &mut World. The usual answer is to share one world across threads behind locks, which turns every system into a contention site and every access into a borrow argument. unlit3dx answers differently: keep one world per concern, and never share one.

  • A sub-world is a nested World sitting on a host entity, owned by the same thread. A drawing world is one: it draws on the main thread, and the host reaches into it only between events. It keeps a job inbox too: spawn_sub_world wires one up, other threads post jobs through the WorldHandle it returns, and the owner calls drain_sub_world between its own steps — the same protocol as a threaded world, minus the thread.
  • A threaded world is a World that belongs to its own thread. Physics, audio and gameplay each run one.

The worlds never call each other. A caller sends a job — a boxed closure that receives the target world’s &mut World — and receives through a subscription: a snapshot for state, a stream for events. That is the whole protocol.

§Jobs

Job is a boxed FnOnce(&mut World). WorldHandle::sender queues one and never blocks: a full queue returns Err(SendError::Full) and a stopped world Err(SendError::Closed), so a world thread that sends to its own queue cannot deadlock against itself. The world thread drains its queue before every tick, so a job never races a tick.

A job is also the only way one world changes another, and it answers nothing: a world that must report something publishes it through a subscription instead. A reply would have the sender waiting on a world that may be busy, stopped, or already gone, and what comes back is state, which has a channel of its own.

One exception is worth its own name. A caller that wants a value one job produces — the entity a build created, the handle an install returned — asks for it through a Pending: it sends the job under a key and answers on the caller’s next tick, under that key. Nothing waits, and a request the world never answers is given up on rather than waited out forever.

Every world the framework builds carries a task set as a component, so a task lasts as long as the world and belongs to it rather than to the call that started it; whoever drives the world pumps it once per turn — a thread before its tick, a host before the dispatch. LocalTasks::pump runs its tasks until none can progress and never blocks, which is what lets a fixed-step tick or a frame behaviour own the await. The GPU bridge drives its device setup that way.

§Snapshots and streams

Receiving is a subscription: JobSender::watch installs a collector in the target world and hands back a reader. Whoever drives that world — its thread, its host stepping it, or its host draining a sub-world — publishes at the end of every turn, once the tick has run, so a tick body publishes nothing itself and cannot publish twice. Which shape the collector fills is the caller’s choice:

  • watch returns a SnapshotReader<T>: state, where only the newest value matters. The collector returns Option<T>.
  • subscribe returns a StreamReader<T>: events, where every value matters. The collector appends to the buffer it is handed, and every value reaches every reader’s queue.

Asking through a Pending is the third shape: one job per request, one answer per job, published once and named by the key the request was made under. A request the world never answers, or one whose world ended, is expired instead of being left hanging.

Which one to reach for follows from the answer the caller needs:

  • A value that is only ever the newest — the poses of the bodies that moved, the playhead’s position, what a world reports about itself — is watch.
  • Every value, in order — a body that spawned, an instance the renderer retired, a note that ended — is subscribe.
  • One value, once, from one job — the entity an install created, the assets a build made — is a Pending request.

The payload is the caller’s type — the library defines none — so a scene collects exactly the data it wants and pays for nothing else.

A snapshot reader is a single-slot mailbox: latest peeks the newest snapshot, there is no backlog and no ordering to reason about, and an unread one is replaced. The slot is an ArcSwapOption, so neither side takes a lock the other contends on: the world swaps the newest snapshot in, and a reader that already holds one keeps reading that copy to the end. A world that collects per tick and a consumer that reads per frame meet in the middle without ever blocking. A collector that returns None leaves the newest snapshot in place, which is how a world that has not changed stays quiet. A value published before any reader subscribed is kept as well, so a reader that arrives late still sees it instead of waiting for the next publish.

A stream reader keeps a queue of its own: pump once per tick folds whatever arrived into that tick’s batch, and with_batch reads it. A reader that ticks slower than the producer still sees every event, in order, and one reader’s pace cannot starve another’s. That is what a collision or a sequence ending needs, and it is also how input reaches a world: InputWriter is a stream whose producer is the window rather than a world.

Nothing is collected unasked, and a subscription lives exactly as long as its reader: dropping the last clone of a reader prunes the collector on the next tick. Subscribing is a job, so it is refused with the queue’s own error when the world is full or has stopped.

§Threads and stepping

A world is spawned through a runner, which decides who calls its tick loop. WorldRunner is that choice made generic, and it is the only difference between the two modes: the bounded job queue, the subscriptions, the proxy entity on the host and the handle the spawn returns are all the same, so the host’s entities and components do not change with the runner and nothing in a tick body has to know which one it is under.

  • Threaded gives the world a thread of its own. WorldThread::spawn_with owns the loop: it runs a one-time init closure before the first job drains, then checks ThreadControl::stop_requested and runs the tick inside catch_unwind, reporting a panic before the thread ends so the host learns why its world died rather than joining a corpse. Despawning the proxy entity stops its thread and waits at most two seconds for it; a tick that will not stop is left detached rather than hanging the dropper. The handle reports on the world — is_finished, and state, a SnapshotReader<WorldState> that reads Running, Stopped or Panicked(message) — and stop asks it to end; join_timeout and detach are on the WorldThread itself, for callers that own one directly.
  • Stepped keeps the world where it was spawned. spawn_stepped_world_with_init puts the nested World inside the proxy component on the host entity, and step_stepped_world runs one iteration on the caller’s thread: drain the queue, then tick, each under catch_unwind — the threaded loop’s order. It answers a StepOutcome (Ran, Finished, Stopped or Panicked) so a caller can tell what the step did, and a StepError when the entity names no stepped world — stepping a threaded world is refused rather than silently doing nothing. with_stepped_world lends the nested World out for work that needs to reach it directly, such as reading a frame back out of a drawing world, and reports the same error instead of answering None.

Pacing is what the mode changes. ThreadControl::wait_timeout blocks a real thread and returns at once when the control is not self-driven, so the same tick body serves both: FixedClock waits out a wall-clock deadline under Threaded, and under Stepped the tick is simply due again on the next call. A caller stepping a world therefore owns the rate — it decides how many steps a frame is worth, which is what the demo’s SceneStepper does.

Both modes turn a measured interval into whole steps with the same rule: fixed_steps carries the fraction of a step a tick did not spend into the next one, and discards anything past MAX_CATCH_UP_STEPS (four) steps, so a stalled host resumes instead of replaying the stall. A tick receives the interval in seconds — spawn_threaded_world and its _with_init form pass it as the tick’s last argument — so a tick body never has to assume the rate it asked to be woken at.

The stepped mode is what a browser and a headless run use. wasm32-unknown-unknown has no thread to spawn, and a snapshot test wants a frame count it controls rather than one a clock chose.

§Bridges

The feature-gated modules are the bridges between worlds. They are a convenience layer over the core, not a privilege: each wraps an external library — rapier, cpal, rustysynth-ext — in the same job-and-snapshot shape, and a caller that wants another library builds that shape itself. The unlit3dx_examples crate is the worked example: its render bridge is an asset server written in the example crate over unlit3d’s generic MeshSource registry, registering its own pipeline family and allocating through the same API a caller would. A render world’s pipeline options come from the LitPipelineKey component installed in it; without one they are derived from the mesh source’s device.

The bridges:

  • physics (on by default): spawn_physics_world_with::<R> runs a rapier PhysicsWorld at a fixed rate (PHYSICS_HZ, 60 Hz). A world on a thread of its own catches up to the time that really passed, at most four steps a tick; a world the host steps takes one step per step it is given. Each step drains rapier’s contacts into a PhysicsContacts component — the collision pairs that started, with the speed each pair was closing at, and those that stopped. It publishes no payload of its own: a caller subscribes with the collector that names the state it wants, and whoever drives the world publishes it. spawn_body (a BodyDesc with a BodyShape) and spawn_fixed_cuboid are the building blocks; init_physics_world spawns the same world as a Physics bundle for a caller that steps it inline with step_once.

  • audio (on by default): spawn_audio_world_with::<R> opens the output device and owns its stream on the audio thread. Its OnAudioFill components are the sources, and one entity may hold several layers in one component, each with an AudioFillOrder of its own: dispatch_audio_fill runs every callback once per chunk of a ring buffer sized by AudioConfig, in that order, mixing their samples and scaling the sum by the world’s GlobalAudioGain; the device callback drains the ring and underruns to silence. A world that carries no source plays nothing. Gameplay never touches audio directly — it sends jobs. The world spawns an AudioFormat component the moment its stream opens — a source that renders at a fixed rate has to be built at the rate the device really uses, and that is not known until then — and an AudioOutputOpen marker with it, so a subscriber can tell a world with somewhere to play from one still waiting for a device and drop stale notes instead of queueing them behind a soundfont load. Installing sources is therefore ordinary work for that world: subscribe to the format, then queue the job that spawns them.

    Every world follows its AudioConfig.output, threaded or stepped alike: Pulled opens no device at all — there is nothing to play to and no callback to pull the ring — and publishes no format either, so a caller that wants sound installs its sources up front and calls dispatch_audio_fill itself when it wants a chunk, which is what the demo’s headless run does, rendering the mix to a file. OnRequest waits for a request_audio_output job and then keeps trying every tick until a device appears. The demo sends the request from the handler of the panel’s Play button on every platform: a browser allows a Web Audio context to start only inside a user gesture, and starting the tracks on the same click keeps the sequencer’s clock and the context in step everywhere else.

  • synth (on by default, implies audio): the pieces to install, built on rustysynth-ext — load_soundfont parses the bytes (typically an include_bytes!, so no filesystem is involved), and SynthVoice::from_font spawns a voice with the OnAudioFill source that renders it. The voice builds its synthesizer on the first fill, from the sample rate that fill names, so a voice can be spawned before the output device has said which rate it runs at; messages sent before then wait in the voice and are played by that first fill. midi_job sends one MidiEventKind to the voice it names, optionally after a delay in seconds. The voices are the whole of the audio world’s synth surface.

    Sequencing belongs to the gameplay world, which owns the clock: MidiSequencer holds a file, the time reached, the index of the next event and its loop point, and MidiSequencer::advance sends the events a frame made due as midi_jobs to a voice. Playing, pausing, rewinding and looping are fields of that component, read and written where it lives — no job and no mailbox stand between a panel and its transport. A voice is shared, so whatever cuts a pass short — a pause, a rewind, a loop back to the marker — releases the notes that pass left sounding instead of letting them ring into the next one, and only those: a sequence keeps the notes it pressed and releases them by name, never with a channel-wide all-notes-off that would cut another sequence’s music. Files come from load_midi_file_with_loop_type (an SMF file, with the loop convention the caller names) or midi_file (timed MidiEventKinds, sorted here so a caller’s list need not be). A file’s loop markers set the loop point; a passage a file never marked can still repeat by writing loop_point by hand. The demo’s install_synth shows the whole install as one function, queued to the audio world as a job that answers the voice’s entity.

  • ui (on with render): ui::UiPanelHost lays a world’s unlit3d::ui::UiPanels out for a unlit3d::ui::UiSource in another world. A panel can only be built where its egui context runs — its callback is handed a &mut egui::Ui from the pass building it — so the panel’s world lays the frame out and tessellates it, and the drawing world uploads and records what comes back. Spawn a ui::UiPanelHost beside the panels; it holds a sender to the drawing world and the unlit3d::relay::SnapshotReader the target comes back through, and the source is set up with unlit3d::ui::UiSource::publish_target to keep the slots frames land in. A frame travels as a job, so the slots stay on the thread that draws them; the target the drawing side measures travels the other way as a snapshot, since a layout only ever needs the newest one. A tick that could not change the picture — a settled panel, no input, the same target, no repaint due — sends nothing, so a still UI costs nothing, and the frame already there is drawn until a newer one replaces it. A frame the drawing world’s queue refused is sent again rather than counted as delivered, so a full queue delays the picture instead of losing it. The clock egui animates against is the frames’ own time, summed from each frame’s duration rather than read off the wall clock: an animation is at the same point at the same frame however long the run took, so a frame captured for comparison does not depend on how busy the machine was. They keep separate contexts: sharing one would have both worlds writing the same memory and font atlas, and egui treats one run_ui call as one whole frame. Their texture ids both start at zero, so a frame keeps the ids it was laid out with and the GPU integration keys textures by origin and id — nothing renumbers them.

  • mcp (off by default): serve_mcp serves the host world and every world it carries — a nested sub-world, or one on a thread of its own — over the Model Context Protocol, so a live scene can be inspected and screenshotted from a terminal while it runs. Each spawn leaves a WorldSender and a WorldName on the proxy entity beside the proxy: the sender is what lets the transport post a command into a world it cannot borrow, and the name is what list_worlds reports. The crate registers recording tools — start_audio_recording, stop_audio_recording, read_audio_recording, cancel_audio_recording and audio_recording_status — which drive a recording in the named world and either write the mix out to a file or hand it back in memory, so a test can assert on the samples without touching the disk. Both answers carry the same samples in the format the caller names, the way the image tools do: a file name’s extension picks it, and a tool that answers inline takes a format argument, so .flac — lossless, and the smaller of the two — is what a caller gets when it names none, with .wav beside it. request_exit asks a host carrying ExitRequest to stop, and pump_mcp dispatches commands when no window event loop runs, which is what lets a host without winit serve MCP at all. A recording source mounts at the end of the fill order, so it captures the mix after every other source and the world’s GlobalAudioGain. The recorder is compiled only for these tools, so mcp turns audio on itself.

§Best practices

Two rules keep a scene’s worlds independent, and they are what make one portable: the same scene runs on threads or stepped, in a window or headless, because no mode ever reaches into another world’s data.

  1. A world’s setup is configuration only. Spawning a physics or audio world — or a drawing world of the caller’s own — passes the configuration its runtime needs — the device, queue and capabilities, the tick rate, the gravity, the buffer size — and builds the generic resources that runtime owns: a resource graph and a renderer, a mesh source, a rapier world, an output stream. Nothing in it names a thing the game has: no ball, no track, no camera angle, no control panel. A world that comes up empty is also a world that can come up early — a drawing sub-world exists before the GPU does, so gameplay holds its handle from the first tick and fills it later.
  2. Every game-specific change to a world arrives as a job. Meshes, materials and instances are requested from the render bridge; bodies are spawned and despawned through the physics queue; notes go to the audio world as jobs, and a whole install — see the demo’s install_synth — is one function wrapped in one job. A snapshot carries state out of a world, and the world that reads it decides what that means; what another world’s content becomes is a job, so a pose gameplay derived from a physics snapshot is sent to the render world rather than left in a mailbox for it to apply.

The test is whether a line of setup could belong to another scene: if it names something the game calls a thing, it belongs in a job. 3. A scene holds one port per world it talks to. A world’s address is a JobSender and the subscriptions it made, so the scene keeps those in one value of its own — the demo’s Scene carries the render, physics and audio senders and the physics snapshot — and passes it to whatever needs to reach a world: a tick body, a spawn function, a control panel. What would otherwise be a field per channel on every object is one argument, and an object that never sends to the audio world never holds a sender for it. The port stays in the caller’s crate: the runtime has no scene to describe, and a bridge of the caller’s own gets the same treatment.

§The application skeleton

The window-facing half of a multi-world app is app-agnostic too, so it ships with the runtime. The gpu module starts the Gpu request as a task of the world it was asked to set up, driven by the pump that opens each frame, and runs ready as a queued command once the answer lands — nothing blocks, wasm-safe:

ⓘ
world.root_scope_mut().spawn(bundle![request_gpu(app, |world, gpu| {
    // ... build the scene where the request started ...
})]);

Gpu::headless is the same request without a window, for tests and tools. The present module is a thin wrapper over unlit3d::winit::presenter that knows how to reach into a sub-world: setup_presenter by default has the scene’s pass draw straight into the swap chain (PresentationPath::Surface), and with its offscreen path on builds PresentationPath::Intermediate instead — a persistent target plus a second pass in the same world that copies it over the surface, which keeps the frame long enough to read back, so screenshots and snapshot tests ask for it. draw_presented is the per-frame behaviour that drains the sub-world, dispatches the frame and presents it, pump_presented keeps frames coming while the window lives, and resize_presented follows the window around. The render bridge a drawing world needs — the asset server and the mesh and camera helpers around it — is the caller’s to build; unlit3dx_examples ships one as src/render.rs.

§Tests

The integration tests are one binary, and it requires audio, physics and synth. Running --no-default-features leaves the suite out of the run rather than failing it: Cargo skips a target whose required features are off. (No case takes a GPU, so render is not required.)

cargo nextest run -p unlit3dx covers the core: jobs and their queues, subscriptions — several readers each getting their own snapshot, a collector that answers nothing leaving the newest in place, a dropped reader pruning its subscription, and a full queue refusing one — thread stop and panic reporting, a threaded world’s sent jobs running, and the same for a stepped world — its jobs run on the step, a tick past the end finishes it, and stepping an entity that carries no stepped world does nothing. The integration tests add the bridges: a physics world dropping a ball onto a floor and reporting the contact, a subscriber queueing the contacts of two separate landings that a mailbox would have collapsed into one, the audio world running jobs and reporting its output opening, multi-world job and snapshot traffic, and a panicking job ending its world with the jobs behind it left unrun. Recording is covered too: it captures the mix the device would play, and both encoders are checked against what they wrote — the WAV byte by byte, the FLAC stream by decoding it back to the samples it was given, which also catches a stream that declares a block size it does not use. The audio MCP tools drive both through the dispatch coverage below. With the mcp feature on, the MCP command dispatch is covered too: world listing, routing to a nested world, and routing to one on a thread of its own or one the host steps — plus a readable failure for an entity that carries no world, a world that has stopped, and a queue that refuses the job — and so are the audio recording tools, from starting a recording to refusing a second one and writing the captured mix out.

Modules§

audio
The audio threaded world: a callback-driven output stream fed by a ring.
channel
The snapshot subscriptions between worlds. Subscriptions: what a world reports to the others, and how they read it.
executor
A single-threaded executor for a world’s async tasks. The async tasks a world runs, and the executor that drives them.
gpu
The GPU context and its asynchronous request.
handle
The uniform handle to a spawned world. The handle every spawn hands back.
host
Sub-worlds, which share the host’s thread. Host helpers: the sub-world that shares the host’s thread.
job
Jobs, the work handed to another world. Jobs: work handed to another world.
mcp
The MCP tools an audio world answers: recording the mix it fills.
physics
The physics threaded world: a fixed-step rapier simulation on its own thread.
prelude
The types most callers of the multi-world model need.
present
The multi-world presentation path.
record
Recording the mix an audio world fills into its output buffer.
request
Requests a world answers, and the waiting for their answers. Requests a world answers, and the waiting for their answers.
synth
Soundfont-backed fill sources: one SynthVoice per soundfont, mixed.
thread
World threads and their pacing. World threads: one thread owning one World, with pacing and stop control.
ui
UI panels laid out in one world and drawn in another. UI panels laid out in one world and drawn in another.
world
Threaded worlds, which run on their own thread. Worlds the host reaches through a proxy component: one on its own thread, or one the host steps itself.

Constants§

FRAME_SECONDS
The duration of a frame at 60 Hz, in seconds.