- weld: Δ UV = 0.5 exact n'est plus soudé (ambigu: fente à sa plus large
vs saut légitime — la colonne u=1 du cône vs le chart disque du cap
tombait exactement dessus et mélangeait les charts)
- collapse: les UV se blendent linéairement. Le fold par coordonnée
(Δ entier → 0) figeait l'UV du sommet sur les vertices de base
(bande de rayures, signalée par l'utilisateur) — il était inutile:
les jumeaux de fente sont gelés, aucun repli ne traverse la fente
- welded renvoie un struct Welded (clippy)
- test cône: dual-chart (fan latéral bilinéaire + disque du cap),
surface latérale r = λ; test seam/span réécrit selon la sémantique finale
- doc: gpu-driven.md, ARCHI_CPU_GPU.md (box LOD), DRAFT.md (D10) alignés
sur la sémantique finale (gel des jumeaux, blend linéaire, seuil strict)
- AGENTS.md: gotcha 'ne jamais folder un saut entier de tuile'
102 tests passent, demo lance et rend sans erreur.
Root cause of the user-reported artifacts (stripes disappearing on the
far LOD): normals were RECOMPUTED from the surviving faces after the
collapse. uv_sphere is wound inward, so the recomputed normals pointed
inward — the far LOD was back-face-lit (measured deviation 2.0 vs 0.0
on L0).
Fix — normals are INHERITED, never recomputed:
- welded() now also welds normals (first-encountered per cluster)
- Collapse owns the normal table; collapse_edge λ-blends + renormalizes
at the same λ as the position (no seam guard: the attribute-aware
weld kept hard-edge vertices separate, so no edge crosses a shading
break)
- compaction reads the post-collapse table instead of recomputing
- the outward reorientation added earlier is removed: the source
winding + normals are preserved as-is, so every LOD level is
shading-compatible with L0 whatever the source orientation
Rim protection (attribute-aware weld leaves UV-seam slits / pole fans
as boundary rims): a face touching such a rim is never removed while
interior collapses remain — strict PQ mode (edges whose incident faces
are fully interior, re-validated at pop) with a best-effort fallback
when the interior alone cannot reach the target. Seam-free meshes
(icosahedron) stay topologically closed; sewn meshes stay geometrically
complete (no hole at the slit) — tests now assert seam-column survival.
Docs: gpu-driven.md §LOD, ARCHI_CPU_GPU LOD note, ROADMAP 4.3 updated
with the attribute-aware weld + rim protection + inherited normals.
Gate: fmt ✓, check 0 warnings ✓, 104 tests ✓, demo runs ✓.
Measured (uv_sphere 32×20): normal max deviation 0.0000 on L0–L2
(was 2.0000), UV max error 0.0084 vs analytical (was 0.5000).
Le vertex-cible d'un repli se déplace au point optimal de l'arête mais
conserveait l'UV du weld — désaccord position/UV croissant en cascade :
la texture 'fuit' et les motifs (rayures) disparaissent aux niveaux
lointains, avec un changement radical entre deux LOD.
- Collapse porte désormais les tables uvs/colors (clonées au weld).
- collapse_edge interpole les UVs de la cible : uv_t ← (1−λ)·uv_s + λ·uv_t,
avec le même λ que le déplacement (cost_and_point renvoie désormais λ).
- Garde-fou seam : si |Δu| > 0.5 ou |Δv| > 0.5 (saut de texture), la cible
garde son UV — l'interpolation ne traverse jamais une seam.
- Couleurs : toujours interpolées (espace colorimétrique continu).
- Compaction : la sortie lit les tables mises à jour (c.uvs/c.colors), pas
les tables d'origine du weld.
- Docs : DRAFT.md (Sortie), gpu-driven.md (décimination), ARCHI_CPU_GPU (LOD).
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.
Add InputState (DRAFT Etapes 15, sous-volt 15.B): pressed/held/released
semantics via HashSet rotation, mouse position/delta/scroll accumulators.
Wire it into App (pub field), forward winit WindowEvents in the event
handler, and begin_frame/end_frame around AppHandler::update.
Handleable key/mouse/scroll logic extracted into private helpers so all
five unit tests avoid constructing winit KeyEvent (private fields).
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).
- Use glam's directx (WebGPU) projection module for both the camera
perspective and the shadow orthographic: NDC clip depth is [0,1] as
wgpu expects, instead of OpenGL's [-1,1] which clipped half the frustum
and broke depth-space consistency with the shadow map.
- Extend the shadow orthographic far plane to 2*r so the whole scene box
(and the shadow cast behind it, toward the camera) is covered.
- Switch the shadow comparison sampler to GreaterEqual so open sky is lit
and surfaces behind a blocker are shadowed (previous LessEqual inverted
the shadow, blackening the entire ground and making the cube float).
- Use the surface->light direction (+position_dir) for the directional
N*L term; the old negation darkened the cube top and lit the camera
faces, producing the inverted-pyramid appearance.
- Drop the now-redundant [0,1] depth remap in the main-pass shader.
For directional lights dir_angle is Vec4::ZERO, so the old code built the
shadow light_view_proj from dir=(0,0,0), making eye==target and look_at_mat4
degenerate -> NaN light_view_proj -> compute_shadow -> fully black frame.
Directional direction lives in position_dir.xyz (surface->light); the shadow
camera looks along the light's travel direction (light->scene), i.e. the
negation, matching the DRAFT spec.
With the shadow map cleared to 1.0 (farthest depth from light) and the
shadow pass writing smaller depths for surfaces closer to the light,
the fragment-to-light distance must be <= the stored surface depth for
lit pixels. GreaterEqual was inverted — everything appeared lit with no
shadows rendered.
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.
Le test du cône comparait l (surface->lumière) à dir_angle.xyz (lumière->scène),
deux directions opposées, donc cone ≈ -1 et facteur spot = 0 : les spots
n'éclairaient rien (cube noir). On teste désormais -l (lumière->point), aligné
avec l'axe du cône. Ajout d'un test Rust qui verrouille l'invariant (alignement
+1 sur l'axe). Build/test/WGSL/fmt OK.
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
La documentation reflète désormais uniquement ce qui *est* et ce qu'on
envisage de faire, pas ce qui aurait pu être (changements d'avis retirés) :
ROADMAP.md
- 1.2 : remplacé le récit de décision 'transform sur Mesh' (DRAFT Étape 8,
déviation) par l'énoncé de l'état actuel : Mesh porte son matériau + sa
Geometry CPU partagée, pas de transform (porté par Entity).
- Phase 2 réécrite : suppression des 'Arènes complètes' (SlotMap) et de la
struct Entity { mesh_id, material_id } contredite par l'Étape 7 (material
déplacé sur Mesh) et le choix String IDs ; migration 'handles typés'
conservée comme unique pointeur (1.4 / Notes de Décision).
- 4.2 : 'Lumières hémisphériques' cochée (déjà dans standard_shader).
- Notes de Décision : retiré 'UVs en Phase 4' (caduque, UVs implémentés dès
Geometry).
PLAN.md
- Statut réel condensé et mis à jour jusqu'à l'Étape 10 (textures).
- Phase 4 : Textures cochée (faite), Lumières laissée en plan (ROADMAP 4.2).
- Check-list pollster dé-obsolétisée ; suppression de la 'Note pour mémoire'
décrivant le module-pivot exec.rs qu'on a décidé de ne pas construire.
DRAFT.md
- Vidé (fin de l'Étape 10) en préparation de l'étape suivante.
- ROADMAP : Phase 4.1 (Textures) cochée — struct Texture, uvs, bind group
shader, Material avec texture diffuse.
- README : jalon 8 (diffuse textures) ajouté à la Roadmap ; ligne resource
'texture' + material enrichi dans le tableau des resources du module.
- DRAFT Étape 10 : cases 10.1-10.6 cochées (terminé et vérifié le
2026-09-18), point d'étape clôturé, bilan de fin d'étape rédigé (y compris
la divergence D2 : multiplication texel * couleur du vertex au lieu de
remplacement, et la structure réelle du PipelineCache).
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.
D1 placeholder 1×1 + groupe 2 sur toutes les pipelines · D2 échantillonnage
inconditionnel · D3 image + Rgba8UnormSrgb · D4 Material propriétaire du bind
group. Suite : implémentation 10.1-10.5 (refactor) puis docs (10.6).
- DRAFT.md vidé Après l'Étape 9 (bilan conservé dans git) et réinitialisé
pour l'Étape 10 : plan Textures avec décisions D1-D4 à valider.
- ROADMAP : coche 'uvs dans Geometry' (déjà implémenté dans le code),
prérequis de l'Étape 10.
- DRAFT.md : cases 9.1-9.4 cochées (terminées et vérifiées 2026-09-18),
point d'étape validé, bilan de fin d'étape rédigé.
- README.md : Renderer::new prend désormais width/height (signature Étape 9).
- ROADMAP.md : décisions D1-D4 actées + resize avec recréation de la depth
texture planifié en Phase 4.4 (acté D3, 2026-09-18).
- 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).
- D2 : Geometry reste en math (structure de donnees pure), re-export racine
- D4 : voie unique Mesh::from_geometry(Arc<Geometry>) ; suppression de l API &[Vertex]
- D5 : retention CPU (Arc<Geometry>) + buffers GPU pre-uploades
- D6 : convertisseur nomme Geometry::to_vertices() avec regles de remplissage
Toutes les decisions D1-D6 et la proposition 8.1 sont desormais validees.
É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.