//! # Exemple PBR — Metallic/Roughness + Normal Mapping (Étape 27) //! //! Démonstration du workflow PBR Cook-Torrance (GGX + Smith + Schlick) avec IBL hémisphérique. //! //! ## Scène //! - Sol : plan 20×20, PBR matte + albedo `ground.jpeg` (metallic=0, roughness=0.8) //! - Cube métal : metallic=1.0, roughness=0.1 → reflet spéculaire net (miroir) //! - Cube plastique : metallic=0.0, roughness=0.4 → spéculaire large et doux //! - Cube rouillé : metallic=0.8, roughness=0.7 → métal rugueux //! - Sphere céramique : metallic=0.3, roughness=0.3 //! - Cube cave : albedo `cave.jpg` + normal map `caveNormal.jpg` (assets, normal map pré-encodée sRGB) //! //! ## Contrôles //! | Touche | Action | //! |--------|--------| //! | Drag (LMB) | Orbite caméra | //! | Molette | Zoom | //! | `R` | Reset caméra | //! //! ## Lancement //! ```bash //! cargo run -p wsg-lib --example pbr //! ``` use glam::Vec3; use winit::event::MouseButton; use winit::keyboard::KeyCode; use wsg_lib::app::AppBuilder; use wsg_lib::camera::CameraController; use wsg_lib::core::{ToneMapper, Transform}; use wsg_lib::mesh::{cube, icosphere, plane}; use wsg_lib::resources::Texture; use wsg_lib::AppHandler; use wsg_lib::utils::WsgError; struct PbrDemo { camera: CameraController, } impl Default for PbrDemo { fn default() -> Self { Self { camera: CameraController::default(), } } } impl AppHandler for PbrDemo { fn setup(&mut self, app: &mut wsg_lib::App) { app.scene .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) .unwrap(); // Textures fichiers (assets/textures) : albedo du sol + albedo/normal cave. // La normal map est pré-encodée sRGB avant upload : `Texture` est toujours // `Rgba8UnormSrgb` (le GPU décode en sRGB à l'échantillonnage), et les données // d'une normal map sont linéaires — l'encodage OETF compense la décodage EOTF // (EOTF(OETF(x)) = x), sinon la perturbation serait visiblement faussée. let (device, queue) = { let ctx = app.context(); (ctx.device.clone(), ctx.queue.clone()) }; let ground_albedo = Texture::from_file( &device, &queue, "ground", &format!("{TEXTURES}/ground.jpeg"), ) .unwrap(); app.scene.add_texture("ground_albedo", ground_albedo).unwrap(); let cave_albedo = Texture::from_file(&device, &queue, "cave", &format!("{TEXTURES}/cave.jpg")).unwrap(); app.scene.add_texture("cave_albedo", cave_albedo).unwrap(); let cave_nm = load_normal_map( &device, &queue, &format!("{TEXTURES}/caveNormal.jpg"), "cave_nm", ); app.scene.add_texture("cave_nm", cave_nm).unwrap(); // Matériaux PBR. app.scene .add_material_pbr_textured("floor", "standard", 0.0, 0.8, Some("ground_albedo"), None) .unwrap(); app.scene.add_material_pbr("metal", "standard", 1.0, 0.1).unwrap(); app.scene.add_material_pbr("plastic", "standard", 0.0, 0.4).unwrap(); app.scene.add_material_pbr("rust", "standard", 0.8, 0.7).unwrap(); app.scene.add_material_pbr("ceramic", "standard", 0.3, 0.3).unwrap(); app.scene .add_material_pbr_textured( "cave", "standard", 0.0, 0.6, Some("cave_albedo"), Some("cave_nm"), ) .unwrap(); // Sol (plan 20×20). app.scene .create_mesh("floor_mesh", plane(1.0, 1.0, 1, 1), Some("floor")) .unwrap(); { let mut tf = Transform::identity(); tf.translation = Vec3::new(0.0, 0.0, 0.0); tf.scale = Vec3::new(20.0, 1.0, 20.0); app.scene.add_entity_with_transform("floor", "floor_mesh", tf).unwrap(); } // Cubes. app.scene.create_mesh("cube_mesh", cube(1.0), None).unwrap(); let cubes: [(&str, &str, Vec3); 4] = [ ("c_metal", "metal", Vec3::new(-3.0, 0.5, 0.0)), ("c_plastic", "plastic", Vec3::new(-1.0, 0.5, 0.0)), ("c_rust", "rust", Vec3::new(1.0, 0.5, 0.0)), ("c_cave", "cave", Vec3::new(3.0, 0.5, 0.0)), ]; for (id, mat, pos) in &cubes { app.scene .create_mesh(&format!("{id}_mesh"), cube(1.0), Some(mat)) .unwrap(); let mut tf = Transform::identity(); tf.translation = *pos; app.scene .add_entity_with_transform(id, &format!("{id}_mesh"), tf) .unwrap(); } // Sphere céramique. app.scene .create_mesh("sphere_mesh", icosphere(0.5, 4), Some("ceramic")) .unwrap(); { let mut tf = Transform::identity(); tf.translation = Vec3::new(0.0, 0.5, -3.0); app.scene .add_entity_with_transform("s_ceramic", "sphere_mesh", tf) .unwrap(); } // Lumières. app.scene .add_directional_light(Vec3::new(-1.0, 2.0, 1.0).normalize(), [1.0, 0.95, 0.9], 2.0) .unwrap(); app.scene .add_point_light(Vec3::new(0.0, 3.0, 2.0), [0.3, 0.5, 1.0], 8.0, 5.0) .unwrap(); // Ambiance (IBL hémisphérique). app.scene.set_ambient([0.3, 0.35, 0.4]); // Caméra. self.camera.yaw = 0.0; self.camera.pitch = 0.3; self.camera.distance = 8.0; self.camera.target = Vec3::new(0.0, 0.5, 0.0); self.camera.apply_to(app.scene.camera_mut()); eprintln!("[PBR] Scene: 6 PBR materials (metal/plastic/rust/ceramic/cave/floor)"); eprintln!("[PBR] Drag=orbit, Wheel=zoom, R=reset"); } fn update(&mut self, app: &mut wsg_lib::App) { // Orbite caméra. 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); self.camera.apply_to(app.scene.camera_mut()); // R = reset. if app.input.key_pressed(KeyCode::KeyR) { self.camera = CameraController::default(); self.camera.target = Vec3::new(0.0, 0.5, 0.0); self.camera.apply_to(app.scene.camera_mut()); } } } /// Texture asset directory, resolved against the crate root so the example works from any CWD. const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures"); /// Charge une normal map depuis un fichier et l'upload en `Texture`. /// /// `Texture` est toujours `Rgba8UnormSrgb` : le GPU applique la EOTF sRGB à /// l'échantillonnage. Une normal map est des données **linéaires** — on pré-encode /// donc chaque canal avec la OETF sRGB avant l'upload, pour que le round-trip /// GPU soit l'identité (EOTF(OETF(x)) = x). Sans ce pré-encodage, la perturbation /// de normale serait visiblement faussée (valeurs compressées vers le noir). fn load_normal_map(device: &wgpu::Device, queue: &wgpu::Queue, path: &str, label: &str) -> Texture { let bytes = std::fs::read(path).expect("normal map asset present in the repo"); let rgba = image::load_from_memory(&bytes).expect("valid image").to_rgba8(); let encoded = rgba .as_raw() .iter() .map(|&c| { let v = c as f32 / 255.0; let e = if v <= 0.0031308 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; (e * 255.0).round().clamp(0.0, 255.0) as u8 }) .collect::>(); Texture::from_rgba8(device, queue, rgba.width(), rgba.height(), &encoded, label) .expect("normal map upload failed") } #[pollster::main] async fn main() -> Result<(), WsgError> { let app = AppBuilder::new() .title("WSG — PBR Metallic/Roughness") .size(1280, 720) .with_hdr(ToneMapper::Aces) .build() .await?; app.run(PbrDemo::default()) }