Files
wsg/README_DETAILS.md
Jérôme Bousquié 997e2d7b66 18 · le contrat lu par un agent : README §4, deux verrous, une campagne, 21 → 0
L'assertion « cette bibliothèque est utilisable par un agent » est maintenant une
propriété testable, et le README la porte en quatrième propriété avec ce qui la
garantit, énoncé par côté du défaut qu'elle empêche (README §4).

Ce que la mesure a trouvé, puis corrigé — les trois trous identifiés :

1. TROUS DE COMMANDE. README_DETAILS conseillait `cargo test --workspace` sans jamais
   nommer `./run-tests-honest.sh` : le document envoyait le lecteur (donc un agent) vers
   la commande qui compte `passed` un test GPU revenu tôt faute de device (gotcha 13.1).
   Corrigé dans les deux README ; verrou `whole_suite_advice_names_the_honest_harness`
   (périmètre 4 fichiers, AGENTS.md exclu, non-vacuité 4 occurrences mesurées / plancher 3).
   Trouvé ROUGE sur l'arbre d'ouverture par son propre verrou.

2. BUILD BRUYANT CHEZ L'APPELANT. 21 avertissements fuyaient dans le build d'une crate
   consommatrice (19 `missing_docs`, `unused_mut`, 3 champs `never read`) → 0.
   - les 19 doc-comments ajoutés (chiffres et oracles cités vérifiés : `FIELD_TOWER_SCALE`
     lu par `pick_tests.rs:2520`, `BLEND_WINDOW` par 4244, le 64/0 de `TexelFilter` mesuré 6.12) ;
   - `missing_docs` passe à `deny` **hors `cfg(test)`** : brut, il faisait échouer le build
     de test (7 erreurs sur `testutil`/`alloc_counter`/`geometry::count`, auxiliaires `pub`
     qui ne sont pas l'API). Porte justifiée par mesure avant pose : 0 warning sous le jeu
     de features du harnais et sous `--all-features`. Prouvée rouge en retirant la doc d'un
     variant (`error: missing documentation for a variant`) ; la porte est épinglée par le
     compilateur, pas par un parseur à faux positifs (14-F2) ;
   - `sort_hist_buf`, `sort_keys_alt_buf`, `sort_digit_buf` supprimés du Renderer : liés aux
     bind groups du tri (stockés), donc gardiens redondants — un bind group wgpu garde ce
     qu'il lie vivant (précédent E4, F4-G7). Commentaires de conception déplacés au site de
     construction. Preuve = harnais rejoué, pas argument.

3. AUCUNE PORTE POUR LE CONTRAT D'ÉCHEC. Créé `docs/user/errors.md` (plafonds, ce que
   l'appelant reçoit, ce qui n'arrive PAS) + index. Verrou dérivé dans les deux sens :
   `the_error_contract_names_every_refused_ceiling` relit les plafonds dans le code expédié
   (gardes `> MAX_` + messages `Err(format!(`) et les confronte à la page : garde non
   documenté → rouge, plafond inventé → rouge, `MAX_CASCADES` réel mais non refusé → VERT
   consigné. Ensemble dérivé mesuré : 4 (`MAX_BATCHES`, `MAX_INSTANCES`, `MAX_LIGHTS`,
   `MAX_LOD_LEVELS`). Index utilisateur : 50 exemples annoncés (il en annonçait 24 ; mesure :
   50 `[[example]]`).

Campagne `campaigns/mutate-agent-contract.py` : T0 double (verrous de doc + compilation
verbatim du bloc quickstart dans une crate à deux dépendances) + 6 cas, 0 écart, arbre rendu.
A1 rend l'API du bloc fictive → ROUGE : c'est le seul cas qui prouve que la jambe « vitrine »
lit le README et non un souvenir.

