e5f3636b42
- 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)
225 lines
8.1 KiB
Rust
225 lines
8.1 KiB
Rust
//! # 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())
|
||
}
|