From 26a3cda6f6a931cd05cc0cd1bc4b26b20390f4c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Wed, 16 Sep 2026 15:55:26 +0200 Subject: [PATCH] feat(shaders): add standard Phong shader with uniform contract (data only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 2 du plan 3D+Phong : nouveau shader unifié, non encore branché à un pipeline (Étape 3 : infra uniforms). - shaders/standard_shader.wgsl : contrat vertex complet (position/normal/uv/color, 56 octets, corrige la défaut latente du basic) ; 2 bind groups formative frame (view/proj/cam_pos/light_dir/light_color/options) + object (model) ; éclairage hemisphérique ambient + diffuse directionnel; mode unlit (options.x) pour que la 2D plate soit un cas partticulier de la 3D. - utils/conf.rs : constantes STANDARD_SHADER_PATH + STANDARD_SHADER (include_str!). - tests/wgsl_validate.rs : validation hors-ligne naga (via wgpu::naga, aucune nouvelle dépendance) tant que le shader n'est pas compilé par un pipeline. - shaders/README.md : documente le contrat standard et le statut du basic. - Erreur WGSL corrigée en validation: cast mat4x4->mat3x3 non supporte, remplacé par construction explicite de la sous-matrice 3x3. - Validation: cargo test (naga OK), check workspace+examples 0 warning, doc OK, fmt OK. --- docs/DRAFT.md | 19 +++-- lib/src/shaders/README.md | 34 ++++++++- lib/src/shaders/standard_shader.wgsl | 109 +++++++++++++++++++++++++++ lib/src/utils/conf.rs | 11 +++ lib/tests/wgsl_validate.rs | 30 ++++++++ 5 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 lib/src/shaders/standard_shader.wgsl create mode 100644 lib/tests/wgsl_validate.rs diff --git a/docs/DRAFT.md b/docs/DRAFT.md index 017c53d..2602b1c 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -45,21 +45,26 @@ toucher au rendu (pure façade de données, validable par compilation). **But** : produire un rendu 3D éclairé via un nouveau shader, sans encore le brancher. -- [ ] 2.1 **Créer `lib/src/shaders/standard_shader.wgsl`** avec le **contrat vertex correct** : +- [X] 2.1 **Créer `lib/src/shaders/standard_shader.wgsl`** avec le **contrat vertex correct** : `@location(0) position : vec3`, `(1) normal : vec3`, `(2) uv : vec2`, `(3) color : vec4`. - - `@group(0) @binding(0)` : `FrameUniforms { view: mat4, proj: mat4, cam_pos: vec4, light_dir: vec4, light_color: vec4 }` + - `@group(0) @binding(0)` : `FrameUniforms { view: mat4, proj: mat4, cam_pos: vec4, light_dir: vec4, light_color: vec4, options: vec4 }` (options.x = unlit flag) - `@group(1) @binding(0)` : `ObjectUniform { model: mat4 }` - `vs_main` : `clip_position = proj * view * model * vec4(position,1)` ; passe `normal`/`color` en espace monde. - `fs_main` : éclairage hémisphérique (ambient) + diffuse directionnel (max(dot(N,L),0)), sortie `vec4(color*light, 1)`. - - **Mode unlit** : un flag dans `FrameUniforms` (ou `light_color` nul) **neutralise la directionnelle** → couleur - plate. Ainsi « 2D » = `standard` non-éclairé, **cas particulier de la 3D** (décision actée). -- [ ] 2.2 **Constantes** : ajouter `STANDARD_SHADER_PATH = "assets/shaders/standard_shader.wgsl"` et + - **Mode unlit** : `options.x != 0` neutralise la directionnelle → couleur plate. Ainsi « 2D » = `standard` + non-éclairé, **cas particulier de la 3D** (décision actée). *(fait — 2026-09-16)* +- [X] 2.2 **Constantes** : ajouter `STANDARD_SHADER_PATH = "assets/shaders/standard_shader.wgsl"` et `STANDARD_SHADER: &str = include_str!("../shaders/standard_shader.wgsl")` dans `lib/src/utils/conf.rs`. + *(fait — 2026-09-16)* - [ ] 2.3 **Migrer `basic` vers le mode unlit de `standard`** (défaut latente réglée) : plus de pipeline au **layout vide séparé**. Le rendu plat = `standard` non-éclairé (identité/ortho + ambiance) sous le **même layout uniformisé**. Le fallback embarqué (`BASIC_SHADER`) devient la variante unlit de `standard`. -- [ ] **Validation** : nouveau `shaders/mod.rs` si include_str le requiert ; `cargo check` OK (le shader n'est - pas encore compilé par un pipeline tant que l'Étape 3 ne le charge pas). + *(bloqué : dépend des bind groups du `PipelineCache`, infra de l'Étape 3)* +- [X] **Validation** : shader validé hors-ligne via un **nouveau test permanent** `lib/tests/wgsl_validate.rs` + (naga via `wgpu::naga`, aucune nouvelle dépendance) ; corrigé au passage le cast `mat4x4 -> mat3x3` non + supporté (construction de la sous-matrice 3×3 explicite). `cargo test` + `cargo check --workspace --examples` + 0 warning ; `cargo doc --no-deps` OK ; `cargo fmt` propre. Le shader n'est pas encore compilé par un pipeline + (Étape 3). *(fait — 2026-09-16)* ## Étape 3 — Infrastructure uniforms dans le `PipelineCache` diff --git a/lib/src/shaders/README.md b/lib/src/shaders/README.md index 7a2c49d..2cca936 100644 --- a/lib/src/shaders/README.md +++ b/lib/src/shaders/README.md @@ -8,7 +8,8 @@ Contains WGSL shader source files used by the PipelineCache module. These are lo | File | Purpose | |------|---------| -| **basic_shader.wgsl** | Default vertex/fragment shader pair (vs_main / fs_main entry points) with position, uv, and color attributes. | +| **basic_shader.wgsl** | Legacy flat/unlit vertex/fragment shader pair (vs_main / fs_main) with position, uv, and color attributes. Scheduled to be replaced by the unlit variant of `standard_shader.wgsl` (DRAFT Étape 2.3 / 5). | +| **standard_shader.wgsl** | Standard (Phong) vertex/fragment shader — ambient + directional diffuse with an explicit unlit mode. Carries the full uniform contract (frame @group(0) + object @group(1)). | ## Shader Contract (basic_shader.wgsl) @@ -26,3 +27,34 @@ The WGSL shader defines: | 2 | color | vec3 | 24 | **Note**: This shader uses a 39-byte vertex stride (3+2+3 floats). It does NOT include normal data or alpha channel interpolation — it outputs fully opaque geometry with per-vertex color passthrough. This differs from the full `Vertex` struct layout (56 bytes with normal + alpha) defined in resources::Vertex; if a full shader matching the Vertex struct is needed, extend this shader accordingly. + +> **Statut** : ce shader n'est plus un pipeline séparé ; il est destiné à disparaître au profit de la variante +> unlit de `standard_shader.wgsl` (DRAFT Étape 2.3 / Étape 5, « un seul layout pour tous »). + +## Shader Contract (standard_shader.wgsl) + +`standard_shader.wgsl` est le shader unifié (Phong) de WSG. Il corrige le défaut latente de `basic` +(contrat vertex incomplet) et expose les deux bind groups partagés par tout matériau. + +### Vertex Input Layout (56-byte stride — correspond au `resources::Vertex`) + +| Location | Attribute | Type | Offset (bytes) | +|----------|-----------|------|----------------| +| 0 | position | vec3 | 0 | +| 1 | normal | vec3 | 12 | +| 2 | uv | vec2 | 24 | +| 3 | color | vec4 | 32 | + +### Uniforms (bind groups) + +| Group / Binding | Struct | Contenu | +|-----------------|--------|---------| +| `@group(0) @binding(0)` | `FrameUniforms` (192 B) | `view`, `proj`, `cam_pos`, `light_dir`, `light_color`, `options` (.x = unlit flag) | +| `@group(1) @binding(0)` | `ObjectUniform` (64 B) | `model` (matrice modèle de l'entité) | + +`light_dir` pointe de la surface vers la lumière ; le fragment shader l'inverse pour le terme N·L. + +### Mode unlit + +Un flag `options.x != 0` neutralise la directionnelle et renvoie la couleur du vertex telle quelle +(couleur plate). Ainsi le rendu 2D plat est un **cas particulier** de la 3D éclairée. diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl new file mode 100644 index 0000000..d2155c9 --- /dev/null +++ b/lib/src/shaders/standard_shader.wgsl @@ -0,0 +1,109 @@ +//! # Standard Shader Module (Phong) +//! +//! 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 »). +//! +//! ## Uniform Contract +//! Two 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] +//! +//! `FrameUniforms` layout (std140 — each element 16-byte aligned, no padding) : +//! | Offset | Field | Type | Meaning | +//! |--------|--------------|-----------|-----------------------------------| +//! | 0 | view | mat4x4 | Camera view matrix | +//! | 64 | proj | mat4x4 | Camera projection matrix | +//! | 128 | cam_pos | vec4 | Camera world position (.xyz) | +//! | 144 | light_dir | vec4 | Light direction (see below) | +//! | 160 | light_color | vec4 | Light color (.rgb) | +//! | 176 | options | vec4 | x = unlit flag (1 => flat color) | +//! | 192 | total | | | +//! +//! `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. +//! +//! ## Vertex Input Layout (matches the full `resources::Vertex` struct, 56-byte stride) +//! | Location | Attribute | Type | Offset (bytes) | +//! |----------|-----------|----------|----------------| +//! | 0 | position | vec3| 0 | +//! | 1 | normal | vec3| 12 | +//! | 2 | uv | vec2| 24 | +//! | 3 | color | vec4| 32 | +//! +//! ## Entry Points +//! - `@vertex vs_main` : world = model * position ; clip = proj * view * world. +//! - `@fragment fs_main` : ambient (hemispheric) + directional diffuse, or flat color when unlit. + +struct VertexInput { + @location(0) position: vec3, + @location(1) normal: vec3, + @location(2) uv: vec2, + @location(3) color: vec4, +}; + +struct FrameUniforms { + view: mat4x4, + proj: mat4x4, + cam_pos: vec4, + light_dir: vec4, + light_color: vec4, + options: vec4, // .x : unlit flag (1 = flat color, no directional lighting) +}; + +struct ObjectUniform { + model: mat4x4, +}; + +@group(0) @binding(0) var frame: FrameUniforms; +@group(1) @binding(0) var object: ObjectUniform; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) world_pos: vec3, + @location(1) normal: vec3, + @location(2) color: vec4, +}; + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + var out: VertexOutput; + let world = object.model * vec4(input.position, 1.0); + out.clip_position = frame.proj * frame.view * world; + out.world_pos = world.xyz; + // Model matrix is assumed to contain no non-uniform scale, so the normal is + // transformed by the upper-left 3x3 without needing an inverse-transpose. + // WGSL n'autorise pas un cast mat4x4 -> mat3x3 ; on construit la sous-matrice + // à partir des trois premières colonnes. + let normal_matrix = mat3x3( + object.model[0].xyz, + object.model[1].xyz, + object.model[2].xyz, + ); + out.normal = normal_matrix * input.normal; + 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. + if (frame.options.x != 0u) { + return in.color; + } + + let n = normalize(in.normal); + // light_dir pointe de la surface vers la lumière ; on inverse pour le terme N·L. + let l = normalize(-frame.light_dir.xyz); + let ndotl = max(dot(n, l), 0.0); + + // Ambient hémisphérique : dépend de la composante verticale de la normale. + let sky = max(n.y, 0.0); + let ambient = frame.light_color.rgb * (0.3 + 0.4 * sky); + + // Diffuse directionnel classique. + let diffuse = frame.light_color.rgb * ndotl; + + let lit = in.color.rgb * (ambient + diffuse); + return vec4(lit, in.color.a); +} diff --git a/lib/src/utils/conf.rs b/lib/src/utils/conf.rs index 2699411..c6f2a10 100644 --- a/lib/src/utils/conf.rs +++ b/lib/src/utils/conf.rs @@ -17,6 +17,17 @@ pub const BASIC_SHADER_PATH: &str = "assets/shaders/basic_shader.wgsl"; /// Serves as a fallback when `BASIC_SHADER_PATH` cannot be read at runtime. pub const BASIC_SHADER: &str = include_str!("../shaders/basic_shader.wgsl"); +/// Path to the standard (Phong) WGSL shader file on disk (runtime). Used by PipelineCache::load_shader() +/// once standardized (Étape 3) : this shader carries the full uniform contract (frame + object bind groups) +/// and supports an unlit mode so flat 2D rendering is a special case of the 3D lit path. +pub const STANDARD_SHADER_PATH: &str = "assets/shaders/standard_shader.wgsl"; + +/// The standard (Phong) WGSL shader source code, embedded at compile time via `include_str!`. +/// Not yet compiled by any pipeline (Étape 2 : shader seul, non branché). Becomes the unified +/// pipeline shader once the uniform infrastructure exists (Étape 3). The unlit variant is the +/// replacement for the flat `basic` family. +pub const STANDARD_SHADER: &str = include_str!("../shaders/standard_shader.wgsl"); + /// Default application title displayed in the OS taskbar/window decorations. pub const APP_DEFAULT_TITLE: &str = "WSG App"; diff --git a/lib/tests/wgsl_validate.rs b/lib/tests/wgsl_validate.rs new file mode 100644 index 0000000..7dfdafb --- /dev/null +++ b/lib/tests/wgsl_validate.rs @@ -0,0 +1,30 @@ +//! # Validation WGSL (naga) +//! +//! Le shader `standard_shader.wgsl` n'est pas encore chargé par un `RenderPipeline` (voir Étapes 3–5) : +//! cette validation hors-ligne via `wgpu::naga` est donc la **seule** garantie de sa validité tant qu'il +//! n'est pas branché. Elle protège contre les régressions futures (ré-édition du shader, changement de +//! layout) sans nécessiter de contexte GPU. +//! +//! Aucune nouvelle dépendance n'est introduite : `wgpu` ré-exporte `naga`, déjà dépendance de `wsg-lib`. + +use wgpu::naga; + +/// Parse et valide complètement le shader embarqué `standard_shader.wgsl` via naga. +/// Un échec ici signifie que le shader serait rejeté par `Device::create_shader_module` à l'Étape 3. +#[test] +fn standard_shader_is_valid_wgsl() { + let src = include_str!("../src/shaders/standard_shader.wgsl"); + let module = naga::front::wgsl::parse_str(src) + .unwrap_or_else(|e| panic!("standard_shader.wgsl : erreur de parsing : {e:?}")); + + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .unwrap_or_else(|e| panic!("standard_shader.wgsl : échec de validation : {e:?}")); + + // Contrat : exactement les deux entrées vs_main / fs_main attendues. + assert!(module.entry_points.len() >= 2, "vs_main + fs_main attendus"); +}