Trois fautes d'instrument trouvées en construisant l'instrument (toutes consignées) :
scanner qui tronquait `MAX_U16_VERTICES` en `MAX_U` ; cas A4 bâti sur `MAX_SPRITES` cru
inventé alors que c'est une constante réelle (`renderer.rs:2070`) ; mutations A3 / sonde de
gate qui n'appliquaient pas l'effet annoncé (une colonne d'un tableau, la première ligne
d'un doc-comment à trois lignes, une erreur de syntaxe au lieu du lint).

Faute commise et consignée : `git checkout --` sur un fichier non commité a détruit mes
propres doc-comments de la séance (gotcha 6.19 / 12.13, que ce dépôt documente) ; réappliqués.

Harnais honnête après tout cela : 1010 exécutés, 0 échec, 0 skip, 19 doc-tests joués.
Paquet et artefact non re-jeués ici (aucun fichier d'artefact de contrat touché hors doc).
2026-11-06 09:19:38 +01:00

27 KiB
Raw Permalink Blame History

WSG — Detailed Documentation

Technical content from the main README: status, architecture, API reference, workflows, 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, automatic scene rendering
Scene resource/entity registry ✅ Working — auto-rendered in one batched pass (App::render_scene)
GPU-driven two-pass pipeline (Compute → indirect draw) ✅ Working (Phase 3) — render_scene + shadow pass 100% indirect; opt-in frustum culling
3D infrastructure (uniform bind groups, MVP + camera) ✅ Working — per-frame camera + per-entity world matrices in shared uniforms
Shadows (shadow mapping) ✅ Working — directional/spot, slope-scaled bias, PCF 3×3
HDR + Tone Mapping ✅ Working — offscreen Rgba16Float, ACES/Reinhard, opt-in
LOD (Level of Detail) ✅ Working — quadric decimation, hysteresis, multi-level buffer
Mesh module (primitives + import) ✅ Working — feature-gated primitives, OBJ + STL + glTF parsers
Bloom ✅ Working — threshold + separable blur + composite, HDR required
Fog ✅ Working — 3 modes (linear, exp, exp²), runtime switchable
MSAA ✅ Working — 4× multisample, resolve pass
DoF ✅ Working — CoC + disc blur, focus presets
PBR (metallic/roughness + normal maps) ✅ Working — Cook-Torrance, GGX, IBL, screen-derivative TBN
Parallax mapping (6.25) ✅ Working — opt-in per material, height read from the normal map's alpha channel, Basic / Occlusion
Logarithmic depth buffer (6.31) ✅ Working — opt-in global, kills z-fighting beyond far > 1000
IBL / Env map (6.22) ✅ Working — opt-in per scene; diffuse from SH9, specular from a GGX-prefiltered Rgba16Float cubemap
Skybox (6.36) ✅ Working — opt-in (with_skybox); the same HDR environment as background, drawn from six real cube faces: never parallaxes, never fogged
Cascaded shadow maps (6.6) ✅ Working — opt-in, 1–4 cascades, split_blend, texel-grid alignment; only worth it on large scenes (a single map is already dense at a 5 m radius)
Depth prepass (6.35) ✅ Working — opt-in; must be armed before any material is created (set_depth_prepass afterwards refuses and logs)
SSAO (6.7) ✅ Working — reads the prepass normals (Rg8Unorm, octahedral), bounded in view space, applied in both fragment entry points; with_ssao(cfg) implies the prepass
Area lights (6.14, 6.37) ✅ Working — rect emitters (add_area_light): analytic irradiance over the clipped half-rectangle, plus a mirrored specular highlight bounded by F · L
Instanced batches (Phase 12) ✅ Working — one draw_indexed_indirect per batch, instanceCount written by the GPU, per-instance frustum culling + compaction, caller-owned GPU-resident matrix buffers; MAX_BATCHES = 256 is a batch ceiling, not an entity ceiling
Instance picking (12.13) ✅ Working — GPU id-buffer picking (pick_instance_at), exact to the silhouette; the per-entity raycast (9.2) stays the better path for entities
Back-to-front sort (Phase 14) ✅ Working — GPU radix sort of compact_index inside each transparent batch; a fixed ~0.25 ms per frame whenever a sortable batch exists (measured, bench 14‑F7)
Publishing (wsg-lib, registry gates) ✅ Gates closed and measured — metadata complete, MIT licence (manifest field and lib/LICENSE text cross-checked), demo media and repository instruments excluded, package contract locked (lib/tests/package_contract.rs: the facade is closed under the types of its own fields; pub use wgpu; at crate level, never in the prelude). Not published yet — pushing to the registry is a decision, not remaining work (draft 16)

Licence: MIT — lib/LICENSE, shipped inside the package.

Note: standard_shader.wgsl (Phong + PBR, with an explicit unlit mode) is the single shader the library ships. Flat 2D drawing is its unlit variant (Renderer::set_unlit(true)).

Do you need wgpu in your own code?

No. The façade (App + AppBuilder + Scene) never asks you to name a wgpu type — measured, the quickstart compiles in a crate whose only dependencies are wsg-lib and pollster (no wgpu, no winit, no glam declared). That is not a claim about style: a consumer crate living in this repository (consumer/) writes the façade gestures and is recompiled against the built library on demand, and a lock derives the set of types the façade can hand back from the façade itself — public fields and method returns of every prelude type — and requires each to be nameable through wsg_lib::* (13 names across 3 roots, 42 occurrences, 0 unknown).

If you deliberately leave the façade — a compute pass, or a texture constructor that takes back the Device and Queue WSG created — name those types through wsg_lib::wgpu instead of adding wgpu to your own dependencies:

// `app` is your `wsg_lib::App`
let device: &wsg_lib::wgpu::Device = app.scene.device();
let buffer = device.create_buffer(&wsg_lib::wgpu::BufferDescriptor {
    label: Some("my compute buffer"),
    size: 64,
    usage: wsg_lib::wgpu::BufferUsages::STORAGE,
    mapped_at_creation: false,
});

Adding wgpu yourself means guessing which version WSG pins; measured, a wrong guess (wgpu = "22") fails with E0308 expected BufferDescriptor<Option<&str>>, found a different BufferDescriptor<Option<&str>> plus a note about two copies of wgpu_types — diagnosable, but only after reading our manifest. Through the re-export it cannot happen. wgpu is deliberately not in wsg_lib::prelude: the façade stays the default path, the re-export is an emergency exit. The same reasoning put glam (Vec3, Vec4, Quat, Mat4) in the prelude: Transform's own fields are glam types, so a façade-only consumer had no way to name what the API handed them.

Feature catalogue

The detailed list, grouped the way the modules are:

  • Rendering — Phong + PBR (metallic/roughness, normal maps via a screen-derivative TBN), unlit mode (set_unlit), HDR (Rgba16Float offscreen) + ACES/Reinhard tone mapping with exposure, parallax mapping (height read from the normal map's alpha, Basic / Occlusion), logarithmic depth (opt-in, kills z-fighting past far > 1000), IBL / env map (SH9 diffuse + GGX-prefiltered Rgba16Float cubemap), skybox (the same environment drawn from six real cube faces: never parallaxes, never fogged)
  • Lighting — directional / point / spot (8 lights), rect area lights (analytic irradiance over the clipped half-rectangle, mirrored specular bounded by F · L), hemisphere ambient, opacity maps, detail maps with LOD fade-out by camera distance, UV repetition-breaking (hex-grid warp + noise), PCF shadows and cascaded shadow maps (1–4, split_blend, texel-grid aligned)
  • GPU-driven — compute matrices, per-instance culling, compaction, draw_indexed_indirect with GPU-written instanceCount, caller-owned GPU-resident matrix buffers, GPU radix sort of transparent batches, GPU picking id-buffer, GPU timestamp queries per pass
  • Post-process — bloom (threshold + separable blur + composite, HDR required), fog (linear / exponential / exponential²), depth of field (CoC + disc blur), MSAA (4×, resolve pass), SSAO (reads the prepass normals, octahedral Rg8Unorm, applied in both fragment entry points)
  • Geometry — 6 procedural primitives (cube, plane, uv_sphere / icosphere, cylinder, cone, torus), OBJ / STL / glTF import, custom Geometry, LOD by quadric edge collapse with seam-aware welding
  • Textures — PNG / JPEG / HDR files, procedural checkerboard / gradient / value noise, explicit colour-vs-data formats (Rgba8UnormSrgb vs Rgba8Unorm, and a lock that shows the two agree at mip 0 and differ at mip 1), sampler choice (upload linear / upload_nearest for atlases)
  • Transparency & sprites — Alpha / AlphaTest / Additive blending modes, alpha-test cutoff, opacity maps, GPU back-to-front sort per batch, billboarded sprites with atlas UV animation
  • Particles — instanced pools with three drivers (GPU compute, CPU emitter, manual), GPU sort
  • Scene interaction — orbital camera, perspective/ortho, unified keyboard/mouse/scroll input, raycasting with BVH, mesh-mesh collision, in-place update_geometry, per-instance and per-entity picking
  • Debug — BBox / BSphere overlays, debug lines / points / strips, wireframe, stats (FPS, draws, instances, per-pass GPU milliseconds)

Architecture

Layer model

  • 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. render_scene batches all entities into one encoder + one submit per frame.
  • Supporting pieces — PipelineCache (shader → compiled RenderPipeline, Arc-shared), Material, Geometry/Mesh/Vertex, Scene (generational arena registry), Camera/Transform.

Resource management (generational arenas)

The Scene stores all resources in generational arenas (slotmap crate) with typed handles (MeshId, MaterialId, TextureId, EntityId, ParticlePoolId, LinePointId). A label→ID index map provides the user-facing string API. Benefits: stale-handle detection, O(1) removal with slot reuse, cache-friendly iteration.

Spec: docs/tech/ARCHI_ARENES.md

In-place geometry update (6.9)

Scene::update_geometry(id, geometry) automatically uses a fast path when the vertex count is unchanged: a single queue.write_buffer on the existing GPU buffer (zero allocation, zero arena churn). A full rebuild (new buffer + arena insert) only occurs when the vertex count changes. BBox is auto-recomputed from the updated Arc<Geometry>.

Double-buffered CPU→GPU buffers (6.10)

All 6 per-frame CPU-written GPU buffers use double-buffering (Dbuf/Dbg pairs alternating by frame parity). This is the standard game-engine pattern: reduces queue depth, future-proofs for Vulkan/D3D12 migration, and enables inspecting the previous frame's buffers in debug tools. The GPU-written matrix_buffer remains single-buffered (no CPU/GPU race within a command buffer). Total overhead: ~1.5 MB.

Spec: docs/tech/ARCHI_CPU_GPU.md

GPU-driven pipeline (Phase 3)

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.

Spec: docs/tech/ARCHI_CPU_GPU.md · User guide: docs/user/cameras/gpu-driven.md

Module layout

lib/src/
├── lib.rs              # crate root, re-exports
├── prelude.rs          # glob re-exports
├── app.rs              # App + AppBuilder
├── handler.rs          # AppHandler trait
├── camera.rs           # Camera, CameraController
├── input.rs            # InputState
├── lights.rs           # Lights, Light, LightType, directional_light, …
├── core/
│   ├── context.rs      # GPU lifecycle (Instance/Surface/Adapter/Device/Queue)
│   ├── renderer.rs     # RenderPass execution, shadow pass, HDR/TM pass
│   ├── frame.rs        # Per-frame RAII (surface texture + view)
│   ├── geometry.rs     # Geometry (positions/normals/UVs/indices) + BBox
│   ├── transform.rs    # Transform (translation/rotation/scale)
│   ├── frustum.rs      # Frustum (6 planes, sphere/box culling)
│   ├── lod.rs          # LOD decimation (quadric edge collapse)
│   ├── shadow.rs       # ShadowConfig (map size, bias, PCF)
│   ├── hdr.rs          # ToneMapper enum (Aces/Reinhard)
│   ├── bloom.rs        # BloomConfig + BloomPipeline
│   ├── msaa.rs         # MsaaConfig
│   ├── fog.rs          # FogConfig + FogMode
│   └── dof.rs          # DoFConfig + DoFPipeline
├── mesh/
│   ├── mod.rs          # Re-exports flat
│   ├── primitives/     # 6 feature-gated generators
│   └── import/         # OBJ + STL + glTF parsers
├── pipeline/           # PipelineCache (shader → RenderPipeline)
├── resources/          # Mesh, Material, Texture, Uniform, Vertex
├── scene/              # Scene (registry), Entity
└── utils/              # Conf constants, WsgError

Quick reference (types)

Concept Type Responsibility
App / AppBuilder Facade Window + event loop + frame + auto scene render
AppHandler Trait setup() / update() / render() callbacks
Scene Struct Registry: shaders, materials, meshes, entities, lights, camera
Context Struct GPU hardware (Instance, Surface, Adapter, Device, Queue)
Renderer Struct RenderPass execution (scene, shadow, HDR/TM, bloom, DoF)
PipelineCache Struct Shader → compiled RenderPipeline cache
Material Struct Shader ID + texture + pipeline + PBR params
Geometry Struct CPU vertex data (positions/normals/UVs/colors/indices)
Mesh / Vertex Struct GPU geometry / interleaved upload tuple
Frame Struct Per-frame RAII (surface texture + view)
Camera / Transform Struct Camera math + per-entity transform
CameraController Struct Orbital camera (orbit/zoom/reset/apply_to)
InputState Struct Unified keyboard/mouse (pressed/held/released, delta, scroll)
Texture Struct GPU image (Rgba8UnormSrgb) + sampler; file (PNG/JPEG) + procedural (checkerboard, gradient, noise)
Lights / Light Struct Light list (directional/point/spot, MAX=8) + ambient
ShadowConfig Struct Shadow map size, bias, PCF taps, scene radius
ToneMapper Enum ACES Filmic / Reinhard
BloomConfig Struct Threshold, intensity, H/V passes
MsaaConfig Struct Sample count (1 = disabled)
FogConfig Struct Mode, near/far, density, color
DoFConfig Struct Focus distance, aperture, max blur
BBox Struct Axis-aligned bounding box (min/max)
Frustum Struct 6 planes, sphere/box culling
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 resolved from the embedded source — no registration
        // is needed, and registering it under a path used to be the repo's own mistake (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();
    }
    fn update(&mut self, app: &mut wsg_lib::App) {
        // your per-frame logic
    }
    // render() default: app.render_scene(frame.view()) — auto-draws everything
}

#[pollster::main]
async fn main() -> Result<(), WsgError> {
    let app = AppBuilder::new().title("WSG").build().await?;
    app.run(MyScene);
    Ok(())
}

Scene methods return Result<_, String> — typed-error unification is on the roadmap.

Manual workflow (advanced)

Bypass the App facade and drive Context, Renderer and PipelineCache yourself:

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() {
    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");
    let format = context.configure(&context.adapter, 800, 600).expect("surface config");

    let mut renderer = Renderer::new(&context, format, 800, 600);
    let mut cache = PipelineCache::new(Arc::new(context.device.clone()));
    // "standard" is a built-in id: Material::new resolves it from the embedded source.
    // register_shader(id, path) exists for a WGSL file that is NOT one of the built-ins, and
    // refuses a path that is not a readable file (13.15).
    let material = Material::new(renderer.format(), "standard", &mut cache);
    let geometry = Geometry::new(vec![-0.5f32, 0.5, 0.0, 0.5, 0.5, 0.0, 0.5, -0.5, 0.0, -0.5, -0.5, 0.0])
        .with_indices(vec![0, 1, 2, 0, 2, 3]);
    let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None);

    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();
}

