Rule A (area-sorted triangle removal) left holes and open boundaries
on closed meshes (visible artifacts when zoomed far out). The decimator
is now a proper quadric edge collapse:
- Collapse: welded u32 topology, per-vertex quadrics (accumulated
incident face planes), edge cost = quadric error at the optimal
point (clamped to the segment) + edge length, BinaryHeap with a
custom Ord (f32 is not Ord; inverted compare, NaN-safe).
- Standard GH semantics WITHOUT the new face: both incident pair faces
degenerate and are removed; neighbouring faces remap and sweep over
the region. Preserves the Euler characteristic and closedness (no
holes, no books, no duplicate faces); interior collapse = -2 faces,
boundary = -1. Guards: non-manifold edge (>2 faces) or a fold
(duplicate sorted triple) rejects the collapse.
- welded(): fuzzy welding (1e-6 relative tolerance, grid + 27-
neighbour broad phase, exact verify) - trig-generated seams differ
by ~1e-16, exact-bit welding missed them.
- Root causes fixed along the way: dead face slots are never reused
(stale edge/vface entries), vfaces updated on remap, degenerate
faces dropped, best-effort target (granularity -2/-1 can land 1-2
off; soft cap, deterministic).
- Docs: DRAFT D10, ROADMAP 4.3, ARCHI_CPU_GPU, gpu-driven, lib
README, mesh/scene/demo comments - 'greedy decimation / Rule A'
replaced by 'quadric edge collapse'.
- lod.rs test: deprecated glam perspective alias -> explicit
glam::camera::rh::proj::opengl::perspective.
cargo test --workspace: 100 passed (94 lib + 3 wgsl + 3 integration),
0 failed; demo runs clean with LOD on.
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).
Add resources::CameraController (Etapes 15.C): spherical yaw/pitch/distance/
target with orbit() (mouse drag), zoom() (wheel, clamped), reset(), and
apply_to(&mut Camera). Add Scene::camera_mut() for in-place per-frame edits.
New lib/examples/demo.rs: all six primitives on a textured ground, standard
Phong material, shadow-casting directional + point + spot lights, and a live
orbital view driven by the unified input (drag=orbit, wheel=zoom, R=reset,
1/2/3=front/side/top presets) plus slow primitive rotation.
6 unit tests for CameraController.
The shadow pass was correct but the demo light was much too steep (52°
elevation), so the blocker's shadow fell in a ~0.5-unit sliver tight against
the cube's base and was invisible against the bright ground (offscreen pixel
probe found a single dark pixel). Verified with an offscreen probe using the
real Renderer::render_scene + shadow path:
- steep front light (0.6,1.1,0.6) -> 1 dark pixel (no visible shadow)
- shallow side light (1.0,0.3,0.0) -> 17 107 pixels (shadow pipeline OK)
- tuned front-right (1.0,0.5,0.0) -> 16 979 pixels (clear visible shadow)
The azimuth matters most: from the elevated front-right camera, a shadow cast
toward -z falls behind the cube and is occluded; one cast toward -x runs across
the ground to the left of the cube and reads clearly. Tuned light therefore sits
front-right and low (toward_light (1.0,0.5,0.0)), keeping the front faces lit
while casting a clearly visible PCF-softened shadow.
Also reapply the LessEqual comparison sampler fix (commit 39167ee had set it,
but was later reverted to GreaterEqual by 9a51ff7 while debugging; the probe
confirms LessEqual is the correct, non-inverted test). Correct 'rotating cube'
to 'cube' in README/ROADMAP (shadow_test scene is static).
Implement shadow mapping for directional lights:
- Scene::set_shadow_caster(Option<usize>) selects the shadow-casting light
by packed frame-array index (None disables; point lights rejected at render).
- Lights::get(index) resolves a packed index across the directional/point/spot lists.
- Renderer allocates a shadow depth map, comparison sampler, group-3 bind groups,
shadow uniform buffer and shadow pipeline; render_scene does a depth-only
shadow pass before the main pass; compute_shadow_light_view_proj builds an
orthographic light-space frustum from the scene radius.
- standard_shader: shadow_light_index/light_view_proj/shadow_params uniforms,
@group(3) depth map + comparison sampler, 3x3 PCF compute_shadow().
- shadow_shader: path/vertex shader with attribute layout matching the shared
vertex buffer (only position consumed).
- shadow_test example: directional shadow caster casts a PCF-softened shadow
onto a ground slab; documented in examples README.
spot_test : le cube tourne désormais sur les axes X et Y (quaternions
composés Y*X, vitesses légèrement différentes), pour que tous les sommets
passent devant le cône et que l'effet du faisceau soit visible sur les 6
faces.
Ajout de lib/examples/README.md : table des exemples avec la commande de
lancement de chacun + conventions. À maintenir à jour à chaque nouvel
exemple.
Pour vérifier visuellement la lumière spot (Étape 13) : seule la spot est allumée,
le cube est noir hors du faisceau. Le cône orienté + bord lissé + éclairage fixe dans
l'espace monde (le cube tourne) sont nets. cargo run -p wsg-lib --example spot_test
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.
- 9.1: allocate shared Depth32Float depth texture + view in Renderer,
sized to the initial surface (create_depth_texture helper, reusable
for the Phase 4.4 resize); Renderer::new now takes width/height.
- 9.2: attach depth_stencil_attachment (clear 1.0 / store) to both the
low-level render and high-level render_scene passes.
- 9.3: every pipeline declares a matching DepthStencilState (write true,
compare Less) via the shared DEPTH_FORMAT constant (D1).
Décisions D1-D4 actées 2026-09-18 (DRAFT Étape 9).
Étape 7 (DRAFT): the PipelineCache moves into the Scene (SceneGpu holds
device + format + cache, wired via Scene::init_gpu in AppRunner::resumed),
so App no longer owns a cache field. Mesh now holds Option<Arc<Material>>
(with material()/set_material()/with_material()); Entity drops material_id
({mesh_id, transform}); iter_entities yields (label, &Mesh, &Transform);
Renderer::render_scene resolves the material from the mesh or the Scene's
lazy default_material(). New declarative Scene helpers: register_shader,
add_material_shader, create_mesh. cube/simple examples migrated; manual
remains low-level and unchanged.
winit 0.30.13 removed WindowBuilder and deprecated EventLoop::run. Move
window/GPU creation into ApplicationHandler::resumed, expose AppHandler::setup
hook, switch App::run to run_app, and migrate both examples. pollster becomes a
regular dependency (used by app.rs).
- AppHandler::render now receives the current &Frame; its default
implementation renders the whole scene automatically via
app.render_scene(frame.view()) (Option A). Users can simply not
implement render for full auto-rendering.
- Add Renderer::render_scene: batch-renders every scene entity in a
single render pass. Factored per-mesh draw logic into a private
draw_entity helper shared with Renderer::render.
- Add App::render_scene(view) delegating to the Renderer.
- Fill simple.rs with a real quad (mesh/material/entity) without
importing wgpu; the scene now auto-renders via the trait default.
- Replace the broken App::new() (which does not exist) with AppBuilder.
- Strip it down to the PLAN's ~15-line target: no explicit winit/wgpu,
AppHandler render() left empty (scene rendering still on the roadmap).
- Verified: builds, opens a black window and runs the update->render->present
loop until closed.
- Update README/PLAN wording now that the simple example compiles (it still
does not draw a scene).