Expand description
English | 简体中文
§unlit3d
The high-level rendering API: the ECS-integrated layer that turns a world of
component-carrying entities into GPU draw commands every frame. It bridges
unlit_ecs and unlit_wgpu, and keeps both as direct dependencies — their
items are reached through their own paths, and the ones most callers need are
re-exported by prelude.
Like the rest of the workspace it is at an early stage of development; APIs
change freely. It stays close to raw wgpu: the renderer uses wgpu resources
directly, and you are expected to know WebGPU to use it well.
§Features
| Feature | Default | Provides |
|---|---|---|
ui | yes | the ui module (an egui overlay drawn as a frame source) and the unlit_wgpu egui backend it draws with |
winit | yes | the winit module: WindowSurface, which presents a Renderer into a window’s swap chain; the winit input translation; and WinitHost, which drives winit’s callbacks as behaviour components |
gltf | no | the gltf module: load a glTF document into an UnlitGltf, patch its images, materials and meshes into another world’s MeshSource, and spawn the entities that draw them |
reflect | no | facet reflection on the components that cross a JSON boundary, plus the proxies glam and ECS values are reflected through; the engine itself never reads it |
mcp | no | the mcp module: the rendering tools a world answers through the Model Context Protocol server the unlit_ecs crate defines. Implies reflect and gltf, and is not built on wasm32 |
With --no-default-features the crate keeps the ECS components, the frame
sources, the mesh path, the pipeline abstraction and the portable input
module — none of which depend on egui or winit. Enabling gltf adds the glTF
loader on top of the same ECS and mesh path; see below. Enabling reflect
derives the reflection a JSON bridge needs, and nothing else in the engine
changes.
§What this layer is for
It treats itself as part of a game engine, so it is built to integrate with other features — physics, audio — and to be extended:
- Extensibility and portability come before convenience: public API prefers
dyn traitover static dispatch, and multi-threading is considered to a degree rather than assumed away. - The common path is easy: CPU-side scene data synchronizes into GPU buffers and renders without the caller managing an upload.
- Low-level control stays available: a caller can bypass the CPU-side data and update GPU buffers directly.
- Culling is CPU frustum culling.
§What other engines do
Why not an ECS with change detection and render extraction, or an OOP scene tree
Bevy, an ECS architecture. The good: a high degree of modularity, good plugin extensibility, and an ECS that can make full use of multi-threading. The bad, mainly from the ECS’s drawbacks and complexity: extraction and derivation between components are error-prone, systems are coupled to one another, inheritance and reuse are not obvious enough, managing complex object state is hard, and representing a scene graph with components is less intuitive than a tree data structure and objects directly.
- The component change-detection mechanism is complex, easy to break and easy
to get wrong, and mechanisms for component dependency updates and derivation
are almost absent. Synchronizing every frame without keeping components may
be expensive, while keeping them drags in complex change detection and
dependency updates. For example,
bevy_render’s resource-extraction pattern is error-prone, especially when components are added and removed: derived components leak easily, updates are easily missed, and change detection easily goes stale and causes over-updating. The complexity shows in bevy’s rendering systems often being huge, potentially handling many components with manyChangedandRemovedComponentsqueries. In OOP, an object maintains its own state internally. - Extensibility shows in adding systems, not in components, and components lack
OOP’s inheritance. For example, extending bevy materials has users register a
plugin for each custom material, which is tedious. The GPU handles a bevy
material needs are often tied to the
Handles of types likeShaderBuffer,MeshandImage, so users cannot use their own resource handles for materials, whereas in OOP extending from a base class is very easy.
Because render resource types are singular, bevy’s RenderAssetUsages mechanism
is also error-prone: once the main world’s data has been extracted as a render
resource, accessing it again errors.
Godot, using OOP, a scene tree and a centralized renderer. The good: ease of use is high, and Godot’s scene tree composes and decomposes easily; the API can be very high-level (Godot’s node tree) or fairly low-level (Godot’s server mode). The bad:
- OOP and inheritance are inconvenient for Rust, and simulating OOP by force is awkward.
- It is not good at handling many entities, and traversing the node tree is expensive. RenderingServer can bypass the node tree, but in theory an ECS’s tight storage still performs better for large numbers of entities.
- It does not automatically and fully use parallelism the way an ECS does. Parallelism is coarse-grained and needs manual tuning: Godot’s parallelism shows in servers, and a node’s per-frame update logic can choose a background thread, but the logic inside servers and nodes cannot run in parallel unless the thread pool is called by hand.
- Layer upon layer of wrapping hurts flexibility and customizability. Custom post-processing through a screen-reading shader or a compositor hook is unfriendly, and customizing the whole rendering pipeline is out of the question.
§The frame model
A frame is not “one main scene plus extras”, and not one pass either: it is a
sequence of passes. Renderer draws nothing itself: it walks the
world’s FramePass entities in
FramePassOrder, and within each pass records the frame
sources below it, in the order each declares through
FrameSourceOrder.
A render world is an ordinary entity tree, and the tree is the frame’s shape: a scope sees the entity it is rooted at, everything below it, and the shared subtree, but never its ancestors or its siblings.
world root the root scope sees every entity
├── shared every scope sees this subtree
│ ├── wgpu::Device + RenderContext
│ ├── wgpu::Queue + RenderContext
│ ├── ResourceGraph + RenderContext every texture, buffer, bind group
│ ├── DeviceCapabilities + RenderContext
│ ├── FrameClock advanced once per frame, before any behaviour
│ ├── OpenUrls
│ └── ... application state a panel reads and writes
├── FramePass(FramePassOrder(0)) + RenderLoadOps
│ ├── Source(MeshSource) a source is a scope of its own
│ │ ├── Camera
│ │ ├── Transform + GpuMesh + GpuMaterial + UnlitPipeline
│ │ └── ...
│ └── Source(UiSource)
│ └── UiPanel
├── FramePass(FramePassOrder(1)) + RenderLoadOps
│ └── Source(BlitSource) samples the pass above's offscreen target
└── Renderer draws nothing; it walks the passesRead the tree as the frame: the renderer walks the world’s
FramePass entities in order and, within each pass, records
the Source entities in that pass’s scope — so a source’s
place in the tree is what chooses its pass, and two sources under one pass draw
different sets without either seeing the other’s. A pass’s scope reaches
everything below it, nested passes included, so a source under a nested pass is
reached by both passes: it is built once per pass, into that pass’s own scene.
A source in the shared subtree is reached by every pass, which is how an overlay
or a full-screen effect is drawn into each target without being repeated in the
tree. A source no pass reaches is reported and draws nothing. The GPU state sits
in shared rather than beside the renderer, so any source reads it from its own
scope without reaching up. A pass is an ancestor of its sources, which a scope
cannot reach, which is why PassInfo exists: it is the pass
telling its sources what they draw into. Attachments are not tree nodes at all — a pass names views in
the ResourceGraph, which is why it can take a new swap-chain view every frame
without the tree changing.
-
A pass is an ordinary entity: it carries the attachments it draws into, the
RenderLoadOpsit is opened with, and the viewport its scenes are clipped to. The sources under it are the ones it records, so a world holds as many passes as the frame needs — a pass drawing into an offscreen target and a pass copying that target onto the swap chain are two entities in one world, not two worlds. -
A pass names views; it does not own them. Attachments come from a
FrameAttachmentsor aWindowSurface, both of which live in the same world and keep their handles stable, so a pass can take a new color view every frame without the sources below it being rebuilt. -
Each source fills a
Sceneinbuild_scene, and the renderer records that scene into the pass opened over the pass’s attachments. A frame is therefore still one encoder and one submission, no matter how many passes it has. -
The built-in mesh rendering is one source,
MeshSource, and it has no more privilege than a caller’s own. There is no mesh-specific field or draw path in the renderer: the built-in unlit family is registered through the sameMeshSource::register_familya caller’s own family uses, and everything specific to it — its key, its instance record, its mesh and material helpers — lives in theunlitmodule. -
The GPU state — the
wgpu::Device, thewgpu::Queueand theResourceGraph— lives in the world as resource components, addressed by aRenderContext.spawn_contextspawns it and returns the addresses;RenderContext::ofresolves the context back from any one of its four entities. It also takes the frame’sDeviceCapabilities, which the caller derives from the adapter — what the device cannot report about itself travels with the frame rather than being rediscovered per source. -
Build and record are two phases. A source registers resources in the graph and stages uploads during
build_scene; recording only reads the scenes it already produced, so no borrow conflict ever arises between a source’s own state and the graph. -
The graph is maintained once per frame, in the build phase.
replaceonly marks; the source maintains exactly once, after its uploads and before it assembles the frame, so the resources nothing holds any more are collected and the dirty bind groups rebuilt in one pass. A source states that point withRenderContext::maintain_scope, a guard that maintains the graph when it drops: every early return a source takes when it has nothing to draw settles the graph with no call to remember, and the source drops the guard explicitly where the frame is assembled. A frame that draws nothing still maintains, and the paths outside the scene — a surface resize, a swap-chain acquire — maintain explicitly rather than holding the old resources until the next scene build. -
A source’s GPU resources live exactly as long as some handle names them, so despawning a source’s entity drops its handles and the next
maintaincollects what that leaves. There is no release call to remember.
A source carries its own state; the frame carries the scenes. egui’s
Context, font atlas and the UI’s own UBO belong to the UI source; the 3D
pipelines, mesh pools, metadata and culling caches belong to the mesh source. A
Scene, by contrast, belongs to a (pass, source) pair and is held by the
renderer, because it is the frame’s temporary product rather than the source’s
state: a source several passes see is built once per pass, into that pass’s own
scene, so each pass records exactly the draws its own target asked for. The
slots outlive the frame so a steady scene reuses their allocations, and a pair
the world no longer reaches gives its buffers back at the end of the frame. A
source that keeps GPU state per target — the UI source’s pipeline and material
table, the blit source’s bind group — keys it on
SurfaceKey, so alternating passes reuse
their own entry instead of rebuilding it every frame. The “main 3D scene” is
just the scene the first recorded source produced, not a special case of the
renderer.
Why the context and the sources are world members rather than renderer fields
This is not “taking the renderer apart” but giving frame sources and third-party sources exactly equal standing: adding or removing a source is spawning/despawning an entity, and a source uses the shared context directly in its own build phase without the renderer passing it along.
- A resource reference is an entity reference, used through the
Entityhandle the caller keeps. - Frame-level context is therefore passed “by entity reference”, not “by threading borrow handles down the stack”, so sources do not conflict over borrows just because they share one context.
- The reverse trade-off: were the context private to the renderer, a source wanting to hold both “its own mutable borrow” and “a mutable borrow of the resource graph” would have only two ways out: split the renderer into dedicated entry points, or push the conflict to a runtime panic with interior mutability. The former grows a borrow-checker-dodging function at every call site, and the latter trades convenience for crashes. Making both the context and the sources members of the world means the conflict never arises, so no dedicated entry point is needed for it.
- A source releases its own GPU resources explicitly, which avoids the complexity of releasing resources automatically by reference count. The nodes a source registers in the resource graph are not reclaimed when the entity is destroyed, so removing a source must release them explicitly.
Building and recording are two phases. The build phase has exclusive access
to the resource graph to register resources and to write the data this frame
will upload; the recording phase only reads each source’s own Scene and never
touches the resource graph. Merging them into one phase would force reading the
resource graph before producing the Scene, leaving only interior mutability to
defer the borrow conflict to runtime — so the two phases stay, in exchange for a
compile-time guarantee.
Why a source declares its order instead of relying on creation order
The order is a required method, and FrameSourceOrder does not
implement Default. With a default value, “forgot to declare the order” and
“really meant to keep creation order” become indistinguishable, and the ordering
intent turns implicit again. Sources with equal order are logged and warned
about, and a source’s order has to be an index the source carries explicitly: it
cannot borrow an entity’s row order within the archetype, because that row order
is not guaranteed stable and is disturbed when components are removed.
The ordering semantics a Scene already has
internally (non-z-sorted first, then by pipeline and by material or view-axis depth) are
unchanged; the ordering among sources is not a hard constraint forced by
pass-level state — a scene carries the pass state it needs and resets it while
recording — but a need of semantics like “UI composites over 3D”.
§Components
A renderable entity carries a GpuMesh,
GpuMaterial and
GpuRenderPipeline. A
GpuRenderPipeline carries a key, not a compiled
pipeline: which concrete pipeline an entity needs depends on the frame’s render
target, the mesh’s vertex layout and — for a strip topology, whose pipeline
must declare the width of the index buffer it binds — the mesh’s index format,
none of them known at spawn time. A family
closes that gap — it holds a Variants cache and a
RenderPipelineFactory, deriving each draw’s
variant from the draw itself and resolving it to a concrete pipeline per frame.
GpuMesh is deliberately small: it holds only the fields
a per-entity walk reads while culling and resolving, with the buffers a draw
binds behind a shared MeshParts handle. Culling and
resolving visit every entity, and a column is contiguous, so walking it pulls
whole cache lines through the cache while only the cull and resolve fields are
used; a mesh carrying its buffers inline would drag them through every walk for
the sake of a few bytes of bounds. The split is by read frequency, not by kind,
so a new field belongs on the side that reads it.
MeshSourceUnlitExt::register_unlit_family
registers the built-in unlit family;
MeshSource::register_family
registers a caller’s own, which is the same route the built-in one takes — down
to the per-instance vertex stream the family owns and declares (see
The unlit pipeline). Other
components: Transform, Camera,
RenderLoadOps,
InstanceColor,
InstanceVisibility and the
ZSortedDrawing marker.
An entity carrying an InstanceVisibility whose
visible is false is skipped before
culling, so nothing is specialized, packed or recorded for it; an entity without the
component is drawn, and its Default is drawn too. The flag is per entity rather
than per mesh because one GpuMesh is usually drawn by many
entities and hiding one must not hide the others, and it is a field rather than the
absence of a component because an entity’s component set is fixed when it is spawned —
hiding by removal would mean despawning and spawning instead of flipping a flag.
A ZSortedDrawing entity is drawn after the
opaque ones and ordered back-to-front by the depth of its mesh’s bounds centre
along the camera’s view axis — see
Camera::view_depth — so a mesh whose pivot
is offset from its geometry still sorts by what it draws rather than by its
origin.
A source is drawn through the first active Camera in
its own scope — an entity whose
Camera::active is false is skipped. A scope is
the source’s own subtree, so a world can hold several sources and each one
frames what it owns; within one scope several cameras can live, and the caller
switches between them by toggling that flag per frame. With no active camera the
source is cleared and nothing is drawn.
The camera convention is the engine’s own: a right-handed, Y-up view
space projected into WebGPU’s [0, 1] clip-space depth range and drawn
with reverse-Z.
A camera holds its view and projection apart:
Camera::view_from_world and
Camera::clip_from_view. A caller composes
them from any projection it likes —
glam::camera::rh
offers both perspective and orthographic constructors — and
Camera::clip_from_world combines them for
the vertex stage. The eye position is not stored: it is
Camera::position, the translation of the
view-to-world matrix. Reverse-Z is what the built-in pipeline’s Greater depth
comparison and the depth clear to 0.0 expect. It is not what
Camera::view_depth reads, since that takes the
depth straight off the view matrix: an orthographic camera sorts z-sorted draws
exactly as a perspective one does.
Why a family sits between the entity and the pipeline cache
A family connects “what the entity wants” to unlit_wgpu’s variant cache:
RenderPipelineKeyboth locates the family (registration and lookup are by key type) and resolves a draw into the variant it needs (variant). Because the key travels with the component, one family can serve entities that start from different policies; and because the key is the component itself, the renderer never has to hand out a family handle.- The variant is derived from the draw, not declared up front: the
DrawContexthands the key the frame’s render target, the mesh’s vertex layout and its index format, and the key folds each of them where it belongs — the target intoUnlitVariant::surface, the vertex layout intochannels, the index format (and topology) intostrip_index_format. The index format matters because a strip topology’s pipeline has to declare the width its draw binds, and only the mesh knows it — the source picks the narrowest format a mesh’s vertex count fits, and widens it while baking in a pool offset on a device withoutbase_vertex. Which dimensions to specialize on is therefore the family’s freedom: the built-in unlit family specializes on its policy plus the target, the vertex layout and the index format, and a custom family can specialize on anything. - The blueprint is lazily evaluated: the variant is asked of the key only on a cache miss, when a compilation is actually about to happen.
- Once the family has compiled a pipeline,
RenderPipelineFactoryturns it into theRegisteredRenderPipelinethe renderer registers — the compiled pipeline and the global group’s rebuild recipe. It andunlit_wgpu’sRenderPipelineDescare an output/input pair, with one compilation between them.
unlit3d deals only in render pipelines, so its pipeline types all say
Render explicitly (GpuRenderPipeline,
RenderPipelineKey,
RenderPipelineId,
RenderPipelineFactory,
RegisteredRenderPipeline).
§Automatic instancing
Entries are sorted so that neighbours share a pipeline and bind groups, then each run of neighbours whose draw state is exactly equal — pipeline, bind groups, buffers and geometry segment — collapses into one instanced draw whose instance range covers the whole run in visible order. Recording costs a command and a state re-bind, so an entity that shares a mesh with the one before it is nearly free.
Why merging is always safe for opaque draws, and never for z-sorted ones
The instance range is what keeps a merged draw correct: instance-stepped
attributes are fetched at the instance’s ordinal, and each family’s instance
stream is packed in visible order, so instances a..b read exactly the records
the separate draws would have read. Per-instance data — the transform, base
color, joint and morph bases, cutoff and metadata index for the built-in family —
lives in that stream, so merging changes nothing any instance reads. A run only
ever merges entries of one family, because a family owns exactly one stream and
the pipeline it resolved to belongs to that family alone.
Opaque draws are depth-tested with blending off, so their order is not observable and merging them is safe. Z-sorted entries never merge, with each other or with anything else: they are blended back-to-front, so the order they are drawn in is the result, and a merged draw would rasterize its instances in record order instead. Their order comes from the depth of each mesh’s bounds centre along the camera’s view axis, so it follows the geometry a mesh actually draws rather than its pivot or its straight-line distance to the eye.
§The unlit pipeline
The built-in unlit pipeline is registered as an ordinary family, and the shader
variant an entity uses contains exactly the channels its mesh has. Nothing about
it is privileged: a caller’s own family is registered through the same
MeshSource::register_family call,
owns its own per-instance vertex stream and binds the frame’s shared inputs
through the same public builders. See
Custom shaders and per-instance data.
What the built-in variant supports, and where pose data lives
- The variant follows the mesh: whether its position, UV and color channels are present, whether they arrive compressed, whether it is skinned or morphed, and whether a material is bound — plus the caller’s policy and the frame’s target. The five per-instance fields (transform, color, joint base, morph base, metadata index) are always bound rather than switched off one by one.
- Joint matrices and morph weights are not in the mesh’s bind group. Meshes
and instances are many-to-one: the same mesh can be drawn by several entities,
and each entity’s pose usually differs. A bind group is bound per mesh and
cannot express per-instance state, and building one bind group per instance is
no better — that amounts to rebuilding bind groups per instance per frame.
So the pose is split in two, each half going where it belongs:
- The data goes into a frame-wide shared SSBO, bound in the global group. Every visible instance’s joint matrices are packed into one array and its morph weights into another.
- The locating information goes into the instance stream. Each instance’s
per-instance record carries a joint-matrix base and, separately, a morph
weight base (two
Uint32s, since skinning and morphing are independent), which the shader uses to find its own slice. The record also carries the base color, the cutoff analphaMode: MASKfragment is compared against, and the mesh metadata index. Per-instance-stepped attributes are addressed by instance index, so merging into an instanced draw changes nothing any instance reads — several instances of one mesh can fold into a single draw while each keeps its own pose, which is exactly what makes automatic instancing possible.
- At this level the pose is a component on a separate entity, and the mesh
entity references it through two separate reference components
(
SkinBindingandMorphBinding). This follows the same line as “a resource reference is an entity reference” and brings two direct benefits:- Sharing means sharing one entity: several meshes referencing one pose entity share one pose, and changing it moves them all; to keep them independent, reference different entities. Both are the same mechanism and need no extra handle type.
- Changing a pose is one component write: no GPU call at all, and the renderer packs and uploads it automatically next frame, consistent with per-frame upload being transparent to the caller.
- The cost is that the references must be given: when a mesh’s vertex stream carries joint indices, or carries morph targets, its entity must carry the matching reference components, otherwise rendering panics rather than silently drawing with pose zero — a silent downgrade turns “forgot to associate” into a hard-to-notice visual bug. Another constraint is that the number of weights must match the mesh’s target count, because the shader’s loop bound is the mesh’s own target count and an out-of-bounds storage read is a runtime error rather than a catchable panic.
§Custom shaders and per-instance data
Nothing in the frame path is reserved for the built-in unlit shader: it lives
in the unlit module as an ordinary family. A caller’s family
registers through the same
MeshSource::register_family call as
the built-in one and supplies its own pipeline — a hand-written
wgpu::RenderPipeline or a WESL module composed against the crate’s public
shader package — plus, if it needs any, its own per-instance vertex data.
A caller who wants the built-in family — its layouts, its vertex streams, its
per-instance record — and only a different entry shader does not register
anything: they put the source in
UnlitOptions::shader before
building the UnlitPipelineKey. The variant’s flags
and the built-in package are in scope, so the source declares the bindings the
built-in shader would have declared, and the layouts stay the variant’s, which is
what makes the swap one field. A family of one’s own is the other way, and the
one to take when more than the shader differs.
§Per-instance vertex data
Per-instance state lives in a vertex stream, not in a bind group, because it
differs per entity while a bind group is bound per mesh. Every family therefore
owns one instance stream, described by
InstanceData:
pub trait InstanceData: 'static {
fn stream(&self) -> InstanceStreamDesc;
fn write(&mut self, context: &mut InstanceContext<'_>, out: &mut [u8]);
}stream declares the vertex-buffer slot and
the record stride, and write fills one record
for one entity. Culling hands the family only the entity and its resolved world
transform; everything else the record carries — the tint, the cutoff, the mesh
metadata index, a skin or morph base — the family reads from the world for
itself, exactly as it likes. That is the collection side of customization: a
family decides which components it collects and what its record looks like. A
family that carries no instance state of its own passes (), whose stream is
empty.
The built-in unlit family is just an implementation of this trait
(UnlitInstance, slot
INSTANCE_SLOT, stride
size_of::<MeshInstance>()) — the same interface a caller implements, with
no private path behind it. It is also the only place the built-in record’s
MeshInstance is built: that record belongs
to the unlit family, not to the shared frame path, so a custom family is handed
no record of the frame’s making — it writes whatever its own pipeline declares.
§Skin and morph state
Skinning and morphing are independent concepts, so the instance context carries two separate growing arrays and two separate pack methods rather than one “pose”:
impl InstanceContext<'_> {
pub fn pack_joints(&mut self) -> u32;
pub fn pack_morph_weights(&mut self, targets: u32) -> u32;
}pack_joints appends the entity’s
joint matrices — the SkinPose its
SkinBinding names — to the frame’s joint array and
returns the index its slice starts at.
pack_morph_weights does the
same for the MorphWeights its
MorphBinding names, checking the weight count
against the mesh’s target count. A family that draws neither calls neither; the
unlit family calls pack_joints for a skinned mesh and pack_morph_weights
for a morphed one, and puts the two returned bases into its record.
The arrays themselves are the source’s: it owns them, grows them and uploads them once per frame, after every family has written its records. A family only appends to them while it writes, which is what keeps them a shared, general facility rather than unlit-specific state.
How a family's records reach the draw
- Each frame the source asks every family to begin a new record list, then walks
the visible entries in sorted order and calls
writefor each one, recording the index it landed at on the entry. A family’s records therefore end up in visible order, one record per entry, exactly as a merged instanced draw expects. - Each family’s
InstanceBuffergrows as needed and is uploaded once per frame through the same pooled staging path as the rest of the frame’s data. - When the scene is assembled, a merged draw binds the slot its family declared and starts its instance range at the first entry’s record index; a family with an empty stream binds nothing.
- Because each family owns its stream, a run of mergeable entries can never span two families: the pipeline identity already separates them, and each family’s records are contiguous within its own list.
§The frame’s shared inputs
A custom pipeline that wants the camera, the globals, or the metadata,
joint-matrix and morph-weight arrays binds them through public builders rather
than reimplementing the layout:
GlobalResources::layout builds the
wgpu::BindGroupLayout from a
GlobalBindings description, and
GlobalResources::rebuild returns the
Rebuild recipe the renderer registers, so the group is
rebuilt whenever the arrays behind it change. The built-in unlit family is a
caller of these same functions.
§Loading glTF documents
The gltf module (cargo feature gltf, off by default) loads a
glTF 2.0 document —
.glb or .gltf — into an UnlitGltf. The document, its
buffers and its images are parsed and decoded eagerly in the constructor, and
the world-space transform of every node is computed up front, so UnlitGltf is
a record of the model, not a GPU resource: it owns no World and no
MeshSource.
What it does is patch the world you already render:
insert_resourcesuploads the whole document in one call and returns agltf::GltfResourcesholding every handle it made; that is the structspawn_node/spawn_default_scenetake. A caller that wants one kind of resource at a time — to share a texture between two documents, say — can instead callinsert_image/insert_material/insert_meshfor a single image, material or mesh, or the batch versions (insert_images,insert_materials,insert_meshes) for everything of one kind, aligned with the document’s own indices, and assemble aGltfResourcesof its own.- Giving up a handle makes exactly what it names collectable: the material’s
bind group lives while the material does, and the texture view it samples is
held by the material too, so dropping the material’s handle is enough to
release both. The next
maintainreclaims them. spawn_node/spawn_default_scenespawn the entities that draw the node’s mesh (or every node reachable from the default scene): one entity per primitive, each carrying the node’s world-spaceTransform, the uploadedGpuMesh, theUnlitPipelinefor the mesh, anInstanceColortinted with the material’s base-color factor, and — when the mesh reads a base-color texture — the matchingGpuMaterial. A primitive whose material isalphaMode: BLENDis also markedZSortedDrawing, so the renderer composites it back-to-front after the opaque geometry. AalphaMode: MASKmaterial instead draws binary coverage: the fragment shader discards every fragment whose alpha falls below the material’salphaCutoff(0.5 when the document leaves it out), so the pipeline’sUnlitOptions::alpha_cutoffis set, the spawned entity carries anInstanceCutoffwhose value travels in the per-instance stream, and the primitive needs neither blending nor a sort.- A primitive carrying both
JOINTS_0andWEIGHTS_0is uploaded with its joint stream and drawn skinned:spawn_node/spawn_default_scenecreate aSkinPoseentity for the node’s skin and put aSkinBindingon the mesh, so writing that pose’s matrices is how a caller animates the skeleton.UnlitGltf::skin_posebuilds the document’s rest pose — one matrix per joint, from the joint nodes’ world transforms and the skin’s inverse bind matrices — as a starting point, andUnlitGltf::skin_joint_countreports how many joints a node’s skin has. - A primitive whose mesh declares a morph target that displaces positions is
uploaded with those displacements and drawn morphed:
spawn_node/spawn_default_scenecreate aMorphWeightsentity for the node and put aMorphBindingon the mesh, so writing that entity’s weights is how a caller morphs the mesh.UnlitGltf::morph_weightsreturns the node’s starting weights — the node’s ownweightswhen it states them, the mesh’s otherwise, and zero for a target left unweighted — padded or truncated to the number of targets that displace positions, which is the length the renderer requires. A target that displaces only normals or tangents is not counted and not uploaded, so a mesh whose every target displaces nothing this loader reads draws undeformed rather than through the morph path. - A primitive whose material is
doubleSidedis drawn from either side: the pipeline variant drops its cull mode, so the back faces the single-sided variant would discard are rasterized instead. Nothing else about the variant changes, because the unlit fragment shader reads no normal — there is no back-face normal to reverse and no lighting equation to evaluate. - A document’s animations are sampled rather than played:
UnlitGltf::animation_count,UnlitGltf::animation_nameandUnlitGltf::animation_durationdescribe the clips it carries, andUnlitGltf::apply_animation(world, clip, time, &spawned)evaluates one at a chosen time. It samples each channel —STEP,LINEARorCUBICSPLINE, with rotations slerped — into the animated nodes’ local transforms, recomposes the world matrices down the node hierarchy, and writes the result where the spawned entities read it: each animated node’sTransform(and its descendants’, which inherit the change), everySkinPosewhose mesh follows a moved joint, and everyMorphWeightsa weight channel names. It takes&World, so a caller drives it from wherever it already has one, and it touches nothing the passed&[GltfNode]does not name.
What loads: positions, UVs, vertex colors, joints, weights and indices;
base-color textures and the materials that sample them; blended
(alphaMode: BLEND), cut-off (alphaMode: MASK) and double-sided materials;
skinned primitives; positional morph targets; node hierarchies, accumulated
into world transforms; and the animations that drive them. What does not yet:
normal and tangent morphs, normals and tangents.
The pipeline key a mesh is uploaded with is always derived from the primitive’s
own attributes (see UnlitGltf::pipeline_key),
so the variant it draws with never asks for a stream the mesh does not have.
An image is uploaded in the GPU format closest to the pixels the loader decoded,
so textures keep their channels and precision instead of all being widened to
RGBA8: 8-bit layouts upload as R8Unorm/Rg8Unorm/Rgba8UnormSrgb, 16-bit
ones as the half-float formats of the same width, and 32-bit float ones as
Rgba32Float. Three-channel layouts widen by one, since neither an sRGB nor a
float format comes in three channels. Two consequences are worth knowing: a
grayscale texture carries its luminance in the red channel — WebGPU has neither
a luminance format nor a component swizzle — so the unlit shader’s
BaseColorChannels::Luminance and BaseColorChannels::LuminanceAlpha expand it
to RGB(A) and decode the luminance from sRGB, which UnlitGltf::pipeline_key
picks for exactly those uploads; and Rgba32Float is unfilterable-float on devices
without Features::FLOAT32_FILTERABLE, in which case the material binds a
non-filtering sampler and UnlitOptions::texture_filtering specializes the
bind-group layout to match.
§UI
ui::UiPanel is itself a behaviour component holding a closure, so
an interface is an entity — a frame can hold as many panels as entities, and the
ui::UiSource driver runs whatever panels the world carries, in
query order.
A panel can only be built where its egui context runs, because the callback is
handed a &mut egui::Ui that belongs to the pass building it. A UI whose
panels live in one world and whose drawing belongs to another therefore crosses
that boundary as data: ui::lay_out runs a world’s panels against a context
the caller owns and returns an ui::ExternalFrame — tessellated primitives,
texture updates and the platform output, all plain owned values. The frame
then crosses as whatever the two worlds agree on: it lands in the
ui::ExternalFrame slots a ui::UiSource owns, so the slots stay on the
thread that draws them, and the target the drawing side measures comes back
through a relay::Snapshot the panel’s world reads when it lays the next
frame out. The two contexts stay
separate: 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
are never rewritten, though both contexts allocate from zero: a frame keeps the
ids its context gave it and the GPU integration keys textures by (origin, id),
so a published frame is drawn from the same tessellation until a newer one
replaces it. unlit3dx builds the cross-world plumbing — a UiPanelHost in the
panel’s world, sending frames as jobs on that world’s own tick — on these
pieces.
Why the panel is a component, and the unit convention that is easy to get wrong
The interface is the behaviour component, not a closure field on the source.
This matches the “the caller is the system” convention: the source is the driver
and decides which panels to call and in what order; a panel that wants to keep
state puts it in its own sibling components (a behaviour component is borrowed
while it runs, so it cannot re-enter a borrow of itself); and a panel receives
&World, so it can read and write components and can queue() structural
changes. Several panels are therefore several entities, added and removed as
needed, and third parties can define their own panel-like components for the
source to select — the source itself need not know which interfaces exist.
The unit convention is the easiest thing to get wrong in this API: egui’s vertices and clip rectangles use logical points while window event coordinates are physical pixels. So the projection uses points (the physical size divided by the scale factor) while the scissor must multiply by the scale factor — the two units are opposite. On top of that, egui calls a panel closure several times for multi-pass layout, so a panel must be idempotent or decide what to do based on the pass number; that is egui’s existing semantics, the same in every backend.
UI itself needs only a color attachment and no depth attachment, but a
pipeline’s depth state must exactly match the pass’s attachments (wgpu
validates strictly by format, ignoring the write and compare settings). In a
pass that has a depth attachment — UI and meshes sharing one pass is the common
case — the UI pipeline must still declare the same depth format and avoid
disturbing the meshes’ depth only by not writing depth and not depth-testing:
“needs no depth” is not the same as “declares no depth”. “The target has no
depth attachment” is also legal, which is why a pipeline’s depth state is
optional; that optional depth state serves the case where the target itself has
no depth attachment, and then every pipeline in the pass must declare no depth,
which is not specific to UI: a frame a BlitSource draws
alone has no depth attachment either. This matches egui’s official backend: by
default it carries no depth state, but when given a depth format it still builds
a state with that same format, no depth writes, and a compare function of
Always. Whether a frame carries a depth attachment at all is the caller’s
choice: FrameAttachments::new allocates
one only when asked, so a window whose sources declare no depth state is drawn
into a frame without one.
§Example
The frame skeleton, with the device/queue pair passed in. spawn_context
puts the GPU state in the world, MeshSource is mounted as a source, and
Renderer is the frame driver:
use unlit3d::prelude::*;
use unlit_wgpu::pipeline::UnlitOptions;
use unlit_wgpu::resources::ResourceGraph;
let (device, queue) =
wgpu::Device::noop(&wgpu::DeviceDescriptor::default());
let mut world = World::new();
// 1. Spawn the frame's GPU context, the built-in mesh source and the
// frame driver, and register the built-in unlit family.
// `DeviceCapabilities` carries what the device cannot report about itself
// (its `base_vertex` support). Derive it from the adapter when one is at
// hand; `default()` is the WebGPU baseline, which is also what `noop` is.
let ctx = spawn_context(
&mut world,
device,
queue,
ResourceGraph::new(),
DeviceCapabilities::default(),
);
let mut mesh_source = MeshSource::new(&world.root_scope(), ctx);
mesh_source.register_unlit_family(&world.root_scope());
let key = UnlitPipelineKey::new(UnlitOptions::standard(&mesh_source.device(&world.root_scope())));
let renderer = world.root_scope_mut().spawn(bundle![Renderer::new(ctx)]);
// 2. Spawn the frame's pass and mount the mesh source under it. A pass is a
// scope: it records the sources below it, and each source in turn is a
// scope of its own, so everything a source draws lives under its entity.
let pass = world
.root_scope_mut()
.spawn(Pass::new(FramePassOrder(0)));
let source = world
.scope_mut(pass)
.unwrap()
.spawn(bundle![Source::new(mesh_source)]);
let scope = world.scope(source).unwrap();
// 3. Allocate geometry and a material through the mesh source, and spawn the
// renderable entity under it, carrying the unlit key.
let (mesh, material) = scope
.query_one::<&mut Source>(source)
.map(|mut source| {
let source = source.as_mut::<MeshSource>().unwrap();
let positions = [[0.0; 3]; 3];
let uvs = [[0.0; 2]; 3];
let colors = [[255u8; 4]; 3];
let indices = [0u32, 1, 2];
let mesh = source.allocate_unlit_mesh(
&scope,
&key,
UnlitMeshDesc {
positions: &positions,
uvs: Some(&uvs),
colors: Some(&colors),
indices: Some(&indices),
..Default::default()
},
);
let device = source.device(&scope);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("example::texture"),
size: wgpu::Extent3d { width: 256, height: 256, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
// The material reads a view of the texture and a sampler, both of
// which the graph keeps for it: only ids cross the boundary.
let texture_view = source.register_texture_and_default_view(&scope, texture).1;
let sampler = source.register_sampler(&scope, None);
let material = source.allocate_unlit_material(&scope, &key, texture_view, sampler);
(mesh, material)
})
.unwrap();
world
.scope_mut(source)
.unwrap()
.spawn(bundle![Transform::default(), mesh, material, UnlitPipeline::new(key)]);
// 4. Give the pass a render target and render one frame (uses the noop
// device, so it produces a valid command buffer without touching a GPU).
// The frame's depth and multisample attachments are allocated and kept by
// `FrameAttachments`; the color attachment is this example's own.
let color = create_color_target(
&world.root_scope().query_one::<&wgpu::Device>(ctx.device).unwrap(),
wgpu::TextureFormat::Rgba8UnormSrgb,
1280, 720,
);
let attachments = FrameAttachments::new(
&world,
ctx,
wgpu::TextureFormat::Rgba8UnormSrgb,
(1280, 720),
1,
true,
);
let scope = world.scope(source).unwrap();
let color_view = scope
.query_one::<&mut Source>(source)
.map(|mut source| {
source
.as_mut::<MeshSource>()
.unwrap()
.register_texture_and_default_view(&scope, color)
.1
})
.unwrap();
world
.root_scope()
.query_one::<&mut FramePass>(pass)
.map(|mut pass| {
pass.set_attachments(&attachments, color_view);
})
.unwrap();
world
.root_scope()
.query_one::<&mut Renderer>(renderer)
.map(|mut r| r.render(&world))
.unwrap();A UI panel is a behaviour component, so mounting an interface is an ordinary spawn:
use unlit3d::prelude::*;
let mut world = World::new();
// The panels of a UI source live under it: a frame hands the source its own
// scope and drives whatever panels it finds there.
let ui = world.root_scope_mut().spawn(bundle![Source::new(UiSource::new())]);
world.scope_mut(ui).unwrap().spawn(bundle![UiPanel::new(|_world, _entity, ui| {
ui.label("hello");
}),]);§Input
input is the portable half of input handling: event types and the behaviour
components that react to them, depending on neither winit nor egui. A frame
feeds events through an InputWriter, which drops each
one into the buffer of every world subscribed to it. A world’s buffer is its
InputEvents: the events that arrived since its last
tick, plus the InputState they built — modifiers,
pointers, buttons, window size. dispatch_input
takes this world’s queue as this tick’s batch, folds it into the state, and
drives the behaviours. A world owns the events it has not ticked on, so a
world that ticks less often than input arrives still reads every event in
order, and nothing has to clear anything afterwards.
distribute_input copies the writer into every
nested world and subscribes it there, so one stream feeds them all while each
builds its own state; a world spawned outside that hierarchy — one a runner
started as a sibling — subscribes itself from its own setup. A world that
reads input without dispatching anything calls
refresh_input once per tick instead, before
anything reads the batch. Translation
from a windowing library’s events lives behind the corresponding feature:
input::winit::WinitInput forwards WindowEvents, and ui::convert turns the
crate’s events into egui’s.
The naming rule, and why dispatch is explicit
- Event types depend on neither winit nor egui: the core types stand alone, and the winit and egui conversions sit behind their respective features. Game logic and UI thus share one event stream and need no two sets of events.
- Event callbacks are behaviour components, isomorphic to the ECS’s existing
behaviour-component paradigm: all matching behaviour components are called per
event category.
OnInputreceives every event, whileOnKey/OnMouse/OnPointer/OnTouch/OnText/OnImeeach receive one category. The mouse family is always namedMouse(MouseEvent/MouseButton/MouseButtons/OnMouse), whilePointermeans specifically a device-independent “point input”: both mouse and touch producePointerEvent, so the same drag behavior works on desktop and touchscreen;Touchkeeps what is unique to a finger (touch id, pressure). Conversely, touch does not set mouse buttons, and the button state inInputStateis mouse-only. InputStateholds the state the events left behind (modifiers, pointer position and the pointers pressed, cursor, buttons pressed, focus, window size and scale) and no events. It lives inside a world’sInputEvents, which is how everything in that world reaches it: one buffer per world, refreshed by that world’s own tick. A shared state would make one world’s reading depend on another’s rate, andInputWritertherefore keeps none — it is only the fan-out. A pointer’s pressed state is recorded per “contact point” (PointerContact, identified by kind and id), so lifting one finger out of several does not misread as the gesture ending.- Events are per world, not per frame: feeding an event pushes it into
every subscribed world’s queue, and that world’s own tick is what takes it
as the tick’s batch. A shared list plus a per-frame
clear_eventswould instead make the slowest reader’s view depend on how often the fastest ran: a world ticking at 60 Hz fed by a 120 Hz frame loses every other event, and a world that skipped a frame loses all of them. A queue is unbounded for the same reason — dropping the oldest event to keep a bound would reintroduce exactly the loss the queue exists to prevent. - Dispatch is called explicitly by the caller in the frame loop, not done
automatically inside
render. The reason: structural changes queued inside a callback need&mut worldto land, whilerenderonly gets&World; automatic dispatch would defer a callback’s queued changes to the next externalapply, and that delay would be invisible to the user. Explicit dispatch also gives the caller control of the timing and order, consistent with “the caller is the system”. - Dispatch traverses the behaviour components directly without collecting entities first: callbacks borrow different cells of different entities, so they do not conflict. The real limits come from the ECS’s own deliberate trade-offs — a callback cannot re-enter a borrow of its own component, nor re-enter a dispatch of the same kind; state is kept in sibling components instead.
Whether the UI wants the pointer or the keyboard is egui’s own answer, not
one the source republishes: after a frame is laid out, egui_wants_pointer_input
and egui_wants_keyboard_input on the source’s context describe what the frame
claimed. A caller reaches that context through the world’s Source value —
source.as_ref::<UiSource>() — the same way it reaches any other source’s
state, and game logic reads it to decide whether to respond. By egui’s
semantics the answer describes the frame just laid out. Nothing is
intercepted automatically.
§winit integration
The winit module is three layers. winit::surface is the presentation half:
WindowSurface configures a window’s swap chain and
hands out a Frame, and
FrameAttachments holds the depth and
multisample targets a frame draws with. winit::event is the bridge: every
ApplicationHandler callback has a behaviour-component family, and
WinitHost is the one ApplicationHandler an
application implements — it owns the world, dispatches each callback to its
family, and holds no application logic of its own. winit::builtin is a set of
ready-made behaviours, and nothing in them has a path an application cannot take.
A callback component is an ordinary behaviour component, written and tested like
any other: it receives &World, the entity it sits on, and the event, and it
must not reach for its own component type or re-enter the host. Structural
changes go through World::queue and land when the host applies after the
dispatch. The callback signature deliberately carries no ActiveEventLoop:
that type can only be built inside a callback, so a component taking it could
never be unit-tested. What a callback needs from the loop is a request instead —
CreateWindowRequest and ExitRequest are executed by the host after the
dispatch, WinitWindow is the window a component asks for a redraw on, and the
host maintains Resumed and DisplayHandle itself.
What happens within one frame is a behaviour too. The frame module
has one family, OnFrame, dispatched by
dispatch_frame; a frame behaviour receives &World,
the entity it sits on, and a &mut Frame carrying the frame’s
delta time, index and size. That is the same shape as the event families, with
the frame context in place of an event. A behaviour spawned with
OnFrame::new states no order: the behaviours that state
none form a band, and the dispatch promises nothing about the order within it.
One whose reads or writes another behaviour depends on states the order at both
ends, with OnFrame::with_order and a
FrameBehaviourOrder, and the library declares no
order values of its own: what runs before what is the application’s to define. A
windowed application’s host world and each world it drives dispatch the same
family, so the scene side is componentised the same way. Spawn a behaviour with
ScopeMut::spawn. A behaviour that needs the rest
of the frame skipped sets a flag the siblings that read it state an order after;
a behaviour that needs structural change queues it, because a callback only
holds &World.
let event_loop = EventLoop::new()?;
let mut host = WinitHost::new();
host.world_mut().root_scope_mut().spawn(WindowSetup {
attributes: Window::default_attributes().with_title("unlit3d"),
});
host.world_mut()
.root_scope_mut()
.spawn(ExitOn(ExitTrigger::CloseRequested));
host.run(event_loop)§Tests
cargo xtask test # the whole workspace
cargo nextest run -p unlit3d --all-features # just this crateThe integration tests are one binary, and its required-features are gltf,
mcp and ui — the features its cases reach. A run without them does not fail:
Cargo skips the target whole, so cargo nextest run -p unlit3d reports the unit
tests and nothing else. --all-features (or naming all three) is what puts the
suite in the run.
The GPU integration tests render scenes into offscreen targets and inspect the
pixels that come back. With the mcp feature on, an end-to-end test builds a
real GPU world on a thread, serves it over a pair of streams and speaks
JSON-RPC to it: it lists the tools, reads the world, spawns a mesh and checks
the screenshot pixel by pixel. The multi-frame snapshot coverage of those scenes lives
in unlit3d_examples,
whose tests/gpu_suite.rs compares them against the SSIMULACRA2 snapshots under
unlit3d_asset_files.
Clone the submodule here with git submodule update --init --checkout, which is
what gets past its update = none; re-bless intentional
changes with SNAPSHOT_UPDATE=1 cargo nextest run -p unlit3d_examples and review
the image diff. Where each test layer sits
across the workspace, and what CI runs, is in the
root README.
§See also
unlit_wgpu— the renderer underneath.unlit_ecs— the world the components live in.unlit3d_examples— a runnable windowed program built on this API.
§License
Dual-licensed under MIT or Apache-2.0, at your option.
Modules§
- attachments
- The depth and multisample attachments a frame draws with.
- blit
- A frame source that copies a texture over the pass’s target.
- bounds
- Bounding volumes and frustum planes for CPU culling.
- components
- Component types for the ECS-based scene description.
- culling
- CPU visibility culling.
- frame
- Frame behaviours: what a frame is made of, as components.
- input
- Input events and the behaviour components that react to them.
- mesh
- The mesh description.
- mesh_
source - The built-in mesh source: the 3D half of a frame.
- pass
- A pass of a frame: where one group of sources draws, and what it draws into.
- pipeline
- The frame’s render-pipeline abstraction.
- prelude
- The types most callers need for typical usage.
- relay
- The handles a value takes when it leaves a world: a snapshot or a stream. The two shapes a value takes when it leaves a world: a slot that keeps the newest one, and a queue that keeps every one.
- renderer
- The frame driver: assembling one frame out of its passes and sources.
- scene
- Assembling a frame’s draw list.
- source
- Frame sources: the things a frame is made of.
- ui
ui - An egui UI overlay, drawn as a frame source.
- unlit
- The built-in unlit pipeline, gathered in one module.
- winit
winit - winit integration: presenting a renderer, and driving callbacks as behaviours.