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::WorldHandleits 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_worldbetween 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::relayholds 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 postedThe 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
Worldsitting 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_worldwires one up, other threads post jobs through theWorldHandleit returns, and the owner callsdrain_sub_worldbetween its own steps — the same protocol as a threaded world, minus the thread. - A threaded world is a
Worldthat 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:
watchreturns aSnapshotReader<T>: state, where only the newest value matters. The collector returnsOption<T>.subscribereturns aStreamReader<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
Pendingrequest.
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.
Threadedgives the world a thread of its own.WorldThread::spawn_withowns the loop: it runs a one-timeinitclosure before the first job drains, then checksThreadControl::stop_requestedand runs the tick insidecatch_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, andstate, aSnapshotReader<WorldState>that readsRunning,StoppedorPanicked(message)— andstopasks it to end;join_timeoutanddetachare on theWorldThreaditself, for callers that own one directly.Steppedkeeps the world where it was spawned.spawn_stepped_world_with_initputs the nestedWorldinside the proxy component on the host entity, andstep_stepped_worldruns one iteration on the caller’s thread: drain the queue, then tick, each undercatch_unwind— the threaded loop’s order. It answers aStepOutcome(Ran,Finished,StoppedorPanicked) so a caller can tell what the step did, and aStepErrorwhen the entity names no stepped world — stepping a threaded world is refused rather than silently doing nothing.with_stepped_worldlends the nestedWorldout 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 answeringNone.
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 rapierPhysicsWorldat 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 aPhysicsContactscomponent — 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(aBodyDescwith aBodyShape) andspawn_fixed_cuboidare the building blocks;init_physics_worldspawns the same world as aPhysicsbundle for a caller that steps it inline withstep_once. -
audio (on by default):
spawn_audio_world_with::<R>opens the output device and owns its stream on the audio thread. ItsOnAudioFillcomponents are the sources, and one entity may hold several layers in one component, each with anAudioFillOrderof its own:dispatch_audio_fillruns every callback once per chunk of a ring buffer sized byAudioConfig, in that order, mixing their samples and scaling the sum by the world’sGlobalAudioGain; 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 anAudioFormatcomponent 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 anAudioOutputOpenmarker 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:Pulledopens 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 callsdispatch_audio_fillitself when it wants a chunk, which is what the demo’s headless run does, rendering the mix to a file.OnRequestwaits for arequest_audio_outputjob 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_soundfontparses the bytes (typically aninclude_bytes!, so no filesystem is involved), andSynthVoice::from_fontspawns a voice with theOnAudioFillsource 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_jobsends oneMidiEventKindto 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:
MidiSequencerholds a file, the time reached, the index of the next event and its loop point, andMidiSequencer::advancesends the events a frame made due asmidi_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 fromload_midi_file_with_loop_type(an SMF file, with the loop convention the caller names) ormidi_file(timedMidiEventKinds, 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 writingloop_pointby hand. The demo’sinstall_synthshows 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::UiPanelHostlays a world’sunlit3d::ui::UiPanels out for aunlit3d::ui::UiSourcein another world. A panel can only be built where its egui context runs — its callback is handed a&mut egui::Uifrom 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 aui::UiPanelHostbeside the panels; it holds a sender to the drawing world and theunlit3d::relay::SnapshotReaderthe target comes back through, and the source is set up withunlit3d::ui::UiSource::publish_targetto 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 onerun_uicall 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_mcpserves 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 aWorldSenderand aWorldNameon 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 whatlist_worldsreports. The crate registers recording tools —start_audio_recording,stop_audio_recording,read_audio_recording,cancel_audio_recordingandaudio_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 aformatargument, so.flac— lossless, and the smaller of the two — is what a caller gets when it names none, with.wavbeside it.request_exitasks a host carryingExitRequestto stop, andpump_mcpdispatches 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’sGlobalAudioGain. The recorder is compiled only for these tools, somcpturnsaudioon 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.
- 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.
- 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
SynthVoiceper 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.