WSG - WGPU Simple Graphics Library

WSG is a Rust library that wraps wgpu and winit for simple GPU drawing. It groups the five core wgpu objects (Instance, Surface, Adapter, Device, Queue) behind a single Context, adds small building blocks (Mesh, Material, PipelineCache, Frame), and exposes the low-level primitives for advanced users.

Status: unstable development version. The manual workflow below is fully working, and the high-level "declarative" workflow (automatic App scene rendering) works for flat/NDC drawing. The GPU-driven two-pass pipeline described in the architecture docs is not implemented yet — see Status and Roadmap.

Status

Area State
Manual workflow (Context + Renderer + PipelineCache) ✅ Working
App / AppBuilder / AppHandler event-loop facade ✅ Working — window, events, frame presentation, and automatic scene rendering (the per-frame view is exposed via Frame::view())
Scene resource/entity registry ✅ Working — the engine renders every registered entity automatically in one batched render pass (App::render_scene)
GPU-driven two-pass pipeline (Compute → indirect draw) 📋 Roadmap — spec in docs/tech/ARCHI_CPU_GPU.md
3D transforms (MVP uniforms, camera in the pipeline) 📋 Roadmap — the bundled shader draws positions straight to NDC today

Note: the bundled basic_shader.wgsl treats vertex positions as already in NDC space, so what you can see today is flat, untransformed drawing (e.g. a colored quad) — not a 3D scene.

What it does

Bypass the App facade and drive Context, Renderer and PipelineCache yourself. This is the only workflow that renders pixels today (same code as the manual example):

use std::sync::Arc;
use winit::event_loop::EventLoop;
use winit::window::WindowBuilder;
use wsg_lib::core::{Context, Frame, Renderer};
use wsg_lib::pipeline::PipelineCache;
use wsg_lib::resources::{Material, Mesh, Vertex};
use wsg_lib::utils;

