Files
wsg/lib/examples/meshes/pbr.rs
T
Jérôme Bousquié e5f3636b42 examples: apply real texture assets to multi-mesh examples
- meshes/cube: procedural checkerboard -> uv_texture.jpg (8x8 UV grid)
- meshes/pbr: floor -> ground.jpeg, bump cube -> cave.jpg + caveNormal.jpg
  (normal map pre-encoded via sRGB OETF to cancel the GPU sRGB decode)
- lights/shadow: ground -> ground.jpeg, cube -> uv_texture.jpg
- effects/demo: ground -> ground.jpeg, cube -> uv_texture.jpg
- effects/fog: ground -> ground.jpeg (tiled 80x80), cubes -> stonewall.jpg
- effects/dof: ground -> ground.jpeg, cubes -> uv_texture.jpg
- cameras/culling: shared cube mesh -> uv_texture.jpg
- add lib/examples/assets/textures/ (19 assets, 6.5 MB)
- document assets + usage in examples READMEs, docs/user/examples.md,
  docs/user/meshes/materials.md (CARGO_MANIFEST_DIR pattern, sRGB caveat)
2026-09-26 10:49:13 +02:00

225 lines
8.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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::<Vec<u8>>();
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())
}