Skip to main content

Crate unlit_wgpu

Crate unlit_wgpu 

Source
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

FeatureDefaultProvides
eguinothe 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 single maintain pass 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 (Snorm16x4 positions, Snorm16x2 UVs, Unorm8x4 colors, Snorm16x2 octahedral normals, Uint16x4 joints, Unorm16x4 weights) and the MeshMetadata decode 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’s OffsetAllocator.
  • buffer_pool and vertex_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 — an Array sub-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 fresh queue.write_buffer allocation 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; and View, 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 with textureLoad. 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. DeviceCapabilities holds base_vertex; whether storage buffers exist is already on the device’s own limits. DeviceTier is 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, and create_render_target for an offscreen frame.
  • readback — copying a texture or a buffer back to the host: readback_texture returns a texture’s whole mip level 0 as tight rows, readback_buffer a 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: a PipelineVariant builds a PipelineDescriptor, and Variants compiles and reuses one SpecializedPipeline per 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, the GlobalBindings description 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 from foldhash’s fixed-seed hasher, the same family hashbrown’s own maps build on. Mutating the value goes through update, which recomputes the word rather than letting it go stale.
  • ui — the egui backend, under the egui feature. Its vertices pack their positions as planar Float32x2 pairs; 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>’s R is the resource type itself: get returns that resource directly with no variant matching, and replace accepts 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 erased ResHandle<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. ResHandle is 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. replace only marks a resource dirty — the resources depending on it need updating too. Nothing happens at the point of the change. A frame calls ResourceGraph::maintain exactly 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 Rebuild closure 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::nodes lists every resource with its ResId, its kind_name, whether it is dirty and whether the graph can rebuild it, and ResourceGraph::resolve resolves an id back to a handle. ResourceGraph::info and ResourceGraph::resource_infos are 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 ResId is the plain, Copy form 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. A ResHandle holds a reference, so a resource lives while any handle to it does; a ResId holds none, which is what lets a report or a transport carry one. A handle yields its id with ResHandle::id, and the id comes back through resolve.
  • A texture view’s format is recorded with the view. This is the one exception to “no unnecessary wrappers”: wgpu cannot tell a TextureView’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.

The GPU resources one frame depends on, as a graph

§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: descriptor turns 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 type P. Caching has nothing to do with the kind of pipeline, so P is not fixed: a render pipeline is a PipelineDescriptor<wgpu::RenderPipeline> and a compute pipeline a PipelineDescriptor<wgpu::ComputePipeline>, both sharing one Variants cache 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_buffer call 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’s StagingBelt is 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 crate

The 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

§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.
uiegui
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.