fix: shadow caster index, torus winding, per-frame device poll ; close stage 15

Bug fixes found while validating stage 15:
- demo: set_shadow_caster(Some(0)) selected the default +Z light (packed
  index 0 from Lights::new()); the demo's warm directional light is packed
  at index 1. The shadow camera then looked down -Z, so misaligned objects
  occluded each other (cone/torus rendered black). Use index 1.
- primitives::torus: index winding was flipped ([a,c,b]); the outer surface
  (outward normals, CCW from outside) was culled and only the dark interior
  stayed visible. Reversed to [a,b,c]/[b,d,c] so it is CCW from outside.
- app: call device.poll() each frame in about_to_wait; without it wgpu
  async callbacks (on_submitted_work_done, map_async) never fire in the
  windowed loop.

Docs:
- conf: clarify the embedded-shader fallback is expected/harmless and that
  SHADOW_SHADER_PATH is kept for API compatibility only.
- README item 15: runtime-verified headless.
- DRAFT.md: emptied to a completion summary per convention (full stage-15
  plan preserved in git history).
This commit is contained in:
Jérôme Bousquié
2026-09-20 20:36:25 +02:00
parent 5a92daf7ab
commit 4bfd712496
6 changed files with 70 additions and 157 deletions
+4 -2
View File
@@ -161,8 +161,10 @@ impl AppHandler for Demo {
)
.unwrap();
// The first directional light (packed index 0) casts shadows.
app.scene.set_shadow_caster(Some(0));
// The warm directional light above casts shadows. It is packed at index 1: index 0 is
// the default +Z directional light pre-loaded by `Lights::new()` (kept here for the
// base lighting), so the demo's own light is the SECOND one in the packed array.
app.scene.set_shadow_caster(Some(1));
app.scene.set_ambient([0.14, 0.14, 0.16]);
// 6. Active camera, driven by the orbital controller (position, distance, preset target).
+12
View File
@@ -290,6 +290,18 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
let Some(app) = self.app.as_mut() else {
return;
};
// Poll the device each frame: wgpu only fires async callbacks (queue.on_submitted_work_done,
// buffer/texture map_async) when the device is polled, and the event loop never does it on
// our behalf. `Wait` with no timeout = block until the most recent submission completes
// (i.e. once per frame on a live GPU, which is what we want for the windowed loop).
// A failed poll (e.g. a device-lost error) is logged, not fatal: the next frame's poll
// will retry, and wgpu surfaces the loss through the device's error handler anyway.
if let Err(e) = app.context().device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
}) {
eprintln!("WSG : device.poll() a échoué ({e:?})");
}
// Étape 15 (input) : débute la frame d'input (rotation pressed/released + reset deltas),
// exécute la logique utilisateur, puis clôt (nettoie les états transitoires).
app.input.begin_frame();
+8 -1
View File
@@ -422,8 +422,15 @@ pub fn torus(major: f32, minor: f32, major_segments: u32, minor_segments: u32) -
let b = a + 1;
let c = a + mn + 1;
let d = c + 1;
// Triangles [a, b, c] / [b, d, c] : en face, l'angle u (majeur) croît avec +u et
// l'angle v (mineur) croît avec +v ; cross(tang_u, tang_v) pointe vers l'EXTÉRIEUR
// du tube (= la normale stockée), donc le winding est CCW vu de l'extérieur —
// cohérent avec `front_face: Face::Ccw` (culling des faces arrière).
// L'ordre [a, c, b] d'origine était inversé : la face externe (CCW vu de l'extérieur,
// normale extérieure) était Cullée et seul l'intérieur du tube, dont les normales
// pointent vers l'extérieur, restait visible — le tore apparaissait noir (N·L ≤ 0).
indices
.extend_from_slice(&[a as u16, c as u16, b as u16, b as u16, c as u16, d as u16]);
.extend_from_slice(&[a as u16, b as u16, c as u16, b as u16, d as u16, c as u16]);
}
}
Geometry::new(positions)
+8 -2
View File
@@ -14,6 +14,11 @@
/// for file-based loading. This is the unified pipeline shader (Étape 3 : un seul layout pour tous) :
/// it 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. The `basic` family was removed (Étape 5).
///
/// NOTE: the shipped `assets/shaders/*.wgsl` files are OPTIONAL — when they are absent (library consumed
/// from a checkout without the assets directory, or from a published crate), `PipelineCache::load_shader`
/// falls back to the embedded `STANDARD_SHADER` source, which is byte-identical. The fallback is therefore
/// expected and harmless, not an error condition.
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!`.
@@ -21,8 +26,9 @@ pub const STANDARD_SHADER_PATH: &str = "assets/shaders/standard_shader.wgsl";
/// pipeline uses the unified layout (frame @0 + object @1), this is the only shader the library ships.
pub const STANDARD_SHADER: &str = include_str!("../shaders/standard_shader.wgsl");
/// Path to the depth-only **shadow** WGSL shader on disk (Étape 14, D4). Used by the Renderer's
/// shadow-map pass: a minimal vertex shader that transforms vertices into light-clip space.
/// Path to the depth-only **shadow** WGSL shader on disk (Étape 14, D4). Kept only for API
/// compatibility — the shadow pass always compiles the embedded `SHADOW_SHADER` directly
/// (it is internal to the library, no external file is ever read).
pub const SHADOW_SHADER_PATH: &str = "assets/shaders/shadow_shader.wgsl";
/// The depth-only shadow WGSL shader source, embedded at compile time via `include_str!`