Expand description
English | 简体中文
§unlit_wgpu
A compact, opinionated 3D renderer on wgpu. It draws a whole scene — opaque
and transparent instances alike — in a single render pass into one
[wgpu::TextureView] chosen by the caller, with a mobile-first bias, and runs
on WebGPU as well as on the WebGL2 baseline. A built-in pipeline shades the
scene with Lambert diffuse lighting.
The crate is layered: the modules below are general facilities a caller builds
any pipeline on top of, and the built-in lit pipeline is an ordinary user of
them. Nothing in the general modules knows about the built-in pipeline: it uses
the same binding and slot conventions, the same vertex compression and the same
resource tracking a caller’s own pipeline would. WESL composition, including the
splice of a caller’s snippets into an entry shader’s hook marks, is a public
facility in compose rather than a private step of the built-in pipeline; the
egui backend is the only optional part of the crate.
It is at an early stage of development; APIs change freely. It has no
dependency on the ECS layer; the ECS-integrated API built on it is
unlit3d.
§Features
| Feature | Default | Provides |
|---|---|---|
egui | no | the ui module: an egui backend that draws tessellated egui output as ordinary screen-space draws |
The WESL package, the compose facilities and the built-in lit and line
entry shaders are always available: they are general facilities a caller’s own
pipeline composes against, not a privileged built-in pipeline behind a feature.
With --no-default-features the crate keeps everything but the egui backend,
and CI checks that combination separately.
§What is in the box
resources— a dependency-tracked graph of the GPU resources a frame uses. Resources are inserted, replaced and given up through it, and the graph propagates “dirty” state to dependents so that derived resources (bind groups, pipelines) are rebuilt lazily. Replacing a resource marks everything transitively built from it dirty, and a singlemaintainpass per frame collects the resources no handle holds any more and runs the rebuild recipes. Virtual nodes own no handle and serve as aggregation roots.mesh— vertex compression (Snorm16x4positions,Snorm16x2UVs,Unorm8x4colors,Snorm16x2octahedral normals,Uint16x4joints,Unorm16x4weights) and theMeshMetadatadecode parameters that make the compact formats usable in a shader, plus a vertex-stream writer that packs channels without materializing them.offset_allocator— an O(1), allocation-free sub-allocator over one contiguous range, using the two-level segregated fit from Aaltonen’sOffsetAllocator.buffer_poolandvertex_pool— GPU buffers sub-allocated with it, so many meshes share one buffer per kind or per vertex layout instead of each owning its own.array_pool— anArraysub-allocated the same way, for variable-length slices a frame-wide array holds rather than one resource each. It keeps a CPU mirror of the bytes, so several slices are written before one upload reaches the GPU.staging— host-visible staging buffers reused across frames instead of a freshqueue.write_bufferallocation per upload.globals— the three uniforms every pipeline binds:Camera, the clip-from-world and view-from-world matrices and the eye position;Globals, the frame’s time, delta and index; andView, the size of the viewport the pass draws through in physical pixels (a shader turning a length into pixels divides by it, and it is the viewport’s own size rather than the target’s, so a letterboxed pass measures what it draws into). Who owns each value is what separates them: the camera and the frame globals are the frame’s, the view is the pass’s, so one camera drawn through two viewports is two views.instance_stream— a per-instance vertex buffer that grows as needed and uploads its records through the staging pool. A caller’s own pipeline uses one per family, so per-instance state needs no privilege from the built-in shader.texel_array— a flat array of fixed-size elements bound either as one storage buffer or, where the device has none, as a texture the shader reads withtextureLoad. Both paths keep the same bytes and the same binding numbers, so a caller picks one handle type and never branches on the device.capabilities— what an adapter can do beyond the WebGPU baseline, captured while the adapter is still alive and carried to where a frame is recorded.DeviceCapabilitiesholdsbase_vertex; whether storage buffers exist is already on the device’s own limits.DeviceTieris the other half — how much of the baseline to request, from the WebGPU baseline (the default) through the adapter’s own limits to WebGL2’s shape.scene— the declarative description of a frame: pipelines, their bind groups, materials, meshes, vertex buffers and draw ranges.render_attachments— the attachments a pass renders into, the pass-opening entry point, andcreate_render_targetfor an offscreen frame.readback— copying a texture or a buffer back to the host:readback_texturereturns a texture’s whole mip level 0 as tight rows,readback_buffera buffer slice. Both block until the copy has run, so a test, a screenshot or a debug tool can inspect what the GPU produced.specialize— variant caching: aPipelineVariantbuilds aPipelineDescriptor, andVariantscompiles and reuses oneSpecializedPipelineper variant.light— the frame’s lights as the shaders read them: one record per bounded light whatever its kind, the uniform that carries the ambient term and the directional lights, the cluster grid’s parameters, and the CPU clustering that turns a frame’s lights into the cell array a draw binds.pipeline— the binding slots, bind-group indices and vertex-buffer slots the crate draws with, theGlobalBindingsdescription and the global bind-group layout builder every pipeline binds the frame’s shared inputs through, plus the built-in lit pipeline.util—Hashed, a value whose hash is computed once up front: hashing it writes the stored word instead of walking the value, which keeps a per-frame key cheap when its members are large. The hash comes fromfoldhash’s fixed-seed hasher, the same family hashbrown’s own maps build on. Mutating the value goes throughupdate, which recomputes the word rather than letting it go stale.ui— the egui backend, under theeguifeature. Its vertices pack their positions as planarFloat32x2pairs; the shader supplies the zero third.
§Example
Everything one scene needs is built once and reused every frame; Example::draw
is what runs in the frame loop. The walkthrough below is complete: device setup,
mesh compression, every bind group and the draw.
use unlit_wgpu::globals::{Camera, Globals, View};
use unlit_wgpu::mesh::{
ChannelEncoding, MeshInstance, MeshMetadata, PositionStreamChannels, SecondaryFlags,
compress_indices, compress_positions,
};
use unlit_wgpu::pipeline::{
BASE_COLOR_SAMPLER_BINDING, BASE_COLOR_TEXTURE_BINDING, CAMERA_BINDING, FRAME_BINDING,
GLOBAL_GROUP, INSTANCE_SLOT, MATERIAL_GROUP, MESH_METADATA_BINDING, PRIMARY_SLOT,
SECONDARY_SLOT, SpecializedLitPipeline, LitOptions, LitVariant, LitVertexChannels,
VIEW_BINDING,
};
use unlit_wgpu::specialize::{SpecializedPipeline, SurfaceKey};
use unlit_wgpu::util::Hashed;
use unlit_wgpu::render_attachments::{
color_clear, create_render_target, default_depth_stencil_format, depth_clear, stencil_clear,
};
use unlit_wgpu::scene::{DrawEntry, DrawRange, Scene};
use zerocopy::IntoBytes;
/// Everything one scene needs, built once and reused every frame.
struct Example {
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: SpecializedLitPipeline,
globals: wgpu::BindGroup,
material: wgpu::BindGroup,
positions: wgpu::Buffer,
secondary: wgpu::Buffer,
indices: wgpu::Buffer,
index_count: u32,
instances: wgpu::Buffer,
instance_count: u32,
}
impl Example {
/// Build the pipeline, the geometry and every bind group.
fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Example {
// 1. Describe the draw. One variant names one exact pipeline: the
// caller's policy, the vertex channels the geometry actually has,
// whether a material and morph targets are bound, and the target
// the pipeline renders into. Equal variants share one compiled
// pipeline, so the variant doubles as the cache key. The target is
// the caller's word: the color format, the sample count and the
// depth attachment are facts about it, not fields of the policy.
let mut options = LitOptions::standard();
let surface = SurfaceKey {
color_format: wgpu::TextureFormat::Bgra8UnormSrgb,
depth_stencil_format: Some(default_depth_stencil_format(device)),
sample_count: 4,
};
// Unlit: the fragment returns its base color without reading the
// frame's light arrays, which is what keeps this example's global
// group to the camera, the frame clock and the mesh metadata.
options.unlit = true;
let variant = LitVariant {
options: Hashed::new(options),
surface,
channels: LitVertexChannels {
position: PositionStreamChannels {
position: Some(ChannelEncoding::CompressedPosition),
joints: false,
},
secondary: SecondaryFlags::UV | SecondaryFlags::COLOR,
},
base_color_texture: true,
morph: false,
strip_index_format: None,
};
let pipeline = SpecializedPipeline::create(device, variant.clone());
// 2. Compress the mesh. Positions and UVs become 16-bit normalized
// integers relative to a bounding box; the decode parameters go
// into `metadata`.
let (positions, uvs, colors, indices) = cube();
let mut metadata = MeshMetadata::default();
// The compressors produce lazy iterators, so nothing is allocated.
let packed_positions: Vec<_> = compress_positions(&positions, &mut metadata).collect();
let packed_indices: Vec<u16> = compress_indices(&indices).unwrap().collect();
// Static geometry goes straight into mapped-at-creation buffers: the
// bytes are written into the buffer's own memory, so there is no
// staging copy and no `COPY_DST` usage to declare. Data that changes
// every frame wants a `COPY_DST` buffer instead, uploaded through a
// `StagingBuffer`, which reuses one staging buffer across frames.
let upload = |bytes: &[u8], usage: wgpu::BufferUsages, label: &str| {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: bytes.len() as u64,
usage,
mapped_at_creation: true,
});
buffer
.slice(..)
.get_mapped_range_mut()
.unwrap()
.copy_from_slice(bytes);
buffer.unmap();
buffer
};
let vertex = wgpu::BufferUsages::VERTEX;
let positions = upload(packed_positions.as_bytes(), vertex, "positions");
let indices = upload(packed_indices.as_bytes(), wgpu::BufferUsages::INDEX, "indices");
// The secondary slot uses the same path, except that the stream
// compresses the raw attributes and interleaves them in attribute
// order as it writes, so they are never materialized in a `Vec<u8>`.
let stream = variant.secondary_stream();
let secondary = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("secondary"),
size: stream.byte_len(packed_positions.len()) as u64,
usage: vertex,
mapped_at_creation: true,
});
{
let mut view = secondary.slice(..).get_mapped_range_mut().unwrap();
stream.write(&uvs, &colors, &[], &mut metadata, view.slice(..).into_slice(..));
}
secondary.unmap();
// 3. Per-instance data: an affine model matrix, a base color and the
// index of the metadata entry this mesh decodes through. The index
// is the mesh's, but it rides the instance stream because that is
// what a draw reaches without a bind group of its own.
let metadata_index = 0;
let instances = [
MeshInstance::new(
glam::Affine3A::from_rotation_y(0.6),
glam::Vec4::new(1.0, 0.85, 0.4, 1.0),
)
.with_metadata_index(metadata_index),
MeshInstance::new(
glam::Affine3A::from_translation(glam::Vec3::new(1.4, 0.0, -0.5)),
glam::Vec4::new(0.4, 0.8, 1.0, 1.0),
)
.with_metadata_index(metadata_index),
];
let instance_count = instances.len() as u32;
let instances = upload(instances.as_bytes(), vertex, "instances");
// 4. The global group: camera, frame clock, view and the per-mesh
// decode parameters. The layouts carry each struct's shader size, so
// wgpu validates the bindings when the bind group is created.
let camera = Camera::from_clip_from_world(glam::Mat4::IDENTITY, glam::Vec3::ZERO);
let globals = Globals::default();
let view = View::new(256.0, 192.0);
let camera = upload(camera.as_bytes(), wgpu::BufferUsages::UNIFORM, "camera");
let globals = upload(globals.as_bytes(), wgpu::BufferUsages::UNIFORM, "globals");
let view = upload(view.as_bytes(), wgpu::BufferUsages::UNIFORM, "view");
let metadata_buffer = upload(
metadata.as_bytes(),
wgpu::BufferUsages::STORAGE,
"mesh_metadata",
);
let globals_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("globals"),
layout: &pipeline.descriptor().bind_group_layouts(device).global,
entries: &[
wgpu::BindGroupEntry {
binding: CAMERA_BINDING,
resource: camera.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: FRAME_BINDING,
resource: globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: VIEW_BINDING,
resource: view.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: MESH_METADATA_BINDING,
resource: metadata_buffer.as_entire_binding(),
},
],
});
// 5. The material group: the base-color texture and its sampler. Only
// present because the variant sets `base_color_texture`.
let (texture_view, sampler) = checkerboard(device, queue);
let material = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("material"),
layout: &pipeline.descriptor().bind_group_layouts(device).material.unwrap(),
entries: &[
wgpu::BindGroupEntry {
binding: BASE_COLOR_TEXTURE_BINDING,
resource: wgpu::BindingResource::TextureView(&texture_view),
},
wgpu::BindGroupEntry {
binding: BASE_COLOR_SAMPLER_BINDING,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
// 6. No mesh group: the built-in variants read every input from the
// global group or the instance stream, so a draw binds none of its
// own. The index remains a general extension point a caller's
// pipeline can bind per-mesh data into.
Example {
device: device.clone(),
queue: queue.clone(),
pipeline,
globals: globals_group,
material,
positions,
secondary,
indices,
index_count: packed_indices.len() as u32,
instances,
instance_count,
}
}
/// Record one frame and submit it.
fn draw(&self, width: u32, height: u32) {
// The attachment set owns every attachment — the color target, the
// multisample and depth textures — and is recreated when the target
// changes. Recording the pass is the caller's: load ops are chosen
// per pass.
let attachments = create_render_target(
&self.device,
wgpu::TextureFormat::Bgra8UnormSrgb,
width,
height,
4,
)
.attachments;
// One draw: the pipeline, every bind group and vertex buffer it needs,
// and what to draw. Slots are bound by index, so a variant only binds
// the buffers its shader declares.
let draw = DrawEntry::new(
&self.pipeline.pipeline,
DrawRange::indexed(0..self.index_count).with_instances(0..self.instance_count),
)
.with_bind_group(GLOBAL_GROUP, &self.globals)
.with_bind_group(MATERIAL_GROUP, &self.material)
.with_vertex_buffer(PRIMARY_SLOT, &self.positions)
.with_vertex_buffer(SECONDARY_SLOT, &self.secondary)
.with_vertex_buffer(INSTANCE_SLOT, &self.instances)
.with_index_buffer(&self.indices, wgpu::IndexFormat::Uint16);
let mut scene = Scene::new();
scene.draws.push(draw);
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("encoder") });
// Everything the scene draws happens in this one pass.
{
let mut pass = attachments.begin_pass(
&mut encoder,
color_clear(),
depth_clear(),
stencil_clear(),
);
scene.record(&mut pass, None);
}
self.queue.submit([encoder.finish()]);
}
}
let example = Example::new(&device, &queue);
example.draw(256, 192);
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();The doc comments of mesh, array_pool, buffer_pool, offset_allocator,
staging and render_attachments also contain runnable examples.
§Shaders
The shaders this crate ships are authored in WESL and bundled at build time; see
shader. The package always carries the modules mirroring the Rust types a
caller binds (camera, globals, view, mesh_metadata, the mesh_compression
decode functions, the light records, the cluster uniform and the polygon form
factor), and the built-in lit entry shader. compose resolves it against the
package with a variant’s conditional-compilation flags and splices caller
snippets into its hook marks; SpecializedLitPipeline uses it to compose lit
with the LitVariant a draw resolves. The package also carries
line, the entry shader a screen-space stroke is drawn with; it reads the
camera and the View the lit shader declares but never reads,
because a stroke’s width is a pixel count rather than a world distance, so
unlit3d’s
LineSource composes it against the package and binds one group, specializing it
per stroke’s LineOptions — the entry shader and the
snippets spliced into it at its hook marks, the hard-edge cut, the depth-stencil
policy, the blend and the write mask; the cut is a WESL
conditional-compilation flag like the lit ones, and on a multisampled target
it yields to alpha-to-coverage, which antialiases the edge the cut would jag,
so the variant sets it on a single-sampled one alone; line_shader
holds the options and the variant; compose performs the splice. The lit
entry shader in turn covers every lit variant: the differences are WESL
conditional-compilation flags, so each composed variant holds one code path and
declares only the bindings and attributes it uses.
A caller who wants their own shader has two ways to it. The closest is
LitOptions::shader: an
LitShader names a module, a vertex and a fragment
snippet, and each is spliced into the built-in lit.wesl at a marked point. The
module one sits among the top-level declarations, after the imports and the
bindings and before the first struct, so it can add functions, types and
constants over the bindings every variant of that shape already declares. The
vertex one sits after the instance transform, before the clip position is built,
where the vertex is a var world. The fragment one sits after the base color is
cut off and lit, before the target’s encoding, where the color is a var color.
A snippet is WESL, spliced while the module is still WESL and before anything
compiles, so it sees every binding, flag and helper the built-in stage sees,
takes part in the conditional compilation, and is what shapes the geometry the
rest of the stage then transforms, lights and clusters. The layouts and the
vertex streams stay the variant’s: a snippet changes what a stage computes, not
what it reads, and one that declares a binding of its own fails pipeline
creation rather than drawing something else. The other way is to compose the
shader directly with wesl:
shader is a WESL StaticPackage, so wesl::resolver::PackageResolver can
resolve import unlit_wgpu::mesh_compression; against the same modules the
built-in pipeline uses, which is what a caller’s own family does.
The module creation the crate itself performs goes through
util::shader_module, a thin wrapper over wgpu’s module creation. In debug
builds it is plain
Device::create_shader_module; in release builds it
selects ShaderRuntimeChecks::unchecked through
Device::create_shader_module_trusted, which skips the runtime checks wgpu
would otherwise inject around every composed shader — shader-side bounds
checks, loop bounding and the like — on shaders that are composed at build
time from this package or a caller’s entry module. wgpu’s own validation and
the browser’s WebGPU validation still run either way.
§Design
§Resources: one dependency-tracked graph
The crate embraces wgpu and manages its resources directly: no unnecessary
wrappers, and no high-level API for CPU-side data management and
synchronization. What it does make convenient is creating its own GPU
resources — UBO and SSBO struct declarations, and mesh quantization and
compression — rather than hiding wgpu itself.
resources keeps every resource and its dependencies in one directed acyclic
graph:
- Acyclicity is an invariant the graph itself guarantees. A dependency that would close a cycle is rejected where it is declared, so the graph can always be traversed in dependency order.
- Handles are typed.
ResHandle<R>’sRis the resource type itself:getreturns that resource directly with no variant matching, andreplaceaccepts only the same type, so an id cannot come to mean a different kind of resource. Where the type cannot be known at compile time — dependency sets, dirty-resource walks, a stored field handed back to the graph — the erasedResHandle<Resource>is still used. - Insertion is immediate. Inserting creates the node and returns its id,
dependencies are declared one at a time with
add_dependency, and there is no intermediate state waiting to be finished. A dependency that cannot be recorded — a cycle — panics where it is declared rather than returning an error: that is a caller error, and it can only be surfaced there. - A handle is a reference.
ResHandleis a counted handle, not a bare index: cloning one takes another reference to the resource and dropping one gives it up. A resource lives exactly while some id names it, so there is no removal call and no handle that goes stale. A dependency holds a reference too, which is what lets a resource built only to feed a consumer live exactly as long as that consumer does. - Tracking is precise, updates are lazy.
replaceonly marks a resource dirty — the resources depending on it need updating too. Nothing happens at the point of the change. A frame callsResourceGraph::maintainexactly once, before it reads any resource: that one pass collects the resources nothing holds any more — following the references a collected node releases, so a whole chain goes in one call — and rebuilds the dirty resources in dependency order. Uploading new bytes into an existing buffer dirties nothing; reallocating it dirties only what actually consumed the old handle. - A rebuild is a recipe, not a callback at the call site. A resource the
graph can rebuild is inserted with a
Rebuildclosure that reads its inputs back out of the graph by id, so it observes a buffer that was reallocated or an array that was replaced at the moment it runs. A dirty node with no recipe stays dirty: the caller changed something the graph cannot rebuild on its own. - The graph is walkable.
ResourceGraph::nodeslists every resource with itsResId, itskind_name, whether it is dirty and whether the graph can rebuild it, andResourceGraph::resolveresolves an id back to a handle.ResourceGraph::infoandResourceGraph::resource_infosare the same picture as plain data, for a reader that only reports it. That is what lets an outside reader — a debug overlay, an MCP server — report the graph without a private view into it. - An id names the resource; a handle keeps it alive. A
ResIdis the plain,Copyform a resource travels as: it carries the generation of its slot, so an id to a removed resource stops resolving instead of quietly naming its replacement. AResHandleholds a reference, so a resource lives while any handle to it does; aResIdholds none, which is what lets a report or a transport carry one. A handle yields its id withResHandle::id, and the id comes back throughresolve. - A texture view’s format is recorded with the view. This is the one
exception to “no unnecessary wrappers”:
wgpucannot tell aTextureView’s format from the view itself, and when an sRGB view covers a non-sRGB texture, the format a pipeline has to match is the view’s rather than the texture’s.
§Drawing: the scene is data
Drawing — the render pass — is data-driven too. Scene is a
declarative list of draws, each naming the pipeline, bind groups, vertex buffers
and draw range it needs, and
Scene::record replays it into a
wgpu::RenderPass:
for draw in scene.draws {
pass.set_pipeline(&draw.pipeline);
for (index, bind_group) in draw.bind_groups {
pass.set_bind_group(index, bind_group);
}
for (slot, buffer, range) in draw.vertex_buffers {
pass.set_vertex_buffer(slot, buffer, range);
}
if let Some((buffer, range, format)) = draw.index_buffer {
pass.set_index_buffer(buffer, range, format);
}
if let Some(scissor) = draw.scissor {
pass.set_scissor_rect(scissor.x, scissor.y, scissor.width, scissor.height);
}
pass.set_stencil_reference(draw.stencil_reference);
match draw.range {
DrawRange::Indexed { indices, base_vertex, instances } => {
pass.draw_indexed(indices, base_vertex, instances)
}
DrawRange::Vertices { vertices, instances } => pass.draw(vertices, instances),
}
}Recording tracks the pass state — pipeline, bind groups, vertex buffers, index
buffer, scissor and stencil reference — and skips a set_* whose target is
already bound, so a scene whose entries are ordered to keep neighbouring draws
alike costs one state change per run rather than one per draw. That ordering is
the higher layer’s job, not this crate’s: Scene::record
records what it is given, and Scene::record_composited
is the one step further it offers — compositing several scenes into one pass
ordered by each draw’s DrawOrder: sequenced draws first
in scene order, then every scene’s depth-sorted pool together, farthest first,
then the trailing draws.
§Pipeline specialization: a variant, a blueprint, a cached pipeline
Why the cache is shaped this way
Compiling a pipeline is expensive and the result is valid for one exact configuration, yet one blueprint derives many concrete pipelines across dimensions like the render target, the vertex layout and the blend state. Automating “compile once per configuration, then reuse” needs two concepts:
- A variant (
PipelineVariant) is the complete, hashable identity of one configuration. It is the cache key and the recipe for the blueprint:descriptorturns it into the blueprint to compile. A variant must be injective — equal variants build equal blueprints, and draws whose blueprints differ resolve to different variants — which is what lets the cache be a single map from variant to index. A variant may be its own blueprint (type Descriptor = Self), as the built-in lit one is. - A blueprint (
PipelineDescriptor<P>) is the wgpu state that compiles into a pipeline of typeP. Caching has nothing to do with the kind of pipeline, soPis not fixed: a render pipeline is aPipelineDescriptor<wgpu::RenderPipeline>and a compute pipeline aPipelineDescriptor<wgpu::ComputePipeline>, both sharing oneVariantscache type.
The cache is one map, with no canonical form. Because a variant carries
everything its blueprint depends on, Variants needs neither a second level
nor a normalization step: the first lookup is the only lookup. The blueprint
itself is not the key because a blueprint cannot be hashed (its compilation
constants hold f64), and because “memoize on a small key” is the whole point
of these types: a family has finitely many variants, and a lookup is cheaper
than comparing a whole blueprint field by field. Variants are never evicted,
matching the bounded-variant assumption.
A blueprint is a semantic description, not a mirror of a wgpu descriptor.
A blueprint holds handles like ShaderModule that need a device to create, so
WESL composition and the actual wgpu calls both stay inside the blueprint’s
create, and the blueprint is built as late as the compile needs it.
Handling a render target is a family’s business, not the framework’s. A
pipeline’s color format, sample count and depth format have to match the
attachments of the pass it is used in, so a variant that draws into a target
folds the target into itself. SurfaceKey names such
a target and SurfaceTarget is the capability a
blueprint implements to apply one — but the cache never sees either. It is not
the framework’s job to guess which part of a variant is the target, and a
compute pipeline has none. “A target with no depth attachment” is legal too, so
a blueprint applies the target optionally and carries no depth state rather
than keeping a stale format.
A bind-group layout is not pipeline state. The built-in lit pipeline’s three layouts (global, material, mesh) follow entirely from the variant, and can be used standalone without compiling anything — to describe a material’s binding interface, say — so they are derived from the variant on demand rather than stored beside the compiled product, which avoids a second source of truth drifting from the blueprint.
The built-in pipeline has no privileges. It is an ordinary user of the same
mechanism: LitVariant is its own blueprint (its PipelineDescriptor), and
SpecializedLitPipeline is nothing more than the alias
SpecializedPipeline<wgpu::RenderPipeline, LitVariant>. There is no second
compilation path laid down for the built-in shader, and the frame’s shared
inputs are equally open: GlobalBindings and global_bind_group_layout build
the global layout any pipeline can bind, so a caller’s own shader reaches the
camera, globals and pose arrays without reimplementing the built-in layout. Registering it with the
higher layer’s family mechanism is
unlit3d’s
business.
§Lighting: forward clustered Lambert
Lighting is an option flag:
LitOptions::unlit turns one WESL flag off, and
the composed fragment multiplies the base color by the frame’s lights instead of
passing it through. Everything else is shared — the layouts, the vertex streams,
the instance record, the mesh allocator — so the same family draws both, and a
mesh allocated for one mode draws in the other.
The shading is Lambert and nothing more: a light contributes its color times a brightness factor, and the sum modulates the base color. There is no specular term, no metallic reflection, no Fresnel and no energy conservation, so a lit surface is a modulation of its own albedo rather than a physical model of a material.
What a light is, and how a fragment finds the ones that reach it
Bounded kinds share one record: a point light with a range, a spot light
with a cone, and a quad light — a convex planar polygon radiating from one
side of its plane. The record is the same LightGpu whatever
the kind, and kind.x says which of the lanes matter, so a cell of lights is a
list of indices into one array.
A directional light is not in the grid. It reaches every fragment, so writing
it into every cell would spend one of a cell’s slots to say nothing; it goes into
LightingUniform instead, which carries up to
MAX_DIRECTIONAL_LIGHTS of them together with
the frame’s ambient term, which the layer above sums from its ambient-light
components. Every lit fragment reads that uniform first and then the lights of
its own cell.
Clustering is CPU work. The view is cut into screen tiles of about
cell_pixels and into
z_slices depth slices. The first slice
absorbs the near end; the rest are logarithmic for perspective and linear for
orthographic projections, over the fixed scene range the config declares. Each
light’s bounding sphere is assigned to the cells it touches, and a cell that
overflows keeps its strongest lights — the ranking is the illumination a light
delivers where the frame is seen from, so the drop is the least visible one. A
fragment then maps its own screen position and view depth to one cell and reads
that cell’s list. The cost of a fragment is the lights in its own cell, not the
lights in the frame.
Doing it on the CPU is what makes one path serve both devices. A GPU cluster builder is a compute shader, which WebGL2 does not have at all; and the cell arrays are frame-wide arrays like the pose ones, uploaded as storage buffers where the device has them and as texels where it does not.
A quad light is shaded by the exact diffuse form factor of its polygon — the closed-form contour integral, not a Monte-Carlo estimate and not a fitted lookup table — which is why it needs no specular machinery: the diffuse term of a polygonal light depends only on the polygon and the fragment, never on the view direction.
Its options are the family’s own, and the higher layer’s LitPipelineKey is
those options under a type of its own — which is what lets one source draw lit
and unlit meshes alike.
§Inked outlines: the inverted hull
LitOptions::inverted_hull is one such
set of options rather than a pipeline of its own, and it draws the band an
ink drawing puts around a silhouette. It is a variant of the same family, so it
shares everything the lit one has — the mesh, the vertex streams, the instance
record, the mesh allocator, the family that resolves it.
What it changes is one flag and the state around it. expanded
pushes each vertex along its own normal before the vertex is transformed to clip
space, by the width the instance carries; the front faces are culled, so of the
pushed-out copy only the part that reaches past the body is left; and the depth
is written off, so the band never occludes the body it surrounds. It is unlit,
which is what makes the band a flat ink colour rather than a shaded surface.
The picture is therefore two entities drawing one GpuMesh —
the body with LitOptions::standard, the
shell with inverted_hull — in one pass,
sharing one buffer and one draw. No second pass, no second mesh, no stencil
trick: the shell only fills where the body’s own silhouette does not reach,
which is a consequence of the two draws and the culling, not of their order.
The width is per instance — MeshInstance::hull_width,
which the layer above fills from an InstanceHullWidth
component if it is there and zero otherwise — because the band’s width belongs
to the entity and not to the pipeline: one mesh drawn by several entities keeps
one pipeline and several widths. It is a width in world units, so a body
further from the camera wears a thinner band. A band that must hold its width in
pixels instead is a screen-space stroke, which is what
LineSource
draws; the two are complements, and both are in one picture in the example’s
outline
scene.
An expanding variant needs the mesh’s normal channel: the vertex is pushed along a normal, so a mesh without one cannot be spread at all. That is asserted while the variant composes rather than left to draw something else, because there is no fallback here — the screen-space derivative a lit draw falls back to lives in the fragment stage, and the push happens before the vertex is transformed. A lit draw of the same normal-less mesh is fine: it derives its normals per fragment.
§Reading the frame’s arrays: storage buffers or texels
The built-in shader reads the frame’s arrays: the per-mesh decode parameters, the frame’s joint matrices, its morph weights and its morph displacements, and — when the variant lights — the lighting uniform, the light records, the cell array, the polygon corners and the cluster parameters. The morph displacements are a mesh’s own geometry, but one array holds them for the whole frame and a mesh names its slice through its metadata entry, which is what leaves the mesh group with nothing to bind. The straightforward binding for each is a read-only storage buffer, and every device that meets the WebGPU baseline takes it.
WebGL2 does not meet that baseline here: GLES 3.0 has no SSBO at all, wgpu
reports max_storage_buffers_per_shader_stage as zero, and a bind-group layout
naming one is rejected outright. The same arrays are therefore also available as
textures — each element’s bytes become f32 lanes of an Rgba32Float or
R32Float 2D texture, read with textureLoad. texel_array owns that layout
and its upload; shader’s array_access.wesl owns the accessors every caller
goes through, so the shader body is written once and only that module knows
which resource arrived.
Both paths keep the binding numbers, the byte layout and the element order, so
the difference stays confined to the resource type. Which one is used follows
the device: LitVariant reads the device’s limits
when it compiles the pipeline, so a storage-less device is never asked for a
binding it rejects. The choice is a property of the device a pipeline is
compiled on rather than of the variant, so it is never part of the cache key.
Every buffer binding states its min_binding_size, and a uniform one states a
size that is a multiple of 16. A device without
BUFFER_BINDINGS_NOT_16_BYTE_ALIGNED — WebGL2, and ANGLE’s GLES — rejects a
pipeline whose uniform binding is not, which is why the uniform types derive
[const_shader_layout::ShaderLayoutCompat]: it rounds each struct’s size up to
16, so the layout cannot drift from the rule. Storage bindings have no such
requirement, and their minimum is the size of one element, which is what lets
an array grow without invalidating the layout.
Why the row width is read back rather than declared
COPY_BYTES_PER_ROW_ALIGNMENT is 256 bytes, so a row of a copied texture has to
span a whole number of those, and an element narrower than that shares its row
with others. Rather than bake the resulting width into the shader, the CPU picks
a row that satisfies both the copy alignment and the texture limit, and the
shader recovers the width with textureDimensions. The shader then depends on
neither the element size nor the device’s resolution limit, and an element never
straddles a row, so growing the array never splits one.
§Per-frame uploads: pooled staging buffers
Why not queue.write_buffer, and why not StagingBelt
Data that changes every frame — the camera, globals and view uniforms, per-instance
data, mesh metadata — is uploaded through staging buffers reused across frames
rather than by calling queue.write_buffer each time:
- Every
queue.write_buffercall allocates a fresh temporary staging buffer and submits a copy of its own, so it can neither batch with the rest of the frame’s work nor avoid an allocation per frame. wgpu’sStagingBeltis equally unsuitable: growth between frames makes it hold onto a block of every size it has ever seen, forever, and it never releases them.- Each destination buffer therefore keeps its own pool of staging buffers: the host writes into a reused mapping, the encoder records the copy, and the mapping goes back to the host for later frames once the copy completes.
The pool’s size settles at the number of frames in flight and does not grow with the frame count; when a frame grows, an undersized buffer is replaced rather than kept alongside; after the size falls for a while it can be reclaimed explicitly.
Per-frame upload is transparent to the caller: the caller maintains only
CPU-side data, such as allocating or removing meshes, and the changes are
synchronized to the GPU automatically when the frame renders, with no upload API
to remember. This differs from the lazy dependency-graph updates in
Resources, which the user triggers
through one maintain call per frame, after a buffer or other resource is
replaced or given up.
A frame’s uploads and the render pass consuming them are recorded into one encoder, so a frame is one submission, which keeps rendering’s completion deterministic; a frame with no content is submitted too, to carry that frame’s uploads.
§Tests
cargo xtask test # the whole workspace, through nextest plus the doctests
cargo nextest run -p unlit_wgpu --all-features # just this crateThe integration tests are one binary, and it requires egui. A run without
that feature does not fail: Cargo skips the target whole, so the suite
leaves the run silently and only the unit tests report.
The GPU integration tests render meshes into offscreen textures, read them back
and compare them against the snapshots under tests/snapshots with the
SSIMULACRA2 perceptual metric. That directory is a symlink into the
unlit3d_asset_files
submodule; clone it here with git submodule update --init --checkout, which is
what gets past the update = none that keeps a git dependency from fetching
snapshots it never compares against. To re-bless a snapshot
after an intentional change, run the test with SNAPSHOT_UPDATE=1 set, then
review the image diff before committing it.
The unit tests live inside src/ and cover the private pure logic; the
integration tests in tests/ reach the crate through its public API only. Where
each layer sits across the workspace, and what CI runs, is in the
root README.
§See also
unlit3d— the ECS-integrated API built on this crate.unlit_ecs— the world that API uses.unlit_wgpu_test_util— the headless GPU test harness.
§License
Dual-licensed under MIT or Apache-2.0, at your option.
Modules§
- array_
pool - A pool that packs many variable-size slices into one array.
- buffer_
pool - A pool that packs many small ranges into one GPU buffer.
- capabilities
- What the device can do beyond the WebGPU baseline, and how to ask for a narrower device.
- compose
- Composing a WESL module of the built-in shader package into WGSL.
- dag 🔒
- A directed acyclic graph with slot handles.
- globals
- Uniforms the built-in pipelines bind.
- instance_
stream - A reused vertex buffer holding one family’s per-frame instance records.
- light
- The frame’s lights, packed for the shaders and clustered on the CPU.
- line_
shader - The caller’s own shader code, spliced into the built-in
line.wesl. - mesh
- Vertex compression and the per-mesh decode metadata.
- offset_
allocator - A fast, hard-real-time sub-allocator over one contiguous range.
- pipeline
- The built-in lit pipeline and the WESL composition behind it.
- readback
- Copying GPU resources back to the host.
- render_
attachments - Attachment management for the single-pass frame.
- resources
- Dependency-tracked GPU resource graph.
- scene
- The declarative description of a frame.
- shader
- The WESL modules of the built-in shader package.
- specialize
- Variant caching for pipeline-like values.
- staging
- A reused host-visible staging buffer for per-frame uploads.
- texel_
array - Texture-backed arrays, for devices whose shaders cannot read storage buffers.
- ui
egui - An egui backend built on the built-in lit pipeline.
- util
- Small helpers that have no better home.
- vertex_
pool - A pool that packs many vertex streams into one buffer per layout.