diff --git a/Cargo.lock b/Cargo.lock index a8b4231..f55f6df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -181,6 +187,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.0" @@ -307,6 +319,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -387,12 +408,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -565,6 +606,21 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -753,6 +809,36 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "naga" version = "30.0.0" @@ -1237,6 +1323,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "polling" version = "3.11.0" @@ -1316,6 +1415,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.39.4" @@ -1520,6 +1625,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2432,6 +2543,7 @@ version = "0.1.0" dependencies = [ "bytemuck", "glam", + "image", "pollster", "thiserror 2.0.18", "wgpu", @@ -2520,3 +2632,24 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 2bb8967..a12ac98 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -13,3 +13,6 @@ thiserror = "2" bytemuck = { version = "1.25.0", features = ["derive"] } glam = { version = "0.33", features = ["bytemuck"] } # feature requis pour Pod/Zeroable sur Mat4/Vec4 (uniform.rs) pollster = { version="1.0.1", features = ["macro"] } +# Étape 10 (Textures, DRAFT D3) : décodage d'images (PNG/JPEG) pour charger des textures diffuses. +# default-features = false pour n'emporter que les codecs utiles (plus petit arbre de compilation). +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diff --git a/lib/examples/cube.rs b/lib/examples/cube.rs index 1a2f1ee..aece89f 100644 --- a/lib/examples/cube.rs +++ b/lib/examples/cube.rs @@ -1,28 +1,31 @@ -//! Étape 5 — MVP 3D : un cube unitaire éclairé qui tourne, rendu automatiquement par la boucle `App`. +//! Étape 5 — MVP 3D : un cube unitaire éclairé qui tourne ; **Étape 10** — le cube est **texturé** +//! (damier procédural) via le nouveau chemin diffues (bind group `@group(2)`). //! //! Démonstration de l'objectif MVP du ROADMAP 1.3 + 1.5 : un mesh 3D avec éclairage Phong à l'écran. //! On suit le workflow déclaratif (comme `simple`) : `AppBuilder` + scène automatique, **sans importer //! wgpu**. Depuis l'Étape 7 la scène possède son `PipelineCache` : on passe par `register_shader` + -//! `add_material_shader` + `create_mesh` + `add_entity` (le matériau est lié au mesh, plus de material_id). -//! Depuis l'Étape 8 (DRAFT 8.4/8.5) le mesh est déclaré à partir d'une **`Geometry`** (positions, normales, -//! indices) plutôt que d'un `&[Vertex]` brut : `Mesh` dérive ses buffers internes via `to_vertices()`. +//! `add_material_shader`/`add_material_texture` + `create_mesh` + `add_entity`. Depuis l'Étape 8 le mesh +//! est déclaré à partir d'une **`Geometry`** (positions, normales, indices). Depuis l'Étape 10 (D4) on +//! enregistre une texture par id (`add_texture`) puis on lie un matériau texturé (`add_material_texture`) ; +//! la texture est générée *procéduralement* (damier RGBA 8×8) pour rester autonome, sans asset sur disque. //! La caméra active par défaut (`Scene::default`, position (0,0,3), fov 45°) cadre le cube, et //! `AppHandler::update` fait tourner l'entité via `set_entity_transform` chaque frame. use glam::Quat; use wsg_lib::AppHandler; use wsg_lib::app::AppBuilder; -use wsg_lib::resources::Geometry; +use wsg_lib::resources::{Geometry, Texture}; use wsg_lib::utils::WsgError; -/// Handler de démonstration : fait tourner le cube dans `update`. +/// Handler de démonstration : fait tourner le cube texturé dans `update`. struct Cube { /// Angle de rotation cumulé (radians), incrémenté à chaque frame. angle: f32, } -/// Construit la `Geometry` d'un cube unitaire centré à l'origine (arête de 1), une normale par face. -/// 24 sommets (4 par face) + 36 indices ; la couleur est absente (défaut blanc opaque via -/// `Geometry::to_vertices`), l'UV est laissé à zéro (inutilisé par `standard` pour un matériau sans texture). +/// Construit la `Geometry` d'un cube unitaire centré à l'origine (arête de 1), une normale et des +/// coordonnées UV par face. 24 sommets (4 par face) + 36 indices ; la couleur est absente (défaut +/// blanc opaque via `Geometry::to_vertices`). Depuis l'Étape 10, chaque face reçoit des UV [0,1]² pour +/// que la texture diffuse soit proprement projetée sur le cube. fn cube_geometry() -> Geometry { let s = 0.5; // demi-arête // Chaque face : (normale sortante, 4 coins). Le culling est désactivé par défaut (PrimitiveState @@ -57,10 +60,14 @@ fn cube_geometry() -> Geometry { let mut positions = Vec::with_capacity(24); let mut normals = Vec::with_capacity(24); + let mut uvs = Vec::with_capacity(24); + // Mapping UV canonique d'un carré : BL(0,0) BR(1,0) TR(1,1) TL(0,1). + let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; for (normal, corners) in faces { - for corner in corners { - positions.push(corner); + for (i, corner) in corners.iter().enumerate() { + positions.push(*corner); normals.push(normal); + uvs.push(quad_uvs[i]); } } @@ -73,20 +80,45 @@ fn cube_geometry() -> Geometry { Geometry::new(positions) .with_normals(normals) + .with_uvs(uvs) .with_indices(indices) } +/// Génère un damier RGBA 8×8 (blanc/brique) *procédural*, sans asset sur disque, pour texturer le +/// cube (Étape 10, D3/D4). Renvoyé en `Vec` brut RGBA8, chargeable via `Texture::from_rgba8`. +fn checkerboard_rgba() -> Vec { + 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 { (255, 255, 255) } else { (190, 40, 40) }; + rgba.extend_from_slice(&[r, g, b, 255]); + } + } + rgba +} + impl AppHandler for Cube { fn setup(&mut self, app: &mut wsg_lib::App) { - // Shader Phong `standard` (porteur des bind groups frame + object). Depuis l'Étape 7 le - // PipelineCache vit dans la scène : `register_shader` / `add_material_shader` / `create_mesh` - // en sont la façade déclarative (le matériau est lié au mesh au moment du create_mesh). + // Shader Phong `standard` (porteur des bind groups frame + object + texture, Étape 10). app.scene .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) .unwrap(); + + // Construit la texture damier avec le device/queue du Context (via `app.context()`), puis + // l'enregistre dans la scène par id ; on lie ensuite un matériau texturé à cette id. + let (device, queue) = { + let ctx = app.context(); + (ctx.device.clone(), ctx.queue.clone()) + }; + let texture = + Texture::from_rgba8(&device, &queue, 8, 8, &checkerboard_rgba(), "checker").unwrap(); + app.scene.add_texture("checker_texture", texture).unwrap(); app.scene - .add_material_shader("cube_material", "standard") + .add_material_texture("cube_material", "standard", "checker_texture") .unwrap(); + app.scene .create_mesh("cube_mesh", cube_geometry(), Some("cube_material")) .unwrap(); diff --git a/lib/examples/manual.rs b/lib/examples/manual.rs index 7e72d69..b65e9c3 100644 --- a/lib/examples/manual.rs +++ b/lib/examples/manual.rs @@ -57,7 +57,7 @@ impl ApplicationHandler for App { // 2. Initialisation du Renderer (Il récupère tout ce dont il a besoin) let device = Arc::new(context.device.clone()); - let mut cache = PipelineCache::new(device); + let mut cache = PipelineCache::new(device, context.queue.clone()); cache .register_shader("standard", utils::STANDARD_SHADER_PATH) .unwrap(); diff --git a/lib/src/app.rs b/lib/src/app.rs index 46f1f15..44350c3 100644 --- a/lib/src/app.rs +++ b/lib/src/app.rs @@ -236,9 +236,9 @@ impl ApplicationHandler for AppRunner { let renderer = Renderer::new(&context, format, self.width, self.height); // Étape 7 (DRAFT 7.1) : the PipelineCache now lives in the Scene. We wire the GPU context - // (device + format + cache) into the Scene before setup so it can build materials/meshes. + // (device + queue + format + cache) into the Scene before setup so it can build materials/meshes. let mut scene = Scene::new(); - scene.init_gpu(device, format); + scene.init_gpu(device, context.queue.clone(), format); let mut app = App { scene, diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index 89ddfc7..09cf27f 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -389,13 +389,15 @@ fn create_depth_texture( (depth_texture, depth_view) } -/// Binds a Material pipeline, the two uniform bind groups, and Mesh buffers into an active render +/// Binds a Material pipeline, the three shared bind groups, and Mesh buffers into an active render /// pass and issues the draw call. Shared by `Renderer::render` and `Renderer::render_scene`. -/// The frame (group 0) and object (group 1) bind groups are **required** by every pipeline layout -/// (Étape 3 : un seul layout pour tous) — they must be bound even if the shader does not read them. -/// Draws indexed geometry when an index buffer exists, otherwise falls back to a non-indexed draw. -/// Inputs: pass (active render pass), mesh (geometry to draw), material (pipeline to bind), -/// frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/identity model). +/// The frame (@0), object (@1) and texture (@2) bind groups are **required** by every pipeline layout +/// (Étape 3 : un seul layout pour tous — Étape 10 : groupe texture) — they must be bound even if the +/// shader does not read them. Draws indexed geometry when an index buffer exists, otherwise falls +/// back to a non-indexed draw. +/// Inputs: pass (active render pass), mesh (geometry to draw), material (pipeline + texture bind +/// group to bind), frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/identity +/// model). fn draw_entity( pass: &mut wgpu::RenderPass<'_>, mesh: &Mesh, @@ -410,6 +412,9 @@ fn draw_entity( pass.set_pipeline(&material.pipeline); pass.set_bind_group(0, frame_bind_group, &[]); pass.set_bind_group(1, object_bind_group, &[]); + // Étape 10 (DRAFT 10.4) : groupe texture — le Material possède son bind group (placeholder + // blanc s'il n'a pas de texture, D1/D2). Toujours liable car posé sur toutes les pipelines. + pass.set_bind_group(2, &material.texture_bind_group, &[]); pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); if let Some(index_buffer) = &mesh.index_buffer { pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16); diff --git a/lib/src/pipeline/mod.rs b/lib/src/pipeline/mod.rs index 56e7cdd..c05335f 100644 --- a/lib/src/pipeline/mod.rs +++ b/lib/src/pipeline/mod.rs @@ -11,4 +11,7 @@ pub mod pipeline_cache; // Re-exports -pub use pipeline_cache::{DEPTH_FORMAT, PipelineCache, create_uniform_bind_group_layouts}; +pub use pipeline_cache::{ + DEPTH_FORMAT, PipelineCache, create_texture_bind_group_layout, + create_uniform_bind_group_layouts, +}; diff --git a/lib/src/pipeline/pipeline_cache.rs b/lib/src/pipeline/pipeline_cache.rs index 0833aff..fb06fbe 100644 --- a/lib/src/pipeline/pipeline_cache.rs +++ b/lib/src/pipeline/pipeline_cache.rs @@ -15,7 +15,7 @@ //! - wgpu 30 requires `compilation_options` in VertexState/FragmentState and `depth_slice` in color attachments. //! - **Batching**: Multiple Materials with the same shader_id share one pipeline, enabling material-level batching in Renderer. -use crate::resources::Vertex; +use crate::resources::{Texture, Vertex}; use crate::utils::STANDARD_SHADER; use std::collections::HashMap; @@ -59,6 +59,37 @@ pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGr ] } +/// Creates the texture bind group layout (group 2) shared by every pipeline (Étape 10, DRAFT D1). +/// Binds the diffuse texture + its sampler in the **fragment** stage only. Added to every pipeline +/// layout alongside the frame (@0) + object (@1) uniform groups, so « un seul layout pour tous » +/// (Étape 3) is preserved: a texture-less `Material` binds the white 1×1 placeholder instead. +/// +/// - `binding 0` : sampler (filtering, linear/repeat — D3). +/// - `binding 1` : `texture_2d` diffuse. +pub fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("texture_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }) +} + /// Depth texture format shared by the whole library (Étape 9, décision D1 du 2026-09-18). /// /// Single z-buffer format used for **both** the depth attachment textures (`Renderer`) and the @@ -76,22 +107,68 @@ pub struct PipelineCache { pipelines: HashMap>, /// Maps shader IDs to file paths on disk for WGSL loading in `load_shader()`. shader_paths: HashMap, + /// Shared bind group layout for the texture group (`@group(2)`), used by every pipeline and by + /// every Material's texture bind group (Étape 10, DRAFT D1 : « un seul layout pour tous »). + texture_bind_group_layout: wgpu::BindGroupLayout, + /// 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-Étape-10 look. + placeholder: Arc, } impl PipelineCache { - /// Creates an empty pipeline cache with no pre-loaded shaders or pipelines. - /// Inputs: device (owned Arc reference to wgpu Device, required for creating ShaderModules and RenderPipelines). - /// Returns a new PipelineCache ready for shader registration via register_shader(). + /// Creates an empty pipeline cache with no pre-loaded shaders or pipelines, plus the shared + /// texture bind group layout (group 2) and the white placeholder texture (Étape 10). + /// Inputs: device (owned Arc reference to wgpu Device), queue (used once to upload the white + /// placeholder). Returns a new PipelineCache ready for shader registration via register_shader(). /// Called at application startup before any Material creation. Shader paths must be registered via register_shader() first. - pub fn new(device: Arc) -> Self { + pub fn new(device: Arc, queue: wgpu::Queue) -> Self { + let placeholder = Texture::white_placeholder(&device, &queue).arc(); + let texture_bind_group_layout = create_texture_bind_group_layout(&device); Self { device, pipelines: HashMap::new(), // Maps shader IDs to file paths on disk for WGSL loading in load_shader(). // When a path exists, it reads from it; otherwise falls back to STANDARD_SHADER constant. shader_paths: HashMap::new(), + texture_bind_group_layout, + placeholder, } } + + /// Returns the shared white placeholder texture, bound by `Material`s without a diffuse texture. + /// Called by `Material` construction (through [`PipelineCache::texture_bind_group`]) and by + /// `Scene::get_texture` fallbacks. Étape 10 (DRAFT D1/D2). + pub fn placeholder(&self) -> &Arc { + &self.placeholder + } + + /// Returns a reference to the shared group-2 bind group layout (sampler + texture), used by + /// every Material to build its texture bind group. Étape 10 (DRAFT D1). + pub fn texture_bind_group_layout(&self) -> &wgpu::BindGroupLayout { + &self.texture_bind_group_layout + } + + /// Builds a group-2 bind group for a Material from its diffuse texture (or the white placeholder + /// when `texture` is `None`). Centralizes the sampler+texture binding so `Material` never touches + /// wgpu directly (Étape 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>) -> wgpu::BindGroup { + let tex = texture.unwrap_or_else(|| self.placeholder.clone()); + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("texture bind group"), + layout: &self.texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Sampler(&tex.sampler), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&tex.view), + }, + ], + }) + } /// Registers an external WGSL shader file path associated with a given ID. /// Inputs: id (unique key for this shader), path (filesystem path to .wgsl file). /// Returns Ok(id) on success or Err(String) if the ID is already registered. Called during scene setup to register custom shaders. @@ -203,12 +280,17 @@ impl PipelineCache { ], }; - // Pipeline layout — the two uniform bind groups (frame @0 + object @1) are attached - // to EVERY pipeline (Étape 3, décision actée « un seul layout pour tous »), even if a - // given shader does not read them. `immediate_size` stays 0 (no var used). - let bind_group_layouts = create_uniform_bind_group_layouts(device); - let layout_refs: Vec> = - bind_group_layouts.iter().map(Some).collect(); + // Pipeline layout — the two uniform bind groups (frame @0 + object @1) AND the texture + // bind group (@2, Étape 10 DRAFT D1) are attached to EVERY pipeline (Étape 3, décision + // actée « un seul layout pour tous »), even if a given shader does not read them. + // `immediate_size` stays 0 (no var used). + let uniform_layouts = create_uniform_bind_group_layouts(device); + let texture_layout = create_texture_bind_group_layout(device); + let layout_refs: Vec> = vec![ + Some(&uniform_layouts[0]), // frame @0 + Some(&uniform_layouts[1]), // object @1 + Some(&texture_layout), // texture @2 + ]; let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("render_pipeline_layout"), diff --git a/lib/src/resources/material.rs b/lib/src/resources/material.rs index 6a3f9a6..b6cb1ba 100644 --- a/lib/src/resources/material.rs +++ b/lib/src/resources/material.rs @@ -1,37 +1,74 @@ -//! # Material Module — Appearance Descriptor (shader_id → RenderPipeline) +//! # Material Module — Appearance Descriptor (shader_id → RenderPipeline + diffuse texture) //! //! Defines `Material`, a lightweight appearance descriptor that pairs a shader identifier with -//! a shared RenderPipeline. Materials are created via PipelineCache to ensure pipeline reuse— +//! a shared RenderPipeline and, since Étape 10 (DRAFT D4), an optional diffuse `Texture` plus the +//! matching group-2 bind group. Materials are created via PipelineCache to ensure pipeline reuse— //! multiple materials referencing the same shader_id point to the identical compiled GPU pipeline. //! //! ## Architecture Notes (per ARCHI_APP.md) //! - **Identifiants**: Each Material is registered in Scene by string identifier, enabling dynamic access //! during the render loop without borrow checker issues. The shader_id serves as the `Handle` key. //! - **Phase de Déclaration**: Materials are instantiated once in the declarative phase before the render loop begins. +//! - **Texture (Étape 10, D4)**: the appearance lives on the Material. A texture-less Material binds +//! the shared white placeholder (DRAFT D1/D2), so every pipeline layout (`@group(2)`) is satisfied. use crate::pipeline::PipelineCache; +use crate::resources::Texture; use std::sync::Arc; -/// Lightweight appearance descriptor: links a shader ID to a shared RenderPipeline. -/// Does not own the pipeline; holds an Arc for zero-copy sharing across objects using the same shader. +/// Lightweight appearance descriptor: links a shader ID to a shared RenderPipeline and an optional +/// diffuse texture. Does not own the pipeline; holds an Arc for zero-copy sharing across objects +/// using the same shader. Owns its texture bind group (group 2), built at construction. pub struct Material { /// Unique shader identifier used to look up or create a compiled RenderPipeline in PipelineCache. pub shader_id: String, /// Shared reference to the compiled GPU render pipeline. Multiple Materials can share one through Arc cloning. pub pipeline: Arc, + /// Diffuse texture sampled by this material. `None` → the white placeholder is bound (DRAFT D1/D2). + pub texture: Option>, + /// 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, } impl Material { - /// Creates a new Material by requesting the cache to provide (or create) its RenderPipeline. + /// Creates a new Material by requesting the cache to provide (or create) its RenderPipeline, and + /// building a group-2 texture bind group that binds the **white placeholder** (no diffuse texture). /// Inputs: format (surface texture format required for fragment output), shader_id (unique key into PipelineCache), /// 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 { - // Request pipeline from cache — returns cached instance if already exists, creates new otherwise + Self::build(format, shader_id, None, cache) + } + + /// Creates a Material with a diffuse texture: compiles/retrieves the pipeline and builds a + /// group-2 texture bind group that samples `texture` (DRAFT D4). Inputs: format, shader_id, + /// texture (the diffuse texture to sample), cache. Returns the texturized Material. + pub fn new_with_texture( + format: wgpu::TextureFormat, + shader_id: &str, + texture: Arc, + cache: &mut PipelineCache, + ) -> Self { + Self::build(format, shader_id, Some(texture), cache) + } + + /// Shared construction: requests the pipeline from the cache, then builds the group-2 texture + /// bind group from `texture` (or the cache placeholder when `None`). The bind group is created + /// right here so it exactly matches the shared layout, keeping « un seul layout pour tous » (D1). + fn build( + format: wgpu::TextureFormat, + shader_id: &str, + texture: Option>, + cache: &mut PipelineCache, + ) -> Self { let pipeline = cache.get_or_create(format, shader_id); + let texture_bind_group = cache.texture_bind_group(texture.clone()); Self { shader_id: shader_id.to_string(), pipeline, + texture, + texture_bind_group, } } } diff --git a/lib/src/resources/mod.rs b/lib/src/resources/mod.rs index 8cfd17e..f4cf34d 100644 --- a/lib/src/resources/mod.rs +++ b/lib/src/resources/mod.rs @@ -15,6 +15,7 @@ pub mod camera; pub mod material; pub mod mesh; +pub mod texture; pub mod uniform; pub mod vertex; @@ -22,6 +23,7 @@ pub mod vertex; pub use camera::Camera; pub use material::Material; pub use mesh::Mesh; +pub use texture::{Texture, TextureError}; pub use uniform::{FrameUniforms, ObjectUniform}; pub use vertex::Vertex; diff --git a/lib/src/resources/texture.rs b/lib/src/resources/texture.rs new file mode 100644 index 0000000..2deafa5 --- /dev/null +++ b/lib/src/resources/texture.rs @@ -0,0 +1,160 @@ +//! # Texture Module — GPU Diffuse Texture (device + view + sampler) +//! +//! Defines `Texture`, the GPU representation of a diffuse image: the backing `wgpu::Texture`, +//! its `TextureView` (for sampling in the shader) and its `Sampler` (filtering/address mode). +//! Added in Étape 10 (DRAFT D3) to texturize the standard shader via bind group `@group(2)`. +//! +//! ## Architecture Notes (per DRAFT Étape 10, D3/D4) +//! - **Format** : `Rgba8UnormSrgb` (espace sRGB, correct pour une couleur diffuse). +//! - **Usage** : `TEXTURE_BINDING | COPY_DST` (échantillonnée en fragment, remplie par upload CPU). +//! - **Mipmaps** : objet unique (`mip_level_count: 1` — YAGNI, pas de génération de mipmaps à cette étape). +//! - **Sampler** : `Linear` + `Repeat` (filtrage doux, coordonnées UV classiques). +//! - **Placeholder** : une texture blanche 1×1 (texel identité multiplicative) sert au `Material` +//! sans texture — voir `white_placeholder`. + +use std::sync::Arc; + +/// GPU diffuse texture format (DRAFT D3): sRGB 8-bit RGBA. Matches the color space expected for +/// sampled diffuse albedo and the fragment bind group layout (`texture_2d`). +pub const TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb; + +/// Errors produced while decoding an image into a `Texture`. +#[derive(Debug, thiserror::Error)] +pub enum TextureError { + /// The image could not be decoded by the `image` crate (corrupt/unsupported file). + #[error("failed to decode image: {0}")] + Decode(#[from] image::ImageError), + /// The pixel buffer chunk (DRW) is empty — nothing to upload. + #[error("no pixel data provided to build the texture")] + Empty, +} + +/// GPU diffuse texture: backing texture, sampling view and sampler. Immutable after creation, +/// shared (behind `Arc`) by `Material`s via the Scene resource depot (Étape 10, D4). +pub struct Texture { + /// Backing GPU image, kept alive for the whole lifetime of the texture. + _texture: wgpu::Texture, + /// Sampling view of the backing image, bound into the group-2 bind group. + pub view: wgpu::TextureView, + /// Sampler (filtering + address mode) used to sample the texture in the shader. + pub sampler: wgpu::Sampler, +} + +impl Texture { + /// The core constructor: uploads raw RGBA8 pixels into a `Rgba8UnormSrgb` 2D texture. + /// This is the primitive used by `from_bytes`/`from_file` (after decoding) and by + /// `white_placeholder`. Inputs: device (GPU), queue (for `write_texture`), width (px), + /// height (px), rgba (raw 4-bytes-per-pixel data, `width * height * 4` long), label (debug). + /// Returns the texture, or `Err(TextureError::Empty)` if `rgba` is empty. + pub fn from_rgba8( + device: &wgpu::Device, + queue: &wgpu::Queue, + width: u32, + height: u32, + rgba: &[u8], + label: &str, + ) -> Result { + if rgba.is_empty() { + return Err(TextureError::Empty); + } + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, // YAGNI : pas de mipmaps à cette étape (DRAFT D3) + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TEXTURE_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * width), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some(label), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + address_mode_w: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Linear, + ..Default::default() + }); + Ok(Self { + _texture: texture, + view, + sampler, + }) + } + + /// Decodes an encoded image (PNG/JPEG via the `image` crate) from a byte slice and uploads it. + /// Inputs: device (GPU), queue (write target), label (debug), bytes (encoded image data). + /// Returns the decoded+uploaded texture, or `Err(TextureError)` on decode/empty failure. + pub fn from_bytes( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &str, + bytes: &[u8], + ) -> Result { + let img = image::load_from_memory(bytes)?; + // Normalise en RGBA8 (sous-échantillonne Luma8/Rgb8 en RGBA8, comme le veut Rgba8UnormSrgb). + let rgba = img.to_rgba8(); + Self::from_rgba8(device, queue, rgba.width(), rgba.height(), &rgba, label) + } + + /// Reads a file from `path` and uploads its pixels via [`Texture::from_bytes`]. + /// Inputs: device (GPU), queue (write target), label (debug), path (image file on disk). + /// Returns the texture, or `Err(TextureError)` if the file cannot be read/decoded. + pub fn from_file( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &str, + path: &str, + ) -> Result { + let bytes = + std::fs::read(path).map_err(|e| TextureError::Decode(image::ImageError::IoError(e)))?; + Self::from_bytes(device, queue, label, &bytes) + } + + /// Builds the white 1×1 placeholder used by `Material`s without a texture (DRAFT D1/D2). + /// A white texel is the multiplicative identity: sampling it leaves the vertex color + /// unchanged, so a texture-less material renders exactly as before (no breakage in + /// unlit vertex-colored geometry). Inputs: device, queue. Returns the 1×1 white texture. + pub fn white_placeholder(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { + Self::from_rgba8( + device, + queue, + 1, + 1, + &[255, 255, 255, 255], + "default white texture", + ) + .expect("1×1 white placeholder must not be empty") + } + + /// Shared convenience wrapper so `Arc` can be created ergonomically by callers. + pub(crate) fn arc(self) -> Arc { + Arc::new(self) + } +} diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index 06d4739..39f39d1 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -17,7 +17,7 @@ use crate::math::{Geometry, Transform}; use crate::pipeline::PipelineCache; -use crate::resources::{Camera, Material, Mesh}; +use crate::resources::{Camera, Material, Mesh, Texture}; use crate::scene::Entity; use std::cell::RefCell; use std::collections::HashMap; @@ -45,6 +45,9 @@ pub struct Scene { meshes: HashMap>, /// Map of material identifiers to owned `Arc` instances. Populated via `add_material()`. materials: HashMap>, + /// Map of diffuse texture identifiers to owned `Arc` instances (Étape 10, D4). + /// Populated via `add_texture()`; materials reference them via `add_material_texture()` by id. + textures: HashMap>, /// Map of entity labels to `Entity` associations. Populated via `add_entity()` / `add_entity_with_transform()`. entities: HashMap, /// Active camera used for rendering. Read each frame by `Renderer::render_scene` to compute the @@ -66,6 +69,7 @@ impl Scene { Self { meshes: HashMap::new(), materials: HashMap::new(), + textures: HashMap::new(), entities: HashMap::new(), camera: Camera::default(), gpu: None, @@ -73,17 +77,19 @@ impl Scene { } } - /// Attaches the GPU-facing pipeline context (device + format + `PipelineCache`) to this Scene, - /// enabling it to build materials and meshes itself. Called once during `AppRunner::resumed`, + /// Attaches the GPU-facing pipeline context (device + queue + format + `PipelineCache`) to this + /// Scene, enabling it to build materials and meshes itself. Called once during `AppRunner::resumed`, /// just after the `Context`/`Renderer` are created and **before** `AppHandler::setup`, so setup - /// can register shaders/materials/meshes/entities using `self`. Returns `&mut self` for chaining. - /// Inputs: device — shared GPU device (Arc clone); format — surface texture output format. + /// can register shaders/materials/textures/meshes/entities using `self`. Returns `&mut self` for chaining. + /// Inputs: device — shared GPU device (Arc clone); queue — GPU command queue (used to build the + /// geometry); format — surface texture output format. pub fn init_gpu( &mut self, device: Arc, + queue: wgpu::Queue, format: wgpu::TextureFormat, ) -> &mut Self { - let cache = PipelineCache::new(device.clone()); + let cache = PipelineCache::new(device.clone(), queue.clone()); self.gpu = Some(SceneGpu { device, format, @@ -133,6 +139,54 @@ impl Scene { Ok(id.to_string()) } + /// Registers a diffuse texture in the Scene's resource depot under a unique identifier, so + /// materials can reference it declaratively (Étape 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. + /// Inputs: id (unique identifier), texture (GPU diffuse texture to register). + pub fn add_texture(&mut self, id: &str, texture: Texture) -> Result { + if self.textures.contains_key(id) { + return Err(format!("Texture ID '{}' already exists.", id)); + } + self.textures.insert(id.to_string(), Arc::new(texture)); + Ok(id.to_string()) + } + + /// Retrieves a registered diffuse texture by its identifier, if present. Called by the user to + /// read back a texture (or by internals when resolving material↔texture links). Étape 10 (D4). + pub fn get_texture(&self, id: &str) -> Option<&Arc> { + self.textures.get(id) + } + + /// Builds and registers a Material from a shader id **and** a diffuse texture registered via + /// [`Scene::add_texture`]. The material samples `texture_id` (Étape 10, D4). Returns Ok(id) or + /// Err(String) if the material id exists or the texture id does not. Inputs: id (material id to + /// register), shader_id (pipeline key), texture_id (existing texture id in this Scene). + pub fn add_material_texture( + &mut self, + id: &str, + shader_id: &str, + texture_id: &str, + ) -> Result { + if self.materials.contains_key(id) { + return Err(format!("Material ID '{}' already exists.", id)); + } + let texture = self + .textures + .get(texture_id) + .ok_or_else(|| format!("Texture '{}' does not exist.", texture_id))? + .clone(); + let mut cache = self.gpu().cache.borrow_mut(); + let material = Arc::new(Material::new_with_texture( + self.gpu().format, + shader_id, + texture, + &mut cache, + )); + drop(cache); + self.materials.insert(id.to_string(), material); + Ok(id.to_string()) + } + /// Builds, (optionally) links to a Material, and registers a Mesh in one declarative call. /// Since Étape 8 the mesh is declared from a CPU `Geometry` (DRAFT Étape 8, D4) instead of raw /// `&[Vertex]`. This builds the shared `Arc` and creates the GPU buffers via diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl index d2155c9..67fab00 100644 --- a/lib/src/shaders/standard_shader.wgsl +++ b/lib/src/shaders/standard_shader.wgsl @@ -1,13 +1,17 @@ -//! # Standard Shader Module (Phong) +//! # Standard Shader Module (Phong + diffuse texture) //! //! Default lit shading pipeline for WSG. Implements an ambient + directional-diffuse //! (Phong-style) lighting model with an explicit "unlit" mode so that flat 2D rendering //! is a special case of the 3D path (see DRAFT décision actée : « 2D ⊂ 3D »). +//! Since Étape 10 (DRAFT D2) the fragment can also sample a diffuse texture whose texel +//! modulates the vertex color (`texel.rgb * in.color.rgb`). //! //! ## Uniform Contract -//! Two bind groups, shared by every material (one single pipeline layout — voir Étape 3) : +//! Three bind groups, shared by every material (one single pipeline layout — voir Étape 3) : //! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights) [192 bytes] //! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes] +//! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10) +//! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d) (Étape 10) //! //! `FrameUniforms` layout (std140 — each element 16-byte aligned, no padding) : //! | Offset | Field | Type | Meaning | @@ -23,6 +27,12 @@ //! `light_dir` convention : vector pointing **from the surface toward the light**. //! The fragment shader negates it to obtain the light direction for the N·L term. //! +//! ## Texturing (Étape 10, D2) +//! The fragment samples `diffuse_texture` **unconditionally**. A texture-less `Material` binds the +//! white 1×1 placeholder (texel = `[1,1,1]`), which is the multiplicative identity: `texel * color` +//! leaves the vertex color unchanged, exactly reproducing the pre-Étape-10 look in both lit and +//! unlit modes. A real texture tints/multiplies the vertex color. +//! //! ## Vertex Input Layout (matches the full `resources::Vertex` struct, 56-byte stride) //! | Location | Attribute | Type | Offset (bytes) | //! |----------|-----------|----------|----------------| @@ -33,7 +43,7 @@ //! //! ## Entry Points //! - `@vertex vs_main` : world = model * position ; clip = proj * view * world. -//! - `@fragment fs_main` : ambient (hemispheric) + directional diffuse, or flat color when unlit. +//! - `@fragment fs_main` : base = texel * vertex color; × (ambient + diffuse) when lit, or base when unlit. struct VertexInput { @location(0) position: vec3, @@ -57,12 +67,17 @@ struct ObjectUniform { @group(0) @binding(0) var frame: FrameUniforms; @group(1) @binding(0) var object: ObjectUniform; +// Étape 10 (DRAFT D1) : groupe texture — sampler (0) + texture diffuse (1). Un matériau sans +// 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; struct VertexOutput { @builtin(position) clip_position: vec4, @location(0) world_pos: vec3, @location(1) normal: vec3, - @location(2) color: vec4, + @location(2) uv: vec2, + @location(3) color: vec4, }; @vertex @@ -81,15 +96,22 @@ fn vs_main(input: VertexInput) -> VertexOutput { object.model[2].xyz, ); out.normal = normal_matrix * input.normal; + out.uv = input.uv; out.color = input.color; return out; } @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // Flat (unlit) mode : pas d'éclairage, couleur du vertex telle quelle. + // Étape 10 (D2) : échantillonnage inconditionnel. Le texel module la couleur du vertex + // (base = texel * color). Avec le placeholder blanc (texel = 1), base == vertex color : + // aucune régression pour les matériaux sans texture, en lit comme en unlit. + let texel = textureSample(diffuse_texture, texture_sampler, in.uv); + let base = texel.rgb * in.color.rgb; + + // Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex telle quelle. if (frame.options.x != 0u) { - return in.color; + return vec4(base, in.color.a); } let n = normalize(in.normal); @@ -104,6 +126,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Diffuse directionnel classique. let diffuse = frame.light_color.rgb * ndotl; - let lit = in.color.rgb * (ambient + diffuse); + let lit = base * (ambient + diffuse); return vec4(lit, in.color.a); }