refactor(resources): activate diffuse textures on all render paths (Étape 10)

Implémente le plan Étape 10 (Phase 4.1 Textures), décisions D1-D4 actées :
- 10.1 : nouveau type resources::Texture (device+view+sampler), format
  Rgba8UnormSrgb, sampler linear/repeat, dep 'image' (png/jpeg). Constructeurs
  from_rgba8 / from_bytes / from_file / white_placeholder.
- 10.2 : create_texture_bind_group_layout (groupe 2 : sampler+texture, fragment).
  build_pipeline pose désormais 3 layouts [frame, object, texture] — « un seul
  layout pour tous » (D1). PipelineCache détient le layout + le placeholder blanc.
- 10.3 : shader standard — UV transmis au fragment (location 2), groupe @2
  texture_sampler + diffuse_texture, échantillonnage inconditionnel
  base = texel * couleur(vertex) (D2) : sans texture (placeholder blanc) pas
  de régression en lit comme en unlit.
- 10.4 : Material gagne texture: Option<Arc<Texture>> + texture_bind_group,
  construit dans le constructeur via le cache (layout partagé + placeholder).
- 10.5 : draw_entity bind @group(2) ; Scene : add_texture / get_texture /
  add_material_texture ; init_gpu accepte la Queue pour bâtir le placeholder.
- exemple cube : géométrie avec UV [0,1]² par face + texture damier procédurale.

Validation : fmt, check 0 warning, tests verts (3 + doc), doc sans missing_docs,
cube/simple/manual lancés sans erreur backend.
This commit is contained in:
Jérôme Bousquié
2026-09-18 14:18:02 +02:00
parent 3de84aa4dc
commit 440f2dffa3
13 changed files with 588 additions and 55 deletions
Generated
+133
View File
@@ -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",
]
+3
View File
@@ -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"] }
+47 -15
View File
@@ -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<u8>` brut RGBA8, chargeable via `Texture::from_rgba8`.
fn checkerboard_rgba() -> Vec<u8> {
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();
+1 -1
View File
@@ -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();
+2 -2
View File
@@ -236,9 +236,9 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
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,
+11 -6
View File
@@ -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);
+4 -1
View File
@@ -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,
};
+93 -11
View File
@@ -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<f32>` 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<String, Arc<wgpu::RenderPipeline>>,
/// Maps shader IDs to file paths on disk for WGSL loading in `load_shader()`.
shader_paths: HashMap<String, String>,
/// 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<Texture>,
}
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<wgpu::Device>) -> Self {
pub fn new(device: Arc<wgpu::Device>, 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<Texture> {
&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<Arc<Texture>>) -> 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<immediate> used).
let bind_group_layouts = create_uniform_bind_group_layouts(device);
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> =
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<immediate> used).
let uniform_layouts = create_uniform_bind_group_layouts(device);
let texture_layout = create_texture_bind_group_layout(device);
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> = 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"),
+43 -6
View File
@@ -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<T>` 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<wgpu::RenderPipeline>,
/// Diffuse texture sampled by this material. `None` → the white placeholder is bound (DRAFT D1/D2).
pub 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,
}
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<Texture>,
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<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());
Self {
shader_id: shader_id.to_string(),
pipeline,
texture,
texture_bind_group,
}
}
}
+2
View File
@@ -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;
+160
View File
@@ -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<f32>`).
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<Self, TextureError> {
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<Self, TextureError> {
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<Self, TextureError> {
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<Texture>` can be created ergonomically by callers.
pub(crate) fn arc(self) -> Arc<Texture> {
Arc::new(self)
}
}
+60 -6
View File
@@ -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<String, Arc<Mesh>>,
/// Map of material identifiers to owned `Arc<Material>` instances. Populated via `add_material()`.
materials: HashMap<String, Arc<Material>>,
/// Map of diffuse texture identifiers to owned `Arc<Texture>` instances (Étape 10, D4).
/// Populated via `add_texture()`; materials reference them via `add_material_texture()` by id.
textures: HashMap<String, Arc<Texture>>,
/// Map of entity labels to `Entity` associations. Populated via `add_entity()` / `add_entity_with_transform()`.
entities: HashMap<String, Entity>,
/// 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<wgpu::Device>,
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<String, String> {
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<Texture>> {
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<String, String> {
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<Geometry>` and creates the GPU buffers via
+29 -7
View File
@@ -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<f32>) (É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<f32>,
@@ -57,12 +67,17 @@ struct ObjectUniform {
@group(0) @binding(0) var<uniform> frame: FrameUniforms;
@group(1) @binding(0) var<uniform> 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<f32>;
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) color: vec4<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
};
@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<f32> {
// 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<f32>(base, in.color.a);
}
let n = normalize(in.normal);
@@ -104,6 +126,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Diffuse directionnel classique.
let diffuse = frame.light_color.rgb * ndotl;
let lit = in.color.rgb * (ambient + diffuse);
let lit = base * (ambient + diffuse);
return vec4<f32>(lit, in.color.a);
}