fn main() {
    // Window + async GPU init
    let event_loop = EventLoop::new().unwrap();
    let window = Arc::new(WindowBuilder::new().build(&event_loop).unwrap());
    let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
    let format = context.configure(&context.adapter, 800, 600).expect("surface config failed");

    // Renderer + shader cache (falls back to the embedded shader if the file is missing)
    let renderer = Renderer::new(&context, format);
    let mut cache = PipelineCache::new(Arc::new(context.device.clone()));
    cache.register_shader("basic", utils::BASIC_SHADER_PATH).unwrap();

    // Material + mesh
    let material = Material::new(renderer.format(), "basic", &mut cache);
    let vertices: [Vertex; 4] = [
        Vertex { position: [-0.5, 0.5, 0.0],  normal: [0.0, 0.0, 1.0], uv: [0.0, 0.0], color: [1.0, 0.0, 0.0, 1.0] },
        Vertex { position: [ 0.5, 0.5, 0.0],  normal: [0.0, 0.0, 1.0], uv: [1.0, 0.0], color: [0.0, 1.0, 0.0, 1.0] },
        Vertex { position: [ 0.5, -0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [1.0, 1.0], color: [0.0, 0.0, 1.0, 1.0] },
        Vertex { position: [-0.5, -0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [0.0, 1.0], color: [1.0, 1.0, 0.0, 1.0] },
    ];
    let indices: [u16; 6] = [0, 1, 2, 0, 2, 3];
    let mesh = Mesh::new(renderer.device(), &vertices, Some(&indices));

    // Render loop
    event_loop.run(|event, elwt| {
        match event {
            winit::event::Event::AboutToWait => window.request_redraw(),
            winit::event::Event::WindowEvent { event: winit::event::WindowEvent::RedrawRequested, .. } => {
                if let Some(frame) = Frame::try_new(&context.surface) {
                    renderer.render(frame.view(), &mesh, &material);
                    renderer.present(frame);
                }
            }
            winit::event::Event::WindowEvent { event: winit::event::WindowEvent::CloseRequested, .. } => elwt.exit(),
            _ => {}
        }
    }).unwrap();
}

Declarative workflow (work in progress)

The intended API: register your scene's resources and entities once, then let App handle the window lifecycle, event processing and frame presentation. Users implement the AppHandler trait to inject per-frame logic:

use wsg_lib::app::AppBuilder;
use wsg_lib::{App, AppHandler};

struct MyGame;

impl AppHandler for MyGame {
    // update() has an empty default — implement it to mutate scene state each frame.
    // render(app, frame) has a default that draws the whole scene automatically via
    // app.render_scene(frame.view()). You don't need to implement it for the common case.
}

#[pollster::main]
async fn main() -> Result<(), wsg_lib::utils::WsgError> {
    let app = AppBuilder::new().build().await?;

    // Register your scene once (string IDs), then App renders it automatically each frame:
    //   app.cache.register_shader("basic", wsg_lib::utils::BASIC_SHADER_PATH)?;
    //   app.scene.add_mesh("quad", Arc::new(mesh))?;
    //   app.scene.add_material("mat", Arc::new(Material::new(app.renderer.format(), "basic", &mut app.cache)))?;
    //   app.scene.add_entity("my_quad", "quad", "mat")?;

    app.run(MyGame)
}

API note: Scene::add_mesh / add_material / add_entity and PipelineCache::register_shader currently return Result<_, String> — typed error unification is on the roadmap.

Architecture overview

  • Manager layer (Context) — owns the GPU hardware lifecycle (Instance → Surface → Adapter → Device → Queue). Created once at startup; configure() sets up the swapchain, Frame wraps each frame's surface texture + view.
  • Executor layer (Renderer) — binds a Material pipeline + Mesh buffers into a RenderPass and submits the commands. Rendering a whole Scene (render_scene) batches all entities into one encoder + one submit per frame; the low-level render still allocates one per object.
  • Supporting pieces — PipelineCache (shader → compiled RenderPipeline, Arc-shared), Material, Mesh/Vertex, Scene (string-ID registry), Camera/Transform (types only, not yet used by the pipeline).

The planned target architecture — a GPU-driven two-pass pipeline (Compute Pass: world matrices + frustum culling → Indirect Draw Buffer, then a single draw_indexed_indirect per frame) — is specified in docs/tech/ARCHI_APP.md and docs/tech/ARCHI_CPU_GPU.md but is not implemented yet.

Quick reference

Concept Type Responsibility Status
App / AppBuilder Facade Window lifecycle + winit event loop + frame presentation ✅ (auto scene rendering via App::render_scene)
AppHandler Trait User-defined update() / render() callbacks ✅ (Frame::view() exposed; default render draws the scene)
Scene Struct String-ID registry: meshes, materials, entities ✅ (registry auto-rendered by the facade)
Context Struct GPU hardware lifecycle (Instance, Surface, Adapter, Device, Queue) ✅
Renderer Struct Binds Material + Mesh into a RenderPass, submits ✅ (render_scene batches one pass/frame)
PipelineCache Struct Shader → compiled RenderPipeline cache ✅
Material Struct Shader ID → RenderPipeline ✅
Mesh / Vertex Struct GPU geometry container / CPU-side vertex tuple ✅
Frame Struct Per-frame RAII wrapper (surface texture + view) ✅
Camera / Transform Struct Camera & transform math 📋 Types only, not in the pipeline

Getting started

WSG is not published on crates.io — depend on it by path:

[dependencies]
wsg-lib = { path = "/path/to/wsg/lib" }
pollster = "0.4"   # only if you use the async AppBuilder
Action Command
Build everything cargo build --workspace
Run the working example cargo run -p wsg-lib --example manual
Check everything (incl. examples) cargo check --all-targets

The manual example is the reference for the low-level workflow. The simple example (App facade) registers a colored quad and renders it automatically through the declarative path — it draws a scene without importing wgpu.

Documentation

The architecture docs live in docs/tech/ and are written in French. Each document states whether it describes the current (implemented) state or the target (planned, not yet implemented) architecture:

  • ARCHI_APP — engine architecture. 🎯 Target — the GPU-driven two-pass pipeline parts are not implemented yet.
  • ARCHI_CPU_GPU — CPU/GPU workload split specification. 🎯 Target — GPU-driven pipeline, ROADMAP Phase 3.
  • ARCHI_RENDU — update/render mutability model. 🎯 Target — model for the future scene auto-render.
  • ARCHI_ARENES — 🎯 Target/deferred — slotmap generational handles; String IDs are used today.
  • FRAME_LOOP — frame lifetime and resource persistence. ✅ Current — implemented.

Roadmap

  1. ✅ Scene auto-rendering — App::render_scene iterates registered entities and draws them in one encoder/submit per frame; the frame view is exposed to AppHandler::render for custom draws. (Done 2026-09-16.)
  2. GPU-driven two-pass pipeline — Compute Pass (world matrices + frustum culling) filling an indirect draw buffer, single draw_indexed_indirect (see ARCHI_CPU_GPU).
  3. CPU→GPU transform sync — persistent transform buffers with ring (triple) buffering.
  4. Real 3D pipeline — MVP uniforms + camera support in the vertex shader.
  5. Typed resource handles — keep String IDs for the MVP (current design, source of truth in Scene); slotmap-based generational handles (ARCHI_ARENES.md) are deferred to a later performance pass.
  6. Error unification — replace Result<_, String> in Scene/PipelineCache with typed errors.
S
Description
No description provided
Readme 2.2 MiB
Languages
Rust 94.2%
WGSL 5.8%