Mesuré avant d'écrire : sur les 167 fichiers .rs/.md que le paquet emporte, 13 liens relatifs, dont 10 sortaient de la racine du crate et 3 pointaient `examples/assets/`, un dossier `exclude` du manifeste. Aucun n'était un lien mort DANS LE DEPOT — cargo test vert, cargo doc vert, et c'est chez le téléchargeur que la cible manque. Le verrou de liens existant ne marchait que `docs/user/**` (13.9, septième occurrence : le périmètre est l'endroit où le défaut a été trouvé la première fois). Verrou : les_fichiers_expedies_n_ont_aucun_lien_hors_du_paquet (package_contract, 16 verrous), deux mâchoires — cible hors racine du crate, et cible sous une entrée `exclude` lue dans le manifeste. Non-vacuité calibrée sur deux bornes mesurées : 9 liens avant correction, 8 après, 0 si la marche devient muette ; plancher 4. Limite écrite : les 8 liens restants sont tous sous examples/, donc une marche src/ muette ne ferait pas bouger le plancher. C'est la deuxième mâchoire qui a trouvé un énoncé faux plutôt qu'un lien : examples/README.md présentait assets/textures/ comme « shipped » alors que D9 l'exclut — vert dans le dépôt, faux chez l'appelant. Le texte nomme maintenant que les médias ne voyagent pas, quelles démos les lisent, et ce que répond le chargeur (NotFound, pas un placeholder).src/README.md § 4 faisait la même affirmation pour assets/gltf/. Corrections : les 10 liens sont nommés plutôt que liés (Renderer::build_gpu_descriptors plutôt qu'ARCHI_APP.md, Renderer::record_ssao_passes plutôt qu'un draft), base de résolution = répertoire du fichier. core/particles.rs pointait ARCHI_PARTICULES.md pour citer un fait (Particle = 112 o) ; la valeur se relit dans Particle::SIZE. Frontière de langue (D17) : le tri se fait par visibilité rustdoc, non par fichier. Cliquet de la surface expédiée reposé à la mesure, chaîne consignée au draft 16 § 22 : 2375 -> 513. Traduits dans la séance : renderer.rs, scene.rs, batch.rs, batch_sort_tests.rs, visibility_tests.rs, pipeline_cache.rs, lib.rs, sky.rs, texture/gpu.rs, uniform.rs, viewport.rs, frame.rs, alloc_counter.rs, particles.rs, context.rs, geometry.rs, conf.rs, line_point.rs, shared/sources.rs, examples/cameras/README.md — plus 21 lignes réellement affichées par les exemples (le seul endroit où l'utilisateur voyait du français sans le savoir) et valeur_bloom -> bloom_value. Campagne mutate-langue.py : L1 rouge, L2 fixture rouge / cliquet vert, L3 et L4 verts par la frontière (privé, module #[cfg(test)]). L5 (bandeau d'exemple) est consigné NON JOUÉ : trois constructions ont échoué, la quatrième rendait VERT sans avoir muté — la famille 13.4 / 9.4, pas un succès. Ce qui reste ouvert, écrit : les mentions en prose d'un chemin docs/... dans un fichier expédié ne sont pas verrouillées ; docs/user/** (31 fichiers) reste à traduire, hors paquet donc hors cliquet. Harnais honnête : 1020 tests, 0 échec, 0 skip, 0 doc-test ignoré, 0 diagnostic rustdoc, 0 diagnostic sur cargo check --examples.
WSG — a light, GPU-driven 3D library on top of wgpu
WSG wraps wgpu into a scene API: you declare meshes, materials and
entities, and the library runs the window, the frame loop, the batching and the presentation. You
never touch Instance / Adapter / Device / Queue, and you pay for nothing you did not ask for.
Four properties, and what each one actually means:
| Light & opt-in | One façade over wgpu; every effect is switched on by you, and a disabled effect allocates nothing and executes nothing. |
| GPU-driven | The per-frame work that can live on the GPU does: instance matrices, frustum culling, compaction, draw counts, transparent sorting, particle simulation. |
| Built for frame budgets | Everything is allocated before the render loop; resources live in memory arenas; nothing is rebuilt or re-uploaded per frame unless it changed. |
| Usable by an agent | The library is designed to be driven by an AI coding agent, and that is a testable property, not a mood: every name, command, snippet and ceiling an agent could copy is confronted with the code by a test, and the suite refuses to report a success it did not observe. What guarantees it is in §4. |
1 · Light: only what you enable is there
You don't need to know wgpu. App + AppBuilder + Scene never ask you to name a wgpu type —
measured, the quickstart below compiles in a crate whose only dependencies are wsg-lib and
pollster, with no wgpu, no winit and no glam declared. A consumer crate living in this
repository (consumer/) writes exactly those gestures and is compiled and tested by the repository's
own test harness, so the claim is re-checked, not asserted. If you deliberately leave the façade, the types you need are
re-exported as wsg_lib::wgpu — details in
README_DETAILS.md.
Opt-in, and the cost of not enabling is zero. Not "cheap": zero. A disabled effect allocates no texture, records no pass, and where the shader still runs it switches on a uniform flag instead of executing the effect.
| Effect | You switch it on with | If you don't |
|---|---|---|
| Shadows (PCF, cascades) | scene.set_shadow_caster(Some(idx)) |
no shadow map, no depth pass, no PCF tap |
| HDR + tone mapping | AppBuilder::with_hdr(ToneMapper::Aces) |
no offscreen texture, no tone-map pass |
| Bloom / DoF / MSAA / fog | with_bloom(..) / with_dof(..) / with_msaa(..) / with_fog(..) |
no texture, no pass, no uniform |
| SSAO (needs the depth prepass) | AppBuilder::with_ssao(cfg) (implies the prepass) |
no normal texture, no AO passes |
| GPU-driven culling | AppBuilder::with_culling(true) |
no compute pipeline, no indirect buffers |
| Area lights, IBL, skybox, parallax, log depth | per scene or per material | analytic fallback, one u32 compare, or nothing |
| Primitives, file importers | Cargo features (prim-*, import-*) |
not compiled into your binary at all |
The full switch list, with what each one costs when it is on, is in README_DETAILS.md.
2 · GPU-driven: the work that can move, moved
render_scene records, in one encoder and one submit: a compute stage that derives what to draw, then
one indirect draw per batch. The CPU sends a small table of parameters; the GPU decides what is
visible and how many instances survive.
| Work | Where it runs |
|---|---|
| World matrices of each instance of a batch | GPU — compute pass compute_matrices; for a GPU-resident batch your own compute shader writes them |
| Frustum culling, per instance (not per batch) | GPU — cull, and cull_shadow for the shadow pass |
| Compaction of surviving instances | GPU — cs_count / cs_compact, and instanceCount of the indirect draw is written by the GPU |
| Draw counts and draw arguments | GPU — draw_indexed_indirect; a culled batch costs no rasterization |
| Back-to-front order of transparent batches | GPU — radix sort (6 dispatches) inside each sortable batch |
| Particle simulation and particle sorting | GPU — compute update + compute sort, per pool |
| Shadow map, depth prepass, SSAO, bloom, DoF, tone map | GPU — their own passes, timed individually |
| "Which instance is under the cursor?" | GPU — id-buffer picking pass, exact to the silhouette |
| Timing of each pass | GPU — timestamp queries, printed by the stats feature as milliseconds |
Deliberately on the CPU, because the CPU is the cheaper place for it: the LOD level choice (one distance test with hysteresis, then applied through a GPU table), BVH raycasting and picking by entity, and authoring of transforms. The GPU is not a religion here; it is where the cost is.
3 · Built for frame budgets
- Declare everything before the loop.
setup()creates the GPU buffers: mesh vertex/index buffers, materials, textures, entity slots, batch tables. Frames then only upload and draw. - Tables are pre-sized, not grown. 256 batch slots, 100 000 instances, a fixed set of double-buffered CPU→GPU tables. Going past a budget is refused with a message naming the entry you called — not accepted and then panicking inside the first frame.
- Arenas, not hash maps. Meshes, materials, textures, entities, particle pools and debug points
live in generational arenas (
slotmap) with free lists, including the GPU table rows: removal is O(1), the index is reused, and a stale handle is detected instead of silently referring to something else. - Nothing is rebuilt per frame. Pipelines are cached (a lock counts shader compilations: an
instanced batch used to recompile its pipeline at every draw — 225 compilations for 9 frames); a
batch's matrices are uploaded once and re-armed only when they change (
dirtyflag); the derived CPU quantities of a static batch are computed once per frame, not once per read. - Measured, not assumed. The release profile is
opt-level = 3,lto = "fat",codegen-units = 1; and thestatsfeature prints FPS, draw calls, instance counts and per-pass GPU milliseconds, so "CPU-bound or GPU-bound?" is a reading, not a guess.
Simulations: animating thousands of objects
The same property that makes a scene cheap — one draw per batch — is what makes a simulation cheap. You get a frame budget for the simulation itself, not for the drawing.
// Illustrative — `lib/examples/cameras/instances.rs` is the runnable version.
// 100 000 cubes: one batch, one draw call, per-instance culling.
let ts: Vec<Transform> = grid_of_transforms();
app.scene.add_instance_batch("grid", "cube", &ts)?;
// Per frame, mutate the matrices in place; the upload is gated by a dirty flag.
app.scene.with_batch_matrices_mut("grid", |mats| {
for (i, m) in mats.iter_mut().enumerate() {
*m = transforms[i];
}
});
What is available for a simulation:
| Need | API | Example to run |
|---|---|---|
| Thousands of moving objects | add_instance_batch · instance_batch · with_batch_matrices_mut |
cargo run -p wsg-lib --example instances |
| Motion computed on the GPU, with your own compute shader | set_batch_gpu_resident · restore_batch_matrices |
cargo run -p wsg-lib --example instances_sim |
| What is actually on screen | per-instance culling + compaction, instanceCount written by the GPU |
cargo run -p wsg-lib --example culling |
| Particle systems (pools of instances) | create_particle_pool + attach_gpu_emitter / attach_cpu_emitter / attach_manual |
cargo run -p wsg-lib --example particles |
| Deformable geometry (per-frame vertices) | update_geometry (fast path: one write_buffer when the vertex count is unchanged) |
cargo run -p wsg-lib --example deformable |
| Collision between scene objects | collides (AABB) · collides_precise (BVH) |
cargo run -p wsg-lib --example collision |
| Selecting what is under the cursor | pick_instance_at (GPU) or ray_at_ndc (CPU, exact to the triangle) |
cargo run -p wsg-lib --example pick_instance |
| Hiding part of a population | set_batch_visible (zero uploads) or a per-instance visibility flag |
cargo run -p wsg-lib --example visibility |
| Correct transparent overlap | GPU back-to-front sort per batch | cargo run -p wsg-lib --example instance_sort |
| Scale + camera for a large world | Camera::with_perspective(fov, near, far), log_depth, orthographic camera |
cargo run -p wsg-lib --example ortho |
Two scales are worth knowing, both measured on this repository: 100 000 instances in one draw call per batch, and a stable frame at 720p once per-frame uploads are gated (the bench prints the GPU milliseconds of each pass). Below a few thousand instances, the plain per-entity path is cheaper than a compute pass — the crossover is printed, not asserted.
Quickstart
use wsg_lib::prelude::*;
use wsg_lib::app::AppBuilder;
use wsg_lib::utils::WsgError;
struct MyScene;
impl AppHandler for MyScene {
fn setup(&mut self, app: &mut wsg_lib::App) {
// "standard" is a built-in shader id — it needs no registration (13.15).
app.scene
.add_material_shader("mat", "standard")
.unwrap();
app.scene
.create_mesh("cube", cube(1.0), Some("mat"))
.unwrap();
app.scene
.add_entity("my_cube", "cube")
.unwrap();
}
}
// `AppBuilder::build` is async (it awaits adapter selection), hence the async main.
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new()
.title("My WSG scene")
.with_hdr(ToneMapper::Aces) // optional
.build()
.await?;
app.run(MyScene)
}
The full showcase, with 6 primitives, 3 lights, shadows, HDR and LOD:
cargo run -p wsg-lib --example demo
Demo media: 14 examples need the repository, not the package
The published crate deliberately ships without the demo media — the glTF models, the HDR
environments and the six skybox JPEGs, 18 MB measured. It is what keeps the package at
2.3 MB (lib/Cargo.toml, exclude), and it is a decision, not an oversight.
Fourteen examples load those files. Run one from a crates.io download and it prints, before the first frame:
Demo media
Missing: /home/you/wsg/lib/examples/assets/textures
The media this example loads are NOT in the published package.
They are in the repository, under `lib/examples/assets/`. To run this
example, clone the repository and run it from the clone:
git clone https://git.iut-rodez.fr/jerome/wsg wsg
cd wsg/lib && cargo run --example <name>
A missing file here is expected for a crates.io download, not a bug.
then fails on the load, with the path named. To run them, use a clone:
git clone https://git.iut-rodez.fr/jerome/wsg wsg
cd wsg/lib && cargo run --example skybox
What is not affected: the library itself reads none of those files, and every test that reads
media reads fixtures shipped inside the crate (lib/assets/tests/). That is why the test suite is
green from a package download while those fourteen examples are not — and why the banner above
names a clone rather than a bug tracker.
What's in the box
- Rendering — Phong + PBR (metallic/roughness, normal maps), unlit mode, HDR + tone mapping, parallax (relief from the height stored in the normal map's alpha), logarithmic depth, IBL / env map (SH9 diffuse + GGX-prefiltered cubemap), skybox
- Lighting — directional, point, spot (8 max), rect area lights (analytic irradiance + bounded specular highlight), hemisphere ambient, opacity maps, detail maps with distance fade-out, UV repetition-breaking, PCF shadows and cascaded shadow maps (1–4, opt-in)
- Post-process — bloom, fog (3 modes), depth of field, MSAA, SSAO reading the prepass normals
- Geometry — 6 procedural primitives, OBJ / STL / glTF import, custom geometry, LOD by quadric decimation
- Textures — PNG / JPEG files and procedural generation (checkerboard, gradient, noise)
- Transparency & sprites — alpha-blended / additive materials, GPU-sorted; billboarded quads with atlas animation
- Camera & input — orbital camera, perspective/ortho, unified keyboard/mouse/scroll, raycasting and picking, debug lines/points/BBox overlays
50 runnable examples in 4 category folders, each explained on stdout with the effect, the expected visual result and the keys: lib/examples/README.md
4 · Usable by an agent
A coding agent uses a library through the same documents you read, and it fails in three predictable ways: it invents an API when a name is ambiguous, it copies a command whose verdict lies, and it reports success from a test that never ran. WSG is arranged so that each of those fails loudly. The claim is carried by instruments, listed next to the failure it prevents:
| What the agent would get wrong | What guarantees it does not |
|---|---|
| A snippet that does not compile | The quickstart block is extracted and compiled verbatim, in a crate whose only dependencies are wsg-lib and pollster, as case T0 of campaigns/mutate-agent-contract.py — an instrument that must know how to say “true” before its refusals mean anything. Measured again 2026‑11‑05: exit 0. The repository also keeps a consumer crate (consumer/) doing the façade gestures, compiled by the suite. |
| An invented method name | documented_api_names_exist_in_the_code types the receiver of every call inside a rust block of the README and docs/user/** and looks it up in the real API; documented_example_commands_name_a_declared_example matches every documented --example command against the manifest. |
| A ceiling it walked past, then a panic mid-frame | Budgets are refused at the call, with a message naming the entry and the counts that got there; the scene is unchanged. the_error_contract_names_every_refused_ceiling derives the ceilings from the shipped source and compares them with docs/user/errors.md, in both directions: an undocumented guard, or an invented ceiling, both fail. |
| A verification command that counts skipped GPU tests as passed | ./run-tests-honest.sh exits non-zero when any test skipped, and refuses to write docs/generated/TEST-SUITE.md when its two independent counters disagree. whole_suite_advice_names_the_honest_harness fails if any document recommends the plain whole-suite command without naming the one that refuses. |
| A described effect that does nothing | Each example prints its own falsifiable prediction (.expects()), and every_banner_claim_has_a_lock requires each printed claim to be linked to a test. |
| Its own build noise, read as a signal | Building wsg-lib as a dependency emits no warning: missing_docs is deny-level, so an undocumented public item is a build error rather than a hint an agent learns to scroll past. |
| A “fixed” claim nobody re-checked | The locks above are themselves mutated by campaigns (mutate-agent-contract.py, mutate-consumer.py, mutate-doc-api.py, mutate-doc-commands.py): a lock that never saw its own defect fail is scenery. |
So: point an agent at this repository, and the shortest honest instruction is “change the code, then
make ./run-tests-honest.sh refuse to complain”. It is not a style preference — the suite is what
separates “compiles” from “does what the document says”.
Documentation
| Where | What |
|---|---|
| README_DETAILS.md | Everything under the hood: architecture, pass pipeline, the opt-in cost table, Cargo features, the full example list, build and release profile |
| docs/user/ | User guide — step-by-step API usage |
| docs/tech/ | Internal architecture and design decisions |
cargo doc -p wsg-lib --no-deps |
API reference (rustdoc) |
- Language: Rust 2024 (
rust-version = "1.87"). - Dependencies: wgpu 30, winit 0.30, glam — re-exported where the API needs them named.
- License: MIT — see
lib/LICENSE.