Features

Feature Default Provides
prim-cube ✅ cube(size)
prim-plane ✅ plane(w, d, seg_x, seg_z)
prim-sphere ✅ uv_sphere(…), icosphere(…)
prim-cylinder ✅ cylinder(…)
prim-cone ✅ cone(…)
prim-torus ✅ torus(…)
all-prims ✅ (default) All 6 primitives
import-obj ✅ load_obj(path), parse_obj(str)
import-stl ✅ load_stl(path), parse_stl(&[u8])
import-gltf ✅ load_gltf(path, mesh_index)

Design principle: opt-in = zero cost

Feature How to enable If NOT enabled
Shadows scene.set_shadow_caster(Some(idx)) No shadow map, no depth pass, no PCF
HDR + TM AppBuilder::with_hdr(ToneMapper::Aces) No offscreen texture, no TM pass
Bloom AppBuilder::with_bloom(BloomConfig::…) No bloom textures, no passes
MSAA AppBuilder::with_msaa(4) (a sample count, not a config struct) Single sample, no resolve
Fog AppBuilder::with_fog(FogConfig::…) No fog uniforms
DoF AppBuilder::with_dof(DoFConfig::…) No CoC/blur textures
GPU-driven culling AppBuilder::with_culling(true) No compute pipeline, no indirect buffers
LOD scene.create_mesh_with_lod(…, levels) Single-level mesh
Primitives Cargo feature prim-* Not compiled
File import Cargo feature import-* Not compiled
Stats overlay Cargo feature stats No metrics collection
Debug BBox/BSphere Cargo feature debug-bbox No overlay geometry
Parallax mapping (6.25) scene.set_material_parallax(id, mode, scale, steps, bias) One u32 compare — no height-map fetch at all
Log depth buffer (6.31) AppBuilder::with_log_depth(true) One log2 per vertex, identical depth encoding
IBL / env map (6.22) scene.set_environment(env) Falls back to the analytic 6.21 hemisphere — zero cost

