Rule A (area-sorted triangle removal) left holes and open boundaries on closed meshes (visible artifacts when zoomed far out). The decimator is now a proper quadric edge collapse: - Collapse: welded u32 topology, per-vertex quadrics (accumulated incident face planes), edge cost = quadric error at the optimal point (clamped to the segment) + edge length, BinaryHeap with a custom Ord (f32 is not Ord; inverted compare, NaN-safe). - Standard GH semantics WITHOUT the new face: both incident pair faces degenerate and are removed; neighbouring faces remap and sweep over the region. Preserves the Euler characteristic and closedness (no holes, no books, no duplicate faces); interior collapse = -2 faces, boundary = -1. Guards: non-manifold edge (>2 faces) or a fold (duplicate sorted triple) rejects the collapse. - welded(): fuzzy welding (1e-6 relative tolerance, grid + 27- neighbour broad phase, exact verify) - trig-generated seams differ by ~1e-16, exact-bit welding missed them. - Root causes fixed along the way: dead face slots are never reused (stale edge/vface entries), vfaces updated on remap, degenerate faces dropped, best-effort target (granularity -2/-1 can land 1-2 off; soft cap, deterministic). - Docs: DRAFT D10, ROADMAP 4.3, ARCHI_CPU_GPU, gpu-driven, lib README, mesh/scene/demo comments - 'greedy decimation / Rule A' replaced by 'quadric edge collapse'. - lod.rs test: deprecated glam perspective alias -> explicit glam::camera::rh::proj::opengl::perspective. cargo test --workspace: 100 passed (94 lib + 3 wgsl + 3 integration), 0 failed; demo runs clean with LOD on.
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 declarative workflow (
AppBuilder+App+AppHandler) is the recommended path and is fully working: scene auto-rendering (App::render_scene), 3D Phong lighting, textures, shadows, camera and unified input — thedemoexample is the showcase. The manual workflow (Context/Renderer/PipelineCache) coexists for fine-grained control. Meshes are declared from a CPUGeometry(retained asArc<Geometry>on the Mesh). The GPU-driven two-pass pipeline (Compute Pass deriving world matrices + frustum culling → indirect draws) is implemented (Step 15, Phase 3):render_sceneand the shadow pass are 100 % indirect, and frustum culling is opt-in (AppBuilder::with_culling(true), off by default) — see Status, docs/user/gpu-driven.md and Roadmap.
Status
| Area | State |
|---|---|
Manual workflow (Context + Renderer + PipelineCache) |
✅ Working (advanced — fine-grained control) |
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) | ✅ Working (Step 15, Phase 3) — render_scene + shadow pass are 100 % indirect; opt-in frustum culling (bug « fenêtre noire » fixed 2026-09-22 — WGSL select argument order — and verified by GPU readback, D14). User doc: gpu-driven.md · spec: ARCHI_CPU_GPU.md |
| 3D infrastructure (uniform bind groups, MVP + camera in the pipeline) | ✅ Working — the Renderer uploads per-frame camera matrices (active Camera) and per-entity world matrices to shared uniform buffers every frame; the MVP is reached (Step 5) : the cube example renders a rotating Phong-lit cube via the standard shader |
Note: standard_shader.wgsl (Phong, with an explicit unlit mode) is the single shader the library ships — flat 2D drawing is its unlit variant (Renderer::set_unlit(true) or app.renderer_mut().set_unlit(true)). See the cube example (3D, lit) and the simple example (2D, unlit).
What it does
Declarative workflow (recommended)
Register your scene once in setup(), then let App handle the window lifecycle, events,
input and frame presentation — without importing wgpu or winit. This is the workflow of
the simple, cube, demo, shadow_test and spot_test examples (excerpt below is simple):
use wsg_lib::app::AppBuilder;
use wsg_lib::resources::Geometry;
use wsg_lib::utils::WsgError;
use wsg_lib::AppHandler;
struct MonQuad;
impl AppHandler for MonQuad {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.renderer_mut().set_unlit(true); // 2D flat (optional)
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
let geometry = Geometry::new(vec![
[-0.5, 0.5, 0.0],
[ 0.5, 0.5, 0.0],
[ 0.5, -0.5, 0.0],
[-0.5, -0.5, 0.0],
])
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0],
[0.0, 1.0, 0.0, 1.0],
[0.0, 0.0, 1.0, 1.0],
[1.0, 1.0, 0.0, 1.0],
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
app.scene.create_mesh("quad_mesh", geometry, None).unwrap(); // None = default material
app.scene.add_entity("quad", "quad_mesh").unwrap();
}
// `update(&mut self, app)` — your per-frame logic (empty default).
// `render(&mut self, app, frame)` — default: `app.render_scene(frame.view())`,
// the whole scene is drawn automatically in one pass per frame.
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Simple").build().await?;
app.run(MonQuad)
}
API note:
Scenemethods currently returnResult<_, String>— typed-error unification is on the roadmap.Scene::create_mesh(id, geometry, material)takes a CPUGeometry(source of truth, retained asArc<Geometry>on the Mesh);material = Noneuses the scene's default material.
The full user documentation (meshes, materials, lights, shadows, camera & input, all examples) lives in docs/user.
Manual workflow (advanced — fine-grained control)
Bypass the App facade and drive Context, Renderer and PipelineCache yourself (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::{Geometry, Material, Mesh};
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)
// `set_unlit(true)` selects flat 2D rendering (the quad below is drawn in NDC space, unlit).
// Step 9: width/height size the depth buffer allocated inside the Renderer.
let mut renderer = Renderer::new(&context, format, 800, 600);
renderer.set_unlit(true);
let mut cache = PipelineCache::new(Arc::new(context.device.clone()));
cache.register_shader("standard", utils::STANDARD_SHADER_PATH).unwrap();
// Material + mesh (Step 8: the mesh is built from a `Geometry` — positions,
// optional attributes via builder, white defaults via `to_vertices`).
let material = Material::new(renderer.format(), "standard", &mut cache);
let geometry = Geometry::new(vec![
[-0.5, 0.5, 0.0], // top-left
[ 0.5, 0.5, 0.0], // top-right
[ 0.5, -0.5, 0.0], // bottom-right
[-0.5, -0.5, 0.0], // bottom-left
])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0], // red
[0.0, 1.0, 0.0, 1.0], // green
[0.0, 0.0, 1.0, 1.0], // blue
[1.0, 1.0, 0.0, 1.0], // yellow
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None);
// 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();
}
Architecture overview
- Manager layer (
Context) — owns the GPU hardware lifecycle (Instance → Surface → Adapter → Device → Queue). Created once at startup;configure()sets up the swapchain,Framewraps each frame's surface texture + view. - Executor layer (
Renderer) — binds aMaterialpipeline +Meshbuffers into a RenderPass and submits the commands. Rendering a wholeScene(render_scene) batches all entities into one encoder + one submit per frame; the low-levelrenderstill allocates one per object. - Supporting pieces —
PipelineCache(shader → compiled RenderPipeline,Arc-shared),Material,Geometry/Mesh/Vertex,Scene(string-ID registry),Camera/Transform(active camera wired to the frame uniforms, Step 4.3).Geometryis the CPU source of truth (positions/normals/UVs/colors),Meshuploads it to GPU buffers and retains theArc<Geometry>,Vertexis the interleaved upload contract (Step 8).
The GPU-driven two-pass pipeline (Step 15, Phase 3) is implemented: a Compute Pass derives each entity's world matrix and fills per-entity indirect draw arguments (with opt-in frustum culling), then the main and shadow render passes issue one indirect draw per active slot. Specified in docs/tech/ARCHI_APP.md and docs/tech/ARCHI_CPU_GPU.md; user-facing guide in docs/user/gpu-driven.md.
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 setup() / update() / render() callbacks |
✅ (default render draws the scene via App::render_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 | ✅ |
| Geometry | Struct | CPU-side scattered vertex data (positions/normals/UVs/colors/indices), source of truth | ✅ (Step 8 — retained Arc<Geometry> on Mesh) |
| Mesh / Vertex | Struct | GPU geometry container / CPU-side interleaved upload tuple | ✅ |
| Frame | Struct | Per-frame RAII wrapper (surface texture + view) | ✅ |
| Camera / Transform | Struct | Camera & transform math | ✅ Active camera + transform wired to per-frame uniforms (Step 4.3) |
| Texture | Struct | GPU diffuse image (device + view + sampler, Rgba8UnormSrgb) |
✅ (Step 10 — from_rgba8/from_bytes/from_file/white_placeholder) |
| Lights / Light | Struct | Scene-wide light list (directional + point + spot, MAX_LIGHTS = 8) + ambient |
✅ (Steps 12-13) |
| CameraController | Struct | Orbital camera (yaw/pitch/distance/target; orbit/zoom/reset/apply_to) |
✅ (Step 15.C) |
| InputState | Struct | Unified keyboard/mouse state (pressed/held/released, mouse delta, scroll) | ✅ (Step 15.B — app.input) |
| math::primitives | Module | Procedural Geometry generators (cube, plane, uv_sphere, icosphere, cylinder, cone, torus) |
✅ (Step 15.A) |
Getting started
WSG is not published on crates.io — depend on it by path:
[dependencies]
wsg-lib = { path = "/path/to/wsg/lib" }
pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (async AppBuilder)
winit = "0.30" # only if your code mentions winit types (KeyCode, MouseButton)
| Action | Command |
|---|---|
| Build everything | cargo build --workspace |
| Run the showcase (primitives, lights, shadows, orbital camera) | cargo run -p wsg-lib --example demo |
| Run the 3D MVP example | cargo run -p wsg-lib --example cube |
| Run the minimal example | cargo run -p wsg-lib --example simple |
| Run the shadow / spot light showcases | cargo run -p wsg-lib --example shadow_test / cargo run -p wsg-lib --example spot_test |
| Run the advanced (manual) example | cargo run -p wsg-lib --example manual |
| Check everything (incl. examples) | cargo check --all-targets |
The demo example is the showcase: one of each primitive, procedural textures, three lights, a shadow-casting light and a live orbital camera. simple is the minimal declarative app (a colored quad, unlit); cube is the 3D MVP (a rotating Phong-lit, textured cube); shadow_test and spot_test isolate the shadow and spot-light systems; manual is the reference for the low-level workflow. All of them except manual use the declarative path and draw a scene without importing wgpu.
Documentation
Three layers (user docs and API reference in English; technical docs in French):
User documentation — docs/user (how to use the API, no wgpu knowledge needed):
- Quickstart — first window, first object, in ~30 lines
- Meshes · Materials & textures · Lights
- Shadows · Camera & input · Examples
Technical documentation — docs/tech/ (internal architecture; each document states whether it describes the current or the target architecture):
- ARCHI_APP — engine architecture. ✅ Current — facade (
App/AppHandler) and GPU-driven two-pass pipeline (implemented in Phase 3, 2026-09-22, with the documented deviations); only the future double-buffering notes remain target. - ARCHI_CPU_GPU — CPU/GPU workload split specification. ✅ Current — implemented in ROADMAP Phase 3 (2026-09-22, Étape 17, decisions D1–D14); deviations from the original spec are noted in the document.
- ARCHI_RENDU — update/render mutability model. ✅ Current dichotomy (auto scene render) / 🎯 Target — material batching.
- ARCHI_ARENES — 🎯 Target/deferred — slotmap generational handles; String IDs are used today.
- FRAME_LOOP — frame lifetime and resource persistence. ✅ Current — implemented.
API reference — full rustdoc: cargo doc -p wsg-lib --no-deps (every public type is documented).
Roadmap
- ✅ Scene auto-rendering —
App::render_sceneiterates registered entities and draws them in one encoder/submit per frame; the frame view is exposed toAppHandler::renderfor custom draws. (Done 2026-09-16.) - ✅ GPU-driven two-pass pipeline — Compute Pass (world matrices + frustum culling) filling an indirect draw buffer, then indirect draws (see ARCHI_CPU_GPU). (Done 2026-09-22 — see item 17. Deviation from the original spec: one indirect draw per slot rather than a single fused draw, D1 — see ARCHI_CPU_GPU.)
- CPU→GPU transform sync — persistent transform buffers with ring (triple) buffering.
- ✅ Real 3D pipeline (MVP reached) — MVP uniforms + camera support in the vertex shader. (Engine plumbing done 2026-09-16; Step 5, 2026-09-17:
standardwired into thecubeexample — a unit cube lit (Phong) and spinning, rendered automatically byApp::render_scene. Removal ofbasic: flat 2D = unlit variant ofstandardviaRenderer::set_unlit.) - 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. - Error unification — replace
Result<_, String>inScene/PipelineCachewith typed errors. - ✅ CPU geometry storage (Step 8) —
Meshretains a sharedArc<Geometry>(CPU source of truth with colors) alongside its GPU buffers; meshes are declared from aGeometryviaMesh::from_geometry/Scene::create_mesh(id, geometry, material)instead of raw&[Vertex]arrays. (Done 2026-09-18;transformstays onEntity— deviation D3.) - ✅ Diffuse textures (Step 10, Phase 4.1) —
resources::Texture(GPU image: device+view+sampler,Rgba8UnormSrgb, loadersfrom_rgba8/from_bytes/from_file) attached to aMaterialas diffuse texture. Thestandardshader samples it via bind group @2 (shared layout: sampler+texture); UVs are forwarded as vertex attribute location 2. Without a texture the material uses a shared 1×1 white placeholder so lit and unlit rendering are unchanged (no regression). Thecubeexample now uses a procedural checkerboard texture. (Done 2026-09-18.) - ✅ Window resize (Step 11, Phase 4.4) —
App::resizereconfigures the surface (Context::configure) and recreates the depth texture (Renderer::resize_depth) together on eachWindowEvent::Resized, so color and depth attachments always match. Guards against 0×0 (minimize). The surface format is re-synced to the Renderer and Scene if it ever changes. (Done 2026-09-18; verified at runtime on thecubeexample.) - ✅ Multi-lighting (Step 12, Phase 4.2) — the scene now carries a global light list (directional + point) with a white ambient, uploaded into the per-frame
FrameUniformsarray each frame.Scene::add_directional_light/add_point_light/set_ambient/clear_lightsconfigure it;FrameUniforms::default()(one white directional along +Z + white ambient) reproduces the pre-multi-light look exactly. Thestandardfragment accumulates ambient + all lights; thecubeexample adds a warm point light on top of the default directional. (Done 2026-09-18.) - ✅ Spot lights (Step 13, Phase 4.2) — spot lights (oriented cone + half-angle) added on top of the multi-lighting system.
Scene::add_spot_light(pos, dir, color, intensity, radius, half_angle)registers a spot light; thestandardfragment accumulates a spot term with a smoothed penumbra (half-angle ± 0.1 rad) and linear attenuation.Lightgrew from 48 to 64 bytes (addeddir_angle);FrameUniformsfrom 576 to 704 bytes (addednum_spot). Non-regression: default scene unchanged. Thecubeexample adds a green spot light aimed at the cube. (Done 2026-09-18.) - ✅ Shadows — shadow mapping (Step 14, Phase 4.2, optional) — classic two-pass shadow mapping on a single light (directional or spot), selected by
Scene::set_shadow_caster(index). A depth-only pass (shadow_shader.wgsl+ dedicatedshadow_pipeline) renders the scene into a 1024²Depth32Floatshadow map (Renderer-owned, slope-scaled depth bias); thestandardfragment re-projects each fragment into light space and applies a PCF 3×3 comparison-sampler test (bind group @3, shared).FrameUniformsgrew from 704 to 784 bytes (shadow_light_index,light_view_proj,shadow_params). Shadows are off by default (shadow_caster = None) sosimple/cube/manual/spot_testare unchanged. Theshadow_testexample casts a soft shadow from a cube onto a ground slab. (Done 2026-09-19.) - ✅ Procedural primitive meshes (Step 15.A) —
math::primitivesprovides drop-inGeometrygenerators (cube,plane,uv_sphere,icosphere,cylinder,cone,torus) with positions + per-face/smooth normals + UVs + indices. Re-exported atmath::*. Thecubeandspot_testexamples now reusemath::cube(1.0)(thecube_geometryhelper was factored away;shadow_testkeeps its genericbox_geometry). (Done 2026-09-20; 6 unit tests.) - ✅ Unified input (Step 15.B) —
core::input::InputStategives cross-frame pressed/held/released semantics for keyboard (physicalKeyCode) and mouse (buttons, position, per-frame delta, wheel scroll), rotated bybegin_frame/end_framearoundAppHandler::update.Appexposes it as a publicinputfield, fed from winitWindowEvents and reset each frame. Gamepad is reserved/deferred (DRAFT D7). (Done 2026-09-20; 5 unit tests; winit event handling is host-driven on the CPU, not WGSL.) - ✅ Orbital camera + final demo (Step 15.C) —
resources::CameraController(yaw/pitch/distance/target,apply_towrites into aCamera, drag-orbit + wheel-zoom + clamps) drives the newdemoexample: one of each primitive, procedural textures, standard Phong material, a shadow-casting directional light + point + spot, and live mouse-orbit / wheel-zoom /Rreset /1/2/3view presets. Run withcargo run -p wsg-lib --example demo. (Done 2026-09-20; runtime-verified headless.) - ✅ User documentation (Step 16, Phase 5) —
docs/user/(quickstart, meshes, materials, lights, shadows, camera & input, examples) written in English and cross-linked to each other, to the tech docs and to rustdoc; tech docs interlinked with their stale status banners refreshed; this README re-anchored (declarative workflow = recommended, manual = advanced,demo= showcase, pollster 1.x). (Done 2026-07-19.) - ✅ GPU-driven rendering (Step 15, Phase 3.1/3.2/3.3) — world matrices and indirect draw args move from CPU to GPU.
shaders/gpu_driven.wgsl(two compute entry points,compute_matrices+cull, one module, explicit 3-group layout) runs before the render passes over a fixed 256-slot table (the world-matrix buffer is bound to theuniformobject slot; WebGPU caps auniformbinding at 64 KB and auniformoffset at 256 B, so each matrix slot is padded to 256 B and 256 × 256 B = 64 KB is the max);render_sceneand the shadow pass become 100 % indirect (one indirect draw per active slot, culled/inactive slots are no-ops), and the per-entity CPU draw loop is gone. Newmath::Frustum(Gribb–Hartmann, WebGPU[0,1]z) +BBoxonGeometry;TransformSlot/MatSlot/BBoxSlot/DrawSlot/CullUniformsPod mirrors of the WGSL structs. Frustum culling is off by default (non-regression) and opt-in viaAppBuilder::with_culling(true)/Renderer::set_culling(bool); thedemoenables it. The object bind-group layout is now dynamic so every entity shares one GPU matrix buffer via per-slot offsets. (Done 2026-09-22; WGSL + frustum + scene-slot tests, 57 lib / 3 WGSL / 3 doctests all green. Culling fix 2026-09-22: the WGSLselectarguments had been written HLSL-style, silently zeroing the draw count of every visible entity — a black window; fixed and verified by GPU readback, see D14 in ARCHI_CPU_GPU.md.)