unlit3d 0.1.0-dev.3

The high-level WebGPU rendering API over unlit_wgpu and unlit_ecs: ECS components, frame sources, input, UI and winit presentation.
Documentation

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 trait over 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

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.

  1. 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 many Changed and RemovedComponents queries. In OOP, an object maintains its own state internally.
  2. 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 like ShaderBuffer, Mesh and Image, 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:

  1. OOP and inheritance are inconvenient for Rust, and simulating OOP by force is awkward.
  2. 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.
  3. 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.
  4. 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 passes

Read 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 RenderLoadOps it 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 FrameAttachments or a WindowSurface, 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 Scene in build_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 same MeshSource::register_family a caller's own family uses, and everything specific to it — its key, its instance record, its mesh and material helpers — lives in the unlit module.

  • The GPU state — the [wgpu::Device], the [wgpu::Queue] and the ResourceGraph — lives in the world as resource components, addressed by a RenderContext. spawn_context spawns it and returns the addresses; RenderContext::of resolves the context back from any one of its four entities. It also takes the frame's DeviceCapabilities, 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. replace only 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 with RenderContext::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 maintain collects 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.

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 Entity handle 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.

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.

A family connects "what the entity wants" to unlit_wgpu's variant cache:

  • RenderPipelineKey both 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 DrawContext hands 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 into UnlitVariant::surface, the vertex layout into channels, the index format (and topology) into strip_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 without base_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, RenderPipelineFactory turns it into the RegisteredRenderPipeline the renderer registers — the compiled pipeline and the global group's rebuild recipe. It and unlit_wgpu's RenderPipelineDesc are 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.

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.

  • 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 an alphaMode: MASK fragment 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 (SkinBinding and MorphBinding). 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.

  • Each frame the source asks every family to begin a new record list, then walks the visible entries in sorted order and calls write for 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 InstanceBuffer grows 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_resources uploads the whole document in one call and returns a gltf::GltfResources holding every handle it made; that is the struct spawn_node / spawn_default_scene take. A caller that wants one kind of resource at a time — to share a texture between two documents, say — can instead call insert_image / insert_material / insert_mesh for 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 a GltfResources of 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 maintain reclaims them.
  • spawn_node / spawn_default_scene spawn 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-space Transform, the uploaded GpuMesh, the UnlitPipeline for the mesh, an InstanceColor tinted with the material's base-color factor, and — when the mesh reads a base-color texture — the matching GpuMaterial. A primitive whose material is alphaMode: BLEND is also marked ZSortedDrawing, so the renderer composites it back-to-front after the opaque geometry. A alphaMode: MASK material instead draws binary coverage: the fragment shader discards every fragment whose alpha falls below the material's alphaCutoff (0.5 when the document leaves it out), so the pipeline's UnlitOptions::alpha_cutoff is set, the spawned entity carries an InstanceCutoff whose value travels in the per-instance stream, and the primitive needs neither blending nor a sort.
  • A primitive carrying both JOINTS_0 and WEIGHTS_0 is uploaded with its joint stream and drawn skinned: spawn_node/spawn_default_scene create a SkinPose entity for the node's skin and put a SkinBinding on the mesh, so writing that pose's matrices is how a caller animates the skeleton. UnlitGltf::skin_pose builds 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, and UnlitGltf::skin_joint_count reports 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_scene create a MorphWeights entity for the node and put a MorphBinding on the mesh, so writing that entity's weights is how a caller morphs the mesh. UnlitGltf::morph_weights returns the node's starting weights — the node's own weights when 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 doubleSided is 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_name and UnlitGltf::animation_duration describe the clips it carries, and UnlitGltf::apply_animation(world, clip, time, &spawned) evaluates one at a chosen time. It samples each channel — STEP, LINEAR or CUBICSPLINE, 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's Transform (and its descendants', which inherit the change), every SkinPose whose mesh follows a moved joint, and every MorphWeights a 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.

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:

# #[cfg(feature = "ui")]
# {
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.

  • 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. OnInput receives every event, while OnKey/OnMouse/OnPointer/OnTouch/OnText/OnIme each receive one category. The mouse family is always named Mouse (MouseEvent/MouseButton/MouseButtons/OnMouse), while Pointer means specifically a device-independent "point input": both mouse and touch produce PointerEvent, so the same drag behavior works on desktop and touchscreen; Touch keeps what is unique to a finger (touch id, pressure). Conversely, touch does not set mouse buttons, and the button state in InputState is mouse-only.
  • InputState holds 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's InputEvents, 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, and InputWriter therefore 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_events would 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 world to land, while render only gets &World; automatic dispatch would defer a callback's queued changes to the next external apply, 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.

# use unlit3d::winit::builtin::{ExitOn, ExitTrigger, WindowSetup};
# use unlit3d::winit::event::WinitHost;
# use winit::event_loop::EventLoop;
# use winit::window::Window;
# fn main() -> Result<(), winit::error::EventLoopError> {
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 crate

The 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

License

Dual-licensed under MIT or Apache-2.0, at your option.