Cargo Features

# Default: all primitives
wsg-lib = { path = "../lib" }

# Minimal: just the cube
wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube"] }

# With OBJ import
wsg-lib = { path = "../lib", features = ["import-obj"] }

# With all file importers
wsg-lib = { path = "../lib", features = ["import-obj", "import-stl", "import-gltf"] }
Feature Default Enables
prim-cube ✅ cube(size)
prim-plane ✅ plane(w, d, seg_x, seg_z)
prim-sphere ✅ uv_sphere(…), icosphere(…)
prim-cylinder ✅ cylinder(…)
prim-cone ✅ cone(…)
prim-torus ✅ torus(…)
all-prims ✅ (default) All 6 primitives
import-obj ✅ load_obj(path), parse_obj(str)
import-stl ✅ load_stl(path), parse_stl(&[u8])
import-gltf ✅ load_gltf(path, mesh_index)
stats ⬜ Real-time metrics on stdout (FPS, draws, resources)
debug-bbox ⬜ Debug BBox/BSphere overlay visualization

Examples

50 runnable examples in 4 category folders. All run from the repo root with cargo run -p wsg-lib --example <name> (feature-gated ones need --features).

Folder Example What it shows
meshes/ simple Minimal declarative workflow (flat unlit quad, ~15 lines)
cube Textured, lit, spinning cube (the 3D MVP)
pbr PBR metallic/roughness + normal mapping
import File import: OBJ / STL / glTF (feature import-*)
deformable Deformable mesh: per-frame vertex ripple
collision Mesh-mesh collision (AABB + precise)
manual Low-level workflow (Context/Renderer/PipelineCache, no App)
lights/ shadow Shadow mapping in isolation
shadow_instances 100 instanced cubes casting individual shadows (12.12)
shadow_test Dedicated shadow test (directional caster + PCF)
spot_test Isolated spot light (beam, penumbra)
emissive Emissive materials + runtime exposure control
cameras/ blend_order A transparent batch is sorted by what it draws — hiding a far cube repaints the centre (13.10)
culling GPU-driven frustum culling (15×15 grid)
instances 100 000 cubes in 1 draw call; L toggles the per-entity path (Phase 12)
instances_sim 100 000 GPU-resident instances driven by a caller-owned compute pass (12.15)
ortho Orthographic camera with perspective/ortho toggle
pick_instance Which of 1 000 instances is under the cursor (12.13)
picking Click-to-select raycasting
visibility Hiding 40 000 instances one band at a time — the visible side of 13.1/13.2/13.3/13.7
effects/ demo Full showcase: 6 primitives, 3 lights, shadows, HDR, LOD
bloom HDR bloom post-process
hdr HDR + tone mapping (ACES/Reinhard)
msaa 4× multisample anti-aliasing
fog 3 fog modes (linear, exponential, exponential²)
dof Depth of field with focus presets
ssao Screen-space ambient occlusion — dark shading hugging the base of every volume; S toggles cast shadows to isolate the AO
particles Particle system: 3 pools × 3 drivers
debug_lines Debug lines/points + BBox/BSphere overlays
transparency Mesh transparency: glass, additive, animated alpha
sprites Billboard sprites with atlas UV animation
procedural_textures Procedural textures: checkerboard, gradient, noise
parallax Parallax mapping: two identical brick walls, POM reveals real depth (6.25)
log_depth Logarithmic depth: 1 cm-apart coplanar quads at 1 km, L toggles (6.31)
env_map IBL: six spheres lit by a procedural HDR sky, E toggles the environment (6.22)
skybox The same HDR environment drawn as the background from six real cube faces: never parallaxes, never fogged; S hides the sky without changing reflections, F cycles fog (6.36)

