291 lines
12 KiB
Rust
291 lines
12 KiB
Rust
//! **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<u8> {
|
||
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<u8> {
|
||
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::<u32>().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,
|
||
})
|
||
}
|