PBR
This commit is contained in:
@@ -319,3 +319,23 @@ cargo run -p wsg-lib --example import --features import-obj
|
||||
```
|
||||
|
||||
Pas de touches — s'exécute et quitte.
|
||||
|
||||
---
|
||||
|
||||
## `pbr` — PBR Metallic/Roughness + Normal Mapping (Étape 27)
|
||||
|
||||
Démonstration du workflow PBR Cook-Torrance : GGX distribution + Smith visibility +
|
||||
Schlick Fresnel + IBL hémisphérique + normal mapping.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example pbr
|
||||
```
|
||||
|
||||
| Touche | Action |
|
||||
|--------|--------|
|
||||
| Drag (LMB) | Orbite caméra |
|
||||
| Molette | Zoom |
|
||||
| `R` | Reset caméra |
|
||||
|
||||
Scène : 6 matériaux PBR (métal miroir, plastique, rouillé, céramique, bump map, sol matte).
|
||||
Le cube avec normal map montre des bumps procéduraux (sin wave).
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! # 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 (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 normal map : bump procédural (sin wave)
|
||||
//!
|
||||
//! ## 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();
|
||||
|
||||
// Normal map procédurale 256×256 : bump sin(x)*sin(y).
|
||||
let bump_map = make_bump_normal_map(&app.context().device, &app.context().queue);
|
||||
app.scene.add_texture("bump_nm", bump_map).unwrap();
|
||||
|
||||
// Matériaux PBR.
|
||||
app.scene.add_material_pbr("floor", "standard", 0.0, 0.8).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("bump", "standard", 0.0, 0.5, None, Some("bump_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_bump", "bump", 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/bump/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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Génère une normal map procédurale 256×256 : pattern sin(x*freq)*sin(y*freq) → bump.
|
||||
/// Chaque pixel : normale perturbée encodée en RGB (nx*0.5+0.5, ny*0.5+0.5, nz*0.5+0.5) * 255.
|
||||
fn make_bump_normal_map(device: &wgpu::Device, queue: &wgpu::Queue) -> Texture {
|
||||
let size = 256u32;
|
||||
let freq = 8.0;
|
||||
let mut pixels: Vec<u8> = vec![0u8; (size * size * 4) as usize];
|
||||
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let u = x as f32 / size as f32;
|
||||
let v = y as f32 / size as f32;
|
||||
let h = (u * freq * std::f32::consts::PI).sin()
|
||||
* (v * freq * std::f32::consts::PI).sin();
|
||||
let eps = 1.0 / size as f32;
|
||||
let hx = ((u + eps) * freq * std::f32::consts::PI).sin()
|
||||
* (v * freq * std::f32::consts::PI).sin();
|
||||
let hy = (u * freq * std::f32::consts::PI).sin()
|
||||
* ((v + eps) * freq * std::f32::consts::PI).sin();
|
||||
let dhdx = (hx - h) / eps;
|
||||
let dhdy = (hy - h) / eps;
|
||||
let n = Vec3::new(-dhdx, -dhdy, 1.0).normalize();
|
||||
let idx = ((y * size + x) * 4) as usize;
|
||||
pixels[idx] = ((n.x * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
|
||||
pixels[idx + 1] = ((n.y * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
|
||||
pixels[idx + 2] = ((n.z * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
|
||||
pixels[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
Texture::from_rgba8(device, queue, size, size, &pixels, "bump_normal_map")
|
||||
.expect("bump normal map creation 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())
|
||||
}
|
||||
@@ -258,6 +258,7 @@ impl Renderer {
|
||||
let identity_object = ObjectUniform {
|
||||
model: glam::Mat4::IDENTITY,
|
||||
emissive: glam::Vec4::ZERO,
|
||||
pbr: glam::Vec4::ZERO,
|
||||
};
|
||||
queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object));
|
||||
let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -1220,6 +1221,7 @@ impl Renderer {
|
||||
}
|
||||
// Emissive (6.2): write per-slot into the matrix buffer padding (bytes 64-79).
|
||||
// The compute pass only overwrites bytes 0-63 (the matrix), so the emissive persists.
|
||||
// PBR (Étape 27): metallic/roughness at bytes 80-95 (always written for correctness).
|
||||
for slot in scene.iter_slot_draws().filter(|s| s.active) {
|
||||
let mat = slot
|
||||
.mesh
|
||||
@@ -1230,6 +1232,10 @@ impl Renderer {
|
||||
let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 64) as u64;
|
||||
self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&mat.emissive));
|
||||
}
|
||||
// PBR params (metallic, roughness) — always written (buffer init to 0 is wrong for PBR).
|
||||
let pbr_data: [f32; 4] = [mat.metallic, mat.roughness, 0.0, 0.0];
|
||||
let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 80) as u64;
|
||||
self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&pbr_data));
|
||||
}
|
||||
|
||||
// 8c. Étape 23: bloom passes (threshold → blur H → blur V → composite).
|
||||
|
||||
@@ -92,6 +92,23 @@ pub fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGrou
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// Étape 27 : normal map (binding 2) + son sampler (binding 3).
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -202,6 +219,9 @@ pub struct PipelineCache {
|
||||
/// White 1×1 placeholder texture bound by materials that have no diffuse texture (DRAFT D1/D2).
|
||||
/// A white texel is the multiplicative identity, so sampling it reproduces the pre-Step-10 look.
|
||||
placeholder: Arc<Texture>,
|
||||
/// Normal map placeholder (128,128,255) = flat normal. Bound when a material has no normal map.
|
||||
/// Étape 27 : ensures group-2 is always satisfied (4 bindings).
|
||||
normal_placeholder: Arc<Texture>,
|
||||
/// MSAA sample count for pipeline compilation (Étape 24). Must match the render pass's
|
||||
/// attachment sample count. 1 = no MSAA (default).
|
||||
sample_count: u32,
|
||||
@@ -215,6 +235,7 @@ impl PipelineCache {
|
||||
/// Called at application startup before any Material creation. Shader paths must be registered via register_shader() first.
|
||||
pub fn new(device: Arc<wgpu::Device>, queue: wgpu::Queue, sample_count: u32) -> Self {
|
||||
let placeholder = Texture::white_placeholder(&device, &queue).arc();
|
||||
let normal_placeholder = Texture::normal_placeholder(&device, &queue).arc();
|
||||
let texture_bind_group_layout = create_texture_bind_group_layout(&device);
|
||||
Self {
|
||||
device,
|
||||
@@ -224,6 +245,7 @@ impl PipelineCache {
|
||||
shader_paths: HashMap::new(),
|
||||
texture_bind_group_layout,
|
||||
placeholder,
|
||||
normal_placeholder,
|
||||
sample_count,
|
||||
}
|
||||
}
|
||||
@@ -246,7 +268,18 @@ impl PipelineCache {
|
||||
/// wgpu directly (Step 10, DRAFT D4). Inputs: texture — the material's diffuse texture, `None`
|
||||
/// for a texture-less material (binds the placeholder). Returns the group-2 bind group.
|
||||
pub fn texture_bind_group(&self, texture: Option<Arc<Texture>>) -> wgpu::BindGroup {
|
||||
Self::texture_bind_group_full(self, texture, None)
|
||||
}
|
||||
|
||||
/// Builds a group-2 bind group with both diffuse and normal map textures (Étape 27).
|
||||
/// `texture` = diffuse (None → white placeholder), `normal_map` = normal map (None → flat placeholder).
|
||||
pub fn texture_bind_group_full(
|
||||
&self,
|
||||
texture: Option<Arc<Texture>>,
|
||||
normal_map: Option<Arc<Texture>>,
|
||||
) -> wgpu::BindGroup {
|
||||
let tex = texture.unwrap_or_else(|| self.placeholder.clone());
|
||||
let nmap = normal_map.unwrap_or_else(|| self.normal_placeholder.clone());
|
||||
self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("texture bind group"),
|
||||
layout: &self.texture_bind_group_layout,
|
||||
@@ -259,6 +292,14 @@ impl PipelineCache {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&tex.view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&nmap.view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&nmap.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -297,24 +338,43 @@ impl PipelineCache {
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
) -> Arc<wgpu::RenderPipeline> {
|
||||
// Step 1: Return cached pipeline if it already exists for this shader_id
|
||||
if let Some(pipeline) = self.pipelines.get(shader_id) {
|
||||
self.get_or_create_entry(format, shader_id, "fs_main")
|
||||
}
|
||||
|
||||
/// Étape 27 : creates (or retrieves) a PBR pipeline using the `fs_pbr` entry point.
|
||||
/// The shader_id is the same WGSL file (standard_shader.wgsl) but with a different fragment entry.
|
||||
pub fn get_or_create_pbr(
|
||||
&mut self,
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
) -> Arc<wgpu::RenderPipeline> {
|
||||
self.get_or_create_entry(format, shader_id, "fs_pbr")
|
||||
}
|
||||
|
||||
/// Shared pipeline creation: loads the shader and builds a pipeline with the given fragment entry point.
|
||||
fn get_or_create_entry(
|
||||
&mut self,
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
entry_point: &str,
|
||||
) -> Arc<wgpu::RenderPipeline> {
|
||||
// Cache key includes the entry point to distinguish fs_main from fs_pbr pipelines.
|
||||
let cache_key = format!("{shader_id}:{entry_point}");
|
||||
if let Some(pipeline) = self.pipelines.get(&cache_key) {
|
||||
return pipeline.clone();
|
||||
}
|
||||
|
||||
// Step 2: Compile a new pipeline — loads shader and builds the GPU render pipeline
|
||||
let path = self
|
||||
.shader_paths
|
||||
.get(shader_id)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(shader_id);
|
||||
let shader = self.load_shader(&self.device, path);
|
||||
let pipeline = self.build_pipeline(format, &shader);
|
||||
let pipeline = self.build_pipeline(format, &shader, entry_point);
|
||||
|
||||
// Step 3: Cache the new pipeline behind Arc and return it
|
||||
let pipeline_arc = Arc::new(pipeline);
|
||||
self.pipelines
|
||||
.insert(shader_id.to_string(), pipeline_arc.clone());
|
||||
.insert(cache_key, pipeline_arc.clone());
|
||||
pipeline_arc
|
||||
}
|
||||
|
||||
@@ -341,6 +401,7 @@ impl PipelineCache {
|
||||
&self,
|
||||
format: wgpu::TextureFormat,
|
||||
shader: &wgpu::ShaderModule,
|
||||
entry_point: &str,
|
||||
) -> wgpu::RenderPipeline {
|
||||
// Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs.
|
||||
// Must match Vertex struct field offsets exactly.
|
||||
@@ -380,7 +441,7 @@ impl PipelineCache {
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_main"),
|
||||
entry_point: Some(entry_point),
|
||||
compilation_options: Default::default(), // required field in wgpu 30
|
||||
// targets is now &[Option<ColorTargetState>] — each wrapped in Some.
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
|
||||
@@ -27,12 +27,18 @@ pub struct Material {
|
||||
pub pipeline: Arc<wgpu::RenderPipeline>,
|
||||
/// Diffuse texture sampled by this material. `None` → the white placeholder is bound (DRAFT D1/D2).
|
||||
pub texture: Option<Arc<Texture>>,
|
||||
/// Étape 27 : normal map texture. `None` → the flat normal placeholder is bound.
|
||||
pub normal_texture: Option<Arc<Texture>>,
|
||||
/// Group-2 bind group linking the diffuse texture (or the placeholder) and its sampler. Built in
|
||||
/// the constructor from the shared layout (DRAFT D4) → bound by `draw_entity` at `@group(2)`.
|
||||
pub texture_bind_group: wgpu::BindGroup,
|
||||
/// Emissive color (rgb) + intensity (a). Offset 64 in the ObjectUniform. Default `[0,0,0,0]`
|
||||
/// = no emission (non-regression). In HDR, `a > 1.0` creates a glow effect.
|
||||
pub emissive: [f32; 4],
|
||||
/// Étape 27 : metallic factor [0,1]. 0 = dielectric, 1 = pure metal. Offset 80 in ObjectUniform.
|
||||
pub metallic: f32,
|
||||
/// Étape 27 : roughness [0,1]. 0 = mirror, 1 = fully rough. Offset 84 in ObjectUniform.
|
||||
pub roughness: f32,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
@@ -42,7 +48,7 @@ impl Material {
|
||||
/// cache (mutable reference for potential insertion of new pipelines).
|
||||
/// Returns a Material holding the Arc-wrapped pipeline. Called at scene initialization time only.
|
||||
pub fn new(format: wgpu::TextureFormat, shader_id: &str, cache: &mut PipelineCache) -> Self {
|
||||
Self::build(format, shader_id, None, cache)
|
||||
Self::build(format, shader_id, None, None, cache)
|
||||
}
|
||||
|
||||
/// Creates a Material with a diffuse texture: compiles/retrieves the pipeline and builds a
|
||||
@@ -54,7 +60,55 @@ impl Material {
|
||||
texture: Arc<Texture>,
|
||||
cache: &mut PipelineCache,
|
||||
) -> Self {
|
||||
Self::build(format, shader_id, Some(texture), cache)
|
||||
Self::build(format, shader_id, Some(texture), None, cache)
|
||||
}
|
||||
|
||||
/// Étape 27 : crée un matériau PBR (Cook-Torrance metallic/roughness + normal mapping).
|
||||
/// Le shader_id doit être un shader contenant l'entry point `fs_pbr`.
|
||||
/// Par défaut : metallic=0, roughness=0.5, pas de normal map.
|
||||
pub fn pbr(
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
metallic: f32,
|
||||
roughness: f32,
|
||||
cache: &mut PipelineCache,
|
||||
) -> Self {
|
||||
let pipeline = cache.get_or_create_pbr(format, shader_id);
|
||||
let texture_bind_group = cache.texture_bind_group_full(None, None);
|
||||
Self {
|
||||
shader_id: shader_id.to_string(),
|
||||
pipeline,
|
||||
texture: None,
|
||||
normal_texture: None,
|
||||
texture_bind_group,
|
||||
emissive: [0.0, 0.0, 0.0, 0.0],
|
||||
metallic,
|
||||
roughness,
|
||||
}
|
||||
}
|
||||
|
||||
/// Étape 27 : PBR avec texture albedo et/ou normal map.
|
||||
pub fn pbr_textured(
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
metallic: f32,
|
||||
roughness: f32,
|
||||
albedo: Option<Arc<Texture>>,
|
||||
normal_map: Option<Arc<Texture>>,
|
||||
cache: &mut PipelineCache,
|
||||
) -> Self {
|
||||
let pipeline = cache.get_or_create_pbr(format, shader_id);
|
||||
let texture_bind_group = cache.texture_bind_group_full(albedo.clone(), normal_map.clone());
|
||||
Self {
|
||||
shader_id: shader_id.to_string(),
|
||||
pipeline,
|
||||
texture: albedo,
|
||||
normal_texture: normal_map,
|
||||
texture_bind_group,
|
||||
emissive: [0.0, 0.0, 0.0, 0.0],
|
||||
metallic,
|
||||
roughness,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared construction: requests the pipeline from the cache, then builds the group-2 texture
|
||||
@@ -64,16 +118,20 @@ impl Material {
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
texture: Option<Arc<Texture>>,
|
||||
normal_texture: Option<Arc<Texture>>,
|
||||
cache: &mut PipelineCache,
|
||||
) -> Self {
|
||||
let pipeline = cache.get_or_create(format, shader_id);
|
||||
let texture_bind_group = cache.texture_bind_group(texture.clone());
|
||||
let texture_bind_group = cache.texture_bind_group_full(texture.clone(), normal_texture.clone());
|
||||
Self {
|
||||
shader_id: shader_id.to_string(),
|
||||
pipeline,
|
||||
texture,
|
||||
normal_texture,
|
||||
texture_bind_group,
|
||||
emissive: [0.0, 0.0, 0.0, 0.0],
|
||||
metallic: 0.0,
|
||||
roughness: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,20 @@ impl Texture {
|
||||
.expect("1×1 white placeholder must not be empty")
|
||||
}
|
||||
|
||||
/// Étape 27 : normal map placeholder (128,128,255) = flat normal pointing up in tangent space.
|
||||
/// Bound by materials without a normal map → `nmap = (0,0,1)` → no perturbation.
|
||||
pub fn normal_placeholder(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
|
||||
Self::from_rgba8(
|
||||
device,
|
||||
queue,
|
||||
1,
|
||||
1,
|
||||
&[128, 128, 255, 255],
|
||||
"default normal map placeholder",
|
||||
)
|
||||
.expect("1×1 normal placeholder must not be empty")
|
||||
}
|
||||
|
||||
/// Shared convenience wrapper so `Arc<Texture>` can be created ergonomically by callers.
|
||||
pub(crate) fn arc(self) -> Arc<Texture> {
|
||||
Arc::new(self)
|
||||
|
||||
@@ -150,6 +150,8 @@ pub struct ObjectUniform {
|
||||
pub model: Mat4,
|
||||
/// Emissive color (rgb) + intensity (a). Offset 64. Zero = no emission (non-regression).
|
||||
pub emissive: Vec4,
|
||||
/// PBR params (Étape 27): metallic, roughness, _pad, _pad. Offset 80.
|
||||
pub pbr: Vec4,
|
||||
}
|
||||
|
||||
/// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's
|
||||
@@ -513,11 +515,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn object_uniform_layout_matches_wgsl() {
|
||||
// Étape 22: ObjectUniform is now 80 bytes (64 matrix + 16 emissive).
|
||||
assert_eq!(size_of::<ObjectUniform>(), 80);
|
||||
// Étape 27: ObjectUniform is now 96 bytes (64 matrix + 16 emissive + 16 pbr).
|
||||
assert_eq!(size_of::<ObjectUniform>(), 96);
|
||||
assert_eq!(align_of::<ObjectUniform>(), 16);
|
||||
assert_eq!(offset_of!(ObjectUniform, model), 0);
|
||||
assert_eq!(offset_of!(ObjectUniform, emissive), 64);
|
||||
assert_eq!(offset_of!(ObjectUniform, pbr), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -200,6 +200,50 @@ impl Scene {
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Étape 27 : crée un matériau PBR (Cook-Torrance metallic/roughness) et l'enregistre.
|
||||
/// Le shader_id doit référencer un shader contenant l'entry point `fs_pbr`.
|
||||
pub fn add_material_pbr(
|
||||
&mut self,
|
||||
id: &str,
|
||||
shader_id: &str,
|
||||
metallic: f32,
|
||||
roughness: f32,
|
||||
) -> Result<String, String> {
|
||||
if self.materials.contains_key(id) {
|
||||
return Err(format!("Material ID '{}' already exists.", id));
|
||||
}
|
||||
let mut cache = self.gpu().cache.borrow_mut();
|
||||
let material = Arc::new(Material::pbr(self.gpu().format, shader_id, metallic, roughness, &mut cache));
|
||||
drop(cache);
|
||||
self.materials.insert(id.to_string(), material);
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Étape 27 : PBR avec texture albedo et/ou normal map (doivent être enregistrées via add_texture).
|
||||
pub fn add_material_pbr_textured(
|
||||
&mut self,
|
||||
id: &str,
|
||||
shader_id: &str,
|
||||
metallic: f32,
|
||||
roughness: f32,
|
||||
albedo_id: Option<&str>,
|
||||
normal_map_id: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
if self.materials.contains_key(id) {
|
||||
return Err(format!("Material ID '{}' already exists.", id));
|
||||
}
|
||||
let albedo = albedo_id.and_then(|tid| self.textures.get(tid).cloned());
|
||||
let normal_map = normal_map_id.and_then(|tid| self.textures.get(tid).cloned());
|
||||
let mut cache = self.gpu().cache.borrow_mut();
|
||||
let material = Arc::new(Material::pbr_textured(
|
||||
self.gpu().format, shader_id, metallic, roughness,
|
||||
albedo, normal_map, &mut cache,
|
||||
));
|
||||
drop(cache);
|
||||
self.materials.insert(id.to_string(), material);
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Registers a diffuse texture in the Scene's resource depot under a unique identifier, so
|
||||
/// materials can reference it declaratively (Step 10, D4). The texture is wrapped in `Arc` for
|
||||
/// zero-copy sharing across materials. Returns Ok(id) or Err(String) if the id already exists.
|
||||
|
||||
@@ -100,6 +100,7 @@ struct FrameUniforms {
|
||||
struct ObjectUniform {
|
||||
model: mat4x4<f32>, // 64 bytes (offset 0)
|
||||
emissive: vec4<f32>, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR)
|
||||
pbr: vec4<f32>, // 16 bytes (offset 80): .x=metallic .y=roughness (Étape 27)
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> frame: FrameUniforms;
|
||||
@@ -108,6 +109,9 @@ struct ObjectUniform {
|
||||
// texture lie le placeholder blanc 1×1 (D2), d'où l'échantillonnage inconditionnel.
|
||||
@group(2) @binding(0) var texture_sampler: sampler;
|
||||
@group(2) @binding(1) var diffuse_texture: texture_2d<f32>;
|
||||
// Étape 27 : normal map (binding 2) + son sampler (binding 3). Placeholder (128,128,255) si absent.
|
||||
@group(2) @binding(2) var normal_texture: texture_2d<f32>;
|
||||
@group(2) @binding(3) var normal_sampler: sampler;
|
||||
// Étape 14 (DRAFT D1/D5) : groupe ombre — comparaison sampler (0) + carte de profondeur (1).
|
||||
// Toujours lié (layout unifié) ; inutilisé tant que `options.y == 0` (ombres désactivées).
|
||||
@group(3) @binding(0) var shadow_sampler: sampler_comparison;
|
||||
@@ -119,6 +123,7 @@ struct VertexOutput {
|
||||
@location(1) normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
@location(3) color: vec4<f32>,
|
||||
@location(4) tangent: vec3<f32>, // Étape 27 : tangente pour normal mapping
|
||||
};
|
||||
|
||||
@vertex
|
||||
@@ -139,6 +144,15 @@ fn vs_main(input: VertexInput) -> VertexOutput {
|
||||
out.normal = normal_matrix * input.normal;
|
||||
out.uv = input.uv;
|
||||
out.color = input.color;
|
||||
// Étape 27 : tangente approximée par cross(normal, référence) — évite un attribut tangent.
|
||||
// La référence est choisie pour éviter la dégénérescence (normal parallèle à l'axe Y).
|
||||
let ref_dir = select(
|
||||
vec3<f32>(0.0, 1.0, 0.0),
|
||||
vec3<f32>(1.0, 0.0, 0.0),
|
||||
abs(input.normal.y) > 0.99,
|
||||
);
|
||||
let tangent_local = normalize(cross(ref_dir, input.normal));
|
||||
out.tangent = normal_matrix * tangent_local;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -291,3 +305,155 @@ fn compute_shadow(world_pos: vec3<f32>, normal: vec3<f32>) -> f32 {
|
||||
}
|
||||
return lit_count / 9.0;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Étape 27 : PBR Cook-Torrance (GGX + Smith + Schlick) + IBL hémisphère + normal mapping
|
||||
// ============================================================================
|
||||
|
||||
const PI: f32 = 3.14159265;
|
||||
|
||||
// GGX/Trowbridge-Reitz distribution : contrôle la largeur du lobe spéculaire.
|
||||
fn distribution_ggx(ndh: f32, roughness: f32) -> f32 {
|
||||
let a = roughness * roughness;
|
||||
let a2 = a * a;
|
||||
let d = ndh * ndh * (a2 - 1.0) + 1.0;
|
||||
return a2 / (PI * d * d);
|
||||
}
|
||||
|
||||
// Smith visibility (GGX correlated) : occlusion microsurface.
|
||||
fn geometry_smith(ndh: f32, ndv: f32, ndl: f32, roughness: f32) -> f32 {
|
||||
let a2 = roughness * roughness;
|
||||
// Heuristic : approxime D * V / 4 (voir "A Practical Improvement to the Direct
|
||||
// Analytic Approximation of the Smith Microsurface Model").
|
||||
let gv = ndl / (ndv * (1.0 - a2) + a2);
|
||||
let gl = ndv * (ndl * (1.0 - a2) + a2);
|
||||
return 0.5 * min(gv, gl);
|
||||
}
|
||||
|
||||
// Fresnel-Schlick : interpolation entre F0 et 1 selon l'angle de vue.
|
||||
fn fresnel_schlick(hv: f32, f0: vec3<f32>) -> vec3<f32> {
|
||||
return f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - hv, 5.0);
|
||||
}
|
||||
|
||||
// BRDF PBR complet : diffuse (Lambert × (1-metallic) × (1-F)) + spéculaire (D×G×F).
|
||||
fn brdf_pbr(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>,
|
||||
base: vec3<f32>, metallic: f32, roughness: f32) -> vec3<f32> {
|
||||
let h = normalize(v + l);
|
||||
let f0 = mix(vec3<f32>(0.04), base, metallic);
|
||||
let ndl = max(dot(n, l), 0.0);
|
||||
let ndv = max(dot(n, v), 0.0);
|
||||
let ndh = max(dot(n, h), 0.0);
|
||||
let hv = max(dot(h, v), 0.0);
|
||||
|
||||
let d = distribution_ggx(ndh, roughness);
|
||||
let g = geometry_smith(ndh, ndv, ndl, roughness);
|
||||
let f = fresnel_schlick(hv, f0);
|
||||
|
||||
// Diffuse : Lambert × (1 - F) × (1 - metallic) — énergie conservée.
|
||||
let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
|
||||
let diffuse = kd * base / PI;
|
||||
|
||||
// Speculaire : D × G × F / (4 × N·V × N·L)
|
||||
let denom = 4.0 * ndv * ndl + 1e-4;
|
||||
let specular = d * g * f / denom;
|
||||
|
||||
return (diffuse + specular) * ndl;
|
||||
}
|
||||
|
||||
// IBL hémisphérique analytique : sky/ground mix + spéculaire approximé par roughness.
|
||||
fn compute_ibl(n: vec3<f32>, base: vec3<f32>, metallic: f32, roughness: f32) -> vec3<f32> {
|
||||
let ambient = frame.ambient.rgb;
|
||||
let sky = ambient;
|
||||
let ground = ambient * 0.3;
|
||||
let ibl_diffuse = mix(ground, sky, n.y * 0.5 + 0.5);
|
||||
|
||||
// Diffuse IBL : Lambert × (1 - metallic) × IBL color
|
||||
let f0 = mix(vec3<f32>(0.04), base, metallic);
|
||||
let f = fresnel_schlick(0.0, f0);
|
||||
let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
|
||||
let diffuse = kd * base * ibl_diffuse / PI;
|
||||
|
||||
// Speculaire IBL : approximation — plus la roughness est faible, plus le spéculaire est "vif".
|
||||
let spec_ibl = mix(ibl_diffuse, vec3<f32>(1.0), (1.0 - roughness) * 0.5);
|
||||
let specular = f * spec_ibl * (0.1 + 0.4 * (1.0 - roughness));
|
||||
|
||||
return diffuse + specular;
|
||||
}
|
||||
|
||||
// Normal mapping : construit la normale perturbée à partir du TBN + normal map.
|
||||
// La tangente vient du vertex shader (cross produit avec une référence anti-dégénérescence).
|
||||
fn compute_pbr_normal(in: VertexOutput) -> vec3<f32> {
|
||||
let n = normalize(in.normal);
|
||||
let t = normalize(in.tangent);
|
||||
let b = normalize(cross(n, t));
|
||||
let tbn = mat3x3<f32>(t, b, n);
|
||||
|
||||
// Échantillonner la normal map (placeholder 128,128,255 → nmap = (0,0,1) → aucun effet).
|
||||
let nmap = textureSample(normal_texture, normal_sampler, in.uv).rgb * 2.0 - 1.0;
|
||||
return normalize(tbn * nmap);
|
||||
}
|
||||
|
||||
// Fragment PBR complet : IBL + lumières (BRDF Cook-Torrance) + emissive + fog.
|
||||
@fragment
|
||||
fn fs_pbr(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
|
||||
let base = texel.rgb * in.color.rgb;
|
||||
|
||||
// Unlit mode (identique à fs_main).
|
||||
if (frame.options.x != 0u) {
|
||||
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
|
||||
let final_rgb = base + emissive_contrib;
|
||||
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
|
||||
}
|
||||
|
||||
let metallic = object.pbr.x;
|
||||
let roughness = clamp(object.pbr.y, 0.045, 1.0);
|
||||
|
||||
// Normal mapping (derivative tangent + normal map texture).
|
||||
let n = compute_pbr_normal(in);
|
||||
let v = normalize(frame.cam_pos.xyz - in.world_pos);
|
||||
|
||||
// IBL (hémisphère analytique).
|
||||
var color = compute_ibl(n, base, metallic, roughness);
|
||||
|
||||
// Lumières directionnelles.
|
||||
for (var i = 0u; i < frame.num_directional; i++) {
|
||||
let l = normalize(frame.lights[i].position_dir.xyz);
|
||||
let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a;
|
||||
let shadow = compute_shadow(in.world_pos, n);
|
||||
color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color * shadow;
|
||||
}
|
||||
|
||||
// Lumières ponctuelles.
|
||||
for (var i = frame.num_directional; i < frame.num_directional + frame.num_point; i++) {
|
||||
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
|
||||
let dist = length(to_light);
|
||||
let l = to_light / max(dist, 1e-4);
|
||||
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
|
||||
let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a * falloff;
|
||||
let shadow = compute_shadow(in.world_pos, n);
|
||||
color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color * shadow;
|
||||
}
|
||||
|
||||
// Lumières spot.
|
||||
let spot_base = frame.num_directional + frame.num_point;
|
||||
for (var i = spot_base; i < spot_base + frame.num_spot; i++) {
|
||||
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
|
||||
let dist = length(to_light);
|
||||
let l = to_light / max(dist, 1e-4);
|
||||
let to_point = -l;
|
||||
let cone = dot(to_point, normalize(frame.lights[i].dir_angle.xyz));
|
||||
let cos_inner = frame.lights[i].dir_angle.w;
|
||||
let cos_outer = cos_inner - 0.1;
|
||||
let spot_factor = clamp((cone - cos_outer) / max(cos_inner - cos_outer, 1e-4), 0.0, 1.0);
|
||||
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
|
||||
let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a * falloff * spot_factor;
|
||||
let shadow = compute_shadow(in.world_pos, n);
|
||||
color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color * shadow;
|
||||
}
|
||||
|
||||
// Emissive.
|
||||
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
|
||||
let final_rgb = color + emissive_contrib;
|
||||
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ fn standard_shader_is_valid_wgsl() {
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("standard_shader.wgsl: validation failed: {e:?}"));
|
||||
|
||||
// Contract: exactly the two expected entry points vs_main / fs_main.
|
||||
assert!(module.entry_points.len() >= 2, "vs_main + fs_main expected");
|
||||
// Contract: at least vs_main + fs_main (+ fs_pbr since Étape 27).
|
||||
assert!(module.entry_points.len() >= 3, "vs_main + fs_main + fs_pbr expected");
|
||||
}
|
||||
|
||||
/// Parses and fully validates the embedded `shadow_shader.wgsl` shader (Step 14, D4) via naga.
|
||||
|
||||
Reference in New Issue
Block a user