Build

cargo build --workspace          # everything
./run-tests-honest.sh            # THE verification that counts: it refuses to report success
                                 # when a GPU test skipped for want of a headless device
cargo test --workspace           # the same suite, but a test that returns early for want of a
                                 # GPU is counted "passed" by the harness — the honest script is
                                 # the one whose verdict you may repeat to someone else
cargo check --all-targets        # quick check; its feature set is NOT the harness feature set
cargo run -p wsg-lib --example demo   # run the showcase

Totals are not written in prose anywhere in this repository — recopied counts have been wrong five times across this project's history. The generated table is docs/generated/TEST-SUITE.md, written by the honest script itself, and the script refuses to write it when its two independent counters disagree.

Release profile

The workspace Cargo.toml configures an aggressive release profile for maximum performance and minimum binary size:

[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = true
panic = "abort"
overflow-checks = false

Roadmap

Phase Status
1 — Foundations (window, render loop, Context) ✅
2 — 3D infrastructure (Geometry, Mesh, Material, Pipeline) ✅
3 — GPU-driven (compute pass, indirect draws, culling) ✅
4 — Advanced rendering (shadows, HDR/TM, lights) ✅
5 — Polish (LOD, camera controller, input, demo) ✅
6 — Post-MVP (bloom, PBR, fog, DoF, MSAA, cascaded shadows, SSAO) 🔶 — what is left is per-feature module work (6.12 texture module, 6.15 textured area lights, 6.16 god rays, 6.27 voronoi); see docs/ROADMAP.md for the per-step state
6.x — Material/render extensions (detail map, opacity map, parallax, repetition-breaking, log depth) ✅ — see docs/ROADMAP.md for the per-step state
7 — Particles ✅
8 — Developer tools (debug lines, points, overlays) ✅
9 — Scene interaction (raycasting, BVH, collision, deformable) ✅
10 — Lines and points (visualisation) ✅
11 — Mesh transparency ✅
12 — Scaling: GPU instancing (1 draw per batch, per-instance culling + compaction) 🔶 — delivered except 12.9 (GPU-resident SPS)
13 — Entity lifecycle (visibility, resize, picking stability) ✅
14 — Intra-batch back-to-front sort ✅
15 — Repository hygiene (draft index, banner claims, doc locks) ✅

Documentation

Where What
docs/user/ User guide
docs/tech/ Internal architecture
docs/ROADMAP.md Roadmap — the status reference, verified against the code by roadmap_consistency
docs/PLAN.md Dated snapshot of the engine state (2026‑09‑30) plus open questions — not the status reference
cargo doc -p wsg-lib --no-deps API reference (rustdoc)