//! **WSG `demo`** — the final showcase example. //! //! Combines everything built throughout the library into one declarative scene: //! //! * a **ground plane** plus one of each procedural primitive from `math::primitives` //! (`cube`, `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) placed around it, //! * a **procedural texture** per mesh (checker / stripe grids, no assets on disk), //! * the **standard** Phong material wired to those textures, //! * an **orbital camera** driven live by the unified input state: //! hold the **left mouse button** and drag to orbit (yaw/pitch), the wheel zooms (distance), //! * `R` resets the view, keys `1`/`2`/`3` jump to front / side / top presets, //! * a **directional** light (the shadow caster) + a **point** light + a **spot** light, //! so the shadow of the cube and the colored light halos are all visible, //! * the primitives slowly rotate in `update`, so depth, lighting and shadows read clearly, //! * **LOD** (Step 19): the rounded primitives are created with three levels each //! (`create_mesh_with_lod`, auto-decimated by halving targets); the CPU picks each entity's //! level from its projected screen size (with hysteresis) — zoom in/out with the wheel and //! the sphere/cylinder/cone/torus visibly lose detail as they shrink on screen. //! * **HDR + Tone Mapping** (Étape 20): the demo enables ACES Filmic tone mapping via //! `AppBuilder::with_hdr(ToneMapper::Aces)`. The main pass renders to an offscreen //! `Rgba16Float` texture, then a fullscreen TM pass compresses it to [0,1] and writes //! to the sRGB surface — highlights are softly rolled off instead of clipping to white. //! //! Doc (this header) follows the English convention used for examples; internal comments stay //! concise and French where helpful. Run with: //! //! `cargo run -p wsg-lib --example demo` use glam::{Quat, Vec3}; use winit::event::MouseButton; use winit::keyboard::KeyCode; use wsg_lib::AppHandler; use wsg_lib::app::AppBuilder; use wsg_lib::core::ToneMapper; use wsg_lib::math::{Transform, cone, cube, cylinder, icosphere, plane, torus, uv_sphere}; use wsg_lib::resources::{CameraController, Texture}; use wsg_lib::utils::WsgError; /// Generates an 8×8 RGBA checkerboard (white / brick) as raw bytes for `Texture::from_rgba8`. fn checkerboard_rgba() -> Vec { const SIZE: u32 = 8; let mut rgba = Vec::with_capacity((SIZE * SIZE * 4) as usize); for y in 0..SIZE { for x in 0..SIZE { let even = (x + y) % 2 == 0; let (r, g, b) = if even { (235, 235, 228) } else { (150, 90, 70) }; rgba.extend_from_slice(&[r, g, b, 255]); } } rgba } /// Generates a vertical stripe texture (blue / cyan), useful to make rotation visible on rounded /// bodies (sphere / cylinder) via the UV seams. fn stripes_rgba() -> Vec { const W: u32 = 32; const H: u32 = 16; let mut rgba = Vec::with_capacity((W * H * 4) as usize); for _y in 0..H { for x in 0..W { let band = (x / 4) % 2 == 0; let (r, g, b) = if band { (40, 90, 190) } else { (120, 210, 235) }; rgba.extend_from_slice(&[r, g, b, 255]); } } rgba } /// Demo handler: holds the orbital controller plus a slow rotation angle. struct Demo { camera: CameraController, angle: f32, /// Phase 3 black-window investigation: number of debug_dump calls already made. dbg: u32, } /// Horizontal radius at which the primitives sit around the origin. const ORBIT_RADIUS: f32 = 1.7; /// Vertical offset so the meshes stand on the ground plane (y = 0). const STAND_HEIGHT: f32 = 0.5; /// Lays out one primitive (already scaled/positioned) at an angle around the origin. fn place(label: &str, mesh: &str, app: &mut wsg_lib::App, index: usize) { let a = index as f32 / 6.0 * std::f32::consts::TAU; let mut tf = Transform::identity(); tf.translation = Vec3::new(a.cos() * ORBIT_RADIUS, STAND_HEIGHT, a.sin() * ORBIT_RADIUS); tf.rotation = Quat::from_rotation_y(a); // face the center app.scene .add_entity_with_transform(label, mesh, tf) .unwrap(); } impl AppHandler for Demo { fn setup(&mut self, app: &mut wsg_lib::App) { // 1. Shader + material base. app.scene .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) .unwrap(); let (device, queue) = { let ctx = app.context(); (ctx.device.clone(), ctx.queue.clone()) }; // 2. Procedural textures, one material per pattern. let checker = Texture::from_rgba8(&device, &queue, 8, 8, &checkerboard_rgba(), "checker").unwrap(); app.scene.add_texture("checker_texture", checker).unwrap(); app.scene .add_material_texture("ground_mat", "standard", "checker_texture") .unwrap(); app.scene .add_material_texture("solid_mat", "standard", "checker_texture") .unwrap(); let stripes = Texture::from_rgba8(&device, &queue, 32, 16, &stripes_rgba(), "stripes").unwrap(); app.scene.add_texture("stripes_texture", stripes).unwrap(); app.scene .add_material_texture("stripes_mat", "standard", "stripes_texture") .unwrap(); // 3. Ground plane (large, thin, textured). app.scene .create_mesh("ground_mesh", plane(9.0, 9.0, 1, 1), Some("ground_mat")) .unwrap(); app.scene.add_entity("ground", "ground_mesh").unwrap(); // 4. One mesh per primitive, each assigned to a textured (or stripe) material. // The cube + ground stay single-level (tiny meshes — LOD would buy nothing); the // rounded primitives get three LOD levels each (Step 19): level 0 is the full mesh, // levels 1.. are auto-generated by quadric edge collapse at halving targets (D10), all // packed into the mesh's single vertex/index buffers (D7). Zooming with the wheel // switches levels on the fly (asymmetric hysteresis, D4). app.scene .create_mesh("cube_mesh", cube(0.8), Some("solid_mat")) .unwrap(); app.scene .create_mesh_with_lod( "sphere_mesh", uv_sphere(0.55, 32, 20), Some("stripes_mat"), 3, ) .unwrap(); app.scene .create_mesh_with_lod("ico_mesh", icosphere(0.5, 2), Some("solid_mat"), 3) .unwrap(); app.scene .create_mesh_with_lod("cyl_mesh", cylinder(0.4, 0.9, 32), Some("stripes_mat"), 3) .unwrap(); app.scene .create_mesh_with_lod("cone_mesh", cone(0.45, 0.9, 32), Some("solid_mat"), 3) .unwrap(); app.scene .create_mesh_with_lod( "torus_mesh", torus(0.42, 0.16, 24, 16), Some("solid_mat"), 3, ) .unwrap(); place("cube_e", "cube_mesh", app, 0); place("sphere_e", "sphere_mesh", app, 1); place("ico_e", "ico_mesh", app, 2); place("cyl_e", "cyl_mesh", app, 3); place("cone_e", "cone_mesh", app, 4); place("torus_e", "torus_mesh", app, 5); // 5. Lights: a shadow-casting directional + a warm point + a green spot. // Start from the default list (directional +Z) so we keep it and add the rest. let toward_light = Vec3::new(1.0, 1.2, 1.0).normalize(); app.scene .add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.5) .unwrap(); app.scene .add_point_light(Vec3::new(0.5, 1.6, 1.8), [1.0, 0.7, 0.3], 1.2, 6.0) .unwrap(); app.scene .add_spot_light( Vec3::new(-2.5, 2.2, 1.0), Vec3::new(2.5, -2.2, -1.0).normalize(), [0.3, 1.0, 0.5], 1.4, 8.0, 0.45, ) .unwrap(); // The warm directional light above casts shadows. It is packed at index 1: index 0 is // the default +Z directional light pre-loaded by `Lights::new()` (kept here for the // base lighting), so the demo's own light is the SECOND one in the packed array. app.scene.set_shadow_caster(Some(1)); app.scene.set_ambient([0.14, 0.14, 0.16]); // 6. Active camera, driven by the orbital controller (position, distance, preset target). self.camera.yaw = 0.6; self.camera.pitch = 0.35; self.camera.distance = 6.5; self.camera.target = Vec3::ZERO; self.camera.apply_to(app.scene.camera_mut()); } fn update(&mut self, app: &mut wsg_lib::App) { // ---- Orbital camera from unified input ---- // Classic arc-rotate: orbit ONLY while the left button is held (drag); the wheel zooms // without any button. Sensitivities use the library defaults (0.005 rad/px orbit, 0.9x // per wheel notch); tune them via `camera.orbit_sensitivity` / `camera.zoom_factor`. let (dx, dy) = app.input.mouse_delta(); if app.input.mouse_button_held(MouseButton::Left) { self.camera.orbit(dx, dy); } let (_, sy) = app.input.scroll_delta(); self.camera.zoom(sy); // R: reset the view. Keys 1/2/3: front / side / top presets. if app.input.key_pressed(KeyCode::KeyR) { // Keep the target but restore a pleasing default framing. self.camera.yaw = 0.6; self.camera.pitch = 0.35; self.camera.distance = 6.5; } if app.input.key_pressed(KeyCode::Digit1) { self.camera.yaw = 0.0; self.camera.pitch = 0.25; self.camera.distance = 6.5; } if app.input.key_pressed(KeyCode::Digit2) { self.camera.yaw = std::f32::consts::FRAC_PI_2; self.camera.pitch = 0.15; self.camera.distance = 6.5; } if app.input.key_pressed(KeyCode::Digit3) { self.camera.yaw = 0.0; self.camera.pitch = 1.25; self.camera.distance = 8.0; } self.camera.apply_to(app.scene.camera_mut()); // ---- Slow rotation of the primitives so lighting/shadow read clearly ---- self.angle += 0.008; let base = *app .scene .entity_transform("cube_e") .expect("cube entity present"); let mut tf = base; tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.4); app.scene.set_entity_transform("cube_e", tf); } fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) { app.render_scene(frame.view()); // Opt-in GPU readback (black-window investigation tooling): WSG_DEBUG_DUMP=N dumps the // first 8 slots of the transform/matrix/draw-args/bbox buffers for N frames (unset = silent, // non-numeric value = 3 frames). Note: orbiting/zooming this camera // can never cull the entity ring — the camera always looks at the origin, so each // entity's angular offset from the view axis is bounded by atan(1.7/6.1) ≈ 15.5°, under // the ~22° vertical half-FOV (verified 2026-09-22: 600 frames swept, GPU==CPU on all // 6000 cull verdicts, zero flips on the ring). Counts only flip to 0 for entities far // off-axis (e.g. behind the near plane) — see docs/user/gpu-driven.md. // Unset → 0 (the showcase stays silent); set but non-numeric (e.g. `WSG_DEBUG_DUMP=on`) → 3. let frames = match std::env::var("WSG_DEBUG_DUMP") { Ok(v) => v.parse::().ok().filter(|&n| n > 0).unwrap_or(3), Err(_) => 0, }; if self.dbg < frames { self.dbg += 1; app.renderer().debug_dump(8); } } } #[pollster::main] async fn main() -> Result<(), WsgError> { // Culling enabled here (Step 15, D8) to exercise the GPU path; it is OFF by default elsewhere. // HDR + ACES tone mapping (Étape 20): renders to an offscreen Rgba16Float texture, then // tone-maps to the sRGB surface. Without `.with_hdr(...)`, the demo would be LDR direct. let app = AppBuilder::new() .title("WSG Demo") .with_culling(true) .with_hdr(ToneMapper::Aces) .build() .await?; app.run(Demo { camera: CameraController::default(), angle: 0.0, dbg: 0, }) }