Compare commits

...

75 Commits

Author SHA1 Message Date
Jérôme Bousquié ab3f056dbb primitive meshes 2026-09-24 14:25:44 +02:00
Jérôme Bousquié 805babe53d HDR 2026-09-24 11:21:35 +02:00
Jérôme Bousquié 004761252b LOD: fige les jumeaux de fente (slit twins) + blend UV linéaire (le fold est supprimé)
- 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.
2026-09-23 16:55:44 +02:00
Jérôme Bousquié 40fac63590 LOD: fix inverted normals on decimated meshes + rim protection
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).
2026-09-23 15:01:56 +02:00
Jérôme Bousquié eac266dd86 LOD: interpoler UVs/couleurs des vertices déplacés par le repli
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).
2026-09-23 12:21:32 +02:00
Jérôme Bousquié a3a7ff4a6b LOD: quadric edge collapse (Garland-Heckbert) replaces Rule A
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.
2026-09-23 11:46:12 +02:00
Jérôme Bousquié f15e920109 LOD GPU 2026-09-22 20:47:36 +02:00
Jérôme Bousquié 531c43a457 material batching 2026-09-22 17:07:52 +02:00
Jérôme Bousquié 3a424afe8c GPU culling 2026-09-22 15:48:15 +02:00
Jérôme Bousquié 3dd372410f update demos et docs 2026-09-21 12:01:28 +02:00
Jérôme Bousquié ef13913464 correction orbital camera inputs 2026-09-21 11:36:19 +02:00
Jérôme Bousquié 5ae978da23 doc 2026-09-21 10:07:32 +02:00
Jérôme Bousquié 4bfd712496 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).
2026-09-20 20:36:25 +02:00
Jérôme Bousquié 5a92daf7ab docs: mark stage 15 complete in DRAFT/ROADMAP/README
Check off 15.A/15.B/15.C + factors + fmt in DRAFT; mark ROADMAP 2.2 and 2.3
done (gamepad noted [~] deferred, DRAFT D7); add README roadmap items 13-15.
Runtime check of the demo remains (user-driven).
2026-09-20 08:10:03 +02:00
Jérôme Bousquié eeb471f37c feat(camera): orbital CameraController + final demo example
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.
2026-09-20 08:10:03 +02:00
Jérôme Bousquié b41f7e259e feat(core): unified input state (keyboard/mouse/scroll)
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).
2026-09-20 07:57:26 +02:00
Jérôme Bousquié 4da89c7178 feat(math): Étape 15.A primitives — cube, plane, uv_sphere, icosphere, cylinder, cone, torus + tests ; factorisation cube_geometry (cube, spot_test) 2026-09-20 07:42:27 +02:00
Jérôme Bousquié 84ceffc755 docs: plan Étape 15 (primitives + input unifié + exemple final) ; ROADMAP items 2.2/2.3 2026-09-19 21:35:17 +02:00
Jérôme Bousquié b2db12e637 docs(draft): correct Étape 14 archived bilan (LessEqual, static cube, visibility fix) 2026-09-19 21:15:52 +02:00
Jérôme Bousquié ab13fa725e fix(shadow): make Étape 14 shadow visible
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).
2026-09-19 21:12:25 +02:00
Jérôme Bousquié bfe68f4393 docs: Étape 14 (shadows) finale — bilan DRAFT, ROADMAP §4.2, README roadmap
- docs/DRAFT.md: document vidé (bilan Étape 14 archivé dans l'historique git)
- docs/ROADMAP.md §4.2: Shadows marqué [x] (Étape 14, mono-lumière PCF)
- README.md: item 12 de la roadmap (shadow mapping)
- cargo fmt --all sur les sources Étape 14
2026-09-19 19:04:12 +02:00
Jérôme Bousquié 9a51ff7602 Fix shadow_test: WebGPU clip depth, correct NdotL, shadow compare
- 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.
2026-09-19 16:10:47 +02:00
Jérôme Bousquié 67bd7af095 fix(shadow): read directional light direction from position_dir, not dir_angle
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.
2026-09-19 13:09:39 +02:00
Jérôme Bousquié 39167ee05f fix(shadow): correct comparison sampler from GreaterEqual to LessEqual
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.
2026-09-19 12:05:57 +02:00
Jérôme Bousquié c2cbd7fadb Étape 14: add shadow mapping (directional light, Phase 4.2)
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.
2026-09-19 09:48:17 +02:00
Jérôme Bousquié 8779af067f docs(draft): plan Étape 14 — Shadows (mono-lumière, Phong PCF) 2026-09-19 08:23:40 +02:00
Jérôme Bousquié a2bd25ecb2 docs: examples README in English (user-facing) 2026-09-18 21:11:47 +02:00
Jérôme Bousquié cc26080ed0 test: spot_test — 2 axes de rotation + README des exemples
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.
2026-09-18 21:06:44 +02:00
Jérôme Bousquié 2582b6f571 fix: spot — corrige le signe du test de cône (cube noir)
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.
2026-09-18 20:48:20 +02:00
Jérôme Bousquié 73085de537 test: exemple spot_test — spot isolée (directionnelle retirée, ambiant bas)
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
2026-09-18 20:38:27 +02:00
Jérôme Bousquié e8ff364d0d feat: lumières spot (cône + angle) (Étape 13, Phase 4.2)
- Light étendu à 4×Vec4 (64 o) : dir_angle (axe du cône + cos demi-angle)
- FrameUniforms 576→704 o : compteur num_spot, _pad[1]
- Lights + spot: Vec<Light> ; into_frame_array renvoie (arr, n_dir, n_point, n_spot)
- Scene::add_spot_light(pos, dir, color, intensity, radius, half_angle) ; clear_lights inclut spot
- standard_shader.wgsl : 3e boucle d'accumulation (pénombre lissée ±0.1 rad + atténuation linéaire)
- exemple cube : lumière spot verte pointée vers le cube
- Docs : shaders/resources README (704 B), README roadmap (item 11), ROADMAP (item coché)
- Build/test/fmt OK (5 tests + validation WGSL + doctests) ; non-régression par défaut
2026-09-18 19:05:13 +02:00
Jérôme Bousquié d518948deb feat: multi-lumières directionnelles + ponctuelles (Étape 12, Phase 4.2)
- Light (48 o) + MAX_LIGHTS=8 ; FrameUniforms étendu (ambient, lights[8], compteurs, _pad)
- resources/lights.rs : Lights (1 dir +Z par défaut, non-régression) + into_frame_array
- Scene : API déclarative add_directional_light / add_point_light / set_ambient / clear_lights
- Renderer::write_frame_uniforms upload les lumières/ambiant de la scène
- standard_shader.wgsl : struct Light + boucles d'accumulation (dir + ponctuelles, atténuation linéaire)
- exemple cube : 1 lumière ponctuelle chaude
- Docs : shaders/resources README, README roadmap, ROADMAP (item coché + Spot notée futur)
- Build/test/fmt OK ; non-régression par défaut (1 dir +Z + ambiant blanc)
2026-09-18 18:26:38 +02:00
Jérôme Bousquié 49ecdea249 docs: ROADMAP + README à jour (Étape 11 resize confirmée) 2026-09-18 17:38:38 +02:00
Jérôme Bousquié 1258668e4f docs: vidé DRAFT (Étape 11 resize confirmée au runtime) 2026-09-18 17:37:22 +02:00
Jérôme Bousquié b0aafefed9 feat(core): Étape 11 — resize (surface + depth, Phase 4.4) 2026-09-18 17:29:45 +02:00
Jérôme Bousquié f4df63a136 docs(draft): plan Étape 11 — resize (surface + depth, Phase 4.4) 2026-09-18 17:16:04 +02:00
Jérôme Bousquié acf819737d docs: purge PLAN/ROADMAP des plans abandonnés, DRAFT vidé (Étape 10 finie)
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.
2026-09-18 15:01:27 +02:00
Jérôme Bousquié 23568e8820 docs(resources): Étape 10 textures — bilan, README et ROADMAP à jour
- 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).
2026-09-18 14:26:30 +02:00
Jérôme Bousquié 440f2dffa3 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.
2026-09-18 14:18:02 +02:00
Jérôme Bousquié 3de84aa4dc docs(draft): acte les décisions D1-D4 de l'Étape 10 (Textures)
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).
2026-09-18 13:30:24 +02:00
Jérôme Bousquié 7a5d627221 docs(draft): épurate Étape 9, plan Étape 10 (Textures, Phase 4.1)
- 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.
2026-09-18 13:21:26 +02:00
Jérôme Bousquié dfa7403260 docs(renderer): Étape 9 depth buffer — bilan, README et resize planifié
- 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).
2026-09-18 13:08:00 +02:00
Jérôme Bousquié fca5d728ad refactor(renderer): activate depth buffer on all render paths (Étape 9)
- 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).
2026-09-18 13:03:52 +02:00
Jérôme Bousquié 17989b177f docs: reset DRAFT.md after Étape 8 completion 2026-09-18 10:55:11 +02:00
Jérôme Bousquié 9ad47e8790 docs(étape8): valider et documenter le refactor stockage CPU Arc<Geometry> (8.6)
- DRAFT.md : cases 8.1-8.6 cochées, état 'terminée et vérifiée 2026-09-18', bilan final.
- README.md : workflow manuel et déclaratif (extraits Mesh::new -> Geometry/from_geometry),
  note API create_mesh(Geometry), section architecture + table quick reference, roadmap +1 (CPU storage).
- ROADMAP.md (1.2) : colors + refactor Mesh/Scene cochés 'fait', déviation D3 'implémenté'.
- PLAN.md : statut réel à jour 2026-09-18 (Étape 8 effectuée).
- resources/README.md : Mesh::new() -> from_geometry() + rétention CPU.
2026-09-18 10:22:43 +02:00
Jérôme Bousquié 4a94ad4ac1 refactor(mesh): stockage CPU Arc<Geometry> (Étape 8, 8.1-8.5)
- geometry: ajoute le champ optionnel colors + constructeur new() et builder
  fluent (.with_normals/.with_uvs/.with_colors/.with_indices) + validate() et
  GeometryError (longueurs des tableaux optionnels + bornes des indices).
- geometry: ajoute to_vertices()/try_into_vertices() -> Vec<Vertex> (zip avec
  défauts : normale [0,0,1], UV [0,0], couleur blanche) (D6).
- mesh: remplace new()/with_material() par from_geometry(device, Arc<Geometry>,
  material); garde geometry: Arc<Geometry> (rétention CPU+GPU, D5) + nouveaux
  accesseurs geometry()/material()/set_material() (D4).
- scene: create_mesh(id, Geometry, Option<&str>) déclare un mesh depuis une
  Geometry; expose Geometry via math (D2) et un ré-export de convenance resources.
- exemples (cube/simple/manual) réécrits pour construire une Geometry.
2026-09-18 10:17:46 +02:00
Jérôme Bousquié 6f6b72ae8d docs(draft): valider les décisions restantes Étape 8 (D2, D4, D5, D6)
- 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.
2026-09-18 08:42:35 +02:00
Jérôme Bousquié 757d20f746 docs(roadmap,draft): valider les décisions Étape 8 (D1, D3, 8.1)
- D1 : Geometry en tableaux + champ colors, conversion Geometry -> Vec<Vertex>
- D3 : deviation ROADMAP 1.2 — transform reste sur Entity, pas sur Mesh
- 8.1 : proposition d'extension de Geometry (couleur + validation) validée
2026-09-18 08:02:34 +02:00
Jérôme Bousquié 7a48f2517c docs: reset DRAFT.md after Étape 7 completion 2026-09-17 13:29:11 +02:00
Jérôme Bousquié 84d7176c71 docs: confirm Étape 7 examples ran (simple, cube) without panic 2026-09-17 13:21:26 +02:00
Jérôme Bousquié eb5f6aa827 docs: mark Étape 7 done (Scene cache, Mesh->Material), update PLAN/ROADMAP/README
DRAFT Étape 7: all 24 boxes checked + point d'étape 2026-09-17. PLAN Phase 2
three lines checked (cache in Scene, Mesh->Material, standard_shader default).
ROADMAP 1.2 material facet noted done while the Arc<Geometry> refactor stays
deferred. README declarative snippet updated to register_shader /
add_material_shader / create_mesh / add_entity.
2026-09-17 13:16:33 +02:00
Jérôme Bousquié 62b5cac11b refactor(resources): Scene owns pipeline cache, Mesh->Material link
É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.
2026-09-17 13:15:30 +02:00
Jérôme Bousquié df546a4ac9 docs: draft Étape 7 implementation plan (Scene owns pipeline cache, Mesh->Material) 2026-09-17 12:13:12 +02:00
Jérôme Bousquié 4be9737065 docs: mark Étape 6.4 done (conventional commits landed) 2026-09-17 10:48:04 +02:00
Jérôme Bousquié d2dd1967cd docs: mark Étape 5 / 3D MVP reached (PLAN, ROADMAP, DRAFT, README) 2026-09-17 10:40:43 +02:00
Jérôme Bousquié 0a85aff17b feat(examples): 3D MVP cube via standard shader, drop basic (Étape 5) 2026-09-17 10:40:43 +02:00
Jérôme Bousquié 43e8bfb40a Update DRAFT.md 2026-09-16 19:19:56 +02:00
Jérôme Bousquié f10e249898 feat(renderer): active camera wired to frame uniforms (Étape 4.3)
- Camera enrichie: fov/near/far stockés, Default (pos (0,0,3), 45°, near 0.1,
  far 100), with_perspective(), projection_matrix(aspect) depuis les params
  stockés (au lieu de les passer en argument).
- Scene porte une caméra active: set_camera()/camera() (défaut Camera::default).
- Renderer::render_scene(view, scene, aspect) écrit chaque frame view/proj/
  cam_pos réels dans le buffer frame (write_frame_uniforms) avant de dessiner;
  le Renderer garde le handle du frame_buffer. Le chemin bas-niveau render()
  conserve les valeurs par défaut (identité).
- App::render_scene calcule l'aspect depuis window.inner_size() (le Renderer
  reste indépendant de la fenêtre).

Docs synchronisées: DRAFT (4.3 coche), README (statut 3D-infra + quick ref),
PLAN (caméras), ROADMAP (1.1/1.3/1.5/2.3).

Validation: check workspace+examples 0 warning, test (Pod + wgsl) OK, doc 0
warning, fmt propre. Le rendu 3D visible attend Étape 5 (brancher standard).
2026-09-16 17:25:50 +02:00
Jérôme Bousquié c1e07b42b4 feat(renderer): uniform bind groups infrastructure (Étape 3 + 4.1/4.2/4.4)
Étape 3 (infrastructure uniforms) + le câblage minimal d'Étape 4 pour garder
les exemples exécutables (wgpu requiert que tous les bind groups du layout
pipeline soient posés au draw) :

- resources/uniform.rs : types bytemuck Pod FrameUniforms (192 B) et
  ObjectUniform (64 B), alignés 16 octets sans padding; offsets vérifiés par un
  test unitaire contre le contrat du shader. glam feature bytemuck activé.
- pipeline_cache: create_uniform_bind_group_layouts() expose les 2 layouts
  (frame @0 Vertex|Fragment + object @1 Vertex); build_pipeline les attache à
  TOUT pipeline (un seul layout pour tous, décision actée).
- Renderer: alloue le buffer frame partagé + BindGroup(0) (défaut identité,
  mode lit) et un object identité partagé pour le chemin bas-niveau; cache
  RefCell<HashMap<label,(buffer,bindgroup)>> par entité, model réécrit chaque
  frame depuis transform.to_matrix(); draw_entity pose groupes 0+1.

4.3 (caméra active + aspect) non implémenté: simple/manual restent exécutables
car basic ignore ces uniforms. Documentation DRAFT mise à jour.

Validation: check workspace+examples 0 warning, doc 0 warning, test (Pod+wgsl)
OK, fmt propre.
2026-09-16 16:56:05 +02:00
Jérôme Bousquié 26a3cda6f6 feat(shaders): add standard Phong shader with uniform contract (data only)
É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.
2026-09-16 15:55:26 +02:00
Jérôme Bousquié 252db88980 feat(lib): expose Camera and Entity with Transform (data foundation)
Étape 1 du plan 3D+Phong : fondations de données sans toucher au rendu.

- resources: exporte Camera (fichier auparavant orphelin), migre les fonctions
  glam dépréciées look_at_rh/perspective_rh_gl vers glam::camera::rh::* (induit
  par la compilation de camera.rs, sinon 2 warnings).
- scene: nouveau type Entity { mesh_id, material_id, transform } (scene/entity.rs);
  Scene::entities passe de HashMap<String,(String,String)> à HashMap<String,Entity>.
- API: add_entity conserve sa signature (transform identité par défaut), ajout de
  add_entity_with_transform, entity_transform, set_entity_transform; iter_entities
  rend désormais aussi le &Transform. renderer et examples inchangés a posteriori.
- Validation: cargo check --workspace et examples 0 warning, cargo doc 0 warning, fmt OK.
2026-09-16 15:26:16 +02:00
Jérôme Bousquié 91007853d9 docs: note pour mémoire sur le couplage au runtime async (pollster)
La lib n'a qu'un seul point de couplage au runtime (block_on dans app.rs) ; on ne crée volontairement
pas d'abstraction à ce stade. Consigné dans PLAN.md et ARCHI_APP.md : isoler derrière un module-pivot
unique si la lib acquiert d'autres appels async. Corrige aussi l'item check-list pollster devenu faux
dev-dependencies -> dependencies depuis la migration winit 0.30.
2026-09-16 14:58:51 +02:00
Jérôme Bousquié 8eec38e55c fix(lib): migrate to winit 0.30 ApplicationHandler model
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).
2026-09-16 14:17:11 +02:00
Jérôme Bousquié bb7fab4911 docs(plan): lock uniform scheme (2 bind groups) and uniform types location (resources/uniform.rs) 2026-09-16 13:40:30 +02:00
Jérôme Bousquié 14e18cda06 docs(plan): unify on a single pipeline layout (2D as degenerate 3D) 2026-09-16 12:06:56 +02:00
Jérôme Bousquié f81144918a docs: detailed plan for 3D+Phong step in DRAFT.md 2026-09-16 11:46:33 +02:00
Jérôme Bousquié b764bbc83d docs: complete README coherence for scene auto-render step 2026-09-16 11:23:58 +02:00
Jérôme Bousquié 9d631b686a docs: clarify PipelineCache item in plan after scene auto-render 2026-09-16 11:18:08 +02:00
Jérôme Bousquié 0a7ebf62ad docs: erase DRAFT scratchpad content for next step 2026-09-16 11:16:48 +02:00
Jérôme Bousquié 0560c1897f docs: record completed scene auto-render step in plan, roadmap, README 2026-09-16 11:16:48 +02:00
Jérôme Bousquié 4acf1d821d feat(core): expose frame view and auto-render the scene
- 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.
2026-09-16 10:36:35 +02:00
Jérôme Bousquié 628c125925 docs: move DRAFT.md into docs/ and confirm step decisions
DRAFT.md now lives in docs/. Confirmed the pending design decisions:
render() auto-renders the scene by default (Option A, alternative B removed),
and simple.rs uses app.renderer.device()/format() via the library API
without importing wgpu.
2026-09-16 10:23:42 +02:00
Jérôme Bousquié 3b0db5fa12 docs: add DRAFT.md with detailed implementation plan for scene auto-rendering 2026-09-16 10:08:35 +02:00
Jérôme Bousquié e6f190e035 docs: add API documentation guidelines (rustdoc) in docs/DOCUMENTATION.md
Codifies the systematic documentation of public items with the API docs
in mind, based on the incidents from the last session: backticks around
every type (Option<Self>, Arc<Mesh>, [f32; 3]) to avoid unresolved-link
and unclosed-HTML-tag warnings, one doc comment per public item, the
#![warn(missing_docs)] policy, verifying '0 warnings' via cargo doc
before committing, and doc-test conventions (rust vs ignore blocks).

Applies to wsg-lib development from now on.
2026-09-16 09:45:33 +02:00
Jérôme Bousquié d81743481b docs: fix rustdoc warnings and enforce full API doc coverage
- Wrap in backticks every bare type in rustdoc comments (vertex.rs,
  frame.rs, pipeline_cache.rs, material.rs, mesh.rs, scene.rs, error.rs)
  so rustdoc no longer misreads them as intra-doc links or HTML tags.
- Add a missing doc comment on the public Frame struct.
- Add #![warn(missing_docs)] to the crate root so unevidenced public
  items are surfaced going forward.

cargo doc --no-deps now generates with zero warnings.
2026-09-16 09:32:25 +02:00
93 changed files with 13192 additions and 1081 deletions
View File
View File
+2
View File
@@ -43,6 +43,8 @@ WGPU doesn't have a native "Context" object — this type groups them together f
- wgpu 30.0.0 is pinned in `lib/Cargo.toml`. The comment says "check the latest version" — verify compatibility before upgrading.
- No feature flags, no dev-dependencies, no tests yet. Adding any requires updating both `Cargo.toml` files if the dependency spans crates.
- The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally.
- **WGSL `select` argument order** (cost us a day): `select(reject, accept, cond)` returns the **second** arg when `cond` is true — the reverse of HLSL's `select(trueVal, falseVal, cond)`. In `shaders/gpu_driven.wgsl` the cull pass must stay `select(0u, u32(flags.z), visible)` (visible ⇒ full count, culled ⇒ 0). Swapped args silently zero the counts of every visible entity → black window. See the GOTCHA comment at the top of that shader.
- **LOD UV blending: never fold integer-tile jumps, freeze seam twins instead** (cost us a day, 2026-09-23): a UV *seam* is two copies of the same 3-D point on integer-apart UVs (u=0/u=1 columns) — it is NOT a mesh edge, so the decimation must record the weld's refused pairs and **freeze** those twins (any edge touching one is excluded from the PQ). A co-facial edge spanning a whole tile (cone apex v=1 ↔ base v=0) is a *legit* chart span — the chart is bilinear, so the UVs **blend linearly** (fold the integer jump to zero and the apex UV smears down the cone side). And the attribute-aware weld refuses a Δ of *exactly* 0.5 (ambiguous: seam at its widest vs legit half-tile jump — the cone's u=1 column vs the cap-disc chart sits exactly there). See the comments in `geometry.rs` (`welded`, `Collapse::collapse_edge`) and the cone/seam regression tests.
<!-- lean-ctx -->
## lean-ctx
-45
View File
@@ -1,45 +0,0 @@
# WSG - WGPU Simple Graphics Library
## Project Type
Rust workspace (2024 edition) wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations.
## Workspace Structure
```
Cargo.toml # workspace root — no dependencies here
lib/Cargo.toml # wsg-lib crate: wgpu 30.0.0, winit 0.30
examples/Cargo.toml # depends on wsg-lib via path reference
lib/lib.rs # lib entry point
lib/context.rs # Context type (aggregates wgpu objects: Instance, Surface, Adapter, Device, Queue)
lib/renderer.rs # renderer implementation
examples/src/main.rs # example binary
```
**Key convention**: `wsg-lib` is referenced from `examples/` via relative path (`path = "../lib"`). Do not publish this to crates.io as-is — it uses a local path dependency.
## Essential Commands
| Action | Command |
|--------|---------|
| Build everything | `cargo build --workspace` |
| Run examples | `cargo run -p examples` |
| Test | `cargo test --workspace` |
| Check | `cargo check --workspace` |
| Format | `cargo fmt --all` |
No custom scripts or linting tooling beyond standard Cargo conventions.
## Architecture Overview
The library's purpose is to abstract the five core wgpu objects into a single **Context**:
- **Instance** — GPU backend selection (Vulkan/Metal/DX12)
- **Surface** — window rendering surface (via winit)
- **Adapter** — physical/logical GPU device
- **Device** — buffer/texture/pipeline creation
- **Queue** — command submission
WGPU doesn't have a native "Context" object — this type groups them together for a simpler user API. See README.md for the French documentation of each component.
## Gotchas
- Rust 2024 edition is used. Ensure your Rust toolchain supports it (`rustup update`).
- wgpu 30.0.0 is pinned in `lib/Cargo.toml`. The comment says "check the latest version" — verify compatibility before upgrading.
- No feature flags, no dev-dependencies, no tests yet. Adding any requires updating both `Cargo.toml` files if the dependency spans crates.
- The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally.
Generated
+476 -241
View File
File diff suppressed because it is too large Load Diff
+139 -76
View File
@@ -2,25 +2,83 @@
WSG is a Rust library that wraps [wgpu](https://github.com/gfx-rs/wgpu) and [winit](https://crates.io/crates/winit) for simple GPU drawing. It groups the five core wgpu objects (Instance, Surface, Adapter, Device, Queue) behind a single `Context`, adds small building blocks (`Mesh`, `Material`, `PipelineCache`, `Frame`), and exposes the low-level primitives for advanced users.
> **Status: unstable development version.** The manual workflow below is fully working. The high-level "declarative" workflow and the GPU-driven two-pass pipeline described in the architecture docs are **not implemented yet** — see [Status](#status) and [Roadmap](#roadmap).
> **Status: unstable development version.** The **declarative workflow** (`AppBuilder` + `App` + `AppHandler`) is the **recommended** path and is fully working: scene auto-rendering (`App::render_scene`), 3D Phong lighting, textures, shadows, camera and unified input — the `demo` example is the showcase. The **manual workflow** (`Context`/`Renderer`/`PipelineCache`) coexists for fine-grained control. Meshes are declared from a CPU `Geometry` (retained as `Arc<Geometry>` on the Mesh). The **GPU-driven two-pass pipeline** (Compute Pass deriving world matrices + frustum culling → indirect draws) is **implemented** (Step 15, Phase 3): `render_scene` and the shadow pass are 100 % indirect, and frustum culling is opt-in (`AppBuilder::with_culling(true)`, off by default) — see [Status](#status), [docs/user/gpu-driven.md](docs/user/gpu-driven.md) and [Roadmap](#roadmap).
## Status
| Area | State |
|------|-------|
| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working |
| `App` / `AppBuilder` / `AppHandler` event-loop facade | 🚧 Scaffold — window, events and frame presentation work, but the `render()` callback cannot draw yet (the per-frame view is not exposed to it) |
| `Scene` resource/entity registry | 🚧 Registration API works; the engine does not render the scene yet |
| GPU-driven two-pass pipeline (Compute → indirect draw) | 📋 Roadmap — spec in [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) |
| 3D transforms (MVP uniforms, camera in the pipeline) | 📋 Roadmap — the bundled shader draws positions straight to NDC today |
| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working (advanced — fine-grained control) |
| `App` / `AppBuilder` / `AppHandler` event-loop facade | ✅ Working — window, events, frame presentation, and **automatic scene rendering** (the per-frame view is exposed via `Frame::view()`) |
| `Scene` resource/entity registry | ✅ Working — the engine renders every registered entity automatically in one batched render pass (`App::render_scene`) |
| GPU-driven two-pass pipeline (Compute → indirect draw) | ✅ Working (Step 15, Phase 3) — `render_scene` + shadow pass are 100 % indirect; opt-in frustum culling (bug « fenêtre noire » fixed 2026-09-22 — WGSL `select` argument order — and verified by GPU readback, D14). User doc: [gpu-driven.md](docs/user/gpu-driven.md) · spec: [ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) |
| 3D infrastructure (uniform bind groups, MVP + camera in the pipeline) | ✅ Working — the `Renderer` uploads per-frame camera matrices (active `Camera`) and per-entity world matrices to shared uniform buffers every frame; the **MVP is reached** (Step 5) : the `cube` example renders a rotating Phong-lit cube via the `standard` shader |
Note: the bundled `basic_shader.wgsl` treats vertex positions as already in NDC space, so what you can see today is flat, untransformed drawing (e.g. a colored quad) — not a 3D scene.
Note: `standard_shader.wgsl` (Phong, with an explicit **unlit** mode) is the **single** shader the library ships — flat 2D drawing is its unlit variant (`Renderer::set_unlit(true)` or `app.renderer_mut().set_unlit(true)`). See the `cube` example (3D, lit) and the `simple` example (2D, unlit).
## What it does
### Manual workflow (working — recommended today)
### Declarative workflow (recommended)
Bypass the `App` facade and drive `Context`, `Renderer` and `PipelineCache` yourself. This is the only workflow that renders pixels today (same code as the `manual` example):
Register your scene once in `setup()`, then let `App` handle the window lifecycle, events,
input and frame presentation — **without importing wgpu or winit**. This is the workflow of
the `simple`, `cube`, `demo`, `shadow_test` and `spot_test` examples (excerpt below is `simple`):
```rust
use wsg_lib::app::AppBuilder;
use wsg_lib::resources::Geometry;
use wsg_lib::utils::WsgError;
use wsg_lib::AppHandler;
struct MonQuad;
impl AppHandler for MonQuad {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.renderer_mut().set_unlit(true); // 2D flat (optional)
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
let geometry = Geometry::new(vec![
[-0.5, 0.5, 0.0],
[ 0.5, 0.5, 0.0],
[ 0.5, -0.5, 0.0],
[-0.5, -0.5, 0.0],
])
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0],
[0.0, 1.0, 0.0, 1.0],
[0.0, 0.0, 1.0, 1.0],
[1.0, 1.0, 0.0, 1.0],
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
app.scene.create_mesh("quad_mesh", geometry, None).unwrap(); // None = default material
app.scene.add_entity("quad", "quad_mesh").unwrap();
}
// `update(&mut self, app)` — your per-frame logic (empty default).
// `render(&mut self, app, frame)` — default: `app.render_scene(frame.view())`,
// the whole scene is drawn automatically in one pass per frame.
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Simple").build().await?;
app.run(MonQuad)
}
```
> API note: `Scene` methods currently return `Result<_, String>` — typed-error unification is
> on the roadmap. `Scene::create_mesh(id, geometry, material)` takes a CPU `Geometry` (source of
> truth, retained as `Arc<Geometry>` on the Mesh); `material = None` uses the scene's default
> material.
The full user documentation (meshes, materials, lights, shadows, camera & input, all examples)
lives in [docs/user](docs/user/README.md).
### Manual workflow (advanced — fine-grained control)
Bypass the `App` facade and drive `Context`, `Renderer` and `PipelineCache` yourself (same code
as the `manual` example):
```rust
use std::sync::Arc;
@@ -28,7 +86,7 @@ use winit::event_loop::EventLoop;
use winit::window::WindowBuilder;
use wsg_lib::core::{Context, Frame, Renderer};
use wsg_lib::pipeline::PipelineCache;
use wsg_lib::resources::{Material, Mesh, Vertex};
use wsg_lib::resources::{Geometry, Material, Mesh};
use wsg_lib::utils;
fn main() {
@@ -39,20 +97,30 @@ fn main() {
let format = context.configure(&context.adapter, 800, 600).expect("surface config failed");
// Renderer + shader cache (falls back to the embedded shader if the file is missing)
let renderer = Renderer::new(&context, format);
// `set_unlit(true)` selects flat 2D rendering (the quad below is drawn in NDC space, unlit).
// Step 9: width/height size the depth buffer allocated inside the Renderer.
let mut renderer = Renderer::new(&context, format, 800, 600);
renderer.set_unlit(true);
let mut cache = PipelineCache::new(Arc::new(context.device.clone()));
cache.register_shader("basic", utils::BASIC_SHADER_PATH).unwrap();
cache.register_shader("standard", utils::STANDARD_SHADER_PATH).unwrap();
// Material + mesh
let material = Material::new(renderer.format(), "basic", &mut cache);
let vertices: [Vertex; 4] = [
Vertex { position: [-0.5, 0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [0.0, 0.0], color: [1.0, 0.0, 0.0, 1.0] },
Vertex { position: [ 0.5, 0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [1.0, 0.0], color: [0.0, 1.0, 0.0, 1.0] },
Vertex { position: [ 0.5, -0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [1.0, 1.0], color: [0.0, 0.0, 1.0, 1.0] },
Vertex { position: [-0.5, -0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [0.0, 1.0], color: [1.0, 1.0, 0.0, 1.0] },
];
let indices: [u16; 6] = [0, 1, 2, 0, 2, 3];
let mesh = Mesh::new(renderer.device(), &vertices, Some(&indices));
// Material + mesh (Step 8: the mesh is built from a `Geometry` — positions,
// optional attributes via builder, white defaults via `to_vertices`).
let material = Material::new(renderer.format(), "standard", &mut cache);
let geometry = Geometry::new(vec![
[-0.5, 0.5, 0.0], // top-left
[ 0.5, 0.5, 0.0], // top-right
[ 0.5, -0.5, 0.0], // bottom-right
[-0.5, -0.5, 0.0], // bottom-left
])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0], // red
[0.0, 1.0, 0.0, 1.0], // green
[0.0, 0.0, 1.0, 1.0], // blue
[1.0, 1.0, 0.0, 1.0], // yellow
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None);
// Render loop
event_loop.run(|event, elwt| {
@@ -71,63 +139,34 @@ fn main() {
}
```
### Declarative workflow (work in progress)
The intended API: register your scene's resources and entities once, then let `App` handle the window lifecycle, event processing and frame presentation. Users implement the `AppHandler` trait to inject per-frame logic:
```rust
use wsg_lib::app::AppBuilder;
use wsg_lib::{App, AppHandler};
struct MyGame;
impl AppHandler for MyGame {
// update() has an empty default — implement it to mutate scene state each frame.
fn render(&mut self, _app: &mut App) {
// The engine acquires and presents the frame around this callback,
// but scene rendering is not automated yet — see Roadmap.
}
}
#[pollster::main]
async fn main() -> Result<(), wsg_lib::utils::WsgError> {
let app = AppBuilder::new().build().await?;
// Scene registration is available (string IDs):
// app.scene.add_mesh("quad", Arc::new(mesh))?;
// app.scene.add_material("mat", Arc::new(material))?;
// app.scene.add_entity("my_quad", "quad", "mat")?;
// ...but the engine will not draw them until the declarative pipeline lands.
app.run(MyGame)
}
```
> API note: `Scene::add_mesh` / `add_material` / `add_entity` and `PipelineCache::register_shader`
> currently return `Result<_, String>` — typed error unification is on the roadmap.
## Architecture overview
- **Manager layer (`Context`)** — owns the GPU hardware lifecycle (Instance → Surface → Adapter → Device → Queue). Created once at startup; `configure()` sets up the swapchain, `Frame` wraps each frame's surface texture + view.
- **Executor layer (`Renderer`)** — binds a `Material` pipeline + `Mesh` buffers into a RenderPass and submits the commands. Today this is one encoder + one submit **per object**.
- **Supporting pieces** — `PipelineCache` (shader → compiled RenderPipeline, `Arc`-shared), `Material`, `Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform` (types only, not yet used by the pipeline).
- **Executor layer (`Renderer`)** — binds a `Material` pipeline + `Mesh` buffers into a RenderPass and submits the commands. Rendering a whole `Scene` (`render_scene`) batches all entities into **one encoder + one submit per frame**; the low-level `render` still allocates one per object.
- **Supporting pieces** — `PipelineCache` (shader → compiled RenderPipeline, `Arc`-shared), `Material`, `Geometry`/`Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform` (active camera wired to the frame uniforms, Step 4.3). `Geometry` is the CPU source of truth (positions/normals/UVs/colors), `Mesh` uploads it to GPU buffers and retains the `Arc<Geometry>`, `Vertex` is the interleaved upload contract (Step 8).
The planned target architecture — a GPU-driven two-pass pipeline (Compute Pass: world matrices + frustum culling → Indirect Draw Buffer, then a single `draw_indexed_indirect` per frame) — is specified in [docs/tech/ARCHI_APP.md](docs/tech/ARCHI_APP.md) and [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) but is **not implemented yet**.
The **GPU-driven two-pass pipeline** (Step 15, Phase 3) is implemented: a Compute Pass derives each entity's world matrix and fills per-entity indirect draw arguments (with opt-in frustum culling), then the main and shadow render passes issue one indirect draw per active slot. Specified in [docs/tech/ARCHI_APP.md](docs/tech/ARCHI_APP.md) and [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md); user-facing guide in [docs/user/gpu-driven.md](docs/user/gpu-driven.md).
## Quick reference
| Concept | Type | Responsibility | Status |
|---------|------|---------------|--------|
| App / AppBuilder | Facade | Window lifecycle + winit event loop + frame presentation | 🚧 Scaffold (no scene rendering) |
| AppHandler | Trait | User-defined `update()` / `render()` callbacks | ✅ (render() has no frame access yet) |
| Scene | Struct | String-ID registry: meshes, materials, entities | 🚧 Registration only |
| App / AppBuilder | Facade | Window lifecycle + winit event loop + frame presentation | ✅ (auto scene rendering via `App::render_scene`) |
| AppHandler | Trait | User-defined `setup()` / `update()` / `render()` callbacks | ✅ (default `render` draws the scene via `App::render_scene`) |
| Scene | Struct | String-ID registry: meshes, materials, entities | ✅ (registry auto-rendered by the facade) |
| Context | Struct | GPU hardware lifecycle (Instance, Surface, Adapter, Device, Queue) | ✅ |
| Renderer | Struct | Binds Material + Mesh into a RenderPass, submits | ✅ (one submit per object) |
| Renderer | Struct | Binds Material + Mesh into a RenderPass, submits | ✅ (`render_scene` batches one pass/frame) |
| PipelineCache | Struct | Shader → compiled RenderPipeline cache | ✅ |
| Material | Struct | Shader ID → RenderPipeline | ✅ |
| Mesh / Vertex | Struct | GPU geometry container / CPU-side vertex tuple | ✅ |
| Geometry | Struct | CPU-side scattered vertex data (positions/normals/UVs/colors/indices), source of truth | ✅ (Step 8 — retained `Arc<Geometry>` on Mesh) |
| Mesh / Vertex | Struct | GPU geometry container / CPU-side interleaved upload tuple | ✅ |
| Frame | Struct | Per-frame RAII wrapper (surface texture + view) | ✅ |
| Camera / Transform | Struct | Camera & transform math | 📋 Types only, not in the pipeline |
| Camera / Transform | Struct | Camera & transform math | ✅ Active camera + transform wired to per-frame uniforms (Step 4.3) |
| Texture | Struct | GPU diffuse image (device + view + sampler, `Rgba8UnormSrgb`) | ✅ (Step 10 — `from_rgba8`/`from_bytes`/`from_file`/`white_placeholder`) |
| Lights / Light | Struct | Scene-wide light list (directional + point + spot, `MAX_LIGHTS = 8`) + ambient | ✅ (Steps 12-13) |
| CameraController | Struct | Orbital camera (yaw/pitch/distance/target; `orbit`/`zoom`/`reset`/`apply_to`) | ✅ (Step 15.C) |
| InputState | Struct | Unified keyboard/mouse state (pressed/held/released, mouse delta, scroll) | ✅ (Step 15.B — `app.input`) |
| math::primitives | Module | Procedural `Geometry` generators (cube, plane, uv_sphere, icosphere, cylinder, cone, torus) | ✅ (Step 15.A) |
## Getting started
@@ -136,32 +175,56 @@ WSG is **not published on crates.io** — depend on it by path:
```toml
[dependencies]
wsg-lib = { path = "/path/to/wsg/lib" }
pollster = "0.4" # only if you use the async AppBuilder
pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (async AppBuilder)
winit = "0.30" # only if your code mentions winit types (KeyCode, MouseButton)
```
| Action | Command |
|--------|---------|
| Build everything | `cargo build --workspace` |
| Run the working example | `cargo run -p wsg-lib --example manual` |
| Run the showcase (primitives, lights, shadows, orbital camera) | `cargo run -p wsg-lib --example demo` |
| Run the 3D MVP example | `cargo run -p wsg-lib --example cube` |
| Run the minimal example | `cargo run -p wsg-lib --example simple` |
| Run the shadow / spot light showcases | `cargo run -p wsg-lib --example shadow_test` / `cargo run -p wsg-lib --example spot_test` |
| Run the advanced (manual) example | `cargo run -p wsg-lib --example manual` |
| Check everything (incl. examples) | `cargo check --all-targets` |
The `manual` example is the reference for the working, pixel-rendering workflow. The `simple` example (App facade) now compiles and opens a window with a running update → render → present loop, but it does not draw a scene yet (see [Roadmap](#roadmap)).
The `demo` example is the showcase: one of each primitive, procedural textures, three lights, a shadow-casting light and a live orbital camera. `simple` is the minimal declarative app (a colored quad, unlit); `cube` is the 3D MVP (a rotating Phong-lit, textured cube); `shadow_test` and `spot_test` isolate the shadow and spot-light systems; `manual` is the reference for the low-level workflow. All of them except `manual` use the declarative path and draw a scene **without importing wgpu**.
## Documentation
The architecture docs live in `docs/tech/` and are written in **French**. Each document states whether it describes the **current** (implemented) state or the **target** (planned, not yet implemented) architecture:
Three layers (user docs and API reference in **English**; technical docs in **French**):
- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. 🎯 **Target** — the GPU-driven two-pass pipeline parts are not implemented yet.
- [ARCHI_CPU_GPU](docs/tech/ARCHI_CPU_GPU.md) — CPU/GPU workload split specification. 🎯 **Target** — GPU-driven pipeline, ROADMAP Phase 3.
- [ARCHI_RENDU](docs/tech/ARCHI_RENDU.md) — update/render mutability model. 🎯 **Target** — model for the future scene auto-render.
**User documentation — [docs/user](docs/user/README.md)** (how to use the API, no wgpu knowledge needed):
- [Quickstart](docs/user/quickstart.md) — first window, first object, in ~30 lines
- [Meshes](docs/user/meshes.md) · [Materials & textures](docs/user/materials.md) · [Lights](docs/user/lights.md)
- [Shadows](docs/user/shadows.md) · [Camera & input](docs/user/camera-input.md) · [Examples](docs/user/examples.md)
**Technical documentation — `docs/tech/`** (internal architecture; each document states whether it describes the **current** or the **target** architecture):
- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. ✅ **Current** — facade (`App`/`AppHandler`) and GPU-driven two-pass pipeline (implemented in Phase 3, 2026-09-22, with the documented deviations); only the future double-buffering notes remain target.
- [ARCHI_CPU_GPU](docs/tech/ARCHI_CPU_GPU.md) — CPU/GPU workload split specification. ✅ **Current** — implemented in ROADMAP Phase 3 (2026-09-22, Étape 17, decisions D1–D14); deviations from the original spec are noted in the document.
- [ARCHI_RENDU](docs/tech/ARCHI_RENDU.md) — update/render mutability model. ✅ Current dichotomy (auto scene render) / 🎯 **Target** — material batching.
- [ARCHI_ARENES](docs/tech/ARCHI_ARENES.md) — 🎯 **Target/deferred** — slotmap generational handles; String IDs are used today.
- [FRAME_LOOP](docs/tech/FRAME_LOOP.md) — frame lifetime and resource persistence. ✅ **Current** — implemented.
**API reference** — full rustdoc: `cargo doc -p wsg-lib --no-deps` (every public type is documented).
## Roadmap
1. **Scene auto-rendering** — `App`/`Renderer` iterate registered entities and draw them in one encoder/submit per frame; expose the frame view to `AppHandler::render` for custom draws.
2. **GPU-driven two-pass pipeline** — Compute Pass (world matrices + frustum culling) filling an indirect draw buffer, single `draw_indexed_indirect` (see ARCHI_CPU_GPU).
1. ✅ **Scene auto-rendering** — `App::render_scene` iterates registered entities and draws them in one encoder/submit per frame; the frame view is exposed to `AppHandler::render` for custom draws. (Done 2026-09-16.)
2. ✅ **GPU-driven two-pass pipeline** — Compute Pass (world matrices + frustum culling) filling an indirect draw buffer, then indirect draws (see ARCHI_CPU_GPU). *(Done 2026-09-22 — see item 17. Deviation from the original spec: one indirect draw **per slot** rather than a single fused draw, D1 — see ARCHI_CPU_GPU.)*
3. **CPU→GPU transform sync** — persistent transform buffers with ring (triple) buffering.
4. **Real 3D pipeline** — MVP uniforms + camera support in the vertex shader.
4. ✅ **Real 3D pipeline (MVP reached)** — MVP uniforms + camera support in the vertex shader. *(Engine plumbing done 2026-09-16; Step 5, 2026-09-17: `standard` wired into the `cube` example — a unit cube lit (Phong) and spinning, rendered automatically by `App::render_scene`. Removal of `basic`: flat 2D = unlit variant of `standard` via `Renderer::set_unlit`.)*
5. **Typed resource handles** — keep String IDs for the MVP (current design, source of truth in `Scene`); slotmap-based generational handles (`ARCHI_ARENES.md`) are deferred to a later performance pass.
6. **Error unification** — replace `Result<_, String>` in `Scene`/`PipelineCache` with typed errors.
7. ✅ **CPU geometry storage (Step 8)** — `Mesh` retains a shared `Arc<Geometry>` (CPU source of truth with colors) alongside its GPU buffers; meshes are declared from a `Geometry` via `Mesh::from_geometry`/`Scene::create_mesh(id, geometry, material)` instead of raw `&[Vertex]` arrays. (Done 2026-09-18; `transform` stays on `Entity` — deviation D3.)
8. ✅ **Diffuse textures (Step 10, Phase 4.1)** — `resources::Texture` (GPU image: device+view+sampler, `Rgba8UnormSrgb`, loaders `from_rgba8`/`from_bytes`/`from_file`) attached to a `Material` as diffuse texture. The `standard` shader samples it via bind group **@2** (shared layout: sampler+texture); UVs are forwarded as vertex attribute location 2. Without a texture the material uses a shared 1×1 white placeholder so lit and unlit rendering are unchanged (no regression). The `cube` example now uses a procedural checkerboard texture. (Done 2026-09-18.)
9. ✅ **Window resize (Step 11, Phase 4.4)** — `App::resize` reconfigures the surface (`Context::configure`) and recreates the depth texture (`Renderer::resize_depth`) together on each `WindowEvent::Resized`, so color and depth attachments always match. Guards against 0×0 (minimize). The surface format is re-synced to the Renderer and Scene if it ever changes. (Done 2026-09-18; verified at runtime on the `cube` example.)
10. ✅ **Multi-lighting (Step 12, Phase 4.2)** — the scene now carries a global light list (directional + point) with a white ambient, uploaded into the per-frame `FrameUniforms` array each frame. `Scene::add_directional_light` / `add_point_light` / `set_ambient` / `clear_lights` configure it; `FrameUniforms::default()` (one white directional along +Z + white ambient) reproduces the pre-multi-light look exactly. The `standard` fragment accumulates ambient + all lights; the `cube` example adds a warm point light on top of the default directional. (Done 2026-09-18.)
11. ✅ **Spot lights (Step 13, Phase 4.2)** — spot lights (oriented cone + half-angle) added on top of the multi-lighting system. `Scene::add_spot_light(pos, dir, color, intensity, radius, half_angle)` registers a spot light; the `standard` fragment accumulates a spot term with a smoothed penumbra (half-angle ± 0.1 rad) and linear attenuation. `Light` grew from 48 to 64 bytes (added `dir_angle`); `FrameUniforms` from 576 to 704 bytes (added `num_spot`). Non-regression: default scene unchanged. The `cube` example adds a green spot light aimed at the cube. (Done 2026-09-18.)
12. ✅ **Shadows — shadow mapping (Step 14, Phase 4.2, optional)** — classic two-pass shadow mapping on a **single** light (directional or spot), selected by `Scene::set_shadow_caster(index)`. A depth-only pass (`shadow_shader.wgsl` + dedicated `shadow_pipeline`) renders the scene into a 1024² `Depth32Float` shadow map (`Renderer`-owned, slope-scaled depth bias); the `standard` fragment re-projects each fragment into light space and applies a **PCF 3×3** comparison-sampler test (bind group **@3**, shared). `FrameUniforms` grew from 704 to 784 bytes (`shadow_light_index`, `light_view_proj`, `shadow_params`). Shadows are **off by default** (`shadow_caster = None`) so `simple`/`cube`/`manual`/`spot_test` are unchanged. The `shadow_test` example casts a soft shadow from a cube onto a ground slab. (Done 2026-09-19.)
13. ✅ **Procedural primitive meshes (Step 15.A)** — `math::primitives` provides drop-in `Geometry` generators (`cube`, `plane`, `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) with positions + per-face/smooth normals + UVs + indices. Re-exported at `math::*`. The `cube` and `spot_test` examples now reuse `math::cube(1.0)` (the `cube_geometry` helper was factored away; `shadow_test` keeps its generic `box_geometry`). (Done 2026-09-20; 6 unit tests.)
14. ✅ **Unified input (Step 15.B)** — `core::input::InputState` gives cross-frame **pressed/held/released** semantics for keyboard (physical `KeyCode`) and mouse (buttons, position, per-frame delta, wheel scroll), rotated by `begin_frame`/`end_frame` around `AppHandler::update`. `App` exposes it as a public `input` field, fed from winit `WindowEvent`s and reset each frame. Gamepad is reserved/deferred (DRAFT D7). (Done 2026-09-20; 5 unit tests; winit event handling is host-driven on the CPU, not WGSL.)
15. ✅ **Orbital camera + final demo (Step 15.C)** — `resources::CameraController` (yaw/pitch/distance/target, `apply_to` writes into a `Camera`, drag-orbit + wheel-zoom + clamps) drives the new `demo` example: one of each primitive, procedural textures, standard Phong material, a shadow-casting directional light + point + spot, and live mouse-orbit / wheel-zoom / `R` reset / `1`/`2`/`3` view presets. Run with `cargo run -p wsg-lib --example demo`. (Done 2026-09-20; runtime-verified headless.)
16. ✅ **User documentation (Step 16, Phase 5)** — `docs/user/` (quickstart, meshes, materials, lights, shadows, camera & input, examples) written in English and cross-linked to each other, to the tech docs and to rustdoc; tech docs interlinked with their stale status banners refreshed; this README re-anchored (declarative workflow = recommended, manual = advanced, `demo` = showcase, pollster 1.x). (Done 2026-07-19.)
17. ✅ **GPU-driven rendering (Step 15, Phase 3.1/3.2/3.3)** — world matrices and indirect draw args move from CPU to GPU. `shaders/gpu_driven.wgsl` (two compute entry points, `compute_matrices` + `cull`, one module, explicit 3-group layout) runs before the render passes over a fixed 256-slot table (the world-matrix buffer is bound to the `uniform` object slot; WebGPU caps a `uniform` binding at 64 KB and a `uniform` offset at 256 B, so each matrix slot is padded to 256 B and 256 × 256 B = 64 KB is the max); `render_scene` and the shadow pass become **100 % indirect** (one indirect draw per active slot, culled/inactive slots are no-ops), and the per-entity CPU draw loop is gone. New `math::Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z) + `BBox` on `Geometry`; `TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms` Pod mirrors of the WGSL structs. Frustum **culling is off by default** (non-regression) and opt-in via `AppBuilder::with_culling(true)` / `Renderer::set_culling(bool)`; the `demo` enables it. The object bind-group layout is now dynamic so every entity shares one GPU matrix buffer via per-slot offsets. (Done 2026-09-22; WGSL + frustum + scene-slot tests, 57 lib / 3 WGSL / 3 doctests all green. **Culling fix 2026-09-22**: the WGSL `select` arguments had been written HLSL-style, silently zeroing the draw count of every *visible* entity — a black window; fixed and verified by GPU readback, see D14 in ARCHI_CPU_GPU.md.)
+80
View File
@@ -0,0 +1,80 @@
---
type: Rule
title: Guidelines for API Documentation (rustdoc)
description: Consignes pour documenter systématiquement le code en pensant à la génération de documentation d'API (rustdoc) dans le projet WSG
tags: [documentation, rustdoc, api, guidelines, wsg-lib]
status: active
stale_after: 2027-01-31T00:00:00Z
related: [docs/rules/DOCUMENTATION.md]
generated: { by: "human:jerome", at: 2026-09-16T00:00:00Z }
---
# Consignes de Documentation d'API (rustdoc)
Ce document complète `docs/rules/DOCUMENTATION.md` (règles générales, en anglais). Il définit la
manière **concrète** d'écrire les commentaires pour que `cargo doc` produise une documentation d'API
de qualité, **sans aucun avertissement**. Ces consignes s'appliquent à toute modification de code du
projet, y compris la doc elle-même.
## Principe
Chaque item public (`pub struct`, `pub enum`, `pub trait`, `pub fn`, `pub const`, module, crate)
est documenté **au moment où il est écrit**, pas après coup. On ne documente pas seulement *ce que*
fait le code, mais *pourquoi* il existe et *quand/par qui* il est appelé (cf. règles générales :
description ≤ 3 lignes, étapes internes ≤ 3 lignes si corps > 15 lignes, points techniques ≤ 3 lignes).
## Règles techniques (issues d'incidents réels)
### 1. Types et code entre backticks
Tout nom de type, fonction, variable ou fragment de code dans un commentaire est enveloppé de
backticks (`` ` ``). Sans cela, rustdoc croit :
- à un **lien intra-doc** pour tout ce qui est entre crochets → avertissement `unresolved link`
(ex. `[f32; 3]` au lieu de `` `[f32; 3]` ``) ;
- à une **balise HTML** pour tout `<X>` → avertissement `unclosed HTML tag`
(ex. `Option<Self>`, `Arc<Mesh>`, `Handle<T>` au lieu de `` `Option<Self>` ``…).
À proscrire : `Option<Self>`, `Vec<Mesh>`, `[f32; 3]`, `Arc<Material>`.
À écrire : `` `Option<Self>` ``, `` `Vec<Mesh>` ``, `` `[f32; 3]` ``, `` `Arc<Material>` ``.
### 2. Un commentaire par item public
- Crate / module : `//!` en tête de fichier.
- Item (struct, enum, trait, fn, const, champs) : `///` juste au-dessus.
- Toute structure publique dont seuls les champs sont commentés déclenche `missing_docs` :
commenter **aussi** la structure elle-même.
### 3. Faire remonter les trous de couverture
`#![warn(missing_docs)]` est actif en tête de `lib.rs`. Tout nouvelle item public sans doc remonte
en **warning** au build de la doc : c'est voulu, il faut le corriger avant de committer.
Un rendu de doc doit toujours terminer par « generated 0 warnings ».
### 4. Vérifier le rendu avant de committer
Après toute modification de doc :
```bash
cargo doc -p wsg-lib --no-deps # doit afficher « generated 0 warnings »
cargo doc --no-deps --open # ouvre la doc dans le navigateur
cargo check --workspace # compile sans erreur
```
### 5. Tests de documentation
- Un bloc de code ```rust``` dans un commentaire est compilé et exécuté par `cargo test` (doc-test) :
il doit compiler **et** tourner.
- Pour un extrait non autonome (dépend de winit/wgpu, etc.), utiliser ` ```ignore ```` ```` au lieu de
` ```rust ``` ` afin de ne pas casser `cargo test`.
## Récapitulatif
| Situation | À faire | À éviter |
|---|---|---|
| Type dans une doc | `` `Arc<Mesh>` `` | `Arc<Mesh>` |
| Tableau dans une doc | `` `[f32; 3]` `` | `[f32; 3]` |
| Item public sans description | ajouter `///` | laisser vide |
| Struct publique | doc sur la struct + les champs | doc sur les champs seuls |
| Extraits exécutables | ` ```rust ``` ` | ` ```ignore ``` ` |
| Extraits non autonomes | ` ```ignore ``` ` | ` ```rust ``` ` |
+99
View File
@@ -0,0 +1,99 @@
# Étape 21 — Module `mesh` : primitives optionnelles + import
**Statut : ✅ TERMINÉE**
## Résumé
Restructuration du module de géométrie :
- `math/` supprimé — types (`Geometry`, `Transform`, `BBox`, `Frustum`, LOD) déplacés vers `core/`
- `primitives.rs` (monolith) → `mesh/primitives/` (6 fichiers, un par famille)
- Nouveau module `wsg::mesh` : point d'entrée unique pour les sources de géométrie
- Features par primitive (`prim-cube`, `prim-sphere`, …) — zéro coût si désactivées
- Parser OBJ intégré (zéro dep externe), wrapper glTF en stub
- `prelude.rs` pour un glob import confortable
- Re-exports top-level : `Geometry`, `Transform`, `BBox`
## Structure finale
```
lib/src/
├── lib.rs # + pub mod mesh, pub mod prelude, re-exports Geometry/Transform/BBox
├── prelude.rs # glob re-exports (types quotidiens)
├── core/
│ ├── mod.rs # + geometry, transform, frustum, lod
│ ├── geometry.rs # ← déplacé de math/
│ ├── transform.rs # ← déplacé de math/
│ ├── frustum.rs # ← déplacé de math/
│ ├── lod.rs # ← déplacé de math/
│ ├── renderer.rs
│ ├── shadow.rs
│ ├── hdr.rs
│ ├── context.rs
│ ├── frame.rs
│ └── input.rs
├── mesh/
│ ├── mod.rs # re-exports flat (cube, plane, sphere, …, load_obj, …)
│ ├── primitives/
│ │ ├── mod.rs
│ │ ├── cube.rs
│ │ ├── plane.rs
│ │ ├── sphere.rs # uv_sphere + icosphere
│ │ ├── cylinder.rs
│ │ ├── cone.rs
│ │ └── torus.rs
│ └── import/
│ ├── mod.rs # MeshImportError
│ ├── obj.rs # parser OBJ (zéro dep)
│ └── gltf.rs # stub (wrapper gltf crate à implémenter)
├── app.rs
├── handler.rs
├── pipeline/
├── resources/
├── scene/
└── utils/
```
## Features (Cargo.toml)
| Feature | Default | Fournit |
|---------|---------|---------|
| `prim-cube` | ✅ (via all-prims) | `cube(size)` |
| `prim-plane` | ✅ | `plane(w, d, sx, sz)` |
| `prim-sphere` | ✅ | `uv_sphere(…)`, `icosphere(…)` |
| `prim-cylinder` | ✅ | `cylinder(…)` |
| `prim-cone` | ✅ | `cone(…)` |
| `prim-torus` | ✅ | `torus(…)` |
| `all-prims` | ✅ (default) | les 6 ci-dessus |
| `import-obj` | ⬜ | `load_obj(path)`, `parse_obj(str)` |
| `import-gltf` | ⬜ | `load_gltf(path)` (stub) |
## Décisions
| # | Décision |
|---|----------|
| D1 | Un seul crate `wsg-lib` — pas de crate séparée |
| D2 | Feature par famille de primitives |
| D3 | Feature par format d'import |
| D4 | Pas de trait `MeshSource` — fonctions qui retournent `Geometry` |
| D5 | `Geometry::new()` / `Scene::add_mesh()` restent en core |
| D6 | Module `wsg::mesh` au même niveau que `core`, `app` |
| D7 | `primitives/` un fichier par famille |
| D8 | `import/` un fichier par format |
| D9 | Import retourne `Result<_, MeshImportError>` |
| D10 | `default = ["all-prims"]` |
| D11 | `all-prims` = les 6 primitives |
| D12 | `math` disparaît — types re-exportés par `core` / top-level |
## Tests
- 107 unit tests (dont 7 tests OBJ parser)
- 4 WGSL validation
- 5 doctests
- **Total : 116 tests, 0 failures**
## Build vérifié
- `cargo check` (default = all-prims) ✅
- `cargo check --no-default-features --features "prim-cube"` ✅
- `cargo check --features "import-obj,import-gltf"` ✅
- `cargo check --examples --features "import-obj"` ✅
+33 -16
View File
@@ -11,11 +11,19 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
Ce plan définit les étapes prioritaires pour finaliser l'architecture actuelle. L'objectif est de rendre l'API intuitive pour l'utilisateur standard tout en conservant la puissance de contrôle pour l'utilisateur avancé.
> **Statut réel (à jour au 2026-09-14).** Ce plan couvre la phase de *consolidation* passée ; la source
> de vérité sur l'état actuel est **README.md** et le code. Plusieurs cases `[X]` ci-dessous ont été
> re-corrigées car elles ne reflétaient plus la réalité : notamment le rendu de la `Scene` n'est
> **pas automatisé** (items Phase 2 et Check-list concernés). Depuis, `simple.rs` a été mis en
> conformité (API `AppBuilder`, ~15 lignes, compilation sans importer `winit`/`wgpu`).
> **Statut réel (à jour au 2026-09-18).** La phase de *consolidation* (Phases 1 à 3 de ce plan) est
> **terminée** ; la source de vérité sur l'état actuel est **README.md** et le code. Depuis la révision
> du 2026-09-14, le rendu de la `Scene` est automatisé en une passe groupée
> (`App::render_scene(frame.view())`, appelée par défaut dans `AppHandler::render`) et `simple.rs`
> (API `AppBuilder`, sans `winit`/`wgpu`) déclare un quad rendu automatiquement. Les étapes suivantes
> ont ensuite : posé l'infrastructure 3D (bind groups uniformes frame+object partagés, caméra active,
> matrices monde par entité — Étapes 3+4, 2026-09-16) ; atteint le **MVP 3D Phong** (Étape 5,
> 2026-09-17 : l'exemple `cube` ; le 2D plat = variante **unlit** de `standard` via
> `Renderer::set_unlit`) ; rattaché le `PipelineCache` à la `Scene` et fait référencer son `Material`
> par chaque `Mesh` (Étape 7) ; donné à `Mesh` une source de vérité **CPU partagée**
> (`geometry: Arc<Geometry>`, Étape 8) ; activé un **depth buffer** sur toutes les passes (Étape 9) ;
> et ajouté les **textures diffuses** (Étape 10, 2026-09-18 : `resources::Texture` + bind group @2 +
> `Material.texture`).
## Phase 1 : Finalisation et Nettoyage de l'Existant (Priorité Absolue)
@@ -45,13 +53,17 @@ Une fois la plomberie encapsulée, nous devons rendre l'assemblage des objets co
### Intégration de la Scene
- [X] Formaliser la structure `Scene` : un conteneur qui liste les Entities.
- [ ] Associer le `PipelineCache` à la Scene pour que le rendu des matériaux soit automatique (actuellement le cache est porté par `App`, indépendant de la Scene ; le rendu n'est pas automatisé).
- [ ] Implémenter la logique `app.render(scene)` : cette méthode doit parcourir la scène, récupérer les matériaux, gérer les pipelines via le cache, et soumettre les draw calls (non implémenté — cf. README, étape 1 du Roadmap : scene auto-rendering).
- [X] Associer le `PipelineCache` à la Scene pour que la gestion des matériaux soit entièrement portée par la scène (actuellement le cache est porté par `App`, indépendant de la Scene — le rendu de la scène est, lui, déjà automatisé depuis 2026-09-16). *(fait — 2026-09-17, DRAFT Étape 7 : `Scene::init_gpu` détient device+format+`PipelineCache` ; `App` n'a plus de champ `cache`)*
- [X] Implémenter la logique de rendu de la scène : `App::render_scene(view)` parcourt la scène,
récupère les matériaux et soumet tous les draw calls en **une seule passe groupée**
(`Renderer::render_scene`), appelée automatiquement chaque frame par l'implémentation par défaut
de `AppHandler::render` (Scene auto-render — réalisé 2026-09-16). Reste à brancher : associé au
`PipelineCache` porté par la `Scene` (cf. ligne précédente).
### Gestion des Matériaux et Shaders
- [ ] S'assurer que chaque Mesh possède une référence vers un Material (à l'heure actuelle le lien est porté par l'entité `(mesh_id, material_id)` de la Scene, pas par le Mesh lui-même).
- [ ] Implémenter le comportement par défaut : si aucun matériau n'est assigné, le moteur injecte automatiquement le `basic_shader` (non implémenté).
- [X] S'assurer que chaque Mesh possède une référence vers un Material (à l'heure actuelle le lien est porté par l'entité `(mesh_id, material_id)` de la Scene, pas par le Mesh lui-même). *(fait — 2026-09-17, DRAFT Étape 7 : `Mesh.material: Option<Arc<Material>>` ; `Entity { mesh_id, transform }`, plus de `material_id`)*
- [X] Implémenter le comportement par défaut : si aucun matériau n'est assigné, le moteur injecte automatiquement le `standard_shader` (variante unlit). *(fait — 2026-09-17, DRAFT Étape 7.3.5 : `Scene::default_material()` injecte `standard` ; le flat reste piloté par `Renderer::set_unlit`)*
## Phase 3 : Documentation et Interface (API "User-Friendly")
@@ -68,15 +80,20 @@ Une fois la plomberie encapsulée, nous devons rendre l'assemblage des objets co
Une fois les phases 1 à 3 validées, nous pourrons introduire :
- [ ] **Système de Lumières** : Ajout de buffers d'uniformes dans le PipelineCache.
- [ ] **Textures** : Intégration d'un module de chargement d'images et de BindGroups.
- [ ] **Caméras** : Gestion des matrices de projection/vue dans la Scene.
- [ ] **Système de Lumières** : Ajout de buffers d'uniformes dans le PipelineCache *(planifié — ROADMAP 4.2, Éclairage avancé)*.
- [X] **Textures** : Intégration d'un module de chargement d'images et de BindGroups *(fait 2026-09-18,
Étape 10, ROADMAP 4.1 : `resources::Texture`, bind group @2, `Material.texture`)*.
- [X] **Caméras** : Gestion des matrices de projection/vue dans la Scene *(fait 2026-09-16, Étape 4.3 —
`Scene::set_camera`/`camera()` porte une caméra active ; `render_scene` écrit view/proj/cam_pos réels
dans le buffer frame chaque frame, aspect calculé depuis la fenêtre)*.
## Check-list de Vérification pour le LLM d'Assistance
- [X] Est-ce que `simple.rs` compile sans importer `winit` ou `wgpu` ? (oui — modèle 15 lignes, API `AppBuilder`)
- [ ] Est-ce que `App::run` gère bien le cycle update → render → present ? (boucle + présentation OK, mais `render()` ne peut pas encore dessiner — vue de frame non exposée)
- [X] Est-ce que `App::run` gère bien le cycle update → render → present ? (oui — la vue de frame est
exposée via `Frame::view()`, `render()` dessine la scène automatiquement en une passe via
`App::render_scene(frame.view())`, la présentation est faite par `App::run`)
- [X] Les modules sont-ils bien exposés via `lib.rs` ?
- [X] `pollster` est-il uniquement en dev-dependencies ?
Ce plan garantit que les fondations sont saines. Une fois la Scene rendue automatiquement par `app.render()`, l'ajout de toute nouvelle fonctionnalité (lumières, textures) deviendra une simple question d'ajout de données dans la structure de scène, sans modification de la boucle de rendu.
- [X] `pollster` est-il isolé de l'utilisateur final ? — résolu : depuis winit 0.30 (2026-09-16),
`pollster` est en `dependencies` de la lib ; le `block_on` de l'init GPU est appelé une seule fois
dans `lib/src/app.rs` (`resumed()`). Les exemples compilent sans le connaître (crates séparées).
+68 -131
View File
@@ -1,153 +1,90 @@
---
type: Roadmap
title: WSG Engine Development Roadmap
description: Development roadmap for the WSG engine from prototype to full-featured 3D rendering engine
tags: [roadmap, development, planning, wsg-lib, 3d-rendering]
status: stable
generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
---
# ROADMAP — WSG
# Roadmap WSG — Prototype → Moteur Complet
**Vision** : une lib Rust de dessin 3D simple, fondée sur `wgpu`, où l'API utilisateur est
déclarative (graph scène + traits) et où le rendu est **100 % GPU-driven** (indirect draws).
> Basé sur l'architecture existante (ARCHI_APP, ARCHI_ARENES, ARCHI_CPU_GPU, ARCHI_RENDU).
> Objectif : prototype fonctionnel d'abord, enrichissement progressif ensuite.
>
> **Point de départ (état réel au 2026-09-14 — la source de vérité est README.md).**
> Les fondations suivantes existent et fonctionnent déjà ; cette roadmap décrit la **trajectoire à
> venir** à partir de cet état (elle reprend les étapes 1-4 du README avant la montée GPU-driven) :
> - Workflow manuel (`Context` + `Renderer` + `PipelineCache`) : ✅ fonctionnel (exemple `manual`).
> - Façade `App` / `AppBuilder` / `AppHandler` : 🚧 scaffold — boucle et présentation OK, mais `render()` ne peut pas encore dessiner (vue de frame non exposée) et le rendu de la scène n'est pas automatisé.
> - `Scene` avec identifiants **String** (décision prise — voir tableau Notes de Décision) : 🚧 enregistrement seul.
> - `Camera` / `Transform` et `glam` : types et mathématiques présents (`math/`, `resources/camera.rs`), non branchés au pipeline.
Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT détaillé
(`DRAFT.md`, remplacé à chaque étape) et sa doc livrée (`docs/tech/`, `docs/user/`).
> **Légende** : ✅ fait · 🔶 partiel · ⬜ à faire · ❌ abandonné
> **Principe** : chaque étape est **additive et opt-in** — non-régression structurelle garantie
> (tout reste désactivable, les chemins existants ne changent pas).
---
## Phase 1️⃣ — Prototype MVP : Un Mesh 3D éclairé à l'écran
## Phase 1 — Fondations ✅
**Objectif** : Afficher un cube (ou autre mesh) 3D avec un éclairage Phong basique.
| # | Item | Statut |
|---|------|:------:|
| 1.1 | Contexte GPU (Instance, Surface, Adapter, Device, Queue) + boucle winit | ✅ |
| 1.2 | Buffers & Pipeline (vertex buffer, pipeline compilé, fullscreen) | ✅ |
| 1.3 | Geometry (struct `Geometry`, buffers GPU, topologie, `PrimitiveTopology`) | ✅ |
### 1.1 Dépendances & Mathématiques
- [x] `glam = "0.33"` ajouté (`lib/Cargo.toml`) — déjà présent, utilisé par `math/transform.rs` et `resources/camera.rs`
- [x] `slotmap` **retiré** — décision prise : **String IDs pour le MVP** ; slotmap reporté à l'étape "handles typés" (voir Notes de Décision)
- [ ] Créer module `math/` (ou `transform.rs`) :
- [ ] Struct `Transform { translation: Vec3, rotation: Quat, scale: Vec3 }`
- [ ] Méthode `to_matrix() -> Mat4` pour calculer la matrice locale
- [ ] Struct `Camera { position: Vec3, target: Vec3, up: Vec3 }` : resources/camera.rs
- [ ] Fonctions `view_matrix()` et `projection_matrix(fov, aspect, near, far)`
## Phase 2 — Scène & Transforms ✅
### 1.2 Geometry & Mesh
- [ ] Créer struct `Geometry` (math/geometry.rs) :
- [ ] `positions: Vec<[f32; 3]>` (obligatoire)
- [ ] `indices: Option<Vec<u16>>` (optionnel)
- [ ] `normals: Option<Vec<[f32; 3]>>` (pour Phong)
- [ ] Refactorer `Mesh` pour contenir :
- [ ] `geometry: Arc<Geometry>`
- [ ] `vertex_buffer: wgpu::Buffer`
- [ ] `index_buffer: Option<wgpu::Buffer>`
- [ ] `transform: Transform` (état CPU)
- [ ] Ajouter un mesh de test (cube unitaire) en exemple
| # | Item | Statut |
|---|------|:------:|
| 2.1 | Primitives procédurales (`cube`, `plane`, `sphere`, `cylinder`, `cone`, `torus`) | ✅ |
| 2.2 | Transforms (struct `Transform`, composition translation × rotation × scale) | ✅ |
| 2.3 | Entités & scène (struct `Entity`, `Scene`, `TransformStore`, graph entité→mesh) | ✅ |
| 2.4 | Camera (struct `Camera`, matrices view + perspective, `CameraController` orbital) | ✅ |
### 1.3 Shader Phong Minimal
- [ ] Créer `standard_shader.wgsl` :
- [ ] Vertex shader : projection * view * world * position
- [ ] Fragment shader : éclairage hémisphérique + diffuse avec une lumière directionnelle
- [ ] Uniforms : `view_matrix`, `proj_matrix`, `world_matrix`, `light_dir`, `light_color`
- [ ] Mettre à jour `Material` pour supporter les uniforms du shader Phong
## Phase 3 — GPU-driven (cœur de la vision) ✅
### 1.4 Scene avec identifiants (MVP : String IDs)
- [x] `Scene` implémentée avec **String IDs** (`HashMap<String, Arc<Mesh>>`, `...Material`, entités) — état actuel validé ; décision : rester en String IDs pour le MVP
- [x] Méthodes : `add_mesh()`, `get_mesh()`, `add_material()`, `add_entity()`, `iter_entities()`, `remove_entity()`
- [ ] **Reporté (étape "Handles typés")** : migrer vers `slotmap` générationnel (`MeshId`/`MaterialId`) quand l'éviction/les performances le justifieront
| # | Item | Statut |
|---|------|:------:|
| 3.1 | Buffers par entité (Transform + Matrix, uniform par slot) | ✅ |
| 3.2 | Compute matrices (compute shader : transform → world matrix) | ✅ |
| 3.3 | Indirect draws (`draw_args` GPU, `draw_indirect` / `draw_indexed_indirect`) | ✅ |
| 3.4 | Culling GPU (bounding sphere → frustum test → indirect args zéro) | ✅ |
### 1.5 Rendu du Prototype
- [ ] Uniform buffer pour la frame : `view_matrix`, `proj_matrix`, `light_dir`
- [ ] Uniform buffer par mesh : `world_matrix` (calculée sur CPU pour le MVP)
- [ ] `Renderer::render()` itère sur les meshes de la Scene et dessine chacun
- [ ] Exemple fonctionnel : un cube éclairé tourne à l'écran
## Phase 4 — Rendu avancé ✅
| # | Item | Statut |
|---|------|:------:|
| 4.1 | Textures & matériaux (struct `Texture`, `Material`, bind groups, shader standard) | ✅ |
| 4.2 | Lighting & ombres (directional + point + spot + ambient, shadow mapping PCF) | ✅ |
| 4.3 | Batching & LOD (batching par matériau, LOD quadric edge collapse + hystérésis) | ✅ |
| 4.4 | **HDR + Tone Mapping** (offscreen `Rgba16Float` + fullscreen TM pass ACES/Reinhard) | ✅ |
## Phase 5 — Qualité & polish ✅
| # | Item | Statut |
|---|------|:------:|
| 5.1 | Exemples (7 examples : hello_triangle → demo) | ✅ |
| 5.2 | Documentation (tech/ + user/ + rustdoc 100 %) | ✅ |
| 5.3 | Tests & robustesse (99 unit + 4 WGSL validation + 3 doctests) | ✅ |
---
## Phase 2️⃣ — Système de Ressources complet
## Phase 6 — Post-MVP ⬜
**Objectif** : Étoffer la Scene avec tous les types de ressources.
> Au-delà du scope initial. Chaque item est opt-in et indépendant.
### 2.1 Arènes complètes
- [ ] `SlotMap<MaterialId, Material>`
- [ ] `SlotMap<TextureId, Texture>` (struct de base)
- [ ] `SlotMap<LightId, Light>` (struct de base)
- [ ] `SlotMap<EntityId, Entity>` pour les entités de la scène
| # | Item | Impact visuel | Effort | Statut |
|---|------|:---:|:---:|:---:|
| 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ⬜ |
| 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ⬜ |
| 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ⬜ |
| 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | Moyen | ⬜ |
| 6.5 | **Normal mapping / PBR** (nouveau shader, tangent space, metalness-roughness) | ⭐⭐⭐ | Élevé | ⬜ |
| 6.6 | **Cascaded Shadow Maps** (2–3 cascades + blend, plus de précision près de la camera) | ⭐⭐ | Élevé | ⬜ |
| 6.7 | **SSAO** (ambient occlusion screen-space, depth + normal buffer) | ⭐⭐ | Élevé | ⬜ |
### 2.2 Entités & Hiérarchie
- [ ] Struct `Entity { mesh_id: Option<MeshId>, material_id: Option<MaterialId>, transform: Transform }`
- [ ] `Scene::add_entity()` → retourne `EntityId`
- [ ] `Scene::iter_entities()` → pour le render loop
### Cibles techniques (refactoring)
### 2.3 Camera dans la Scene
- [ ] Intégrer `Camera` comme ressource de la Scene
- [ ] Permettre plusieurs caméras (actuelle/inactive)
- [ ] Exposer API : `scene.set_active_camera(camera_id)`
| # | Item | Statut |
|---|------|:------:|
| 6.8 | Handles typés par ressource (slotmap) — `docs/tech/ARCHI_ARENES.md` | ⬜ |
| 6.9 | API update géométrie par entité (per-frame, sans rebuild complet) | ⬜ |
| 6.10 | Double-buffering des buffers Transform/Matrix (désync CPU/GPU) | ⬜ |
| 6.11 | **Module `mesh`** : primitives en features optionnelles + import (OBJ/gltf) — `math/` supprimé | ✅ |
---
## Phase 3️⃣ — GPU-Driven Rendering
## Liens
**Objectif** : Déléguer les calculs de transformation et culling au GPU (suivre ARCHI_CPU_GPU.md).
### 3.1 Compute Shader
- [ ] Buffer `TransformBuffer` (CPU → GPU) : positions/rotations/échelles brutes
- [ ] Buffer `MatrixBuffer` (GPU calculé) : World Matrices finales
- [ ] Compute shader : calcul des World Matrices pour tous les meshes
### 3.2 Frustum Culling GPU
- [ ] Ajouter `BBox` dans `Geometry` (center + extents)
- [ ] Buffer `BoundingBoxBuffer` (CPU → GPU, statique)
- [ ] Compute shader : culling basé sur la frustum de caméra
- [ ] Buffer `IndirectDrawBuffer` rempli par le GPU
### 3.3 Rendu Indirect
- [ ] `draw_indexed_indirect()` au lieu de draw calls individuels
- [ ] Un seul command draw pour tous les objets visibles
---
## Phase 4️⃣ — Fonctionnalités Avancées
**Objectif** : Qualité visuelle et performances.
### 4.1 Textures
- [ ] Struct `Texture` avec chargement d'image
- [ ] Ajouter `uvs: Option<Vec<[f32; 2]>>` dans `Geometry`
- [ ] BindGroup pour les textures dans le shader
- [ ] `Material` supporte une texture diffuse
### 4.2 Éclairage avancé
- [ ] Support multi-lumières (directionnelles, ponctuelles)
- [ ] Lumières hémisphériques
- [ ] Shadows (optionnel)
### 4.3 Optimisations
- [ ] Batching par Material (réduction des state changes GPU)
- [ ] Level of Detail (LOD)
- [ ] HDR + Tone Mapping (optionnel)
---
## Phase 5️⃣ — Documentation & Polish
- [ ] Exemple complet : mesh texturé, éclairé, avec caméra orbitale
- [ ] Documentation API (`docs/ARCHI_SCENE.md`)
- [ ] Tests unitaires : `Geometry`, `Scene`, `Transform`
- [ ] README mis à jour avec les nouvelles fonctionnalités
---
## Notes de Décision
| Décision | Raison |
|----------|--------|
| **Normals dès Phase 1** | Nécessaires pour le shader Phong ; sans elles, pas d'éclairage |
| **UVs en Phase 4** | Inutiles avant les textures ; on garde `Geometry` simple au départ |
| **BBox en Phase 3** | Utile uniquement pour le frustum culling GPU |
| **World Matrix CPU → MVP, GPU → Phase 3** | Le MVP est plus simple avec un uniform par mesh ; la migration GPU-driven est progressive |
| **String IDs pour le MVP, slotmap reporté** | Le code et le README utilisent des String IDs (simples, sûrs, figés avant la boucle de rendu) ; `ARCHI_ARENES.md` reste la cible "handles typés" pour plus tard. La dépendance `slotmap` a été retirée tant qu'elle est inutilisée |
| **Present mode FIFO figé pour l'instant** | Le swapchain utilise `PresentMode::Fifo` avec `desired_maximum_frame_latency: 2` (double buffering vsync) — défaut sûr : pas de tearing, énergie minimale, zéro artefact. On **gèle ce choix** ; `Mailbox` (triple buffering) pourra être exposé en option et `Immediate` restera réservé à l'offscreen, **on s'occupera du present mode le moment venu** (quand le pipeline GPU-driven arrivera, Phase 3) — ce n'est pas bloquant pour les étapes 1-2 |
- **Prochaine étape** : [DRAFT.md](DRAFT.md) (détail de l'étape en cours, remplacée à chaque itération)
- **Architecture** : [docs/tech/](tech/ARCHI_APP.md)
- **Utilisation** : [docs/user/](user/README.md)
- **Livre de recette** : [docs/PLAN.md](PLAN.md)
+35 -15
View File
@@ -15,14 +15,20 @@ stale_after: 2027-01-31
wsg_lib est un moteur de rendu modulaire basé sur wgpu. Il adopte une architecture à deux niveaux : une façade de haut niveau pour la productivité et un accès bas niveau pour un contrôle total.
> **État du document : CIBLE (architecture visée, en grande partie non implémentée).**
> Les sections §1, §4B, §5 et §6 décrivent la **cible** : pipeline GPU-driven à deux passes
> (Compute Pass → `draw_indexed_indirect`), buffers persistants en VRAM (Transform/Matrix/BBox/Indirect)
> et synchronisation single/double buffer. **Rien de tout cela n'existe encore dans le code** — c'est
> la trajectoire de ROADMAP.md (et README étape 2-3). L'état **réel actuel** est dans README.md :
> workflow manuel uniquement, `Renderer` dessine un objet par soumission, shader en NDC sans MVP.
> La §3 (`App`/`AppHandler`) correspond à l'état actuel, à une nuance près : `render()` ne peut pas
> encore dessiner la scène (l'acquisition/présentation de frame fonctionne, pas le rendu de la scène).
> **État du document : ACTUEL** — façade (`App`/`AppHandler`, §3, §4A) et pipeline GPU-driven
> (§1, §4B, §5, §6) **implémenté en Phase 3** du ROADMAP (Étape 17, 2026-09-22, décisions
> D1–D14). La façade `AppBuilder`/`App`/`AppHandler` est livrée et est le **workflow recommandé** :
> `setup` (déclaration de la scène) → par frame `update` (mutation) →
> `render` (défaut : `App::render_scene` = itération des entités + **rendu groupé en une passe**,
> un `CommandEncoder`/soumission par frame ; passe d'ombre en tête si un caster est actif).
> Exemples : `simple` (2D unlit), `cube` (3D éclairé), `demo` (vitrine : primitives, lumières,
> ombres, caméra orbitale, culling GPU activé). Le workflow **manuel** (exemple `manual`) coexiste
> pour le contrôle fin.
> Les sections §1, §4B, §5 et §6 décrivent le pipeline GPU-driven **tel qu'implémenté**, avec les
> écarts documentés (cf. `ARCHI_CPU_GPU.md`) : un draw indirect par slot (D1), table fixe de 256 slots
> (D12), culling par sphère conservative (D5), single buffer (D4), et le piège de l'ordre des
> arguments de `select` en WGSL (D14, bug « fenêtre noire » corrigé le 2026-09-22). La section
> « Notes pour l'implémentation future » (double buffering) reste **CIBLE**.
## 1. Philosophie et Principes
@@ -72,13 +78,20 @@ pub trait AppHandler {
- **`update()`** : appelé en premier. L'utilisateur peut modifier librement la scène (transformations, ajout/suppression d'entités). Ces modifications sont synchronisées vers le GPU via un **single buffer** Transform avant la passe de calcul.
- **`render()`** : appelé après. Il ne sert qu'à injecter du rendu personnalisé (debug, HUD, etc.). La Scene reste immuable : aucune mutation d'état métier.
> **Note pour mémoire (init GPU / runtime async)** : depuis la migration winit 0.30, l'init GPU
> se fait dans le callback synchrone `resumed()`, donc via `pollster::block_on(Context::new(...))`
> dans `app.rs`. C'est actuellement le **seul** point de couplage de la lib à un runtime async.
> On ne crée volontairement pas d'abstraction tant qu'il n'y a qu'un appel ; si la lib acquiert
> d'autres appels async, isoler le runtime derrière un module-pivot unique (`exec::block_on`),
> seul fichier à modifier pour basculer vers tokio/futures-executor. Voir PLAN.md (note pour mémoire).
## 4. Workflow et Cycle de Vie
### A. Initialisation (Configuration)
- **Shaders** : Chargés avant la renderloop.
- **PipelineCache** : Enregistre les shaders.
- **Matériaux** : Créés avec un shader associé. Un mesh sans matériau explicite utilise `basic_shader` par défaut.
- **Matériaux** : Créés avec un shader associé. Un mesh sans matériau explicite utilise le matériau par défaut de la scène (`standard`).
- **Scene** : Assemblage des objets. L'utilisateur peuple la scène via `app.scene`.
### B. Boucle de Rendu — Pipeline GPU-Driven
@@ -97,8 +110,8 @@ Le moteur gère la renderloop interne via un pipeline à **deux passes séquenti
1. **Update** (`AppHandler::update`) — L'utilisateur modifie la scène (transformations, entités). Ces changements sont synchronisés vers le GPU via un **single buffer** Transform avant la passe de calcul.
> La synchronisation est assurée par le pipeline wgpu : `queue.submit()` après le compute pass garantit que les données Transform sont valides avant le render pass suivant. Aucun double buffering n'est nécessaire tant que la latence maximale de la surface (via `desired_maximum_frame_latency`) est ≥ 3.
2. **Compute Pass** — Un compute shader lit les Transform bruts, calcule les World Matrices finales, effectue le Frustum Culling par AABB, et remplit l'Indirect Draw Buffer avec les identifiants des objets visibles.
3. **Render Pass** — Le CPU émet une unique commande `draw_indexed_indirect`. Le GPU pioche dans l'Indirect Draw Buffer et dessine uniquement les objets visibles, sans intervention du CPU.
2. **Compute Pass** — Deux entry points compute séquentiels (`compute_matrices` puis `cull`, un seul module WGSL) lisent les Transform bruts, calculent les World Matrices finales, effectuent le Frustum Culling par **sphère conservative** (D5), et remplissent l'Indirect Draw Buffer avec les **comptes** de draw des objets visibles (0 si cullé/inactif).
3. **Render Pass** — Le CPU émet **un draw indirect par slot** (écart D1 — la cible initiale prévoyait une commande unique fusionnée). Le GPU pioche les comptes dans l'Indirect Draw Buffer et dessine uniquement les objets non cullés et actifs, sans intervention du CPU.
4. **Présentation** — La surface est présentée à l'écran.
L'ordre d'appel des méthodes sur le `CommandEncoder` (`begin_compute_pass` puis `begin_render_pass`) garantit l'exécution séquentielle. Les barrières de mémoire entre passes sont insérées automatiquement par le pilote.
@@ -107,10 +120,11 @@ L'ordre d'appel des méthodes sur le `CommandEncoder` (`begin_compute_pass` puis
| Buffer | Rôle | Type wGPU | Direction du flux |
|--------|------|-----------|-------------------|
| Transform Buffer | Positions/rotations/échelles brutes | Storage Buffer | CPU → GPU |
| Matrix Buffer | World Matrices finales calculées | Storage Buffer | GPU (Calculé) → GPU (Lu par Render) |
| Bounding Box Buffer | AABB de chaque mesh pour culling | Storage Buffer | CPU → GPU (Statique) |
| Indirect Draw Buffer | Liste dynamique des objets à dessiner | Indirect + Storage | GPU (Rempli par Compute) → GPU (Lu par Render) |
| Transform Buffer | Positions/rotations/échelles brutes + flags par entité (64 o/slot) | Storage Buffer | CPU → GPU (chaque frame, `write_buffer`) |
| Matrix Buffer | World Matrices finales calculées (256 o/slot, padded — D12) | Storage + Uniform | GPU (Calculé) → GPU (Lu par Render) |
| Bounding Box Buffer | Coins min/max de l'AABB de chaque mesh (32 o/slot) | Storage Buffer | CPU → GPU (quand l'ensemble des meshes change) |
| Indirect Draw Buffer | Comptes de draw par slot (80 o/slot, zéro = no-op) | Indirect + Storage | GPU (Rempli par Compute) → GPU (Lu par Render) |
| CullUniforms | 6 plans du frustum + `num_slots` + `culling` (112 o) | Uniform Buffer | CPU → GPU (chaque frame) — réservé au compute (group 2) |
> **Synchronisation single buffer** : Les buffers Transform et Matrix utilisent un **single buffer** en phase initiale. Le CPU écrit dans le buffer pendant `update()`, puis le compute shader lit les données au frame suivant via `queue.submit()` qui garantit la séquence d'exécution. Cette approche fonctionne correctement tant que la surface a une latence maximale ≥ 2 frames (configuré via `desired_maximum_frame_latency`). Le double buffering sera ajouté uniquement si des artefacts visuels apparaissent à haute fréquence (typiquement > 90 fps sur machines rapides).
@@ -148,3 +162,9 @@ Single buffer (phase initiale) : Update écrit, Compute lit au frame suivant —
- **Synchronisation** : Toujours appeler `begin_compute_pass` avant `begin_render_pass` sur le même `CommandEncoder`. Les barrières entre passes sont automatiques — ne jamais insérer de barrière manuelle sauf besoin critique.
- **Synchronisation single buffer (phase initiale)** : La séquence `queue.submit()` après chaque compute pass garantit que les données Transform sont valides avant le render pass suivant. Aucun conflit de lecture/écriture n'est possible tant que `desired_maximum_frame_latency` ≥ 3.
- **Double Buffering (future migration)** : Sera implémenté sur les buffers Transform et Matrix seulement, pas sur BoundingBox ni Indirect Draw. Le switch se résume à : dupliquer ces deux buffers, ajouter une méthode `swap()` appelée dans `AboutToWait`, modifier les bind groups pour pointer vers l'index courant. Pas besoin de refonte architecturale.
## Liens
- [ARCHI_RENDU](ARCHI_RENDU.md) · [FRAME_LOOP](FRAME_LOOP.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [ARCHI_ARENES](ARCHI_ARENES.md)
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
- Référence API : `cargo doc -p wsg-lib --no-deps`
+7 -1
View File
@@ -220,7 +220,7 @@ impl ResourceManager {
6. Suppression Dynamique : Bien que possible, la suppression de ressources pendant la boucle de rendu doit être faite avec prudence. Assurez-vous que les entités ou objets qui référençaient cette ressource soient informés ou nettoyés pour éviter d'utiliser des Handles invalides. La suppression est souvent mieux gérée en fin de frame ou via un système de "marquage pour suppression" suivi d'un nettoyage différé.
7. Futur : SecondaryMaps : slotmap permet d'utiliser des SecondaryMap pour associer dynamiquement des données à des ressources existantes sans modifier leur structure principale. Par exemple, `SecondaryMap<MeshId, Transform>` pourrait stocker les transformations actuelles de chaque maillage. Cela peut être utile pour le rendu ou pour des systèmes de physique/transformation indépendants.
> **Note sur les Transforms côté GPU** : `SecondaryMap<MeshId, Transform>` est une suggestion d'approche générale. Si le modèle le plus performant pour votre cas d'usage est plutôt un vecteur/plat de Transforms (`Vec<Transform>`) alimentant un Storage Buffer CPU → GPU (comme décrit dans [ARCHI_CPU_GPU](ARCHI_CPU_GPU)), alors c'est cette approche qu'il faut adopter. Comme toutes les ressources sont créées avant le début de la boucle de rendu, vous pouvez décider à ce moment-là du meilleur modèle de stockage — en fonction du volume de meshes et de la fréquence de mise à jour des transforms.
> **Note sur les Transforms côté GPU** : `SecondaryMap<MeshId, Transform>` est une suggestion d'approche générale. Si le modèle le plus performant pour votre cas d'usage est plutôt un vecteur/plat de Transforms (`Vec<Transform>`) alimentant un Storage Buffer CPU → GPU (comme décrit dans [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md)), alors c'est cette approche qu'il faut adopter. Comme toutes les ressources sont créées avant le début de la boucle de rendu, vous pouvez décider à ce moment-là du meilleur modèle de stockage — en fonction du volume de meshes et de la fréquence de mise à jour des transforms.
# Avantages de cette Approche
@@ -230,3 +230,9 @@ impl ResourceManager {
* Conformité avec Rust : Respecte les principes de propriété et de sécurité mémoire de Rust sans recourir à Rc<RefCell<T>> ou d'autres constructions potentiellement coûteuses ou moins sûres pour la gestion partagée des ressources.
* Typage Fort : Les types MeshId, MaterialId, etc., empêchent les erreurs de compilation liées au mélange de Handles de types différents.
* Extensibilité : L'écosystème slotmap (SecondaryMap) offre des perspectives pour des architectures plus complexes à l'avenir.
## Liens
- [ARCHI_APP](ARCHI_APP.md) · [ARCHI_RENDU](ARCHI_RENDU.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [FRAME_LOOP](FRAME_LOOP.md)
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
- Référence API : `cargo doc -p wsg-lib --no-deps`
+71 -18
View File
@@ -7,7 +7,7 @@ actor: person/jerome
sources: []
generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
verified: true
status: target
status: current
stale_after: 2027-01-31
---
@@ -16,13 +16,47 @@ Bonnes Pratiques & Guide d'Implémentation
Ce document sert de spécification technique et de trame d'implémentation pour l'architecture de rendu 3D pilotée par le GPU (GPU-Driven Rendering) utilisant wgpu. L'objectif est de déléguer un maximum de charges de calcul au GPU pour soulager le CPU et maximiser les performances de parallélisme.
> **État du document : CIBLE (spécification du pipeline GPU-driven, non implémenté).**
> **État du document : ACTUEL (implémenté — Phase 3 du ROADMAP, Étapes 17–19, validé 2026-09-22).**
> La répartition CPU/GPU, le compute pass (World Matrices + Frustum Culling), l'Indirect Draw Buffer
> et les buffers persistants en VRAM décrits ici correspondent à la **Phase 3 du ROADMAP** et aux
> README étapes 2-3. **Aucun de ces mécanismes n'existe encore dans le code.** Aujourd'hui le rendu est
> piloté par le CPU, **objet par objet** (une soumission par mesh, voir README.md et l'exemple `manual`).
> Considérez ce document comme la spécification de référence pour l'implémentation future du pipeline
> GPU-driven, pas comme une description de l'état actuel.
> et les buffers persistants en VRAM décrits ici sont en place : `shaders/gpu_driven.wgsl`
> (deux entry points `compute_matrices` + `cull`, un module, layout explicite à 3 groupes) et les
> buffers de slots du `Renderer` (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`,
> capacité fixe de 256 slots).
> Ce document est la **référence durable** de la conception : le draft d'origine de l'Étape 17
> (décisions D1–D14, layouts, plan de validation) a été vidé de `docs/DRAFT.md` après validation
> et vit dans le git (`git show 3a424af:docs/DRAFT.md`) ; l'essentiel en est repris ci-dessous.
> **Écarts documentés** (numérotation du draft d'origine) : (D1) un draw indirect **par slot** plutôt qu'une
> commande unique fusionnée ; (D12) 256 slots, slot matrice padded à 256 o (plafond `uniform` WebGPU) ;
> (D5) culling par **sphère** conservative dérivée de l'AABB locale du mesh, pas par l'AABB transformée
> exacte ; (D4) single buffer, pas de double-buffering.
> **Piège connu (2026-09-22, D14)** : l'ordre des arguments de `select` en WGSL est l'inverse de la
> convention HLSL — l'avoir inversé a produit un bug « fenêtre noire » (entités visibles remises à 0),
> corrigé et vérifié par readback GPU. Documenté en tête de `gpu_driven.wgsl` et dans `AGENTS.md`.
> **Batching par material (Étape 18, 2026-09-22)** : la passe principale émet désormais les draws
> groupés par `Material` (1 `set_pipeline` + 1 bind group @2 par matériau distinct, pas par entité ;
> le pass d'ombre — un seul pipeline — est inchangé). Réordonnancement sûr car tous les pipelines
> sont opaques (`BlendState::REPLACE`) ; les no-ops cullés restent émis dans leur groupe.
> Détail : `docs/user/gpu-driven.md` § « Batching by material ».
> **LOD (Étape 19, 2026-09-23)** : le pass `cull` remplit désormais les arguments indirects à partir
> du **niveau de détail** du slot, et non d'un seul jeu de comptes. Le choix du niveau est fait **côté CPU**
> (rayon de la sphère bounding projeté en pixels + hystérésis asymétrique — `math/lod.rs`, pur et unit-testé) ;
> le GPU n'effectue que le mappage niveau → ligne de la table LOD du mesh. Les niveaux d'un mesh sont
> générés par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated` : les
> arêtes au coût quadrique minimal sont repliées en premier ; soudure **consciente des attributs**
> — un doublon ne fusionne que si UV strictement < ½ tuile par coordonnée (un Δ = ½ exact est
> ambigu : fente à sa plus large vs saut légitime) ET normales proches (dot > 0.9) ; les paires
> refusées à UV écart d'entier sont **enregistrées** (jumeaux de fente) — ; un mesh sans couture
> reste fermé (pas de trous, pas de « books »), et sur un mesh couturé les jumeaux de fente sont
> **gelés** (toute arête qui y touche est exclue de la file — la fente zéro-largeur reste fermée à
> tous les niveaux) ; UVs/couleurs/normales **blendés linéairement** au repli (le chart est
> bilinéaire → exact au nouveau point — jamais de fold de tuile, qui figeait l'UV d'un sommet sur
> les vertices de base), normales **héritées** de la source (jamais recalculées — l'éclairage reste
> identique au niveau 0 quelle que soit l'orientation source) ; rebase u16) et **empilés dans les
> buffers vertex/index du mesh** (offsets en
> unités d'élément, pas d'octet — c'est ce qu'exigent les arguments `drawIndirect*` de WebGPU ; plafond u16 :
> 65 535 sommets/mesh, 4 niveaux max). LOD activé par défaut ; `set_lod_enabled(false)` restaure un rendu
> bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md`
> § « Level of Detail ».
1. Répartition des Rôles : CPU vs GPU (La Source de Vérité)
@@ -31,7 +65,8 @@ Pour éviter les goulets d'étranglement dus aux allers-retours sur le bus PCIe,
Côté CPU (Source de Vérité)
- Ce qu'il conserve : Les données logiques et les transformations brutes des objets (ex: Vec<Transform> contenant la position, la rotation, et l'échelle).
- Ce qu'il fait : Il gère la logique de jeu, l'IA, le réseau et les interactions globales.
- Ce qu'il ne fait plus : Il ne calcule plus les matrices de transformation mondiales (World Matrices) en masse, et ne fait plus de tests de visibilité unitaires.
- Ce qu'il ne fait plus : Il ne calcule plus les matrices de transformation mondiales (World Matrices) en masse, et ne fait plus de tests de visibilité unitaires (le culling frustum reste 100 % GPU).
- Ce qu'il fait en plus (LOD, Étape 19) : le **choix du niveau de détail** par entité — O(N) projections de sphères en pixels + hystérésis, coût négligeable. C'est l'unique décision de visibilité/détail conservée côté CPU : elle dépend de la taille écran (un choix artistique), pas de la géométrie, et l'hystérésis a besoin de l'état de la frame précédente.
Côté GPU (Exécutant Autonome)
- Ce qu'il calcule : Les World Matrices, le Frustum Culling, et la génération des listes de dessin indirectes.
@@ -50,13 +85,13 @@ L'exécution des tâches s'appuie sur une structure séquentielle stricte au sei
```
Étape par étape :
- Mise à jour CPU (Minimaliste) : Le CPU écrit les transformations brutes (Transform) modifiées dans un buffer GPU mappé (single buffer en phase initiale — la synchronisation est assurée par `queue.submit()` qui garantit la séquence d'exécution). Double buffering sera ajouté uniquement si des artefacts apparaissent à haute fréquence (> 90 fps).
- Mise à jour CPU (Minimaliste) : Le CPU écrit les transformations brutes (Transform) modifiées dans un buffer GPU mappé (single buffer en phase initiale — la synchronisation est assurée par `queue.submit()` qui garantit la séquence d'exécution), **et le niveau LOD de chaque slot** (1 u32/slot, Étape 19). Double buffering sera ajouté uniquement si des artefacts apparaissent à haute fréquence (> 90 fps).
- Pass de Calcul (Compute Pass) :
- Calcul des World Matrices : Un compute shader lit les transformations brutes et génère la matrice 4x4 finale pour chaque mesh.
- Frustum Culling GPU : Le même compute shader (ou un compute pass dédié) compare la Bounding Box (AABB) de chaque objet avec les plans de la caméra (matrice de projection/vue).
- Remplissage du Buffer Indirect : Si l'objet est visible, son identifiant est injecté dans un buffer de commandes de dessin indirect (Indirect Draw Buffer).
- Frustum Culling GPU : Un compute pass dédié (`cull`) compare la **sphère bounding** de chaque objet (D5 — conservative, dérivée de l'AABB locale du mesh et de l'échelle de l'entité) avec les 6 plans du frustum de la caméra.
- Remplissage du Buffer Indirect : le pass `cull` lit le **niveau LOD** du slot, en choisit la ligne dans la table LOD du mesh (`LodTable` : 4 lignes d'offsets/comptes en unités d'élément) et écrit les arguments dans le `DrawSlot` (80 o) — mis à 0 si l'objet est cullé ou inactif (no-op). Le niveau 0 porte les comptes du mesh complet, donc LOD désactivé ≡ pré-LOD bit-à-bit.
- Pass de Rendu (Render Pass) :
- Le CPU émet une unique commande globale : draw_indexed_indirect.
- Le CPU émet **un draw indirect par slot** (écart D1 — la spécification initiale prévoyait une commande unique fusionnée) ; les slots à compte 0 (cullés/inactifs/vides) sont des no-ops.
- Le GPU pioche directement dans le buffer préparé par le compute pass et dessine uniquement les objets visibles, sans intervention du CPU.
3. Stratégie de Synchronisation
@@ -66,9 +101,27 @@ L'exécution des tâches s'appuie sur une structure séquentielle stricte au sei
4. Synthèse des Structures de Données en VRAM
Pour implémenter cette architecture, prévoyez l'utilisation des buffers wGPU suivants :
Nom du Buffer,Rôle,Type wGPU,Direction du flux
Transform Buffer,Stocke les positions/rotations/échelles brutes.,Storage Buffer,CPU → GPU
Matrix Buffer,Stocke les World Matrices finales calculées.,Storage Buffer,GPU (Calculé) → GPU (Lu par le Render)
Bounding Box Buffer,Stocke les AABB de chaque mesh pour le culling.,Storage Buffer,CPU → GPU (Statique)
Indirect Draw Buffer,Contient la liste dynamique des objets à dessiner.,Indirect Buffer + Storage,GPU (Rempli par Compute) → GPU (Lu par Render)
L'implémentation utilise les buffers wGPU suivants (tous créés par le `Renderer` à l'initialisation, capacité fixe de 256 slots) :
| Buffer | Rôle | Type wGPU | Direction du flux |
|--------|------|-----------|-------------------|
| Transform Buffer | Positions/rotations/échelles brutes + flags par entité (64 o/slot) | Storage Buffer | CPU → GPU (chaque frame, `write_buffer`) |
| Matrix Buffer | World Matrices finales calculées (256 o/slot, padded — D12) | Storage + Uniform Buffer | GPU (Calculé) → GPU (Lu par le Render) |
| Bounding Box Buffer | Coins min/max de l'AABB de chaque mesh (32 o/slot) | Storage Buffer | CPU → GPU (quand l'ensemble des meshes change) |
| Indirect Draw Buffer | Comptes de draw par slot (80 o/slot, zéro = no-op) | Indirect + Storage Buffer | GPU (Rempli par Compute) → GPU (Lu par le Render) |
| CullUniforms | 6 plans du frustum + `num_slots` + `culling` (112 o) | Uniform Buffer | CPU → GPU (chaque frame) — réservé au compute (group 2) |
| Lod Levels | Niveau LOD par slot choisi par le CPU (4 o/slot) | Storage Buffer (read-only) | CPU → GPU (chaque frame) |
| Lod Tables | Table par mesh : `count` + 4 lignes de 16 o (offset/compte d'éléments) (80 o/mesh) | Storage Buffer (read-only) | CPU → GPU (quand l'ensemble des meshes change) |
**Buffers de géométrie LOD (Étape 19)** : les niveaux d'un mesh sont **empilés** — un seul buffer vertex et un
seul buffer index par mesh, contenant les niveaux concaténés (L0, L1, …). Les lignes de la table LOD portent
les offsets en **unités d'élément** (premier vertex / premier index), car les arguments `drawIndirect*` de
WebGPU s'expriment en éléments, et le buffer est lié en entier à l'offset 0. Conséquences : un mesh LOD ne
peux dépasser 65 535 sommets au total (indices u16) et 4 niveaux (`MAX_LOD_LEVELS`) ; le mélange indexé/non-indexé
dans un même mesh est supporté (la commande de draw par slot suit le niveau choisi par le CPU).
## Liens
- [ARCHI_APP](ARCHI_APP.md) · [ARCHI_RENDU](ARCHI_RENDU.md) · [ARCHI_ARENES](ARCHI_ARENES.md) · [FRAME_LOOP](FRAME_LOOP.md)
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
- Référence API : `cargo doc -p wsg-lib --no-deps`
+20 -8
View File
@@ -15,13 +15,15 @@ stale_after: 2027-01-31
Ce document définit la stratégie de gestion de la mutabilité et des données du moteur wsg_lib, conçue pour maximiser la performance et garantir la sécurité mémoire via Rust.
> **État du document : CIBLE (modèle de mutabilité pour le futur rendu automatisé).**
> Le cycle update/render strict, l'itération **automatique** des entités et `renderer.render_scene()`
> décrits ici ne sont **pas implémentés** : c'est l'**étape 1 du Roadmap README** (scene auto-rendering).
> Aujourd'hui `App::run` acquiert/présente la frame mais `render()` ne peut pas encore dessiner la scène,
> et le `Renderer` ne dessine qu'un objet par soumission, à la main (exemple `manual`). La terminologie
> `MeshId`/`MaterialId` (handles typés) est celle de la **cible** ; l'état actuel utilise des **String IDs**
> dans `Scene`. La dichotomie update/render reste toutefois le modèle de référence retenu pour la suite.
> **État du document : ACTUEL pour la dichotomie update/render (implémentée) ; CIBLE pour le batching.**
> Le cycle strict est en place : `AppHandler::update` (mutation libre de la scène) tourne avant
> `AppHandler::render`, dont l'implémentation par défaut appelle `app.render_scene(frame.view())` —
> le moteur itère automatiquement les entités et les dessine en **une passe groupée** par frame
> (rendu automatisé livré le 2026-09-16 ; la passe d'ombre est ajoutée en tête quand un caster est
> actif). Le workflow **manuel** (`Renderer::render` objet par objet, exemple `manual`) coexiste
> pour le contrôle fin. Reste en **cible** : le **tri/batching par matériau** (ROADMAP 4.3) et les
> **handles typés** `MeshId`/`MaterialId` (voir [ARCHI_ARENES](ARCHI_ARENES.md)) — l'état actuel
> utilise des **String IDs** dans `Scene`.
## 1. La Dichotomie Update / Render
@@ -65,4 +67,14 @@ Bien que cette architecture facilite la gestion de la mémoire, des règles stri
> "Si vous devez changer la position d'un objet ou son matériau, faites-le dans `update()`. Si vous avez besoin d'afficher un élément de debug ou un rendu spécial, faites-le dans `render()`, mais traitez les objets de la scène comme des données en lecture seule."
Cette structure permet au projet d'être extrêmement scalable. L'ajout futur de fonctionnalités (Lumières, Textures, Caméras) ne nécessitera que d'ajouter de nouveaux conteneurs dans la Scene et de mettre à jour le système de tri dans `Renderer::render_scene()` (méthode à créer — cible de l'étape 1 du Roadmap README).
Cette structure permet au projet d'être extrêmement scalable. L'ajout des fonctionnalités Lumières,
Textures et Caméras (livrées — voir [ROADMAP](../ROADMAP.md)) a effectivement consisté à ajouter des
conteneurs dans la Scene (`lights`, `textures`, `camera`) et à les consommer dans
`Renderer::render_scene()` (existant — il écrit les uniformes de frame chaque frame). Il restera à
y ajouter le **système de tri par matériau** (batching, ROADMAP 4.3) quand il sera justifié.
## Liens
- [ARCHI_APP](ARCHI_APP.md) · [FRAME_LOOP](FRAME_LOOP.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [ARCHI_ARENES](ARCHI_ARENES.md)
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
- Référence API : `cargo doc -p wsg-lib --no-deps`
+29 -9
View File
@@ -14,12 +14,25 @@ stale_after: 2027-01-31
# La Boucle de Rendu (Frame Loop)
> **État du document : ACTUEL (implémenté).** Ce document décrit la frame lifetime telle qu'elle est
> réellement implémentée. Il concerne le rendu **CPU-piloté actuel** (objet par objet, exemple `manual`).
> Le pipeline GPU-driven de l'état **visé** est décrit dans ARCHI_APP.md / ARCHI_CPU_GPU.md (cible).
> réellement implémentée. **Deux flux coexistent** : le flux **facade `App`** (rendu automatique de la
> scène, `App::render_scene` — le workflow recommandé, exemples `simple`/`cube`/`demo`) et le flux
> **manuel** (`Context`/`Renderer`/`Frame` pilotés à la main — exemple `manual`, un objet par soumission).
> Le pipeline **GPU-driven** (compute pass + draw indirect) de l'état **visé** est décrit dans
> [ARCHI_APP](ARCHI_APP.md) / [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) (cible).
Pour afficher quelque chose, nous suivons un cycle immuable appelé la Frame Lifetime. Deux flux coexistent, tous deux basés sur `Frame` :
Pour afficher quelque chose, nous suivons un cycle immuable appelé la Frame Lifetime, basé sur `Frame` :
**Flux `Frame` (utilisé par `App::run` et l'exemple `manual`) :**
**Flux facade `App` (recommandé — `App::run` + `AppHandler`) :**
- **`Context::get_next_frame()`** : acquiert la surface texture et crée sa `TextureView` (dans `Frame`).
- **`AppHandler::render` (défaut) → `App::render_scene(view)`** : le moteur itère les entités de la
scène et les dessine en **une passe groupée** (un `CommandEncoder` + une soumission par frame ;
passe d'ombre en tête si un caster est actif).
- **`Renderer::present(frame)`** : présente l'image à l'écran.
- Chaque frame, avant `update`, le moteur appelle `device.poll()` (les callbacks asynchrones wgpu —
`on_submitted_work_done`, `map_async` — ne se déclenchent que lors d'un poll), et la fenêtre
redimensionnée est gérée par `App::resize` (surface + depth texture recréées ensemble).
**Flux `manual` (exemple `manual` — un objet par soumission) :**
- **`Context::get_next_frame()`** (ou `Frame::try_new(&context.surface)`) : acquiert la surface texture et crée sa `TextureView` (dans `Frame`).
- **`Renderer::render(&view, &mesh, &material)`** : crée un `CommandEncoder`, écrit les ordres de dessin dans la `TextureView`, puis soumet à la file (`queue`).
- **`Renderer::present(frame)`** : présente l'image à l'écran.
@@ -28,6 +41,12 @@ Pour afficher quelque chose, nous suivons un cycle immuable appelé la Frame Lif
- **`Context::begin_frame()`** : acquiert la surface et renvoie la `wgpu::SurfaceTexture` (sans vue).
- **`Context::end_frame(surface_texture)`** : soumet et présente cette texture.
## Liens
- [ARCHI_APP](ARCHI_APP.md) · [ARCHI_RENDU](ARCHI_RENDU.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [ARCHI_ARENES](ARCHI_ARENES.md)
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
- Référence API : `cargo doc -p wsg-lib --no-deps`
---
## Pourquoi cette séparation est vitale
@@ -49,10 +68,11 @@ Avec notre nouvelle architecture "Atelier", la distinction est devenue encore pl
| CommandEncoder | Par-Frame | Ton "carnet de notes" temporaire pour les ordres du GPU. |
| TextureView | Par-Frame | Fenêtre temporaire sur la texture active du swapchain. |
> **Ressources GPU persistantes (single buffer) — CIBLE, non implémenté** : À l'état **visé**, les
> buffers Transform et Matrix vivent en VRAM avec un single buffer en phase initiale (le CPU écrit
> pendant `update()`, le compute shader lit au frame suivant, séquencé par `queue.submit()`), puis un
> double buffering si des artefacts apparaissent à haute fréquence. **Aucune de ces ressources n'existe
> encore dans le code** — c'est la cible GPU-driven (ROADMAP Phase 3 / ARCHI_CPU_GPU).
> **Ressources GPU persistantes (single buffer) — implémenté (Phase 3, 2026-09-22)** : les buffers
> Transform, Matrix, BBox et Indirect Draw vivent en VRAM (créés à l'initialisation du `Renderer`,
> capacité fixe de 256 slots). Le CPU écrit les transforms chaque frame par `queue.write_buffer`
> **dans le même `CommandEncoder`** que les compute passes, qui les lisent **dans la même frame**
> (l'ordre est garanti par l'encoder, pas par `queue.submit()` inter-frames). Le double buffering
> reste la **cible** si des artefacts apparaissent à haute fréquence (voir ARCHI_CPU_GPU / ARCHI_APP).
---
+60
View File
@@ -0,0 +1,60 @@
# User documentation — WSG
**Usage** documentation for the `wsg-lib` crate: how to build a 3D rendering application
without touching wgpu directly. It targets a developer with basic Rust knowledge; no prior
GPU graphics background is required.
> **Not to be confused**: these pages explain *how to use* the API. The **technical**
> documentation (internal architecture, design decisions, future targets) lives in
> [../tech/](../tech/ARCHI_APP.md), and the exhaustive API reference is generated by rustdoc
> (`cargo doc -p wsg-lib --no-deps`).
## Where to start
1. [Quickstart](quickstart.md) — your first window and your first object, in ~30 lines.
2. Then, at your own pace, depending on what you need:
| Page | Topic |
|-------|-------|
| [Meshes](meshes.md) | Geometries: procedural primitives, custom `Geometry`, entities and `Transform` |
| [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures |
| [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` |
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall |
| [Mesh & primitives](mesh.md) | Procedural generators + file import (OBJ), feature-gated |
| [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` |
| [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling |
| [Camera & input](camera-input.md) | Active camera, orbital controller, unified keyboard/mouse state |
| [Examples](examples.md) | The 7 repo examples, the advanced `manual` workflow, adding your own example |
The pages are cross-linked: each page ends with a link to the next one.
## Design principle: opt-in = zero cost
WSG follows a strict rule: **a feature you don't enable costs nothing at runtime**.
| Feature | How to enable | If NOT enabled |
|---------|--------------|----------------|
| Shadows | `scene.set_shadow_caster(Some(idx))` | No shadow map allocated, no depth pass, no PCF sampling |
| HDR + Tone mapping | `AppBuilder::with_hdr(ToneMapper::Aces)` | No offscreen texture, no TM pass, direct-to-surface render |
| GPU-driven culling | `AppBuilder::with_gpu_driven(true)` | No compute pipeline, no indirect draw buffers |
| LOD | `scene.create_mesh_with_lod(…, levels)` | Single-level mesh, no decimation, no hysteresis |
| Primitives | Cargo feature `prim-*` (default: all) | Not compiled at all |
| File import | Cargo feature `import-*` | Not compiled at all |
The distinction matters:
- **Runtime opt-in** (shadows, HDR, culling, LOD): the code is compiled into your binary
but is **completely inert** if you never call the activation method. No GPU resources are
allocated, no passes execute, no per-frame overhead. The cost of the code being in the
binary is a few KB — negligible.
- **Compile-time opt-in** (primitives, import): the code is **not compiled at all** unless
you opt in via Cargo features. This matters when you want to minimize compile time or
binary size for a minimal build.
You can mix both: build with `--no-default-features --features "prim-cube"` for a minimal
binary, then enable shadows/HDR at runtime only for the scenes that need them.
## Links
- Technical documentation (architecture): [ARCHI_APP](../tech/ARCHI_APP.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md) · [ARCHI_CPU_GPU](../tech/ARCHI_CPU_GPU.md) · [ARCHI_ARENES](../tech/ARCHI_ARENES.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md) · [DRAFT](../DRAFT.md)
- Full API reference: `cargo doc -p wsg-lib --no-deps`
+127
View File
@@ -0,0 +1,127 @@
# Camera & input
Two bricks drive the viewpoint: the scene's **active `Camera`** (view/projection matrices
built every frame) and the unified **`InputState`** (keyboard/mouse, cross-frame
semantics). The orbital **`CameraController`** bridges the two.
## 1. The active camera
The scene holds a single camera, read by the engine every frame to write the view/projection
matrices into the frame buffer (aspect recomputed from the window size).
```rust
use wsg_lib::resources::Camera;
use glam::Vec3;
app.scene.set_camera(Camera::new(
Vec3::new(3.0, 2.0, 3.0), // eye position
Vec3::ZERO, // target point
Vec3::Y, // "up" vector
));
```
- **Default**: position `(0, 0, 3)`, looking at the origin, 45° vertical fov, near 0.1,
far 100 — frames a unit cube with no tuning.
- `Camera::with_perspective(fov, near, far)` adjusts the projection (fov in radians).
- Read: `app.scene.camera()`; direct mutation: `app.scene.camera_mut()`.
- The `up` field matters: the orbital camera forces it to `+Y` (level horizon).
> The matrices use the **WebGPU** convention (NDC depth `[0,1]`) — do not replace
> `projection_matrix` with an OpenGL `[-1,1]` projection, the near part of the frustum would
> be clipped.
## 2. The orbital controller
`CameraController` represents the viewpoint in spherical coordinates around a target:
`yaw` (azimuth around +Y), `pitch` (elevation, bounded to ±~83°), `distance` (radius,
bounded to `[0.1, 100]`), `target` (target point).
```rust
use wsg_lib::resources::CameraController;
let mut ctrl = CameraController::default(); // target at origin, distance 3, front view
ctrl.orbit(dx, dy); // mouse drag: yaw/pitch (bounded pitch, no poles)
ctrl.zoom(scroll_y); // wheel: zoom (positive scroll = move closer)
ctrl.reset(); // back to the default framing
ctrl.apply_to(app.scene.camera_mut()); // write the framing into the active camera (do this EVERY frame)
```
`CameraController::from_camera(&cam)` rebuilds a controller from an existing camera
(useful to start the orbit from a manual framing).
Two public fields tune the feel of the camera (defaults in parentheses):
| Field | Meaning | Default |
|-------|---------|---------|
| `orbit_sensitivity` | radians of yaw per pixel of mouse delta | `0.005` (~110° per full window width) |
| `zoom_factor` | multiplicative distance change per wheel notch (`distance *= factor^scroll`) | `0.9` (10% per notch) |
The exact wiring snippet (orbit + zoom + reset + `1`/`2`/`3` presets, driven from
`app.input`) is in [`demo.rs`](../../lib/examples/demo.rs), `update()` section.
## 3. The unified input state
`app.input` (public field of `App`) is fed by winit events and **rotated** automatically
every frame (`begin_frame`/`end_frame` around your `update`). Three semantics per control:
| Semantics | Methods | Meaning |
|------------|----------|---------|
| **pressed** | `key_pressed(code)`, `mouse_button_pressed(btn)` | true **only** on the frame the key/button was just pressed |
| **held** | `key_held(code)`, `mouse_button_held(btn)` | true while the key/button stays down |
| **released** | `key_released(code)`, `mouse_button_released(btn)` | true **only** on the release frame |
Plus: `mouse_position() -> (f32, f32)`, `mouse_delta() -> (f32, f32)` (accumulated over the
frame, reset between frames), `scroll_delta() -> (f32, f32)` (wheel, in **line/notch units** —
`PixelDelta` events are normalized by /32 so one physical wheel notch ≈ 1.0 on every backend).
> **Button-gated orbit**: `mouse_delta()` returns movement *whenever* the mouse moves. For a
> classic arc-rotate camera, apply it only while a button is held — that is what the `demo` does:
> `if app.input.mouse_button_held(MouseButton::Left) { self.camera.orbit(dx, dy); }`.
> Free-movement orbit (no button) is also possible, just drop the condition.
`KeyCode` values are winit's physical codes (`winit::keyboard::KeyCode`); mouse buttons are
`winit::event::MouseButton`. The library does not re-export them: if your code mentions
them, add `winit = "0.30"` to your own dependencies (as the examples do). Input-less
applications (like `simple`/`cube`) don't need winit: `app.input` remains usable, only
`KeyCode` comparisons require the import.
```rust
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit (left-drag gated) + zoom driven by the mouse (excerpts from demo):
let (dx, dy) = app.input.mouse_delta();
if app.input.mouse_button_held(MouseButton::Left) {
self.camera.orbit(dx, dy);
}
let (_, sy) = app.input.scroll_delta();
self.camera.zoom(sy);
// R: reset — key_pressed fires once, not on key-repeat.
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.6;
self.camera.pitch = 0.35;
self.camera.distance = 6.5;
}
self.camera.apply_to(app.scene.camera_mut());
}
```
> **Gamepad**: the API is reserved (`InputState` will pass through `DeviceEvent`s) but not
> implemented yet — deferred, see [ROADMAP](../ROADMAP.md).
## 4. Common recipes
| Need | Recipe |
|--------|--------|
| Standard orbital camera | `CameraController` + `mouse_delta`/`scroll_delta` (snippet above) |
| FPS camera (WASD) | `key_held(KeyCode::KeyW)` in `update` → move `camera.position`/`target`; override `render()` if needed |
| Changing the orbit target | `ctrl.target = subject_position;` (following an object) |
| View presets | `key_pressed(Digit1/2/3)` → write yaw/pitch/distance (from the `demo`) |
| Tuning the camera speed | `ctrl.orbit_sensitivity = 0.003;` (slower orbit), `ctrl.zoom_factor = 0.95;` (gentler zoom) |
## Links
- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md)
- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
+47
View File
@@ -0,0 +1,47 @@
# Examples
Seven examples live in [`lib/examples/`](../../lib/examples/) and all launch with
`cargo run -p wsg-lib --example <name>`. They are **self-contained**: no assets on disk
(procedural textures, hard-coded geometries).
| Example | Command | What it shows | Corresponding page |
|---------|----------|---------------|--------------------|
| `simple` | `cargo run -p wsg-lib --example simple` | The minimal declarative workflow: a two-tone 2D quad, **unlit**, rendered automatically. The "15 lines, no wgpu" model | [Quickstart](quickstart.md), [Materials](materials.md) (§ unlit) |
| `cube` | `cargo run -p wsg-lib --example cube` | The 3D MVP: a textured (checkerboard) cube, lit (directional + point + spot), spinning | [Meshes](meshes.md), [Materials](materials.md), [Lights](lights.md) |
| `demo` | `cargo run -p wsg-lib --example demo` | The full showcase: ground + 6 primitives, textures, 3 lights, **shadows**, **orbital camera** on keyboard/mouse (left-drag = orbit, wheel = zoom, `R` = reset, `1`/`2`/`3` = presets) | [All pages](README.md) |
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Isolated shadow mapping: a cube casts a PCF-softened shadow on the ground (`clear_lights` technique → caster at index 0) | [Shadows](shadows.md) |
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Isolated spot (ambient nearly zero): the directed beam, the penumbra, the attenuation | [Lights](lights.md) |
| `manual` | `cargo run -p wsg-lib --example manual` | The **advanced** workflow: `Context`/`Renderer`/`PipelineCache` driven by hand, without the `App` facade (winit 0.30 `ApplicationHandler`) | below |
## The `manual` workflow (advanced)
When the `App` facade doesn't fit (fine-grained loop control, integration into an existing
framework, experimentation), you bypass `App` and drive directly:
- `Context` (*Manager* layer): GPU lifecycle — `Instance`/`Surface`/`Adapter`/`Device`/
`Queue`, `configure()` for the swapchain, `get_next_frame()`.
- `Renderer` (*Executor* layer): `render(view, mesh, material)` = one object per submission;
`present(frame)`.
- `PipelineCache`: `register_shader(id, path)` then `Material::new(format, id, &mut cache)`.
The window and GPU are created in winit 0.30's `resumed()` callback (`run_app` +
`ApplicationHandler`), as in `app.rs`. The reference file is
[`manual.rs`](../../lib/examples/manual.rs); the two-layer architecture is detailed in
[ARCHI_APP](../tech/ARCHI_APP.md) and [FRAME_LOOP](../tech/FRAME_LOOP.md).
> **Tip**: start with the declarative workflow. The manual workflow doesn't render more
> pixels — it gives more control over command encoding.
## Adding your own example
Repo conventions (see `lib/examples/README.md`):
1. Create `lib/examples/my_example.rs` (Cargo discovers it automatically).
2. Keep it **self-contained**: procedural textures, hard-coded geometries, no external assets.
3. Use the declarative workflow (`AppBuilder` + `Scene`) when possible.
4. Document the example in `lib/examples/README.md` (and here, `docs/user/examples.md`).
## Links
- [User README](README.md) · [Quickstart](quickstart.md) · [Camera & input](camera-input.md)
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md)
+245
View File
@@ -0,0 +1,245 @@
# GPU-driven rendering
WSG's scene rendering is **GPU-driven**: the per-entity world matrices and the indirect draw
arguments are computed on the GPU each frame, so the CPU no longer loops over entities to issue
draw calls. This page explains what that means for you, how to opt into **frustum culling**, and how the
**Level of Detail (LOD)** system works.
## What runs on the GPU
Each frame, before the render passes, two compute passes run over a fixed-capacity slot table
(256 entities, allocated once):
1. **`compute_matrices`** derives each entity's world matrix from its transform
(translation / rotation / scale). The result feeds the render pipelines as the per-entity
model matrix.
2. **`cull`** decides per-entity visibility and fills the **indirect draw arguments** (the
vertex/index count, zeroed when the entity is culled or inactive). With LOD on (the default), the
count it writes comes from the mesh's **LOD table** at the level the CPU chose for the slot this
frame — see [Level of Detail](#level-of-detail-lod).
The main and shadow render passes are then **100 % indirect**: each active slot issues one
indirect draw that reads its own count and world matrix. A culled or inactive slot has a zero
count, so its draw is a no-op. The CPU only rewrites the transform slots and the cull uniforms
each frame — it never iterates the entities to issue draws.
You do not need to do anything special to get this: `render_scene` is GPU-driven by default.
## Batching by material
The main render pass batches the draws by material: all entities sharing the same material are
drawn back to back, so the GPU pipeline and the material's texture bind group are switched **once
per distinct material**, not once per entity (the per-draw work — matrix offset, vertex/index
buffers, the indirect draw itself — is unchanged). The grouping is internal: it does not change
the rendered image and there is nothing to configure.
> **Constraint:** the batching reorders the draws, which is safe here because every pipeline in
> the engine is **opaque** (`BlendState::REPLACE`, no alpha blending) — the depth buffer resolves
> the draw order. If transparent materials are ever added, the transparent draws must be isolated
> (sorted back-to-front at the end of the pass) and must not interleave with the grouped opaque
> draws.
## Frustum culling (opt-in)
Culling is **off by default**. The culling pass still runs, but with culling disabled it marks
every active entity visible — so the rendered image is **identical** to a CPU-culled scene.
This protects you from a culling bug (an object that should be visible vanishing) becoming a
silent correctness issue.
To enable culling, build your `App` with `.with_culling(true)`:
```rust
let app = AppBuilder::new()
.title("My app")
.with_culling(true) // skip entities whose bounding sphere leaves the frustum
.build()
.await?;
```
Or toggle it at runtime on the renderer:
```rust
app.renderer().set_culling(true); // enable
app.renderer().set_culling(false); // disable again
```
## How culling works
When culling is on, each entity's **local-axis-aligned bounding box** (computed once from its
geometry, `Geometry::bbox()`) is treated as a **bounding sphere**:
- **center** = the box center, transformed by the entity's world transform (rotation +
translation; scale is folded into the radius),
- **radius** = the box's circumradius scaled by the entity's largest scale component.
The sphere is tested against the six camera frustum planes. If it is **fully outside** (beyond
a plane by more than its radius), the entity is culled; otherwise it is drawn.
The sphere is a **conservative** approximation of the box: it can draw an object that is partly
out of view (false negative), but it will **never cull an object that is actually visible**
(false positive). For tight culling you would need per-mesh sphere fitting or per-face tests,
which are out of scope for v1.
## Level of Detail (LOD)
LOD is **on by default**: distant entities automatically draw a coarser version of their mesh, so
the GPU stops spending fillrate and vertex work on detail the eye cannot see. It is a quality
feature with a performance payoff — unlike culling, it is safe to leave on because the
worst case (a level chosen too fine) is exactly what you would have drawn anyway.
### How it works
LOD is a **CPU-decided, GPU-executed** split (the one deliberate per-entity decision kept on the
CPU):
1. **Setup (once per mesh).** Each mesh can carry up to 4 levels. Levels 1..3 are generated
automatically from level 0 by **quadric edge collapse** (Garland–Heckbert,
`Geometry::generate_lod_levels`): edges are ranked by quadric error and collapsed
cheapest-first; an interior collapse merges both incident triangles (−2 faces) and remaps the
neighbours — no new face, so a **seam-free mesh stays closed** (no holes, no non-manifold
"books") and a boundary collapse removes one face; duplicate corners are welded **aware of
their attributes** (relative position tolerance 1e-6, merged only when the UVs are strictly
less than half a tile apart on both coordinates — an offset of exactly ½ is ambiguous: a wrap
seam at its widest or a legitimate half-tile jump — and the normals within ~25°; a seam or a
hard edge therefore stays a separate corner, and the weld *records* the integer-apart pairs it
refused); on a mesh with a UV seam those **seam twins are frozen** — every edge touching one
is excluded from the collapse queue — so the zero-width slit stays closed at every level, and
the rim protection (no boundary collapse while any interior edge remains) keeps the surface
geometrically complete; a survivor **moved** by a collapse gets its UV/color/normal
**blended linearly** between the collapsed endpoints (same λ as its new position — the chart
is bilinear, so the blend is the exact chart value at the new point: texture and shading stay
attached to the surface and coarsen smoothly across levels, and a seam is never crossed because
its twins are frozen, not because a blend is rejected; normals are **inherited from the
source, never recomputed**, so the lighting is identical to level 0 whatever the source's
winding), and the
levels are **packed into the mesh's single
vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry.
2. **Per frame (CPU).** For each entity, the bounding sphere used by culling is projected to screen
pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going
finer is immediate, going coarser only below 80 % of the bound (a 20 % dead band) — which is what
prevents flicker when an entity hovers around a threshold. Default thresholds: 48 px and 12 px
(bigger than 48 px → full detail; smaller than 12 px → coarsest).
3. **Per frame (GPU).** The `cull` pass reads the slot's level, looks up the matching row of the
mesh's LOD table (element-unit offsets + counts), and writes the indirect draw arguments from it.
### Using it
```rust
// One level (the default): create_mesh is unchanged.
let id = scene.create_mesh("hero", &geometry, &material)?;
// Auto-generated levels 1..3 (decimated at half, quarter, eighth the triangle count).
let id = scene.create_mesh_with_lod("hero", &geometry, &material, 4)?;
// Or supply your own levels (same attributes, same indexed-ness as level 0).
scene.add_mesh_lod("hero", 1, &my_coarse_geometry)?;
```
Toggle at runtime (off = every slot forced to level 0 = byte-identical rendering to the pre-LOD
engine — the level-0 rows carry the full-mesh counts, so nothing else changes):
```rust
app.renderer().set_lod_enabled(false);
```
### Constraint: packed LOD buffers
A level is **not a separate buffer**: the mesh's levels are concatenated into its one vertex buffer
and one index buffer, and the per-mesh LOD table stores each level's offsets/counts. Two
consequences:
- **u16 indices** → the *sum* of all levels must stay under 65 535 vertices (the scene rejects a
level set that would not fit, with a clear error);
- **at most 4 levels** per mesh (`MAX_LOD_LEVELS`, also the size of the GPU table row).
Indexed-ness: levels supplied through `add_mesh_lod` must match level 0's indexed-ness (validated).
Auto-generated levels from a **non-indexed** level 0 are indexed anyway (decimation rebuilds with
indices), and the packed buffer supports that mix — the per-slot draw command follows the level the
CPU chose (the shadow pass always uses the level-0 command, so casters stay at full detail).
### How to verify LOD with the debug dump
The debug dump (below) prints, per frame: the per-slot **levels** and each mesh's **LOD table**
(rows = `vertex_offset / vertex_count / index_offset / index_count`, element units). The clean test
is to **zoom the camera out**: the entities' perceived size drops below the thresholds, the levels
step up (0 → 1 → 2), and the indirect argument counts shrink to the corresponding rows — e.g. the
demo's 3 840-index sphere drops to 1 824, then 912 — while the levels stay **stable frame to frame**
(hysteresis holding). Verified 2026-09-23: at the demo's default distance every entity sits at
level 0 with full counts; zoomed to 4.6×, all multi-level meshes select level 1 with exactly their
L1 rows, stable across frames.
## Debugging the GPU path
If something looks wrong — a missing object, a black window — the GPU-side slot tables can be
read back and printed. The `Renderer` ships a debug helper (intentionally **not** part of the
documented API):
```rust
app.renderer().debug_dump(8); // prints the first 8 GPU slots to stderr
```
It dumps exactly what the GPU sees: the transform slots, the derived world matrices, the
indirect draw arguments, the mesh bounding boxes, the cull uniforms, the per-slot **LOD levels**
and the per-mesh **LOD tables**. A slot whose vertex count reads `0` was zeroed by the cull pass
(culled, inactive, or beyond `num_slots`); a full count means the entity is drawn — and with LOD
on, the *row* the count comes from tells you the selected level (see above). In the `demo` example the dump is opt-in via an environment
variable, so the showcase stays silent by default:
```sh
WSG_DEBUG_DUMP=120 cargo run -p wsg-lib --example demo
```
`WSG_DEBUG_DUMP=N` dumps for the first *N* frames. The demo stays **silent** when the variable is
unset; a set-but-non-numeric value (e.g. `WSG_DEBUG_DUMP=on`) gives 3 frames.
**How to verify culling is actually working** (a correct culling pass is invisible — culled
objects were off-screen anyway — so the proof is in the counts, not the image):
1. Launch the demo with `WSG_DEBUG_DUMP=120` (the demo has culling **on** and an orbiting
camera — drag the mouse to orbit).
2. Note first that orbiting/zooming this camera **cannot cull the entity ring**: the camera
always looks at the origin, so each entity's angular offset from the view axis is bounded
by `atan(ring radius / camera distance)` = `atan(1.7/6.1)` ≈ 15.5°, under the ~22° vertical
half-FOV. The seven demo entities therefore keep their **full** counts (cube `36`, sphere
`3840`, …) in every orientation — that is the expected and correct behaviour (verified
2026-09-22: 600-frame camera sweep, GPU cull verdicts matched an independent CPU sphere
test on all 6000 entity frames, zero flips on the ring).
3. To see the counts actually flip to **`0`**, you need an entity well **off the target axis**
— e.g. one placed far away so it ends up behind the near plane. Its count then toggles
`0` ↔ full as the camera orbits, while the on-axis entities stay full. (This off-axis test
is the one that verified the cull path end-to-end, positive and negative.)
4. Optional A/B: temporarily build with `.with_culling(false)` and repeat — with culling off,
every entity keeps its full count in **every** orientation (the off-axis one included).
This readback is the reference truth when a shader bug is suspected: it shows both the computed
counts and the raw inputs of the cull pass, independently of what ends up on screen. (It is how
the 2026-09-22 « black window » bug — an inverted WGSL `select` argument order — was diagnosed
and verified fixed, see the D14 note in `docs/tech/ARCHI_CPU_GPU.md`.)
## Limitations
- **Culling is all-or-nothing per entity.** There is no partial (per-triangle) culling.
- **The sphere is a coarse bound** for elongated meshes (a long thin box gets a large sphere).
If your scene is dominated by such shapes, culling may bring little gain.
- **Capacity is 256 entities per render pass.** Beyond that, extra entities are not drawn.
This is the largest a single-buffer design can address under WebGPU's two `uniform` rules: a
single `uniform` binding is capped at 64 KB, *and* a `uniform` offset must be a multiple of 256 B.
A 64-byte matrix can never be individually addressable by a `uniform` offset, so each matrix
slot is padded to 256 B — and 256 slots × 256 B = 64 KB is the maximum. It is amply generous
for a simple scene (the demo has 7).
- **Mesh bounding boxes are recomputed when meshes are added**; a scene whose mesh set changes
at runtime simply re-uploads the small bbox table (a few bytes per mesh).
- **LOD levels are packed into the mesh's own buffers**: u16 indices cap the *total* across all
levels at 65 535 vertices, and there are at most 4 levels. The decimation (quadric edge collapse
+ attribute-aware welding) is a setup-time cost only (a few ms for thousands of triangles); the
per-frame cost is one sphere projection per entity on the CPU.
- **LOD detail loss is visible by design** — the hysteresis dead band makes the pop rare and
one-directional (immediate when gaining detail, delayed when losing it), but a coarse level is
coarser. `set_lod_enabled(false)` is the escape hatch.
Culling is a **performance** feature, not a visual one: with it off you get the same image with
the indirect-draw machinery still active.
---
Next: [Examples](examples.md) · Back to [User documentation index](README.md)
+77
View File
@@ -0,0 +1,77 @@
# HDR & Tone Mapping
> **Étape 20** — Opt-in HDR rendering with tone mapping.
## What it does
By default, the WSG renderer draws directly to the window's sRGB surface. Color values
above 1.0 are **clipped** (saturated to white) — you lose all information in bright areas.
When HDR is enabled, the pipeline becomes:
```
Main pass → offscreen Rgba16Float texture (unbounded float)
TM pass → fullscreen triangle samples HDR texture, applies curve, writes to sRGB surface
```
The tone mapping **compresses** the [0, ∞) range to [0, 1] with a perceptual curve,
so bright areas are smoothly rolled off instead of clipping.
## Enabling HDR
```rust
use wsg_lib::core::ToneMapper;
use wsg_lib::app::AppBuilder;
let app = AppBuilder::new()
.title("My HDR App")
.with_hdr(ToneMapper::Aces) // ← enables HDR
.build()
.await?;
```
Without `.with_hdr(...)`, the renderer operates in LDR mode (direct to surface, zero overhead).
## Tone mapping curves
| Variant | Curve | Use case |
|---------|-------|----------|
| `ToneMapper::Aces` | ACES Filmic (Narkowicz 2015) | Cinematic look, soft highlight rolloff, good contrast |
| `ToneMapper::Reinhard` | `x / (1 + x)` | Simple, flat; less contrast but computationally trivial |
The curve is **compiled into the pipeline** at construction time (one WGSL entry point
per variant) — there is no runtime branching cost.
## Cost
| HDR state | Extra per-frame cost |
|-----------|---------------------|
| Disabled (default) | **Zero** — no texture, no pass, no pipeline |
| Enabled | +1 fullscreen render pass (triangle, 3 verts) + 1 offscreen texture (same size as window) |
The extra pass is negligible on any GPU (a few hundred microseconds). The offscreen
texture costs ~12 bytes/pixel of VRAM (RGBA16F = 8 bytes/px + the surface's own buffer).
## How it works (technical)
- **Offscreen texture**: `Rgba16Float`, same size as the window. Created in `Renderer::new`,
recreated on resize.
- **Main pass**: the color attachment targets the HDR texture instead of the surface.
The `standard_shader.wgsl` fragment output (linear float, unbounded) is stored as-is.
- **TM pass**: a fullscreen triangle (3 vertices, no vertex buffer) samples the HDR texture,
multiplies by exposure (currently fixed at 1.0), applies the tone curve, and writes to
the sRGB surface. The hardware performs the linear→sRGB gamma conversion automatically
(the surface format is `Rgba8UnormSrgb`).
- **No double gamma**: the shader outputs linear [0,1]; the sRGB surface encoding is
handled by the rasterizer.
## Exposure
Currently fixed at 1.0 (no user control yet). A future step will expose an
`exposure` field in a `HdrConfig` struct for live adjustment.
## See also
- [Shadows](shadows.md) — the other opt-in visual feature
- [GPU-driven rendering](gpu-driven.md) — the compute pipeline that feeds the main pass
- [Examples](examples.md) — the `demo` example enables HDR by default
+84
View File
@@ -0,0 +1,84 @@
# Lights
Lights are **scene-global**: a single list is packed into the frame uniforms every frame, and
**all** entities receive their lighting (per-material lights are out of the current scope).
## Model
- Bounded capacity: **`MAX_LIGHTS = 8`** lights in total (directional + point + spot
combined). Adding beyond that returns an error.
- **Default**: one white directional light along **+Z** (from the surface point toward the
light) + white ambient. This default exactly reproduces the historical single-light
rendering — your scene "just works" with no configuration.
- Ambient (`set_ambient`) is a global hemispherical term, independent of the lights.
## Adding lights
```rust
use glam::Vec3;
// Directional: `dir` points FROM the surface point TOWARD the light.
app.scene
.add_directional_light(Vec3::new(1.0, 1.2, 1.0).normalize(), [1.0, 0.98, 0.92], 1.5)
.unwrap();
// Point: world position, tint, intensity, attenuation radius (linear down to 0).
app.scene
.add_point_light(Vec3::new(0.5, 1.6, 1.8), [1.0, 0.7, 0.3], 1.2, 6.0)
.unwrap();
// Spot: position, cone axis (FROM the light TOWARD the scene), tint, intensity, radius,
// half-angle in radians (penumbra smoothed at the edge).
app.scene.add_spot_light(
Vec3::new(-2.5, 2.2, 1.0), // position
Vec3::new(2.5, -2.2, -1.0).normalize(), // axis, toward the scene
[0.3, 1.0, 0.5], // green tint
1.4, 8.0, 0.45, // intensity, radius, half-angle (~26°)
).unwrap();
```
These three calls are the ones in the [`demo`](../../lib/examples/demo.rs) example;
[`cube.rs`](../../lib/examples/cube.rs) shows a point + a spot on top of the default
directional, and [`spot_test.rs`](../../lib/examples/spot_test.rs) isolates a single spot
(ambient nearly zero).
Global settings:
| Method | Effect |
|---------|--------|
| `set_ambient([r, g, b])` | hemispherical ambient color (default white) |
| `clear_lights()` | empties the list — only ambient will light the scene (useful for a flat look without switching to unlit) |
| `set_lights(Lights)` | replaces the whole list (batch reset) |
| `lights()` | reads the current list |
## ⚠️ Packed indices (important for shadows)
Lights are stacked in the GPU array **by type, in order**:
```
index 0 .. n_dir-1 : directional
index n_dir .. +n_point-1 : point
index … .. +n_spot-1 : spot
```
Two consequences:
1. **Index 0 is the default +Z directional** (the one `Lights::new()` pre-loads),
not your first added light. This is a classic pitfall — see
[Shadows](shadows.md).
2. If you want **your** light to be the only one (and thus at index 0), clear the list
first: `app.scene.clear_lights();` then `add_*_light(…)` (this is the technique in
[`shadow_test.rs`](../../lib/examples/shadow_test.rs)).
## Intensities and tints
- `color` is an RGB in `[0..1]`; `intensity` is an unbounded multiplier.
- Local lights (point/spot) attenuate **linearly** — intensity drops to zero at `radius`.
Beyond the radius, the light contributes nothing.
- The `standard` shader accumulates ambient + all lights (no mutual occlusion between
lights; the spot cone culling happens at the fragment).
## Links
- [User README](README.md) · [Shadows](shadows.md) · [Materials & textures](materials.md)
- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
+100
View File
@@ -0,0 +1,100 @@
# Materials & textures
A **`Material`** describes a mesh's appearance: it references a shader (by id) and
optionally a **diffuse texture**. Several materials pointing at the same shader share the
same compiled GPU pipeline (the `PipelineCache` held by the scene).
The engine ships a single shader: **`standard`** — multi-light Phong lighting (see
[Lights](lights.md)), with an **unlit** mode for flat rendering.
## 1. Registering the shader
```rust
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
```
> **Note**: `STANDARD_SHADER_PATH` points to an optional file on disk; if it is missing
> (the normal case for the embedded library), loading falls back to the shader **embedded at
> compile time** (`include_str!`, byte-identical). The fallback message you may see is
> therefore **expected and harmless**.
For a custom shader: register your `.wgsl` file path under an id of your choice (it must
expose the same bind groups as `standard` — frame @0, object @1, texture @2, shadow @3 — see
[ARCHI_RENDU](../tech/ARCHI_RENDU.md) and the
[`shaders/standard_shader.wgsl`](../../lib/src/shaders/standard_shader.wgsl) file).
## 2. Creating materials
```rust
// Textureless material: the color comes from per-vertex colors (or white by default).
app.scene.add_material_shader("mat", "standard").unwrap();
// Textured material: the texture must first be registered in the scene (below).
app.scene.add_material_texture("mat_textured", "standard", "my_texture").unwrap();
```
Binding a material to a mesh happens at mesh creation (see [Meshes](meshes.md)):
```rust
app.scene.create_mesh("cube_mesh", cube(1.0), Some("mat_textured")).unwrap();
```
A mesh created with `material = None` is rendered with the scene's **default material**
(`standard`, built once then cached) — that is the behavior of the
[`simple`](../../lib/examples/simple.rs) example.
## 3. Diffuse textures
`Texture` is a GPU image in `Rgba8UnormSrgb` (linear sampler, repeat addressing).
Four constructors:
| Constructor | Usage |
|--------------|-------|
| `Texture::from_rgba8(device, queue, w, h, rgba, label)` | raw RGBA8 bytes (procedural) |
| `Texture::from_bytes(device, queue, label, bytes)` | encoded data (PNG/JPEG… via the `image` crate) |
| `Texture::from_file(device, queue, label, path)` | image file on disk |
| `Texture::white_placeholder(device, queue)` | 1×1 white — used internally when a material has no texture |
You get `device`/`queue` in `setup()` via `app.context()`:
```rust
let (device, queue) = {
let ctx = app.context();
(ctx.device.clone(), ctx.queue.clone())
};
let texture = Texture::from_rgba8(&device, &queue, 8, 8, &my_rgba, "checker").unwrap();
app.scene.add_texture("checker_texture", texture).unwrap();
app.scene.add_material_texture("ground_mat", "standard", "checker_texture").unwrap();
```
The exact snippet (8×8 checkerboard + stripes generation) is in
[`demo.rs`](../../lib/examples/demo.rs) and [`cube.rs`](../../lib/examples/cube.rs).
Two conditions for a texture to show up:
1. the material is created via `add_material_texture` (otherwise the 1×1 white placeholder
is bound — no visual effect, no regression);
2. the `Geometry` carries **UVs** (`.with_uvs(…)`). Without UVs, sampling is constant.
The procedural primitives (`uv_sphere`, `cube`, …) already provide them.
## 4. Unlit mode (flat / 2D rendering)
"Flat" rendering (vertex colors as-is, no lighting) is a **renderer switch**, not a material:
```rust
app.renderer_mut().set_unlit(true); // in setup()
```
This is the mode of the `simple` example (2D quad). In this mode the scene's lights are
ignored; per-vertex colors (or white) are rendered directly. 2D is a special case of 3D:
the single `standard` pipeline serves both.
> `clear_lights()` (see [Lights](lights.md)) gives a similar result but keeps the lit
> pipeline: only ambient stays active. Use it when you want to "turn off the lights" without
> switching to unlit.
## Links
- [User README](README.md) · [Meshes](meshes.md) · [Lights](lights.md) · [Examples](examples.md)
- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
+108
View File
@@ -0,0 +1,108 @@
# Module `mesh` — Sources de géométrie
Le module `wsg::mesh` est le point d'entrée unique pour **d'où vient la géométrie** :
générateurs procéduraux ou import de fichiers.
## Primitives procédurales
Chaque famille de primitives est derrière une **feature** — vous ne compilez que ce dont vous avez besoin.
| Feature | Fonction | Description |
|---------|----------|-------------|
| `prim-cube` | `cube(size)` | Cube centré, 24 sommets, normales par face |
| `prim-plane` | `plane(w, d, seg_x, seg_z)` | Plan horizontal XZ (normale +Y), subdivisé |
| `prim-sphere` | `uv_sphere(r, sectors, stacks)` | Sphère lat/long, normales lisses |
| `prim-sphere` | `icosphere(r, subdivisions)` | Icosphère (subdiv icosahedron) |
| `prim-cylinder` | `cylinder(r, h, sectors)` | Cylindre (côté + caps), normales analytiques |
| `prim-cone` | `cone(r, h, sectors)` | Cône (apex + base fermée) |
| `prim-torus` | `torus(major, minor, seg_maj, seg_min)` | Tore, normales lisses |
### Features par défaut
```toml
# Cargo.toml de votre projet
[dependencies]
wsg-lib = { path = "../lib" }
# Default: toutes les primitives activées (all-prims)
```
```toml
# Ne compiler que le cube et la sphère :
wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube", "prim-sphere"] }
```
### Usage
```rust
use wsg_lib::prelude::*;
let cube = cube(2.0);
let sphere = uv_sphere(1.0, 32, 16);
let ico = icosphere(1.0, 2);
// Tous retournent un Geometry (positions + normals + UVs + indices)
assert_eq!(cube.positions.len(), 24);
```
## Import de fichiers
| Feature | Fonction | Format |
|---------|----------|--------|
| `import-obj` | `load_obj(path)` / `parse_obj(str)` | Wavefront OBJ |
| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB (stub) |
### Parser OBJ
Supporte : `v`, `vn`, `vt`, `f` (3-4 sommets, triangulation en éventail).
Si le fichier n'a pas de normales, elles sont **calculées** (pondération par aire).
```rust
use wsg_lib::mesh::{load_obj, parse_obj};
// Depuis un fichier
let geom = load_obj("model.obj")?;
// Depuis une string
let geom = parse_obj("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n")?;
```
### Erreurs
```rust
use wsg_lib::mesh::import::MeshImportError;
match load_obj("missing.obj") {
Ok(geom) => { /* … */ }
Err(MeshImportError::Io(e)) => eprintln!("fichier inaccessible: {e}"),
Err(MeshImportError::Parse(e)) => eprintln!("syntaxe invalide: {e}"),
Err(MeshImportError::Unsupported(e)) => eprintln!("feature non supportée: {e}"),
}
```
## De `Geometry` à la scène
Le module `mesh` produit des `Geometry` (données CPU). Pour les rendre,
passez par `Scene::create_mesh` qui les transfère en GPU :
```rust
use wsg_lib::prelude::*;
use wsg_lib::mesh::cube;
// Dans AppHandler::setup :
let geom = cube(1.0);
app.scene.create_mesh("my_mesh", geom, Some("my_mat"))?;
app.scene.add_entity("my_entity", "my_mesh")?;
```
## Example
```sh
cargo run -p wsg-lib --example import --features import-obj -- model.obj
```
## Convention
- **Y-up**, origine centrée (sauf `plane` : plan XZ à y=0)
- Normales **sortantes**
- UVs dans [0,1]²
- Winding **CCW** (face avant)
+125
View File
@@ -0,0 +1,125 @@
# Meshes: geometries, entities and transforms
A displayed object in WSG goes through three levels:
```
Geometry (CPU, source of truth) ──► Mesh (GPU: vertex/index buffers) ──► Entity (placement in the scene)
```
- **`Geometry`**: raw CPU-side data — positions + optional normals/UVs/colors/indices.
- **`Mesh`**: GPU container (buffers uploaded once). It **retains** its `Arc<Geometry>` on the
CPU side, along with its material.
- **`Entity`**: a `mesh + Transform` association. This is the unit the engine draws. The same
`Mesh` can be shared by several entities (each with its own `Transform`).
## 1. Procedural primitives (the shortest path)
The `math::primitives` module provides ready-to-use `Geometry` generators
(positions + normals + UVs + indices):
| Function | Parameters | Result |
|----------|-----------|--------|
| `cube(size)` | side length | origin-centered cube, per-face normals |
| `plane(width, depth, seg_x, seg_z)` | dimensions + subdivisions | horizontal plane (Y-up), UVs |
| `uv_sphere(radius, sectors, stacks)` | radius + resolution | UV sphere (seam visible) |
| `icosphere(radius, subdivisions)` | radius + subdivisions | smooth sphere (normalized, seam-free) |
| `cylinder(radius, height, sectors)` | radius, height, resolution | centered cylinder |
| `cone(radius, height, sectors)` | radius, height, resolution | cone (base at the bottom when translated in Y) |
| `torus(major, minor, major_segments, minor_segments)` | radii + resolution | torus |
```rust
use wsg_lib::math::{cube, icosphere, torus};
app.scene.create_mesh("cube_mesh", cube(0.8), Some("solid_mat")).unwrap();
app.scene.create_mesh("sphere_mesh", icosphere(0.5, 2), Some("solid_mat")).unwrap();
```
## 2. Custom `Geometry` (your own mesh)
`Geometry` is a builder: positions are mandatory, everything else is optional
(sensible defaults are applied at upload — e.g. normal `[0,0,1]`, white color).
```rust
use wsg_lib::resources::Geometry;
let geometry = Geometry::new(vec![
[-0.5, 0.5, 0.0],
[ 0.5, 0.5, 0.0],
[ 0.5, -0.5, 0.0],
[-0.5, -0.5, 0.0],
])
.with_normals(vec![[0.0, 0.0, 1.0]; 4]) // required for lighting (Phong)
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0],
[0.0, 1.0, 0.0, 1.0],
[0.0, 0.0, 1.0, 1.0],
[1.0, 1.0, 0.0, 1.0],
])
.with_indices(vec![0, 1, 2, 0, 2, 3]); // triangulation (without indices: triangle list)
```
Other attributes: `.with_uvs(vec![[u, v], …])` (required for textures — see
[Materials & textures](materials.md)). `geometry.validate()` checks the arrays for
consistency (aligned lengths, indices in range) before upload.
> **Indices**: `Vec<u16>` — a custom mesh must therefore stay under 65,536 vertices. The
> engine's primitives respect this limit.
## 3. Registering in the scene
```rust
// The mesh is built (GPU buffers) and bound to its material in one call.
// `material = None`: the scene will use its default material (`standard`) at render time.
app.scene.create_mesh("cube_mesh", geometry, Some("cube_material"))?;
// The entity references the mesh by its id (String IDs).
app.scene.add_entity("cube", "cube_mesh")?;
// …or with an explicit placement:
app.scene.add_entity_with_transform("cube", "cube_mesh", transform)?;
```
All these methods return `Result<_, String>` (unifying the typed errors is on the
horizon — see [ROADMAP](../ROADMAP.md)).
## 4. Moving / animating: the `Transform`
Placement lives on the **entity** (not on the mesh): `Transform { translation: Vec3,
rotation: Quat, scale: Vec3 }`, converted to a world matrix by the engine every frame.
The snippet below is the animation from the [`cube`](../../lib/examples/cube.rs) example:
```rust
fn update(&mut self, app: &mut wsg_lib::App) {
self.angle += 0.02;
let mut tf = *app.scene.entity_transform("cube").expect("entity present");
tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.3);
app.scene.set_entity_transform("cube", tf);
}
```
Other entity operations: `entity_transform(label)` (read), `remove_entity(label)` (hides
without freeing resources), `entity_count()`.
> **Rotation order**: `Quat` does not commute — `rot_y * rot_x` is not `rot_x * rot_y`.
> The order above (Y then X) gives a readable "top spinning" motion.
## 5. Mesh sharing
Create **one** mesh per geometry and as many entities as occurrences:
```rust
app.scene.create_mesh("rock_mesh", icosphere(0.3, 1), Some("rock_mat")).unwrap();
for i in 0..10 {
let label = format!("rock_{i}");
let mut tf = Transform::identity();
tf.translation = Vec3::new(i as f32 * 0.8, 0.15, 0.0);
app.scene.add_entity_with_transform(&label, "rock_mesh", tf).unwrap();
}
```
The GPU buffers are uploaded only once; only the world matrices differ.
## Links
- [User README](README.md) · [Quickstart](quickstart.md) · [Materials & textures](materials.md) · [Lights](lights.md)
- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
+131
View File
@@ -0,0 +1,131 @@
# Quickstart
Goal: a window showing an object, with the render loop handled by the library. You will only
write three things: a struct implementing `AppHandler`, your scene declaration in `setup()`,
and your `main()`.
## Prerequisites
- A recent Rust toolchain (the library is **edition 2024** — run `rustup update` if needed).
- A windowing environment (X11/Wayland on Linux, or native macOS/Windows).
- WSG is **not published on crates.io**: it is consumed by file path.
## 1. Dependencies
In your application's `Cargo.toml`:
```toml
[dependencies]
wsg-lib = { path = "/path/to/wsg/lib" }
pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (AppBuilder is async)
```
## 2. The minimal application
This snippet is the [`simple`](../../lib/examples/simple.rs) example from the repo, almost
verbatim: a flat two-tone quad, rendered automatically every frame.
```rust
use wsg_lib::app::AppBuilder;
use wsg_lib::resources::Geometry;
use wsg_lib::utils::WsgError;
use wsg_lib::AppHandler;
struct MyQuad;
impl AppHandler for MyQuad {
fn setup(&mut self, app: &mut wsg_lib::App) {
// Flat 2D: the `standard` shader in unlit mode returns the vertex color as-is.
app.renderer_mut().set_unlit(true);
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
let geometry = Geometry::new(vec![
[-0.5, 0.5, 0.0],
[ 0.5, 0.5, 0.0],
[ 0.5, -0.5, 0.0],
[-0.5, -0.5, 0.0],
])
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0], // red
[0.0, 1.0, 0.0, 1.0], // green
[0.0, 0.0, 1.0, 1.0], // blue
[1.0, 1.0, 0.0, 1.0], // yellow
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
// `None`: the scene injects its default material (`standard`) at render time.
app.scene.create_mesh("quad_mesh", geometry, None).unwrap();
app.scene.add_entity("quad", "quad_mesh").unwrap();
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Simple").build().await?;
app.run(MyQuad)
}
```
Note: **no `wgpu` or `winit` imports** — the `App` facade encapsulates them entirely.
## 3. What the library does for you
The full lifecycle, as driven by `App::run` (technical details in
[FRAME_LOOP](../tech/FRAME_LOOP.md)):
```
AppBuilder::build() creates the event loop
│
App::run(handler) starts the loop
│
resumed (winit) window + GPU (Instance/Surface/Adapter/Device/Queue) + Renderer
│
handler.setup(&mut app) ← you declare the scene here (once, GPU ready)
│
▼ per frame, in a loop:
input.begin_frame() current frame's keyboard/mouse state
handler.update(&mut app) ← your logic (motion, input, …)
input.end_frame()
handler.render(app, frame) ← default: app.render_scene(frame.view())
│ (the whole scene is drawn automatically, one pass per frame)
└─ present → next frame
```
So you implement:
| Hook | When | Role | Default |
|------|-------|------|---------|
| `setup(&mut self, app)` | once, GPU ready | declare shaders, materials, textures, meshes, entities, lights, camera | empty |
| `update(&mut self, app)` | every frame, before render | animate: transforms, input, lights… | empty |
| `render(&mut self, app, frame)` | every frame, after update | **default**: draws the whole scene; override for custom rendering | `app.render_scene(frame.view())` |
Golden rule: **mutate the scene in `update()`** (and `setup()`), only read it in `render()`
(model detailed in [ARCHI_RENDU](../tech/ARCHI_RENDU.md)).
## 4. Running it
From the WSG repo root (the examples live in `lib/examples/`):
| Command | What you see |
|----------|--------------|
| `cargo run -p wsg-lib --example simple` | the quad above (flat 2D, unlit) |
| `cargo run -p wsg-lib --example cube` | a textured, lit, spinning cube (3D) |
| `cargo run -p wsg-lib --example demo` | the full showcase: 6 primitives + lights + shadows + orbital camera |
For your own application: create a crate, add the §1 dependency, paste the §2 code into
`src/main.rs`, and `cargo run`.
## 5. Where to go next
- Want a 3D object? → [Meshes](meshes.md)
- Want to change the look / add a texture? → [Materials & textures](materials.md)
- Want lights? → [Lights](lights.md)
- Want to see everything at once? → the `demo` example ([Examples](examples.md))
## Links
- [User README](README.md) · [Meshes](meshes.md) · [Examples](examples.md)
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
+79
View File
@@ -0,0 +1,79 @@
# Shadows (shadow mapping)
Shadows are **off by default** and are enabled by designating **a single** casting light:
```rust
app.scene.set_shadow_caster(Some(index)); // packed index — see the pitfall below
app.scene.set_shadow_caster(None); // shadows off (default)
```
Only a **directional or spot** light can cast shadows. A **point** light index disables the
shadow pass (cubemap shadows are out of scope).
## ⚠️ The packed-index pitfall
`set_shadow_caster` takes the light's index **in the packed array** (directionals first,
then point, then spot — recalled in [Lights](lights.md)).
**Index 0 is the default +Z directional** pre-loaded by `Lights::new()`, not necessarily
your light. Symptom of a wrong index: the shadow camera looks in an unexpected direction and
misaligned objects occlude each other (blackened objects, ghost shadows).
Two ways to avoid it:
1. **Clear the list before adding yours** — your light becomes index 0:
```rust
app.scene.clear_lights(); // removes the default +Z
app.scene.add_directional_light(dir, [1.0, 0.98, 0.92], 1.6).unwrap();
app.scene.set_shadow_caster(Some(0)); // now it really is YOUR light
```
This is the technique in [`shadow_test.rs`](../../lib/examples/shadow_test.rs).
2. **Count the indices** — if you keep the default light and add yours, it lands at index 1:
```rust
app.scene.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.5).unwrap(); // → index 1
app.scene.set_shadow_caster(Some(1)); // this is the demo's warm light that casts
```
This is the technique in [`demo.rs`](../../lib/examples/demo.rs).
## How it works (to understand the limits)
Each frame, if a caster is active, the engine runs **two passes** (technical details in
[FRAME_LOOP](../tech/FRAME_LOOP.md)):
1. **Shadow pass**: the scene is rendered as seen *from the light* (depth-only
`shadow_shader.wgsl` shader) into a 1024² `Depth32Float` shadow map (size configurable
via `SHADOW_MAP_SIZE`), with a depth bias (slope-scaled + constant) to avoid shadow acne.
2. **Color pass**: the `standard` fragment shader re-projects each fragment into light space
and compares its depth against the map via a **3×3 PCF** (softened shadow edges).
Things to know:
- **Directional light**: the shadow frustum is orthographic, centered on the scene center
(`SHADOW_SCENE_CENTER`, radius `SHADOW_SCENE_RADIUS = 5.0` by default). Objects **far from
the origin** may fall outside the frustum and stop casting.
- **Spot light**: the light's cone naturally bounds the shadow.
- Only one light casts at a time (no multi-light shadows).
- Shadows only affect meshes rendered by `standard` in lit mode — a renderer in unlit mode
(see [Materials & textures](materials.md)) receives none.
## Tuning shadow rendering
The constants `SHADOW_MAP_SIZE`, `SHADOW_DEPTH_BIAS`, `SHADOW_SCENE_RADIUS`,
`SHADOW_SCENE_CENTER` are exposed in `wsg_lib::utils` (defaults: 1024, 0.006, 5.0, origin).
Tuning tips:
- **Speckled shadow edges (acne)**: raise the bias.
- **Peter-panning** (shadow detached from the object): lower the bias.
- **Shadow clipped at the scene edge**: raise the frustum radius (directional).
- **Shadows too blurry, want them crisper**: raise the map size.
## Links
- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md)
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
+22 -5
View File
@@ -6,12 +6,29 @@ edition = "2024"
[lib]
path = "src/lib.rs"
[features]
default = ["all-prims"]
# Primitives procédurales (zéro dep externe)
prim-cube = []
prim-plane = []
prim-sphere = []
prim-cylinder = []
prim-cone = []
prim-torus = []
all-prims = ["prim-cube", "prim-plane", "prim-sphere", "prim-cylinder", "prim-cone", "prim-torus"]
# Import de fichiers
import-obj = []
import-gltf = []
[dependencies]
wgpu = "30.0.0" # Vérifiez la version la plus récente
winit = "0.29" # For window management — pinned to match examples
winit = "0.30.13" # For window management — pinned to match examples
thiserror = "2"
bytemuck = { version = "1.25.0", features = ["derive"] }
glam = "0.33"
[dev-dependencies]
pollster = { version="0.4.0", features = ["macro"] }
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"] }
+23
View File
@@ -0,0 +1,23 @@
# Examples
Each `.rs` file in this directory is a **standalone example** auto-discovered by Cargo
(`cargo build -p wsg-lib --examples`). To run an example:
```bash
cargo run -p wsg-lib --example <name>
```
| Example | Command | Description |
|---------|---------|-------------|
| `demo` | `cargo run -p wsg-lib --example demo` | **Showcase**: one of each primitive, procedural textures, directional + point + spot lights, a shadow-casting light, and a live orbital camera (drag / wheel zoom / `R` reset / `1`-`3` presets). |
| `simple` | `cargo run -p wsg-lib --example simple` | Flat unlit quad (minimal declarative workflow, `AppBuilder` + auto scene). |
| `cube` | `cargo run -p wsg-lib --example cube` | Textured cube (procedural checker) lit by a directional + point + spot light. |
| `manual` | `cargo run -p wsg-lib --example manual` | Low-level workflow: `Context`, `Renderer`, `PipelineCache`, `Mesh` used directly (no `App` facade). |
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Spot-light isolation: only one spot is on (near-zero ambient), cube rotates on two axes so the oriented beam is clearly visible. |
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Shadow mapping: one directional light is the shadow caster (`set_shadow_caster(Some(0))`); a cube casts a PCF-softened shadow onto a thin ground slab. |
## Conventions
- Examples are **self-contained**: no assets loaded from disk (procedural textures, hardcoded geometry).
- They use the declarative workflow (`AppBuilder` + `Scene`) except `manual`, which bypasses the `App` facade.
- When adding a new example: create a `.rs` file in this directory, document it here, and reference it in the root README if appropriate.
+111
View File
@@ -0,0 +1,111 @@
//! A lit unit cube that rotates, **textured** with a procedural checkerboard via the diffuse path
//! (bind group `@group(2)`).
//!
//! A 3D mesh with Phong lighting on screen — the library's 3D showcase.
//! Follows the declarative workflow (like `simple`): `AppBuilder` + automatic scene, **no wgpu import**.
//! The scene owns its `PipelineCache`: go through `register_shader` +
//! `add_material_shader`/`add_material_texture` + `create_mesh` + `add_entity`. The mesh
//! is declared from a **`Geometry`** (positions, normals, indices). A texture is
//! registered by id (`add_texture`) and a textured material bound to it (`add_material_texture`);
//! the texture is generated *procedurally* (RGBA 8×8 checkerboard) to stay self-contained, no on-disk asset.
//! The default active camera (`Scene::default`, position (0,0,3), fov 45°) frames the cube, and
//! `AppHandler::update` rotates the entity via `set_entity_transform` each frame.
use glam::{Quat, Vec3};
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::mesh::cube;
use wsg_lib::resources::Texture;
use wsg_lib::utils::WsgError;
/// Demo handler: rotates the textured cube in `update`.
struct Cube {
/// Cumulative rotation angle (radians), incremented each frame.
angle: f32,
}
/// Generates a *procedural* RGBA 8×8 checkerboard (white/brick), no on-disk asset, to texture the
/// cube. Returned as a raw RGBA8 `Vec<u8>`, loadable 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) {
// Phong shader `standard` (carries the frame + object + texture bind groups).
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Builds the checkerboard texture with the Context's device/queue (via `app.context()`), then
// registers it in the scene by id; a textured material is then bound to that 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_texture("cube_material", "standard", "checker_texture")
.unwrap();
app.scene
.create_mesh("cube_mesh", cube(1.0), Some("cube_material"))
.unwrap();
app.scene.add_entity("cube", "cube_mesh").unwrap();
// In addition to the default directional light (+Z), a warm **point** light
// is added in front of the cube. Its halo (linear attenuation over the
// radius) is visible on the near face of the cube, on top of the directional lighting.
app.scene
.add_point_light(
Vec3::new(1.0, 0.5, 1.5), // world position, in front/right of the cube
[1.0, 0.7, 0.3], // warm tint
1.0, // intensity
3.0, // attenuation radius
)
.unwrap();
// A green **spot** light aimed at the cube from the left.
// The cone (half-angle ~20°) projects a directed beam onto the cube's faces, with a
// smoothed penumbra at the edge and linear attenuation over the radius.
app.scene
.add_spot_light(
Vec3::new(-2.0, 1.0, 1.5), // world position, left/above/behind the camera
Vec3::new(2.0, -1.0, -1.5).normalize(), // cone axis, toward the cube (origin)
[0.3, 1.0, 0.4], // green tint
1.2, // intensity
4.0, // attenuation radius
0.35, // half-angle (~20°) in radians
)
.unwrap();
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Cumulative cube rotation (double axis for a more readable motion).
self.angle += 0.02;
let base = *app
.scene
.entity_transform("cube")
.expect("cube entity present");
let mut transform = base;
transform.rotation =
Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.3);
app.scene.set_entity_transform("cube", transform);
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Cube").build().await?;
app.run(Cube { angle: 0.0 })
}
+291
View File
@@ -0,0 +1,291 @@
//! **WSG `demo`** — the final showcase example.
//!
//! Combines everything built throughout the library into one declarative scene:
//!
//! * a **ground plane** plus one of each procedural primitive from `math::primitives`
//! (`cube`, `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) placed around it,
//! * a **procedural texture** per mesh (checker / stripe grids, no assets on disk),
//! * the **standard** Phong material wired to those textures,
//! * an **orbital camera** driven live by the unified input state:
//! hold the **left mouse button** and drag to orbit (yaw/pitch), the wheel zooms (distance),
//! * `R` resets the view, keys `1`/`2`/`3` jump to front / side / top presets,
//! * a **directional** light (the shadow caster) + a **point** light + a **spot** light,
//! so the shadow of the cube and the colored light halos are all visible,
//! * the primitives slowly rotate in `update`, so depth, lighting and shadows read clearly,
//! * **LOD** (Step 19): the rounded primitives are created with three levels each
//! (`create_mesh_with_lod`, auto-decimated by halving targets); the CPU picks each entity's
//! level from its projected screen size (with hysteresis) — zoom in/out with the wheel and
//! the sphere/cylinder/cone/torus visibly lose detail as they shrink on screen.
//! * **HDR + Tone Mapping** (Étape 20): the demo enables ACES Filmic tone mapping via
//! `AppBuilder::with_hdr(ToneMapper::Aces)`. The main pass renders to an offscreen
//! `Rgba16Float` texture, then a fullscreen TM pass compresses it to [0,1] and writes
//! to the sRGB surface — highlights are softly rolled off instead of clipping to white.
//!
//! Doc (this header) follows the English convention used for examples; internal comments stay
//! concise and French where helpful. Run with:
//!
//! `cargo run -p wsg-lib --example demo`
use glam::{Quat, Vec3};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::core::ToneMapper;
use wsg_lib::core::Transform;
use wsg_lib::mesh::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
use wsg_lib::resources::{CameraController, Texture};
use wsg_lib::utils::WsgError;
/// Generates an 8×8 RGBA checkerboard (white / brick) as raw bytes for `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 { (235, 235, 228) } else { (150, 90, 70) };
rgba.extend_from_slice(&[r, g, b, 255]);
}
}
rgba
}
/// Generates a vertical stripe texture (blue / cyan), useful to make rotation visible on rounded
/// bodies (sphere / cylinder) via the UV seams.
fn stripes_rgba() -> Vec<u8> {
const W: u32 = 32;
const H: u32 = 16;
let mut rgba = Vec::with_capacity((W * H * 4) as usize);
for _y in 0..H {
for x in 0..W {
let band = (x / 4) % 2 == 0;
let (r, g, b) = if band { (40, 90, 190) } else { (120, 210, 235) };
rgba.extend_from_slice(&[r, g, b, 255]);
}
}
rgba
}
/// Demo handler: holds the orbital controller plus a slow rotation angle.
struct Demo {
camera: CameraController,
angle: f32,
/// Phase 3 black-window investigation: number of debug_dump calls already made.
dbg: u32,
}
/// Horizontal radius at which the primitives sit around the origin.
const ORBIT_RADIUS: f32 = 1.7;
/// Vertical offset so the meshes stand on the ground plane (y = 0).
const STAND_HEIGHT: f32 = 0.5;
/// Lays out one primitive (already scaled/positioned) at an angle around the origin.
fn place(label: &str, mesh: &str, app: &mut wsg_lib::App, index: usize) {
let a = index as f32 / 6.0 * std::f32::consts::TAU;
let mut tf = Transform::identity();
tf.translation = Vec3::new(a.cos() * ORBIT_RADIUS, STAND_HEIGHT, a.sin() * ORBIT_RADIUS);
tf.rotation = Quat::from_rotation_y(a); // face the center
app.scene
.add_entity_with_transform(label, mesh, tf)
.unwrap();
}
impl AppHandler for Demo {
fn setup(&mut self, app: &mut wsg_lib::App) {
// 1. Shader + material base.
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
let (device, queue) = {
let ctx = app.context();
(ctx.device.clone(), ctx.queue.clone())
};
// 2. Procedural textures, one material per pattern.
let checker =
Texture::from_rgba8(&device, &queue, 8, 8, &checkerboard_rgba(), "checker").unwrap();
app.scene.add_texture("checker_texture", checker).unwrap();
app.scene
.add_material_texture("ground_mat", "standard", "checker_texture")
.unwrap();
app.scene
.add_material_texture("solid_mat", "standard", "checker_texture")
.unwrap();
let stripes =
Texture::from_rgba8(&device, &queue, 32, 16, &stripes_rgba(), "stripes").unwrap();
app.scene.add_texture("stripes_texture", stripes).unwrap();
app.scene
.add_material_texture("stripes_mat", "standard", "stripes_texture")
.unwrap();
// 3. Ground plane (large, thin, textured).
app.scene
.create_mesh("ground_mesh", plane(9.0, 9.0, 1, 1), Some("ground_mat"))
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// 4. One mesh per primitive, each assigned to a textured (or stripe) material.
// The cube + ground stay single-level (tiny meshes — LOD would buy nothing); the
// rounded primitives get three LOD levels each (Step 19): level 0 is the full mesh,
// levels 1.. are auto-generated by quadric edge collapse at halving targets (D10), all
// packed into the mesh's single vertex/index buffers (D7). Zooming with the wheel
// switches levels on the fly (asymmetric hysteresis, D4).
app.scene
.create_mesh("cube_mesh", cube(0.8), Some("solid_mat"))
.unwrap();
app.scene
.create_mesh_with_lod(
"sphere_mesh",
uv_sphere(0.55, 32, 20),
Some("stripes_mat"),
3,
)
.unwrap();
app.scene
.create_mesh_with_lod("ico_mesh", icosphere(0.5, 2), Some("solid_mat"), 3)
.unwrap();
app.scene
.create_mesh_with_lod("cyl_mesh", cylinder(0.4, 0.9, 32), Some("stripes_mat"), 3)
.unwrap();
app.scene
.create_mesh_with_lod("cone_mesh", cone(0.45, 0.9, 32), Some("solid_mat"), 3)
.unwrap();
app.scene
.create_mesh_with_lod(
"torus_mesh",
torus(0.42, 0.16, 24, 16),
Some("solid_mat"),
3,
)
.unwrap();
place("cube_e", "cube_mesh", app, 0);
place("sphere_e", "sphere_mesh", app, 1);
place("ico_e", "ico_mesh", app, 2);
place("cyl_e", "cyl_mesh", app, 3);
place("cone_e", "cone_mesh", app, 4);
place("torus_e", "torus_mesh", app, 5);
// 5. Lights: a shadow-casting directional + a warm point + a green spot.
// Start from the default list (directional +Z) so we keep it and add the rest.
let toward_light = Vec3::new(1.0, 1.2, 1.0).normalize();
app.scene
.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.5)
.unwrap();
app.scene
.add_point_light(Vec3::new(0.5, 1.6, 1.8), [1.0, 0.7, 0.3], 1.2, 6.0)
.unwrap();
app.scene
.add_spot_light(
Vec3::new(-2.5, 2.2, 1.0),
Vec3::new(2.5, -2.2, -1.0).normalize(),
[0.3, 1.0, 0.5],
1.4,
8.0,
0.45,
)
.unwrap();
// 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).
self.camera.yaw = 0.6;
self.camera.pitch = 0.35;
self.camera.distance = 6.5;
self.camera.target = Vec3::ZERO;
self.camera.apply_to(app.scene.camera_mut());
}
fn update(&mut self, app: &mut wsg_lib::App) {
// ---- Orbital camera from unified input ----
// Classic arc-rotate: orbit ONLY while the left button is held (drag); the wheel zooms
// without any button. Sensitivities use the library defaults (0.005 rad/px orbit, 0.9x
// per wheel notch); tune them via `camera.orbit_sensitivity` / `camera.zoom_factor`.
let (dx, dy) = app.input.mouse_delta();
if app.input.mouse_button_held(MouseButton::Left) {
self.camera.orbit(dx, dy);
}
let (_, sy) = app.input.scroll_delta();
self.camera.zoom(sy);
// R: reset the view. Keys 1/2/3: front / side / top presets.
if app.input.key_pressed(KeyCode::KeyR) {
// Keep the target but restore a pleasing default framing.
self.camera.yaw = 0.6;
self.camera.pitch = 0.35;
self.camera.distance = 6.5;
}
if app.input.key_pressed(KeyCode::Digit1) {
self.camera.yaw = 0.0;
self.camera.pitch = 0.25;
self.camera.distance = 6.5;
}
if app.input.key_pressed(KeyCode::Digit2) {
self.camera.yaw = std::f32::consts::FRAC_PI_2;
self.camera.pitch = 0.15;
self.camera.distance = 6.5;
}
if app.input.key_pressed(KeyCode::Digit3) {
self.camera.yaw = 0.0;
self.camera.pitch = 1.25;
self.camera.distance = 8.0;
}
self.camera.apply_to(app.scene.camera_mut());
// ---- Slow rotation of the primitives so lighting/shadow read clearly ----
self.angle += 0.008;
let base = *app
.scene
.entity_transform("cube_e")
.expect("cube entity present");
let mut tf = base;
tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.4);
app.scene.set_entity_transform("cube_e", tf);
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
// Opt-in GPU readback (black-window investigation tooling): WSG_DEBUG_DUMP=N dumps the
// first 8 slots of the transform/matrix/draw-args/bbox buffers for N frames (unset = silent,
// non-numeric value = 3 frames). Note: orbiting/zooming this camera
// can never cull the entity ring — the camera always looks at the origin, so each
// entity's angular offset from the view axis is bounded by atan(1.7/6.1) ≈ 15.5°, under
// the ~22° vertical half-FOV (verified 2026-09-22: 600 frames swept, GPU==CPU on all
// 6000 cull verdicts, zero flips on the ring). Counts only flip to 0 for entities far
// off-axis (e.g. behind the near plane) — see docs/user/gpu-driven.md.
// Unset → 0 (the showcase stays silent); set but non-numeric (e.g. `WSG_DEBUG_DUMP=on`) → 3.
let frames = match std::env::var("WSG_DEBUG_DUMP") {
Ok(v) => v.parse::<u32>().ok().filter(|&n| n > 0).unwrap_or(3),
Err(_) => 0,
};
if self.dbg < frames {
self.dbg += 1;
app.renderer().debug_dump(8);
}
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
// Culling enabled here (Step 15, D8) to exercise the GPU path; it is OFF by default elsewhere.
// HDR + ACES tone mapping (Étape 20): renders to an offscreen Rgba16Float texture, then
// tone-maps to the sRGB surface. Without `.with_hdr(...)`, the demo would be LDR direct.
let app = AppBuilder::new()
.title("WSG Demo")
.with_culling(true)
.with_hdr(ToneMapper::Aces)
.build()
.await?;
app.run(Demo {
camera: CameraController::default(),
angle: 0.0,
dbg: 0,
})
}
+66
View File
@@ -0,0 +1,66 @@
//! # Example: File Import (OBJ)
//!
//! Demonstrates loading a Wavefront OBJ file with `wsg_lib::mesh::load_obj`.
//! Parses the file and prints geometry statistics.
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example import --features import-obj -- /path/to/model.obj
//! ```
//!
//! Without a file argument, parses a built-in sample triangle.
use wsg_lib::mesh::import::parse_obj;
use wsg_lib::mesh::load_obj;
fn main() {
let args: Vec<String> = std::env::args().collect();
let content = if args.len() > 1 {
let path = &args[1];
eprintln!("Loading: {path}");
match load_obj(path) {
Ok(geom) => {
print_stats(&geom);
return;
}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
} else {
eprintln!("No file argument — parsing a built-in sample.");
eprintln!("Usage: import <model.obj>");
// Built-in sample: a simple triangle with UVs and normals
"v 0.0 0.0 0.0\nv 1.0 0.0 0.0\nv 0.5 1.0 0.0\nvn 0 0 1\nvt 0.0 0.0\nvt 1.0 0.0\nvt 0.5 1.0\nf 1/1/1 2/2/1 3/3/1\n"
};
let geom = parse_obj(content).expect("sample should parse");
print_stats(&geom);
}
fn print_stats(geom: &wsg_lib::Geometry) {
println!("\n=== Geometry Statistics ===");
println!(" Vertices: {}", geom.positions.len());
if let Some(n) = &geom.normals {
println!(" Normals: {}", n.len());
}
if let Some(uv) = &geom.uvs {
println!(" UVs: {}", uv.len());
}
if let Some(idx) = &geom.indices {
println!(" Indices: {} ({} triangles)", idx.len(), idx.len() / 3);
}
if let Err(e) = geom.validate() {
println!(" Validation FAILED: {e}");
} else {
println!(" Validation: OK");
}
// Bounding box
if let Some(bbox) = geom.bbox() {
println!(" BBox min: {:?}", bbox.min);
println!(" BBox max: {:?}", bbox.max);
}
println!();
}
+127 -81
View File
@@ -1,101 +1,147 @@
//! Low-level workflow: direct use of `Context`, `Renderer`, `PipelineCache`, `Mesh` and
//! `Material`, bypassing the `App` facade. Renders a flat quad (shader `standard` **unlit**) via the
//! winit 0.30 loop (`EventLoop::run_app` + `ApplicationHandler`). The window and the GPU are created
//! in `resumed()` (winit 0.30 only exposes the display after resume). The mesh is built via
//! `Mesh::from_geometry(device, Arc<Geometry>, None)` from a `Geometry` (positions + colors per vertex).
use std::sync::Arc;
use winit::event_loop::EventLoop;
use winit::window::WindowBuilder;
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowAttributes};
use wsg_lib::core::Context;
use wsg_lib::core::Frame;
use wsg_lib::core::Renderer;
use wsg_lib::core::{Renderer, ShadowConfig};
use wsg_lib::pipeline::PipelineCache;
use wsg_lib::resources::Material;
use wsg_lib::resources::Mesh;
use wsg_lib::resources::Vertex;
use wsg_lib::resources::{Geometry, Material, Mesh};
use wsg_lib::utils;
fn main() {
println!(
"Répertoire courant : {:?}",
std::env::current_dir().unwrap()
);
let event_loop = EventLoop::new().unwrap();
let window = Arc::new(WindowBuilder::new().build(&event_loop).unwrap());
/// Low-level application: holds the GPU objects + window, all created in `resumed`.
struct App {
/// System window, shared via Arc (as in app.rs).
window: Option<Arc<Window>>,
/// GPU context (Instance, Surface, Adapter, Device, Queue).
context: Option<Context>,
/// Execution layer that submits draw calls.
renderer: Option<Renderer>,
/// Shader/pipeline cache.
cache: Option<PipelineCache>,
/// Quad material (pipeline).
material: Option<Material>,
/// Quad mesh (vertices + indices).
mesh: Option<Mesh>,
}
// 1. Initialisation
let context = pollster::block_on(Context::new(window.clone())).expect("Échec init GPU");
impl ApplicationHandler for App {
/// Creates the window then the GPU, and builds the mesh/material. Runs once at startup.
/// Redundant `resumed` creating again? Double protection via `self.context.is_some()`.
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.context.is_some() {
return;
}
event_loop.set_control_flow(ControlFlow::Poll);
// Configuration de la surface et récupération du format
let format = context
.configure(&context.adapter, 800, 600)
.expect("Échec configuration");
let attrs = WindowAttributes::default()
.with_title("WSG Manual")
.with_inner_size(LogicalSize::new(800.0, 600.0));
let window = Arc::new(event_loop.create_window(attrs).unwrap());
// 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);
cache
.register_shader("basic", utils::BASIC_SHADER_PATH)
.unwrap();
// 1. Initialisation
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
let renderer = Renderer::new(&context, format);
// Surface configuration and format retrieval
let format = context
.configure(&context.adapter, 800, 600)
.expect("configuration failed");
// 3. Material : On utilise renderer.device() et renderer.format()
let material = Material::new(renderer.format(), "basic", &mut cache);
// 2. Renderer initialization (it retrieves everything it needs)
let device = Arc::new(context.device.clone());
let mut cache = PipelineCache::new(device, context.queue.clone());
cache
.register_shader("standard", utils::STANDARD_SHADER_PATH)
.unwrap();
// Mesh : On utilise le device du renderer
let vertices = [
// Position (x,y,z) | Normale (x,y,z) | UV (u,v) | Couleur (r,g,b,a)
Vertex {
position: [-0.5, 0.5, 0.0],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
color: [1.0, 0.0, 0.0, 1.0],
}, // Haut-Gauche (Rouge)
Vertex {
position: [0.5, 0.5, 0.0],
normal: [0.0, 0.0, 1.0],
uv: [1.0, 0.0],
color: [0.0, 1.0, 0.0, 1.0],
}, // Haut-Droite (Vert)
Vertex {
position: [0.5, -0.5, 0.0],
normal: [0.0, 0.0, 1.0],
uv: [1.0, 1.0],
color: [0.0, 0.0, 1.0, 1.0],
}, // Bas-Droite (Bleu)
Vertex {
position: [-0.5, -0.5, 0.0],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 1.0],
color: [1.0, 1.0, 0.0, 1.0],
}, // Bas-Gauche (Jaune)
];
let indices: [u16; 6] = [0, 1, 2, 0, 2, 3];
let mesh = Mesh::new(renderer.device(), &vertices, Some(&indices));
// Flat 2D rendering: `standard` in unlit mode (the frame+object bind groups are set by
// draw_entity, the default frame matrix is the identity → NDC positions unchanged).
let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None);
renderer.set_unlit(true);
// Render loop
event_loop
.run(|event, elwt| {
match event {
winit::event::Event::AboutToWait => {
window.request_redraw();
}
winit::event::Event::WindowEvent {
event: winit::event::WindowEvent::RedrawRequested,
..
} => {
// 3. Material: uses renderer.device() and renderer.format()
let material = Material::new(renderer.format(), "standard", &mut cache);
// Mesh: uses the renderer's device. The mesh is built from a `Geometry`
// (positions + colors per vertex) via `Mesh::from_geometry` — the mesh also keeps the
// `Arc<Geometry>` on the CPU side.
let geometry = Geometry::new(vec![
// Position (x,y,z) | Color (r,g,b,a) — normals/UVs default via to_vertices
[-0.5, 0.5, 0.0],
[0.5, 0.5, 0.0],
[0.5, -0.5, 0.0],
[-0.5, -0.5, 0.0],
])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0], // top-left (red)
[0.0, 1.0, 0.0, 1.0], // top-right (green)
[0.0, 0.0, 1.0, 1.0], // bottom-right (blue)
[1.0, 1.0, 0.0, 1.0], // bottom-left (yellow)
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None);
self.window = Some(window);
self.context = Some(context);
self.renderer = Some(renderer);
self.cache = Some(cache);
self.material = Some(material);
self.mesh = Some(mesh);
}
/// Each frame, requests a redraw for continuous rendering (animation).
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(window) = &self.window {
window.request_redraw();
}
}
/// Window event dispatch: RedrawRequested renders then presents, CloseRequested exits.
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId,
event: WindowEvent,
) {
match event {
winit::event::WindowEvent::RedrawRequested => {
if let (Some(context), Some(renderer), Some(mesh), Some(material)) =
(&self.context, &self.renderer, &self.mesh, &self.material)
{
if let Some(frame) = Frame::try_new(&context.surface) {
// 1. Rendu (plus d'arguments device/queue inutiles)
renderer.render(frame.view(), &mesh, &material);
// 1. Render (no more useless device/queue arguments)
renderer.render(frame.view(), mesh, material);
// 2. Présentation
// 2. Present
renderer.present(frame);
}
}
winit::event::Event::WindowEvent {
event: winit::event::WindowEvent::CloseRequested,
..
} => {
elwt.exit(); // C'est ici que tu demandes à la boucle de s'arrêter
}
_ => (),
}
})
.unwrap();
winit::event::WindowEvent::CloseRequested => {
event_loop.exit(); // this is where you ask the loop to stop
}
_ => (),
}
}
}
fn main() {
println!("Current directory: {:?}", std::env::current_dir().unwrap());
let event_loop = EventLoop::new().unwrap();
let mut app = App {
window: None,
context: None,
renderer: None,
cache: None,
material: None,
mesh: None,
};
event_loop.run_app(&mut app).unwrap();
}
+152
View File
@@ -0,0 +1,152 @@
//! Dedicated test for **shadow mapping**.
//!
//! A single **directional** light is configured as the shadow caster
//! (`Scene::set_shadow_caster(Some(0))`). The cube sits on a large thin ground
//! slab, so its silhouette is projected as a crisp PCF-softened shadow. With a
//! small ambient term the shadow is clearly visible and the light/shadow
//! directions are easy to read:
//!
//! 1. the **blocker** (cube) casts a directional shadow that stretches along
//! the ground opposite the light direction. The light sits at the camera's
//! front-right and low-ish, so its shadow runs clearly across the ground to
//! the left of the cube and is easy to see,
//! 2. the shadow edge is **softened** by 3×3 PCF (no hard jagged border),
//! 3. the lit faces are bright while the shadowed ground stays near-ambient,
//! proving the depth comparison is applied per-pixel.
//!
//! Run with: `cargo run -p wsg-lib --example shadow_test`
use glam::Vec3;
use wsg_lib::resources::{Camera, Geometry};
use wsg_lib::utils::WsgError;
/// Shadow handler: a fixed scene (ground slab + cube blocker) lit by one
/// shadow-casting directional light.
struct ShadowTest;
/// Axis-aligned box geometry (24 vertices / 36 indices, per-face normals + uvs).
fn box_geometry(hx: f32, hy: f32, hz: f32) -> Geometry {
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
(
[0.0, 0.0, 1.0],
[[-hx, -hy, hz], [hx, -hy, hz], [hx, hy, hz], [-hx, hy, hz]],
), // +Z
(
[0.0, 0.0, -1.0],
[
[hx, -hy, -hz],
[-hx, -hy, -hz],
[-hx, hy, -hz],
[hx, hy, -hz],
],
), // -Z
(
[1.0, 0.0, 0.0],
[[hx, -hy, -hz], [hx, hy, -hz], [hx, hy, hz], [hx, -hy, hz]],
), // +X
(
[-1.0, 0.0, 0.0],
[
[-hx, -hy, hz],
[-hx, hy, hz],
[-hx, hy, -hz],
[-hx, -hy, -hz],
],
), // -X
(
[0.0, 1.0, 0.0],
[[-hx, hy, -hz], [hx, hy, -hz], [hx, hy, hz], [-hx, hy, hz]],
), // +Y
(
[0.0, -1.0, 0.0],
[
[-hx, -hy, hz],
[hx, -hy, hz],
[hx, -hy, -hz],
[-hx, -hy, -hz],
],
), // -Y
];
let mut positions = Vec::with_capacity(24);
let mut normals = Vec::with_capacity(24);
let mut uvs = Vec::with_capacity(24);
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 (i, corner) in corners.iter().enumerate() {
positions.push(*corner);
normals.push(normal);
uvs.push(quad_uvs[i]);
}
}
let mut indices = Vec::with_capacity(36);
for face in 0..6u16 {
let b = face * 4;
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
impl wsg_lib::AppHandler for ShadowTest {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
app.scene.add_material_shader("mat", "standard").unwrap();
// Ground slab (thin, wide) lying with its top at y = 0.
app.scene
.create_mesh("ground_mesh", box_geometry(5.0, 0.05, 5.0), Some("mat"))
.unwrap();
app.scene
.add_entity_with_transform(
"ground",
"ground_mesh",
wsg_lib::core::Transform::identity(),
)
.unwrap();
// Blocker cube centred at the origin, standing on the ground (bottom at y = 0).
app.scene
.create_mesh("cube_mesh", box_geometry(0.5, 0.5, 0.5), Some("mat"))
.unwrap();
let mut cube_tf = wsg_lib::core::Transform::identity();
cube_tf.translation = Vec3::new(0.0, 0.5, 0.0);
app.scene
.add_entity_with_transform("cube", "cube_mesh", cube_tf)
.unwrap();
// One directional light only: replace the default list.
app.scene.clear_lights();
// Direction "from surface toward the light": the light sits up and to the +x side
// (the camera's right), at a lowish elevation. Its shadow is then cast toward -x,
// running clearly across the ground to the left of the cube. A steeper or more
// frontal light would push the shadow tight against the cube's base or behind it,
// where it is occluded by the cube from this elevated front-right view.
let toward_light = Vec3::new(1.0, 0.5, 0.0).normalize();
app.scene
.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.6)
.unwrap();
// Make this directional light (packed index 0) the shadow caster.
app.scene.set_shadow_caster(Some(0));
// Small ambient so the shadowed side of the ground stays readable.
app.scene.set_ambient([0.12, 0.12, 0.14]);
// Slightly elevated view so both the cube and its ground shadow are framed.
app.scene
.set_camera(Camera::new(Vec3::new(3.4, 2.6, 3.4), Vec3::ZERO, Vec3::Y));
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = wsg_lib::app::AppBuilder::new()
.title("WSG Shadow Test")
.build()
.await?;
app.run(ShadowTest)
}
+40 -6
View File
@@ -1,15 +1,49 @@
//! Workflow déclaratif minimal (~15 lignes), sans manipulation WGPU explicite.
//! `AppBuilder` ouvre la fenêtre, construit le `Context`/`Renderer` et fait tourner la boucle
//! update → render → present. Le rendu automatisé de la scène n'est pas encore en place
//! (README, Roadmap étape 1) : `render()` est donc vide pour l'instant.
//! Minimal declarative workflow, no explicit WGPU handling in this file.
//! `AppBuilder` creates the event loop, then `App::run` opens the window, builds the `Context`/`Renderer`
//! and drives the update → render → present loop. In winit 0.30 the GPU only exists
//! after `resumed`: that is why shader registration + mesh/material/entity creation live in
//! the `AppHandler::setup` hook, called once the context is ready. The PipelineCache
//! lives in the scene (`Scene::init_gpu`, called in `resumed`): go through `register_shader` +
//! `add_material_shader` + `create_mesh` + `add_entity`, the material being bound to the mesh. The mesh
//! is declared from a **`Geometry`**: per-vertex positions + colors
//! for the unlit quad. The scene renders automatically: the default `render()` method calls
//! `app.render_scene(frame.view())`.
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::resources::Geometry;
use wsg_lib::utils::WsgError;
use wsg_lib::{App, AppHandler};
struct MonQuad;
impl AppHandler for MonQuad {
fn render(&mut self, _app: &mut App) {}
fn setup(&mut self, app: &mut wsg_lib::App) {
// Flat 2D example: the `standard` shader in **unlit** mode (options.x = 1) returns the vertex
// color as-is. Flat 2D is thus a special case of 3D — a single pipeline for all.
app.renderer_mut().set_unlit(true);
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
let geometry = Geometry::new(vec![
[-0.5, 0.5, 0.0], // top-left
[0.5, 0.5, 0.0], // top-right
[0.5, -0.5, 0.0], // bottom-right
[-0.5, -0.5, 0.0], // bottom-left
])
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0], // top-left (red)
[0.0, 1.0, 0.0, 1.0], // top-right (green)
[0.0, 0.0, 1.0, 1.0], // bottom-right (blue)
[1.0, 1.0, 0.0, 1.0], // bottom-left (yellow)
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
// Default material: `None` lets the Scene inject its `standard` at render time
// (`Scene::default_material`) — this exercises the default path.
app.scene.create_mesh("quad_mesh", geometry, None).unwrap();
app.scene.add_entity("quad", "quad_mesh").unwrap();
}
}
#[pollster::main]
+84
View File
@@ -0,0 +1,84 @@
//! Test dedicated to **spot lights**.
//!
//! In this example, **only** a spot light is on (the default directional light is
//! removed via `clear_lights()`) and the ambient is deliberately **very low**. The cube therefore
//! appears nearly black except where the spot's cone reaches it: you clearly see
//!
//! 1. a **directed beam** (not an omni halo like the point light),
//! 2. a **smoothed edge** (penumbra) at the cone's limit,
//! 3. the lighting that **follows the cube** as it rotates (the cone is fixed in world space).
//!
//! Run with: `cargo run -p wsg-lib --example spot_test`
use glam::{Quat, Vec3};
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::mesh::cube;
use wsg_lib::utils::WsgError;
/// Test handler: cube rotating slowly on two axes, lit **only** by a spot.
struct SpotTest {
angle_x: f32,
angle_y: f32,
}
impl AppHandler for SpotTest {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
app.scene.add_material_shader("mat", "standard").unwrap();
app.scene
.create_mesh("cube_mesh", cube(1.0), Some("mat"))
.unwrap();
app.scene.add_entity("cube", "cube_mesh").unwrap();
// Remove the default directional light to isolate the spot.
app.scene.clear_lights();
// Near-zero ambient: the cube is black outside the beam, the cone stands out.
app.scene.set_ambient([0.03, 0.03, 0.03]);
// The spot is above/behind the camera, aimed at the origin (the cube).
// World position (0, 2, 3), cone axis toward (0,0,0).
let spot_pos = Vec3::new(0.0, 2.0, 3.0);
let spot_dir = (Vec3::ZERO - spot_pos).normalize(); // points at the cube
app.scene
.add_spot_light(
spot_pos,
spot_dir,
[1.0, 0.9, 0.6], // warm tint
2.0, // intensity
10.0, // attenuation radius (wide, the cube is at ~3.6)
0.45, // half-angle (~26°) — wide enough to cover the cube
)
.unwrap();
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Slow rotation on two axes (X and Y): the cone is fixed in world space,
// so a fixed region of the cube stays lit while the cube rotates.
// The two axes let you see the beam's effect on the 6 faces without
// a favored orientation (a Y rotation alone would leave the +Y/-Y faces fixed).
self.angle_x += 0.007;
self.angle_y += 0.011;
let base = *app
.scene
.entity_transform("cube")
.expect("cube entity present");
let mut transform = base;
// Y * X composition: the X axis rotates in the frame already oriented by Y,
// which gives a precession motion (all vertices pass in front of
// the cone in turn).
transform.rotation =
Quat::from_rotation_y(self.angle_y) * Quat::from_rotation_x(self.angle_x);
app.scene.set_entity_transform("cube", transform);
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Spot Test").build().await?;
app.run(SpotTest {
angle_x: 0.0,
angle_y: 0.0,
})
}
+7 -5
View File
@@ -2,14 +2,16 @@
## Overview
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into seven modules:
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into eight modules (plus the `shaders/` asset directory):
| Module | Responsibility |
|--------|---------------|
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration |
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry), Material (appearance descriptor) |
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration (incl. the GPU-driven compute passes + opt-in frustum culling); also `InputState` (unified keyboard/mouse input, Step 15.B) |
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry + bounding box, multi-level LOD via packed vertex/index buffers), Material (appearance descriptor), Texture, Lights, Camera + CameraController, and the uniform slot types (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`/`LodRow`/`LodTable`) |
| **pipeline** | PipelineCache — WGSL shader loading and RenderPipeline compilation cache |
| **scene** | Scene — resource depot and entity graph for declarative rendering setup |
| **shaders** | Embedded WGSL sources (`standard`, `shadow`, `gpu_driven`) loaded via the `include_str!` fallback in `utils::conf` |
| **scene** | Scene — resource depot and slot-based entity graph for declarative rendering setup (Step 17) |
| **math** | Transform, Geometry (per-attribute mesh data + AABB, quadric edge collapse `decimated`/`generate_lod_levels`), `Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z), `lod` (per-frame level selection: `projected_radius_px` + `lod_level` with asymmetric hysteresis) and `primitives` (procedural mesh generators) |
| **utils** | Configuration constants and WsgError type |
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation |
| **handler** | AppHandler trait — user-defined game logic interface injected into the render loop |
@@ -18,7 +20,7 @@ This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://gi
The library supports two workflows:
- **Declarative (recommended)**: Use `Scene` to register resources and associate entities before the render loop begins. This is the "App facade" pattern described in [ARCHI_APP](../../docs/ARCHI_APP.md).
- **Declarative (recommended)**: Use `Scene` to register resources and associate entities before the render loop begins. This is the "App facade" pattern described in [ARCHI_APP](../../docs/tech/ARCHI_APP.md).
- **Manual**: Manipulate Context, Renderer, and PipelineCache directly for fine-grained control.
## Dependency Flow
+348 -75
View File
@@ -8,82 +8,172 @@
//! ## Interaction with Other Modules
//! - **core::context**: Consumes GPU hardware lifecycle via Context; acquires frames for rendering.
//! - **core::renderer**: Delegates draw call execution to Renderer per frame.
//! - **pipeline::pipeline_cache**: Holds PipelineCache instance for shader/pipeline management.
//! - **scene::scene**: Exposes Scene as mutable field so users can register resources and entities.
//! Since Step 7 the `PipelineCache` lives *inside* the Scene (via `Scene::init_gpu`), owned and
//! used for material building there.
//! - **utils::conf**: Provides default window title, dimensions, and embedded WGSL source.
//! - **handler**: Defines the AppHandler trait that users implement for custom logic.
//!
//! ## Architecture Note (winit 0.30)
//! winit 0.30 removed the synchronous window-creation API (`WindowBuilder`) and the closure-based
//! `EventLoop::run`, replacing them with the [`ApplicationHandler`] model driven by `EventLoop::run_app`.
//! Windows can only be created inside `ApplicationHandler::resumed()`. Consequently this module builds
//! the window and GPU context lazily inside `AppRunner`'s `resumed()` callback, and exposes the
//! user-facing `AppBuilder::build → App::run` flow over that model. `AppHandler::setup()` is invoked
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
use crate::AppHandler;
use crate::core::{Context, Renderer};
use crate::pipeline::PipelineCache;
use crate::core::{Context, InputState, Renderer, ShadowConfig, ToneMapper};
use crate::scene::Scene;
use crate::utils::WsgError;
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
use std::sync::Arc;
use winit::event_loop::EventLoop;
use winit::window::Window;
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowAttributes};
/// High-level application facade that orchestrates window lifecycle, event loop, and rendering automation.
/// Encapsulates all five WGPU objects (Instance, Surface, Adapter, Device, Queue) plus the render loop.
/// Users create an App via AppBuilder, then run it with their implementation of AppHandler.
///
/// The GPU-facing fields (`context`, `renderer`, `window`) are created lazily when the application is
/// resumed (see `AppRunner`); they are only populated after `App::run` has started. The `PipelineCache`
/// is not a field here: since Step 7 it lives in the `Scene`'s pipeline context (via `Scene::init_gpu`).
/// Access GPU resources through the `context()`, `renderer()` and `window()` accessors, which are
/// guaranteed to work inside `AppHandler::setup`, `update` and `render`.
pub struct App {
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
pub context: Context,
/// Executor layer — binds Materials and Meshes into RenderPasses during draw calls.
pub renderer: Renderer,
/// Winit event loop for window management. Set to None after run() consumes it.
pub event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
/// Shader compilation cache — manages RenderPipelines keyed by shader_id.
pub cache: PipelineCache,
/// Resource depot and entity graph — users register Meshes/Materials here before the render loop begins.
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
pub scene: Scene,
/// Unified input state (keyboard/mouse/scroll, DRAFT Step 15). Fed by the winit window events
/// and rotated each frame by `begin_frame`/`end_frame` around `AppHandler::update`. Read it in
/// `update` via `app.input` (e.g. `app.input.key_held(KeyCode::KeyW)`).
pub input: InputState,
/// Window title, read by the runner when the window is created in `resumed`.
pub(crate) title: String,
/// Window width, read by the runner when the window is created in `resumed`.
pub(crate) width: u32,
/// Window height, read by the runner when the window is created in `resumed`.
pub(crate) height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
pub(crate) culling: bool,
/// Shadow mapping configuration; passed to `Renderer::new` in `resumed`.
pub(crate) shadow_config: ShadowConfig,
/// HDR / tone mapping (Étape 20). `None` = LDR direct (default, zero overhead);
/// `Some(t)` = render to Rgba16Float offscreen + tone mapping pass to the surface.
pub(crate) hdr: Option<ToneMapper>,
/// Winit event loop for window management. Set to None after run() consumes it.
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
context: Option<Context>,
/// Executor layer — binds Materials and Meshes into RenderPasses during draw calls.
renderer: Option<Renderer>,
/// The OS-level window backing this application. Shared via Arc for multi-owner access.
pub window: Arc<Window>,
window: Option<Arc<Window>>,
}
impl App {
/// Returns a reference to the GPU renderer.
/// Panics if called before `App::run` has created the renderer (i.e. before `resumed` fires).
pub fn renderer(&self) -> &Renderer {
self.renderer
.as_ref()
.expect("renderer not initialized yet — call app.run(handler) first")
}
/// Returns a mutable reference to the GPU renderer.
/// Panics if called before `App::run` has created the renderer (i.e. before `resumed` fires).
/// Callers can configure the renderer here, e.g. `app.renderer_mut().set_unlit(true)` in `setup`
/// to select flat 2D rendering (DRAFT Step 5).
pub fn renderer_mut(&mut self) -> &mut Renderer {
self.renderer
.as_mut()
.expect("renderer not initialized yet — call app.run(handler) first")
}
/// Returns a reference to the GPU hardware context.
/// Panics if called before `App::run` has created the context (i.e. before `resumed` fires).
pub fn context(&self) -> &Context {
self.context
.as_ref()
.expect("context not initialized yet — call app.run(handler) first")
}
/// Returns a reference to the window backing this application.
/// Panics if called before `App::run` has created the window (i.e. before `resumed` fires).
pub fn window(&self) -> &Window {
self.window
.as_ref()
.expect("window not initialized yet — call app.run(handler) first")
.as_ref()
}
/// Runs the application's main loop: processes events, updates logic per frame, renders, and presents.
/// Inputs: handler — user-provided AppHandler implementation containing game logic.
/// Returns Ok(()) on success or Err(WsgError::WindowSystem) if the event loop exits abnormally.
/// Called once at application entry point; runs until the window is closed or an error occurs.
/// Internal steps: 1) take EventLoop from Option → 2) enter winit event loop →
/// 3a) on AboutToWait: call handler.update() + request_redraw →
/// 3b) on RedrawRequested: acquire frame → call handler.render() → present frame →
/// 3c) on CloseRequested: exit event loop.
pub fn run<H: AppHandler + 'static>(mut self, mut handler: H) -> Result<(), WsgError> {
// On extrait l'event_loop de manière sûre grâce au Option
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // Erreur si déjà pris
/// Internal steps: 1) take EventLoop from Option → 2) build an `AppRunner` around the handler →
/// 3) on resumed: create window/context/renderer/cache and call handler.setup() →
/// 4) on about_to_wait: call handler.update() + request_redraw →
/// 5) on RedrawRequested: acquire frame → call handler.render() → present frame →
/// 6) on CloseRequested: exit the event loop.
pub fn run<H: AppHandler + 'static>(mut self, handler: H) -> Result<(), WsgError> {
// Extract the event_loop safely via Option
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // error if already taken
let mut runner = AppRunner {
title: self.title.clone(),
width: self.width,
height: self.height,
culling: self.culling,
shadow_config: self.shadow_config.clone(),
hdr: self.hdr,
handler,
app: None,
};
event_loop
.run(move |event, elwt| {
match event {
winit::event::Event::AboutToWait => {
// update logic
handler.update(&mut self);
self.window.request_redraw();
}
winit::event::Event::WindowEvent {
event: winit::event::WindowEvent::RedrawRequested,
..
} => {
// Rendering logic
let frame = self.context.get_next_frame();
// On appelle le render() de l'utilisateur
handler.render(&mut self);
// On présente automatiquement
self.renderer.present(frame);
}
winit::event::Event::WindowEvent {
event: winit::event::WindowEvent::CloseRequested,
..
} => {
elwt.exit();
}
_ => {}
}
})
.run_app(&mut runner)
.map_err(|_| WsgError::WindowSystem)
}
/// Renders every entity in `self.scene` into the given color view in a single batched render pass.
/// Called automatically each frame by the default `AppHandler::render`, or manually by users
/// who override `render` to control drawing themselves.
/// Inputs: view — the frame's texture view acting as the color attachment target.
///
/// The viewport aspect ratio (needed for the active camera's perspective projection, Step 4.3)
/// is derived here from the window's current inner size, so the `Renderer` stays independent of
/// the windowing backend.
pub fn render_scene(&self, view: &wgpu::TextureView) {
let size = self.window().inner_size();
let aspect = size.width as f32 / size.height.max(1) as f32;
self.renderer().render_scene(view, &self.scene, aspect);
}
/// Resizes the surface and depth texture to a new window size (ROADMAP Phase 4.4).
/// Reconfigures the surface via `Context::configure` (which returns the chosen format) and
/// recreates the depth texture via `Renderer::resize_depth` so the color and depth attachments
/// stay the same size. If the surface format changes (rare, deterministic per window), the
/// Scene's GPU context is re-initialized to the new format; otherwise the swap alone suffices.
/// Inputs: width/height — the new surface dimensions in pixels.
/// Returns Ok(()) on success or a `WsgError` if the surface cannot be reconfigured.
pub fn resize(&mut self, width: u32, height: u32) -> Result<(), WsgError> {
let context = self.context.as_ref().ok_or(WsgError::SurfaceIncompatible)?;
let old_format = self.renderer().format();
let new_format = context.configure(&context.adapter, width, height)?;
self.renderer_mut().resize_depth(width, height);
self.renderer_mut().set_format(new_format);
if new_format != old_format && self.hdr.is_none() {
// Surface format changed (rare): re-wire the Scene's GPU context so its
// PipelineCache/pipelines match the new surface format.
// Étape 20: when HDR is active, the Scene uses Rgba16Float regardless of the
// surface format, so no re-init is needed on surface format change.
let device = std::sync::Arc::new(self.renderer_mut().device().clone());
self.scene
.init_gpu(device, self.context().queue.clone(), new_format);
}
Ok(())
}
}
/// Builder for constructing a configured `App` instance with custom title and dimensions.
@@ -95,6 +185,13 @@ pub struct AppBuilder {
width: u32,
/// Window height in pixels.
height: u32,
/// GPU frustum culling enabled (Step 15, D8). Defaults to false (non-regression).
culling: bool,
/// Shadow mapping configuration (map size, biases, frustum). Defaults to sensible values.
shadow_config: ShadowConfig,
/// HDR / tone mapping (Étape 20). `None` = LDR direct (default); `Some(t)` activates
/// the offscreen HDR texture + tone mapping pass.
hdr: Option<ToneMapper>,
}
impl AppBuilder {
@@ -105,6 +202,9 @@ impl AppBuilder {
title: APP_DEFAULT_TITLE.to_string(),
width: APP_DEFAULT_WIDTH,
height: APP_DEFAULT_HEIGHT,
culling: false,
shadow_config: ShadowConfig::default(),
hdr: None,
}
}
/// Sets the window title to display in the OS taskbar/window decorations.
@@ -120,34 +220,207 @@ impl AppBuilder {
self.height = height;
self
}
/// Builds the configured `App` instance by creating all required components in order:
/// EventLoop → Window → Context → Renderer → PipelineCache → Scene.
/// Returns Ok(App) on success or Err(WsgError) if any component fails during creation.
/// Called after setting desired properties via the builder pattern; triggers async GPU initialization.
/// Enables GPU frustum culling (Step 15, D8). When true, entities whose bounding sphere is
/// fully outside the camera frustum are skipped (their indirect draw args are zeroed on the
/// GPU). Defaults to **off** (non-regression): the culling compute pass still runs but marks
/// every active entity visible, so the rendered image is identical to culling-off.
pub fn with_culling(mut self, enabled: bool) -> Self {
self.culling = enabled;
self
}
/// Sets the shadow mapping configuration (map size, depth/slope bias, ortho frustum).
/// Defaults to `ShadowConfig::default()` (1024² map, bias 0.002, slope 0.004, radius 5.0).
pub fn with_shadow_config(mut self, config: ShadowConfig) -> Self {
self.shadow_config = config;
self
}
/// Enables HDR rendering with the given tone mapping curve (Étape 20). The main pass
/// renders into an offscreen `Rgba16Float` texture, then a fullscreen tone mapping pass
/// compresses the result to [0,1] and writes it to the sRGB surface. Without this call,
/// the renderer draws directly to the surface (LDR, zero overhead).
pub fn with_hdr(mut self, tonemapper: ToneMapper) -> Self {
self.hdr = Some(tonemapper);
self
}
/// Builds the configured `App` instance: creates the event loop and stores the window
/// configuration. The GPU context, window and renderer are created later, when the event loop
/// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
/// Returns Ok(App) on success or Err(WsgError) if the event loop cannot be created.
/// Called after setting desired properties via the builder pattern before `App::run`.
pub async fn build(self) -> Result<App, WsgError> {
let event_loop = EventLoop::new().unwrap();
let window = Arc::new(
winit::window::WindowBuilder::new()
.with_title(&self.title)
.build(&event_loop)
.map_err(|_| WsgError::WindowSystem)?,
);
let context = Context::new(window.clone()).await?;
let device = Arc::new(context.device.clone());
let format = context
.configure(&context.adapter, self.width, self.height)
.map_err(|_| WsgError::SurfaceIncompatible)?;
let renderer = Renderer::new(&context, format);
let cache = PipelineCache::new(device);
let scene = Scene::new();
let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
Ok(App {
context,
renderer,
cache,
scene,
scene: Scene::new(),
input: InputState::new(),
title: self.title,
width: self.width,
height: self.height,
culling: self.culling,
shadow_config: self.shadow_config,
hdr: self.hdr,
event_loop: Some(event_loop),
window,
context: None,
renderer: None,
window: None,
})
}
}
/// Internal runner that adapts a user `AppHandler` to winit's 0.30 `ApplicationHandler` model.
/// It owns the window/GPU lifecycle: everything is created lazily inside `resumed()`, then the
/// user's `setup`, `update` and `render` hooks are driven from the corresponding winit events.
struct AppRunner<H: AppHandler> {
/// Window title, applied when the window is created in `resumed`.
title: String,
/// Window width in pixels, applied when the window is created in `resumed`.
width: u32,
/// Window height in pixels, applied when the window is created in `resumed`.
height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
culling: bool,
/// Shadow mapping configuration; passed to `Renderer::new` in `resumed`.
shadow_config: ShadowConfig,
/// HDR / tone mapping (Étape 20); passed to `Renderer::new` in `resumed`.
hdr: Option<ToneMapper>,
/// The user-provided game logic.
handler: H,
/// The fully-built App facade, populated on the first `resumed` event.
app: Option<App>,
}
impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
/// Builds the window, GPU context, renderer and shader cache, then invokes the user's `setup`.
/// Guarded so redundant back-to-back `resumed` events do not re-initialize the GPU.
/// Inputs: event_loop — the active event loop used to create the window and control redrawing.
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.app.is_some() {
return;
}
event_loop.set_control_flow(ControlFlow::Poll);
let attrs = WindowAttributes::default()
.with_title(&self.title)
.with_inner_size(LogicalSize::new(self.width as f64, self.height as f64));
let window = Arc::new(
event_loop
.create_window(attrs)
.map_err(|_| WsgError::WindowSystem)
.expect("failed to create window"),
);
// GPU initialization (blocking, kept as simple as possible)
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
let format = context
.configure(&context.adapter, self.width, self.height)
.expect("surface configuration failed");
let device = Arc::new(context.device.clone());
let renderer =
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr);
// Step 15, D8: apply the culling flag (off by default — non-regression).
renderer.set_culling(self.culling);
// Step 7 (DRAFT 7.1): the PipelineCache now lives in the Scene. We wire the GPU context
// (device + queue + format + cache) into the Scene before setup so it can build materials/meshes.
// Étape 20: when HDR is active, the main pass targets Rgba16Float (not the surface format),
// so the Scene's pipelines must be compiled for that format.
let main_format = if self.hdr.is_some() {
wgpu::TextureFormat::Rgba16Float
} else {
format
};
let mut scene = Scene::new();
scene.init_gpu(device, context.queue.clone(), main_format);
let mut app = App {
scene,
input: InputState::new(),
title: self.title.clone(),
width: self.width,
height: self.height,
culling: self.culling,
shadow_config: self.shadow_config.clone(),
hdr: self.hdr,
event_loop: None,
context: Some(context),
renderer: Some(renderer),
window: Some(window),
};
// Let the user register shaders/meshes/materials/entities once the GPU is ready.
self.handler.setup(&mut app);
self.app = Some(app);
}
/// Drives the user's per-frame update and requests a redraw so the window renders continuously.
/// Inputs: _event_loop — active event loop (unused here).
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
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() failed ({e:?})");
}
// Step 15 (input): start the input frame (rotate pressed/released + reset deltas),
// run the user logic, then close (clear the transient states).
app.input.begin_frame();
self.handler.update(app);
app.input.end_frame();
app.window().request_redraw();
}
/// Dispatches window events: RedrawRequested renders/presents a frame, CloseRequested exits.
/// Inputs: event_loop — active event loop, used to exit on close; event — the window event.
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId,
event: WindowEvent,
) {
let Some(app) = self.app.as_mut() else {
return;
};
// Step 15 (input): feed the unified state from winit events (keyboard/mouse/wheel).
app.input.handle_window_event(&event);
match event {
WindowEvent::Resized(size) => {
// Guard (D3): minimizing the window sends Resized(0x0); never reconfigure at 0.
let w = size.width as u32;
let h = size.height as u32;
if w == 0 || h == 0 {
return;
}
// Step 11: reconfigure surface + depth to the new size, then re-render.
if let Err(e) = app.resize(w, h) {
eprintln!("WSG: resize error ({e:?})");
}
app.window().request_redraw();
}
WindowEvent::RedrawRequested => {
// Guard (D6): do not render on a zero-sized surface (minimized window).
let size = app.window().inner_size();
if size.width == 0 || size.height == 0 {
return;
}
// Rendering logic
let frame = app.context().get_next_frame();
// Call the user's render() (receives the current frame)
self.handler.render(app, &frame);
// Present automatically
app.renderer().present(frame);
}
WindowEvent::CloseRequested => {
event_loop.exit();
}
_ => {}
}
}
}
+1
View File
@@ -9,6 +9,7 @@ The `core` module contains two architectural layers that drive rendering:
| **context** | **Manager layer** — owns GPU hardware resource lifecycle (Instance, Surface, Adapter, Device, Queue). Initializes GPU at startup; orchestrates frame-by-frame rendering via begin_frame() / end_frame(). Does not own rendering logic. |
| **renderer** | **Executor layer** — owns Device/Queue references after initialization from Context. Orchestrates draw calls by binding Material pipelines and Mesh vertex data into a RenderPass. Does not own raw hardware resources externally or RenderPipelines/shaders. |
| **frame** | Per-frame RAII wrapper around the surface texture and its TextureView. Exists only for the duration of a single rendering pass. |
| **input** | `InputState` (Step 15.B) — unified cross-frame keyboard/mouse state (pressed/held/released, mouse delta, wheel scroll). Rotated by `begin_frame`/`end_frame` around `AppHandler::update`; exposed by `App` as a public `input` field. |
## Interaction with Other Modules
+2 -2
View File
@@ -10,10 +10,10 @@
//! - **error**: returns WsgError variants from all fallible methods.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Phase de Déclaration**: Context is created once at application startup before the render loop begins.
//! - **Declaration Phase**: Context is created once at application startup before the render loop begins.
//! This follows the declarative workflow where all GPU state is configured upfront.
//! - **Injection Async**: Context::new() is async because the runtime must be injected at creation time.
//! - **Accès Bas-Niveau**: Advanced users can bypass the Scene facade and manipulate Context directly
//! - **Low-Level Access**: Advanced users can bypass the Scene facade and manipulate Context directly
//! through App.renderer(), App.context(), etc., for fine-grained control over wgpu handles.
use std::sync::Arc;
+6 -2
View File
@@ -9,12 +9,16 @@
//! - **context**: provides the Surface from which Frame acquires the current texture.
//! - **renderer**: passes Frame's TextureView to render() as the color attachment target.
//! - **error**: does not use errors directly; Frame::new() panics on acquisition failure while
//! Frame::try_new() returns Option<Self> for graceful recovery.
//! Frame::try_new() returns `Option<Self>` for graceful recovery.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Phase d'Exécution**: Frame is acquired at the start of each render loop iteration and released after rendering.
//! - **Execution Phase**: Frame is acquired at the start of each render loop iteration and released after rendering.
//! - **Ergonomie**: Users interact with frames through Renderer::render() + Renderer::present(), not directly with wgpu handles.
/// A per-frame RAII wrapper around the surface texture and its `TextureView`.
/// Owned for the duration of a single render pass: acquired via `Frame::new()`/`try_new()` at the
/// start of each frame loop iteration, used by `Renderer` as the color attachment target, then
/// dropped after `present()` submits it to the GPU queue.
pub struct Frame {
/// The GPU surface texture representing the current display buffer to be presented.
pub surface_texture: wgpu::SurfaceTexture,
+178
View File
@@ -0,0 +1,178 @@
//! # Frustum Module
//!
//! View-projection frustum representation and plane extraction, for frustum culling (Phase 3,
//! Step 15.6). Planes follow the Gribb-Hartmann convention, adapted to WebGPU's `[0, 1]` clip-space
//! z range (the `directx` projection produced by [`crate::resources::Camera::projection_matrix`]).
//!
//! Each plane is a `[f32; 4]` `(normal, d)` such that a world point `p` is **inside** the frustum
//! iff `dot(p, normal) + d >= 0` for every plane. The six planes are extracted from the rows of the
//! view-projection matrix `M` (world to clip space), whose NDC conventions are x, y in `[-1, 1]` and
//! z in `[0, 1]`:
//!
//! | plane | clip-space inequality | row combination |
//! |--------|-----------------------|-----------------|
//! | left | cx + cw >= 0 | w + x |
//! | right | -cx + cw >= 0 | w - x |
//! | bottom | cy + cw >= 0 | w + y |
//! | top | -cy + cw >= 0 | w - y |
//! | near | cz >= 0 | z |
//! | far | -cz + cw >= 0 | w - z |
//!
//! (For the `[0, 1]` z range the near plane is the z row alone — `cz >= 0` — whereas the classic
//! `[-1, 1]` Gribb-Hartmann uses `w + z`. The far plane `w - z` is the same in both.)
//! Each plane is normalized to a unit normal so the signed-distance test is scale-invariant.
use glam::{Mat4, Vec3, Vec4};
/// A view-projection frustum represented by its six bounding planes.
///
/// Each plane is a `[f32; 4]` `(normal, d)`: a world point `p` is inside when
/// `dot(p, normal) + d >= 0`. Built from a view-projection matrix via
/// [`Frustum::from_view_proj`] and uploaded to the GPU culling compute shader (Phase 3).
#[derive(Debug, Clone, Copy)]
pub struct Frustum {
/// The six frustum planes, order: `[left, right, bottom, top, near, far]`.
pub planes: [[f32; 4]; 6],
}
impl Frustum {
/// Extracts the six frustum planes from a view-projection matrix (Gribb-Hartmann, adapted to
/// WebGPU's `[0, 1]` clip-space z). Inputs: m — the `projection * view` matrix (world to clip
/// space). Returns the frustum with unit-length plane normals.
pub fn from_view_proj(m: &Mat4) -> Self {
// glam stores Mat4 by column; transpose so `.x_axis`/`.y_axis`/... are the rows of M,
// i.e. the clip-space basis vectors the Gribb-Hartmann method combines.
let mt = m.transpose();
let r0 = mt.x_axis; // row 0 of M -> clip x
let r1 = mt.y_axis; // row 1 of M -> clip y
let r2 = mt.z_axis; // row 2 of M -> clip z
let r3 = mt.w_axis; // row 3 of M -> clip w
let raw: [Vec4; 6] = [
r3 + r0, // left
r3 - r0, // right
r3 + r1, // bottom
r3 - r1, // top
r2, // near
r3 - r2, // far
];
let planes = raw.map(|p| {
let n = Vec3::new(p.x, p.y, p.z);
let len = n.length();
if len > 1e-8 {
let nn = n / len;
[nn.x, nn.y, nn.z, p.w / len]
} else {
[0.0, 0.0, 0.0, 0.0]
}
});
Self { planes }
}
/// Tests whether a world-space point lies inside the frustum (inside every plane).
/// Inputs: p — a world-space point. Returns true if it satisfies all six plane inequalities.
pub fn contains_point(&self, p: Vec3) -> bool {
self.planes
.iter()
.all(|plane| plane[0] * p.x + plane[1] * p.y + plane[2] * p.z + plane[3] >= 0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resources::camera::Camera;
/// Builds the view-projection matrix for a camera at `(0,0,d)` looking at the origin (45 deg fov,
/// near 0.1, far 100), matching the `directx` (WebGPU `[0,1]`) projection used by the renderer.
fn vp(d: f32) -> Mat4 {
let cam = Camera::new(Vec3::new(0.0, 0.0, d), Vec3::ZERO, Vec3::Y).with_perspective(
45.0_f32.to_radians(),
0.1,
100.0,
);
cam.projection_matrix(1.0) * cam.view_matrix()
}
#[test]
fn origin_inside_when_camera_looks_at_it() {
let fr = Frustum::from_view_proj(&vp(10.0));
assert!(
fr.contains_point(Vec3::new(0.0, 0.0, 0.0)),
"the look-at target must be inside the frustum"
);
}
#[test]
fn behind_camera_is_culled() {
let fr = Frustum::from_view_proj(&vp(10.0));
// Camera at z = 10 looks toward -z; a point at z = 50 is behind it.
assert!(
!fr.contains_point(Vec3::new(0.0, 0.0, 50.0)),
"a point behind the camera must be culled"
);
}
#[test]
fn far_to_the_side_is_culled() {
let fr = Frustum::from_view_proj(&vp(10.0));
// Far off to the side, well outside the 45-degree field of view.
assert!(
!fr.contains_point(Vec3::new(1000.0, 0.0, 0.0)),
"a point far to the side must be culled"
);
}
#[test]
fn beyond_far_plane_is_culled() {
let fr = Frustum::from_view_proj(&vp(10.0));
// z = -500 is 510 units in front of the camera (at z = 10), beyond far = 100.
assert!(
!fr.contains_point(Vec3::new(0.0, 0.0, -500.0)),
"a point beyond the far plane must be culled"
);
}
#[test]
fn planes_are_unit_length() {
let fr = Frustum::from_view_proj(&vp(10.0));
for plane in fr.planes {
let len = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
assert!(
(len - 1.0).abs() < 1e-3,
"plane normal must be unit length, got {len}"
);
}
}
/// Reproduces the `demo` example's exact camera + entity layout and confirms the GPU cull
/// pass would NOT cull any of them (they sit at radius 1.7 around the origin, in front of the
/// orbital camera). If this fails, the demo's black window is a frustum-culling bug.
#[test]
fn demo_camera_sees_all_primitives() {
use crate::resources::CameraController;
let mut ctrl = CameraController::default();
ctrl.yaw = 0.6;
ctrl.pitch = 0.35;
ctrl.distance = 6.5;
ctrl.target = Vec3::ZERO;
let mut cam = Camera::default();
ctrl.apply_to(&mut cam);
let vp = cam.projection_matrix(1.0) * cam.view_matrix();
let fr = Frustum::from_view_proj(&vp);
// Ground plane center (origin).
assert!(
fr.contains_point(Vec3::ZERO),
"origin (ground center) must be inside"
);
// The six primitives, placed by demo::place at radius 1.7, y = 0.5.
for i in 0..6 {
let a = i as f32 / 6.0 * std::f32::consts::TAU;
let p = Vec3::new(a.cos() * 1.7, 0.5, a.sin() * 1.7);
assert!(
fr.contains_point(p),
"primitive {i} at {} must be inside the frustum",
p
);
}
}
}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
//! # HDR / Tone Mapping Configuration (Étape 20)
//!
//! Defines the `ToneMapper` enum (selects the tone mapping curve) and provides the
//! configuration passed to the `Renderer` when HDR is enabled. The HDR pipeline
//! (offscreen `Rgba16Float` texture + fullscreen tone mapping pass) is **opt-in**:
//! without it, the renderer draws directly to the sRGB surface (zero overhead).
/// Selects the tone mapping curve applied by the HDR pass.
///
/// The choice is compiled into the pipeline at construction time (one entry point per
/// variant) — there is no runtime branching cost.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToneMapper {
/// ACES Filmic (Narkowicz 2015 approximation). Cinematic contrast, used in AAA
/// games and film pipelines. Softly compresses highlights while preserving
/// midtone contrast.
Aces,
/// Reinhard: `x / (1 + x)`. Simple, flat response. Less contrast than ACES but
/// computationally trivial.
Reinhard,
}
impl ToneMapper {
/// Returns the WGSL entry point name for this tone mapper variant.
pub(crate) fn entry_point(&self) -> &'static str {
match self {
ToneMapper::Aces => "fs_aces",
ToneMapper::Reinhard => "fs_reinhard",
}
}
/// Human-readable label (for debug output).
pub fn label(&self) -> &'static str {
match self {
ToneMapper::Aces => "ACES",
ToneMapper::Reinhard => "Reinhard",
}
}
}
impl std::fmt::Display for ToneMapper {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
+332
View File
@@ -0,0 +1,332 @@
//! # Input Module — Unified Input State (Step 15, ROADMAP 2.3)
//!
//! **Unified** input state (keyboard / mouse / wheel) with cross-frame
//! **pressed / held / released** semantics, fed by **winit** events (`WindowEvent`), on the **CPU
//! (Rust)** side — WGSL (the GPU shader language) has no I/O. This module is embodied in `App::input`
//! and driven by the loop: `begin_frame()` before `AppHandler::update`, `end_frame()` after.
//!
//! ## Conventions
//! - **Keyboard**: identified by `KeyCode` (physical key, independent of the AZERTY/QWERTY layout:
//! the Z key on AZERTY is `KeyCode::KeyW`). `pressed`/`released` are valid for a single frame,
//! `held` stays true as long as the key is held down.
//! - **Mouse**: absolute position (pixels), per-frame delta (derived from `CursorMoved`, i.e. relative
//! movement — suitable for a drag-orbit camera), buttons
//! `pressed`/`held`/`released`, wheel (`scroll`), `y > 0` = wheel upward.
//! - **Gamepad**: reserved for a future minimal v1 (DRAFT D7); the API is ready to accept
//! a `GamepadState` without breaking existing code (the final example only needs keyboard + mouse).
//!
//! ## Query examples (in `AppHandler::update`)
//! ```
//! # use winit::keyboard::{KeyCode, PhysicalKey};
//! # fn demo(input: &wsg_lib::core::input::InputState) {
//! if input.key_held(KeyCode::KeyW) { /* move forward */ }
//! if input.key_pressed(KeyCode::Space) { /* jump */ }
//! let (dx, dy) = input.mouse_delta();
//! if input.mouse_button_held(winit::event::MouseButton::Left) { /* orbit */ }
//! let (_, zoom) = input.scroll_delta();
//! # }
//! ```
use std::collections::HashSet;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use winit::keyboard::{KeyCode, PhysicalKey};
/// Unified input state, aggregate of the keyboard, mouse and wheel groups. It is **refreshed every
/// frame** by `App` via `begin_frame`/`end_frame`, and read by the user in
/// `AppHandler::update` via `app.input`.
#[derive(Debug, Default, Clone)]
pub struct InputState {
// ---- Keyboard ----
/// Physically held-down keys as of now (persists across frames).
held: HashSet<KeyCode>,
/// Keys pressed during the current frame (valid for a single frame).
pressed: HashSet<KeyCode>,
/// Keys released during the current frame (valid for a single frame).
released: HashSet<KeyCode>,
/// `pressed` accumulator between two `begin_frame` calls (consumed on rotation).
frame_pressed: HashSet<KeyCode>,
/// `released` accumulator between two `begin_frame` calls.
frame_released: HashSet<KeyCode>,
// ---- Mouse ----
/// Absolute cursor position in pixels (last received).
mouse_position: (f32, f32),
/// Previous absolute position, to derive the `CursorMoved` delta.
last_mouse_position: Option<(f32, f32)>,
/// Frame accumulator for the relative movement (events between two `begin_frame` calls),
/// rotated into `mouse_delta` at the next `begin_frame` (same pattern as the keyboard).
frame_mouse_delta: (f32, f32),
/// Cumulative relative movement during the current frame (queryable in `update`).
mouse_delta: (f32, f32),
/// Buttons currently held down.
held_buttons: HashSet<MouseButton>,
/// Buttons pressed during the current frame.
pressed_buttons: HashSet<MouseButton>,
/// Buttons released during the current frame.
released_buttons: HashSet<MouseButton>,
/// Button accumulators between two `begin_frame` calls.
frame_pressed_buttons: HashSet<MouseButton>,
frame_released_buttons: HashSet<MouseButton>,
// ---- Wheel ----
/// Frame accumulator for the scroll (x, y) (events between two `begin_frame` calls),
/// rotated into `scroll` at the next `begin_frame`.
frame_scroll: (f32, f32),
/// Cumulative scroll during the current frame (x, y) (queryable in `update`).
scroll: (f32, f32),
// ---- Gamepad (reserved) ----
// (DRAFT D7: optional minimal v1, deferred — the API will extend without breakage.)
}
impl InputState {
/// Creates a fresh `InputState` (all states empty). Equivalent to `Default`.
pub fn new() -> Self {
Self::default()
}
/// Consumes a winit window event and updates the internal state (accumulators). Irrelevant
/// events are ignored. Rotation into the queryable sets (`pressed`/`released`) happens at the
/// next `begin_frame`.
pub fn handle_window_event(&mut self, event: &WindowEvent) {
match event {
WindowEvent::KeyboardInput { event: ke, .. } => {
let PhysicalKey::Code(code) = ke.physical_key else {
return; // non-character keys (e.g. system keys) ignored
};
self.key_input(code, ke.state);
}
WindowEvent::MouseInput { state, button, .. } => self.mouse_button(*button, *state),
WindowEvent::CursorMoved { position, .. } => {
self.cursor_move(position.x as f32, position.y as f32);
}
WindowEvent::MouseWheel { delta, .. } => match delta {
MouseScrollDelta::LineDelta(x, y) => self.wheel(*x, *y),
// PixelDelta (most Wayland compositors) reports raw pixels — one wheel notch is
// typically ~32 px, so normalize to line (notch) units to keep `scroll_delta()`
// in the same scale as LineDelta backends (X11). Without this, `zoom()` would
// apply `factor^100` per notch and snap to the clamp in a single wheel step.
MouseScrollDelta::PixelDelta(p) => self.wheel(p.x as f32 / 32.0, p.y as f32 / 32.0),
},
_ => {}
}
}
/// Records a raw keyboard event (physical key + state), called by
/// [`InputState::handle_window_event`]. Split out so it can be tested without building a `KeyEvent`.
fn key_input(&mut self, code: KeyCode, state: ElementState) {
match state {
ElementState::Pressed => {
self.held.insert(code);
self.frame_pressed.insert(code);
}
ElementState::Released => {
self.held.remove(&code);
self.frame_released.insert(code);
}
}
}
/// Records a raw mouse-button event, called by [`InputState::handle_window_event`].
fn mouse_button(&mut self, button: MouseButton, state: ElementState) {
match state {
ElementState::Pressed => {
self.held_buttons.insert(button);
self.frame_pressed_buttons.insert(button);
}
ElementState::Released => {
self.held_buttons.remove(&button);
self.frame_released_buttons.insert(button);
}
}
}
/// Updates the cursor position and accumulates the relative movement in the frame buffer. Called
/// by [`InputState::handle_window_event`]; the frame buffer is rotated into the queryable
/// `mouse_delta` at the next [`InputState::begin_frame`].
fn cursor_move(&mut self, x: f32, y: f32) {
if let Some((px, py)) = self.last_mouse_position {
self.frame_mouse_delta.0 += x - px;
self.frame_mouse_delta.1 += y - py;
}
self.last_mouse_position = Some((x, y));
self.mouse_position = (x, y);
}
/// Accumulates the wheel scroll in the frame buffer (in **line/notch units** — `handle_window_event`
/// normalizes `PixelDelta` by /32 before calling this). The frame buffer is rotated into the
/// queryable `scroll` at the next [`InputState::begin_frame`].
fn wheel(&mut self, dx: f32, dy: f32) {
self.frame_scroll.0 += dx;
self.frame_scroll.1 += dy;
}
/// Starts a new input frame: **rotates** all the event accumulators (keyboard pressed/released,
/// buttons, mouse delta and wheel — accumulated between two `begin_frame` calls) into the
/// queryable state. Call this **before** `AppHandler::update`.
pub fn begin_frame(&mut self) {
self.pressed = std::mem::take(&mut self.frame_pressed);
self.released = std::mem::take(&mut self.frame_released);
self.pressed_buttons = std::mem::take(&mut self.frame_pressed_buttons);
self.released_buttons = std::mem::take(&mut self.frame_released_buttons);
self.mouse_delta = self.frame_mouse_delta;
self.frame_mouse_delta = (0.0, 0.0);
self.scroll = self.frame_scroll;
self.frame_scroll = (0.0, 0.0);
}
/// Ends a frame: clears the transient state consumed by `update` (`pressed`/`released`, button
/// sets, queryable mouse delta and wheel). The `held` states and the cursor position are kept.
/// Call this **after** `AppHandler::update` (or `render`).
pub fn end_frame(&mut self) {
self.pressed.clear();
self.released.clear();
self.pressed_buttons.clear();
self.released_buttons.clear();
self.mouse_delta = (0.0, 0.0);
self.scroll = (0.0, 0.0);
}
// ---- Keyboard queries ----
/// True if `code` was **pressed** during the current frame (a single frame only).
pub fn key_pressed(&self, code: KeyCode) -> bool {
self.pressed.contains(&code)
}
/// True if `code` is **held** down (persists across frames).
pub fn key_held(&self, code: KeyCode) -> bool {
self.held.contains(&code)
}
/// True if `code` was **released** during the current frame (a single frame only).
pub fn key_released(&self, code: KeyCode) -> bool {
self.released.contains(&code)
}
// ---- Mouse queries ----
/// Absolute cursor position in pixels (last position received).
pub fn mouse_position(&self) -> (f32, f32) {
self.mouse_position
}
/// Cumulative relative mouse movement during the current frame.
pub fn mouse_delta(&self) -> (f32, f32) {
self.mouse_delta
}
/// Cumulative wheel scroll during the current frame, in **line (notch) units**
/// (`(dx, dy)`, `dy > 0` = wheel up). `PixelDelta` events are normalized by /32 so the scale
/// is backend-independent (one physical wheel notch ≈ 1.0).
pub fn scroll_delta(&self) -> (f32, f32) {
self.scroll
}
/// True if `button` was **pressed** during the current frame (a single frame only).
pub fn mouse_button_pressed(&self, button: MouseButton) -> bool {
self.pressed_buttons.contains(&button)
}
/// True if `button` is **held** down (persists across frames).
pub fn mouse_button_held(&self, button: MouseButton) -> bool {
self.held_buttons.contains(&button)
}
/// True if `button` was **released** during the current frame (a single frame only).
pub fn mouse_button_released(&self, button: MouseButton) -> bool {
self.released_buttons.contains(&button)
}
}
#[cfg(test)]
mod tests {
use super::*;
use winit::event::MouseButton;
#[test]
fn keyboard_pressed_held_released_lifecycle() {
let mut input = InputState::new();
input.key_input(KeyCode::KeyW, ElementState::Pressed);
input.begin_frame();
assert!(input.key_pressed(KeyCode::KeyW));
assert!(input.key_held(KeyCode::KeyW));
assert!(!input.key_released(KeyCode::KeyW));
input.end_frame();
// Next frame without a new event: no longer "pressed", still "held".
input.begin_frame();
assert!(!input.key_pressed(KeyCode::KeyW));
assert!(input.key_held(KeyCode::KeyW));
input.end_frame();
// Release.
input.key_input(KeyCode::KeyW, ElementState::Released);
input.begin_frame();
assert!(input.key_released(KeyCode::KeyW));
assert!(!input.key_held(KeyCode::KeyW));
}
#[test]
fn mouse_buttons_lifecycle() {
let mut input = InputState::new();
input.mouse_button(MouseButton::Left, ElementState::Pressed);
input.begin_frame();
assert!(input.mouse_button_pressed(MouseButton::Left));
assert!(input.mouse_button_held(MouseButton::Left));
input.end_frame();
input.begin_frame();
assert!(!input.mouse_button_pressed(MouseButton::Left));
assert!(input.mouse_button_held(MouseButton::Left));
input.mouse_button(MouseButton::Left, ElementState::Released);
input.end_frame();
input.begin_frame();
assert!(input.mouse_button_released(MouseButton::Left));
assert!(!input.mouse_button_held(MouseButton::Left));
}
#[test]
fn mouse_delta_and_position_accumulate() {
// Real winit order: events arrive BETWEEN two frames, then `begin_frame` rotates the
// accumulator into the queryable delta (a `begin_frame` before the events would not lose
// them, the queryable copy is separate from the frame buffer).
let mut input = InputState::new();
input.begin_frame(); // frame 1 starts (empty)
input.cursor_move(10.0, 20.0);
input.cursor_move(30.0, 40.0);
input.end_frame();
// Frame 2 starts: the movement accumulated during frame 1 is rotated into the queryable
// delta and read by `update`.
input.begin_frame();
assert_eq!(input.mouse_delta(), (20.0, 20.0));
assert_eq!(input.mouse_position(), (30.0, 40.0));
input.end_frame();
// Frame 3 without new events: the queryable delta is back to zero, the position persists.
input.begin_frame();
assert_eq!(input.mouse_delta(), (0.0, 0.0));
assert_eq!(input.mouse_position(), (30.0, 40.0));
// Frame 4: a small movement accumulates and is rotated in again.
input.cursor_move(31.0, 42.0);
input.begin_frame();
assert_eq!(input.mouse_delta(), (1.0, 2.0));
}
#[test]
fn scroll_accumulates_per_frame() {
// Real winit order: wheel events accumulate between frames, `begin_frame` rotates them.
let mut input = InputState::new();
input.wheel(1.0, 2.0);
input.wheel(0.5, -1.0);
input.begin_frame();
assert_eq!(input.scroll_delta(), (1.5, 1.0));
input.end_frame();
// Next frame without new scroll: the queryable delta is zero.
input.begin_frame();
assert_eq!(input.scroll_delta(), (0.0, 0.0));
}
#[test]
fn default_is_empty() {
let input = InputState::default();
assert!(!input.key_held(KeyCode::KeyW));
assert!(!input.mouse_button_held(MouseButton::Left));
assert_eq!(input.mouse_delta(), (0.0, 0.0));
assert_eq!(input.scroll_delta(), (0.0, 0.0));
}
}
+248
View File
@@ -0,0 +1,248 @@
//! # LOD — Per-frame Level Selection (pure, testable without a GPU)
//!
//! The pure functions behind the LOD feature (Step 19, D1/D4/D8). Each frame the **CPU**
//! decides which detail level every entity draws; these functions do that math:
//!
//! - [`projected_radius_px`]: the entity's *perceived size* — its bounding-sphere radius in
//! screen pixels (the **same sphere** the GPU frustum culling uses, D8);
//! - [`lod_level`]: the level decision with **asymmetric hysteresis** (D4) — the core
//! anti-flicker mechanism.
//!
//! Both are pure (no GPU, no state beyond the caller-supplied `last` level) → unit-testable.
//! The Renderer calls them per slot each frame and uploads the resulting levels to the GPU,
//! which only maps level → draw args (the packed-buffer offsets live in the per-mesh LOD
//! table — see `resources::uniform::LodTable`).
use glam::{Mat4, Vec3, Vec4};
/// Projected radius (in **pixels**) of a bounding sphere, given the camera's view/projection.
///
/// The sphere center (world space) is transformed into view space; a sphere at depth `d` with
/// radius `r` subtends `r / d` in view space, which the projection's vertical scale
/// (`proj.y.y = 1 / tan(fov / 2)`) maps to NDC — multiplied by `height_px / 2` (half the
/// viewport height in pixels) gives pixels.
///
/// A sphere whose center is inside/behind the near plane (`depth <= 1e-4`) returns
/// `f32::INFINITY` — the entity dominates the screen, so the finest level (0) is chosen.
pub fn projected_radius_px(
center_world: Vec3,
radius: f32,
view: Mat4,
proj: Mat4,
height_px: f32,
) -> f32 {
let v = view * Vec4::new(center_world.x, center_world.y, center_world.z, 1.0);
let depth = -v.z; // view space: the camera looks along -Z (glam `look_at_mat4`)
if depth <= 1e-4 {
return f32::INFINITY;
}
(radius / depth) * proj.y_axis.y * (height_px * 0.5)
}
/// Level decision with **asymmetric hysteresis** (Step 19, D4).
///
/// `thresholds` is a **descending** pixel radius: `thresholds[k]` is the radius *above which*
/// level k+1 is required (i.e. level k is sufficient up to that bound; level 0 has no bound).
/// Levels beyond the threshold count share the last bound (clamped) — e.g. with `[48, 12]`
/// only the first three levels are distinct.
///
/// Hysteresis (dead band):
/// - to a **finer** level: immediate, as soon as `radius_px` exceeds the current level's bound;
/// - to a **coarser** level: only if `radius_px <= bound(k) * 0.8` (20 % dead band), stepped
/// incrementally (each intermediate bound × 0.8 must hold).
///
/// The "detail loss" pop (going coarser) is therefore delayed; the "detail regain" pop (going
/// finer) is immediate — standard engine practice. `f32::INFINITY` (object at the camera)
/// always returns 0. The result is always within `0..=max_level`.
pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32]) -> u32 {
if radius_px.is_infinite() || max_level == 0 || thresholds.is_empty() {
return 0;
}
// Bound for level k+1: the k-th threshold, clamped for levels beyond the threshold count.
let bound = |k: u32| thresholds[(k as usize).min(thresholds.len() - 1)];
let last = (last as usize).min(max_level as usize) as u32;
// Target without hysteresis: the coarsest level whose bound is still satisfied.
let mut target = 0u32;
let mut k = 0u32;
while k < max_level {
if radius_px <= bound(k) {
target = k + 1;
k += 1;
} else {
break;
}
}
if target <= last {
// Finer or equal: immediate (no dead band on the way to more detail).
target
} else {
// Coarser: 20 % dead band per step, incremental.
let mut lvl = last;
while lvl < target {
if radius_px <= bound(lvl) * 0.8 {
lvl += 1;
} else {
break;
}
}
lvl
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Mat4;
use glam::Vec3;
/// A camera at `(0, 0, dist)` looking at the origin, up `+Y`, with vertical `fov`.
fn camera(dist: f32, fov: f32) -> (Mat4, Mat4) {
let view =
glam::camera::rh::view::look_at_mat4(Vec3::new(0.0, 0.0, dist), Vec3::ZERO, Vec3::Y);
let proj = glam::camera::rh::proj::opengl::perspective(fov, 1.0, 0.1, 100.0);
(view, proj)
}
// ========================================================================
// projected_radius_px
// ========================================================================
#[test]
fn projected_radius_analytic() {
// Sphere of radius 1 at the origin; camera 5 units away; fov = 90°
// (proj vertical scale = 1/tan(45°) = 1); viewport 1000 px tall.
// Expected: (1 / 5) * 1 * 500 = 100 px.
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
let r = projected_radius_px(Vec3::ZERO, 1.0, view, proj, 1000.0);
assert!((r - 100.0).abs() < 1e-3, "expected 100 px, got {r}");
}
#[test]
fn projected_radius_scale_invariance() {
// 10x bigger object 10x further away → same projected radius (similarity).
let (view1, proj1) = camera(5.0, std::f32::consts::PI / 2.0);
let (view2, proj2) = camera(50.0, std::f32::consts::PI / 2.0);
let r1 = projected_radius_px(Vec3::ZERO, 1.0, view1, proj1, 1000.0);
let r2 = projected_radius_px(Vec3::ZERO, 10.0, view2, proj2, 1000.0);
assert!((r1 - r2).abs() < 1e-2, "expected equal, got {r1} vs {r2}");
}
#[test]
fn projected_radius_at_camera_is_infinite() {
// Center at the camera position → depth 0 → INFINITY (finest level).
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
let r = projected_radius_px(Vec3::new(0.0, 0.0, 5.0), 1.0, view, proj, 1000.0);
assert!(r.is_infinite());
}
#[test]
fn projected_radius_behind_camera_is_infinite() {
// Center behind the camera → negative depth → INFINITY.
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
let r = projected_radius_px(Vec3::new(0.0, 0.0, 20.0), 1.0, view, proj, 1000.0);
assert!(r.is_infinite());
}
#[test]
fn projected_radius_narrower_fov_larger_pixels() {
// Narrower FOV (zoomed in) → LARGER vertical projection scale (1/tan(fov/2)) →
// more pixels for the same sphere at the same distance.
let fov_narrow = std::f32::consts::PI / 3.0; // 60°
let fov_wide = std::f32::consts::PI / 2.0; // 90°
let (v1, p1) = camera(5.0, fov_narrow);
let (v2, p2) = camera(5.0, fov_wide);
let r1 = projected_radius_px(Vec3::ZERO, 1.0, v1, p1, 1000.0);
let r2 = projected_radius_px(Vec3::ZERO, 1.0, v2, p2, 1000.0);
assert!(
r1 > r2,
"narrower FOV should give more pixels: {r1} vs {r2}"
);
}
// ========================================================================
// lod_level
// ========================================================================
#[test]
fn lod_level_simple_thresholds() {
let t = [48.0f32, 12.0];
// r > 48 → level 0 (too big for any coarser level).
assert_eq!(lod_level(100.0, 0, 2, &t), 0);
assert_eq!(lod_level(52.0, 0, 2, &t), 0);
// 48 >= r > 38.4 (0.8·48): target is L1, but the dead band holds it at L0.
assert_eq!(lod_level(44.0, 0, 2, &t), 0);
// r <= 38.4 → L1.
assert_eq!(lod_level(38.4, 0, 2, &t), 1);
assert_eq!(lod_level(30.0, 0, 2, &t), 1);
// 12 > r > 9.6 (0.8·12): target L2, dead band holds at L1.
assert_eq!(lod_level(10.0, 0, 2, &t), 1);
// r <= 9.6 → L2 (both steps pass the band).
assert_eq!(lod_level(9.6, 0, 2, &t), 2);
assert_eq!(lod_level(9.0, 0, 2, &t), 2);
}
#[test]
fn lod_level_finer_is_immediate() {
let t = [48.0f32, 12.0];
// Already coarse (L2); radius grows past 48 → immediately back to L0.
assert_eq!(lod_level(100.0, 2, 2, &t), 0);
// L2, radius between the bounds → immediately to L1.
assert_eq!(lod_level(30.0, 2, 2, &t), 1);
// L1, radius past 48 → immediately to L0.
assert_eq!(lod_level(52.0, 1, 2, &t), 0);
// L1, radius below 12 → target L2 but dead band (10 > 9.6) holds at L1.
assert_eq!(lod_level(10.0, 1, 2, &t), 1);
// L1, radius below 9.6 → L2.
assert_eq!(lod_level(9.0, 1, 2, &t), 2);
}
#[test]
fn lod_level_oscillation_is_stable() {
// Anti-flicker (D4): a radius oscillating ±10 % around threshold 48 (43.2..52.8)
// must not make the level flip back and forth.
let t = [48.0f32];
let mut level = 0u32;
for _ in 0..100 {
for r in [43.2f32, 52.8, 43.2, 52.8] {
level = lod_level(r, level, 2, &t);
}
}
// Whatever level it settled on, it must not have changed on the last pass.
let before = level;
for r in [43.2f32, 52.8, 43.2, 52.8] {
level = lod_level(r, level, 2, &t);
}
assert_eq!(before, level, "level flickered around the threshold");
// From L0 the oscillation never leaves L0 (coarser needs r ≤ 38.4).
assert_eq!(lod_level(43.2, 0, 2, &t), 0);
assert_eq!(lod_level(52.8, 0, 2, &t), 0);
}
#[test]
fn lod_level_clamped_thresholds_for_extra_levels() {
// 4 levels but only 2 thresholds: levels 2 and 3 share the last bound (12).
let t = [48.0f32, 12.0];
// r = 9 passes both bands (38.4, 9.6) AND the clamped third bound (0.8·12) → L3.
assert_eq!(lod_level(9.0, 0, 3, &t), 3);
// r = 10 passes the first two targets but the clamped band holds at L2.
assert_eq!(lod_level(10.0, 0, 3, &t), 1);
}
#[test]
fn lod_level_infinite_returns_zero() {
let t = [48.0f32, 12.0];
assert_eq!(lod_level(f32::INFINITY, 2, 2, &t), 0);
assert_eq!(lod_level(f32::INFINITY, 0, 2, &t), 0);
}
#[test]
fn lod_level_degenerate_inputs() {
let t = [48.0f32];
assert_eq!(lod_level(1.0, 5, 0, &t), 0); // max_level 0
assert_eq!(lod_level(1.0, 0, 2, &[]), 0); // no thresholds
// Stale `last` beyond max_level is clamped, not a panic.
assert_eq!(lod_level(100.0, 9, 2, &t), 0);
}
}
+14
View File
@@ -11,9 +11,23 @@
pub mod context;
pub mod frame;
pub mod frustum;
pub mod geometry;
pub mod hdr;
pub mod input;
pub mod lod;
pub mod renderer;
pub mod shadow;
pub mod transform;
// Re-exports
pub use context::Context;
pub use frame::Frame;
pub use frustum::Frustum;
pub use geometry::{BBox, Geometry, GeometryError};
pub use hdr::ToneMapper;
pub use input::InputState;
pub use lod::{lod_level, projected_radius_px};
pub use renderer::Renderer;
pub use shadow::ShadowConfig;
pub use transform::Transform;
+1630 -23
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
//! Shadow mapping configuration.
//!
//! Users of the WSG library can tune shadow quality/behavior without modifying the library
//! source. All fields have sensible defaults (see [`ShadowConfig::default`]); pass a custom
//! config via [`AppBuilder::with_shadow_config`](crate::app::AppBuilder::with_shadow_config).
use crate::utils::conf::{
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
SHADOW_SLOPE_BIAS,
};
/// Configuration for the shadow mapping system.
///
/// Controls the shadow map resolution, depth bias (anti-acne), and the orthographic frustum
/// that frames the scene from the shadow-casting light's point of view.
///
/// # Usage
/// ```ignore
/// use wsg_lib::core::ShadowConfig;
///
/// let app = AppBuilder::new()
/// .with_shadow_config(ShadowConfig {
/// map_size: 2048, // higher resolution → sharper shadows
/// depth_bias: 0.002, // constant bias (NDC depth units)
/// slope_bias: 0.006, // slope-scaled bias coefficient
/// scene_center: [0.0, 0.0, 0.0], // where to center the ortho frustum
/// scene_radius: 8.0, // half-extent of the ortho frustum (world units)
/// ..Default::default()
/// })
/// .build()
/// .await?;
/// ```
#[derive(Debug, Clone)]
pub struct ShadowConfig {
/// Shadow map resolution in pixels per side (square map). Higher = sharper shadows,
/// more VRAM. Defaults to 1024.
pub map_size: u32,
/// Constant depth bias subtracted from the reference depth before the shadow comparison.
/// This is the *minimum* bias; the slope-scaled term adds more for grazing angles.
/// Defaults to 0.002.
pub depth_bias: f32,
/// Slope-scaled bias coefficient. The effective bias is
/// `max(depth_bias, slope_bias * (1.0 - |dot(N, L)|))` — it grows as the surface normal
/// becomes perpendicular to the light direction, where shadow acne is worst.
/// Defaults to 0.004.
pub slope_bias: f32,
/// World-space center of the orthographic shadow frustum. The frustum is oriented along
/// the shadow light's direction and centered on this point. Defaults to `[0.0, 0.0, 0.0]`.
pub scene_center: [f32; 3],
/// Half-extent (world units) of the orthographic shadow frustum. Must be large enough to
/// encompass all shadow-casting and receiving geometry. Defaults to 5.0.
pub scene_radius: f32,
}
impl Default for ShadowConfig {
fn default() -> Self {
Self {
map_size: SHADOW_MAP_SIZE,
depth_bias: SHADOW_DEPTH_BIAS,
slope_bias: SHADOW_SLOPE_BIAS,
scene_center: SHADOW_SCENE_CENTER,
scene_radius: SHADOW_SCENE_RADIUS,
}
}
}
+104
View File
@@ -0,0 +1,104 @@
//! # Transform Module
//!
//! Defines the `Transform` struct for representing object transformations in 3D space,
//! including translation, rotation, and scale. Also provides functionality to convert
//! the transform into a 4x4 matrix for use in shaders.
//!
//! ## Usage
//! - Used by `Scene` entities to define their position in the world
//! - Converted to `Mat4` for MVP matrix calculations in the renderer
//!
//! ## Related Types
//! - `Transform`: Core struct for position/rotation/scale
//! - `to_matrix()`: Converts transform to a 4x4 matrix
use glam::{Mat4, Quat, Vec3};
/// Represents a 3D transformation with translation, rotation, and scale.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Transform {
/// Translation vector in 3D space
pub translation: Vec3,
/// Rotation as a quaternion
pub rotation: Quat,
/// Scale factors along X, Y, Z axes
pub scale: Vec3,
}
impl Transform {
/// Creates a new identity transform.
pub fn identity() -> Self {
Self {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
}
}
/// Converts the transform into a 4x4 transformation matrix.
///
/// # Returns
/// A `Mat4` representing the transformation matrix
pub fn to_matrix(&self) -> Mat4 {
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vec_close(a: Vec3, b: Vec3) -> bool {
(a - b).length() < 1e-5
}
#[test]
fn identity_transform_is_identity_matrix() {
assert_eq!(Transform::identity().to_matrix(), Mat4::IDENTITY);
}
#[test]
fn translation_only() {
let t = Transform {
translation: Vec3::new(1.0, 2.0, 3.0),
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
};
assert_eq!(
t.to_matrix(),
Mat4::from_translation(Vec3::new(1.0, 2.0, 3.0))
);
}
#[test]
fn scale_only() {
let t = Transform {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale: Vec3::new(2.0, 3.0, 4.0),
};
assert_eq!(t.to_matrix(), Mat4::from_scale(Vec3::new(2.0, 3.0, 4.0)));
}
#[test]
fn quarter_turn_around_y() {
let t = Transform {
translation: Vec3::ZERO,
rotation: Quat::from_axis_angle(Vec3::Y, std::f32::consts::FRAC_PI_2),
scale: Vec3::ONE,
};
let v = t.to_matrix().transform_point3(Vec3::X);
assert!(vec_close(v, Vec3::new(0.0, 0.0, -1.0)), "got {v}");
}
#[test]
fn combined_trs_moves_a_point() {
let t = Transform {
translation: Vec3::new(10.0, 0.0, 0.0),
rotation: Quat::IDENTITY,
scale: Vec3::new(2.0, 2.0, 2.0),
};
let v = t.to_matrix().transform_point3(Vec3::new(1.0, 0.0, 0.0));
assert!(vec_close(v, Vec3::new(12.0, 0.0, 0.0)), "got {v}");
}
}
+16 -6
View File
@@ -17,18 +17,28 @@
//! the engine "brick by brick" through direct Context/PipelineCache/Renderer manipulation if needed.
use crate::app::App;
use crate::core::Frame;
/// Trait defining user-provided game logic injected into the render loop at two callback points.
/// Users implement this trait to define what happens per-frame: update (pre-render logic) and
/// render (draw call execution). Default implementations provide empty update for convenience.
/// render (draw call execution). Default implementations provide empty update and automatic
/// scene rendering for convenience.
pub trait AppHandler {
/// Called once by `App::run`, right after the window/GPU context are created (winit `resumed`).
/// Use it to register shaders, build Meshes/Materials, and populate `app.scene` before the loop
/// starts. Default implementation does nothing.
/// Inputs: app — mutable reference to the fully-initialized App facade.
fn setup(&mut self, _app: &mut App) {}
/// Called once per frame before rendering begins. Used for physics updates, input processing,
/// entity management, and any other pre-render logic. Default implementation does nothing.
/// Inputs: _app — mutable reference to the App facade providing access to all subsystems.
fn update(&mut self, _app: &mut App) {}
/// Called during each RedrawRequested event after frame acquisition. Used for executing draw calls
/// by iterating Scene entities and calling app.renderer.render(view, mesh, material) per entity.
/// Must be implemented — called every frame that needs rendering.
/// Inputs: app — mutable reference to the App facade providing access to all subsystems.
fn render(&mut self, app: &mut App);
/// Called during each RedrawRequested event after frame acquisition, receiving the current frame.
/// Used for custom draw call execution. Default implementation renders the whole scene
/// automatically (`app.render_scene(frame.view())`), so most users don't need to override it.
/// Advanced users override this method to control drawing manually.
/// Inputs: app — mutable reference to the App facade; frame — the acquired frame exposing its view.
fn render(&mut self, app: &mut App, frame: &Frame) {
app.render_scene(frame.view());
}
}
+27 -6
View File
@@ -1,9 +1,9 @@
//! # WSG Library Crate Root
//!
//! The top-level entry point for the wsg-lib crate. Exposes seven public modules organized by architectural responsibility:
//! **app** (App facade + AppBuilder), **handler** (AppHandler trait), **core** (Manager + Executor layers),
//! **resources** (data types), **pipeline** (shader compilation cache), **scene** (resource graph and entity management),
//! and **utils** (configuration and error handling).
//! The top-level entry point for the wsg-lib crate. Exposes eight public modules organized by architectural responsibility:
//! **app** (App facade + AppBuilder), **handler** (AppHandler trait), **core** (Manager + Executor + geometry types),
//! **mesh** (geometry sources: primitives + import), **resources** (data types), **pipeline** (shader compilation cache),
//! **scene** (resource graph and entity management), **prelude** (glob re-exports), and **utils** (configuration and error handling).
//!
//! ## Module Interaction Map
//! - `core` consumes resources from `resources`, pipelines from `pipeline`, and errors from `utils`.
@@ -22,17 +22,21 @@
//! ```ignore
//! use wsg_lib::core::{Context, Renderer};
//! use wsg_lib::resources::{Mesh, Material, Vertex};
//! use wsg_lib::utils::BASIC_SHADER;
//! use wsg_lib::utils::STANDARD_SHADER;
//! ```
// Warn if a public API item has no rustdoc comment, keeping API coverage at 100%.
#![warn(missing_docs)]
pub mod app;
pub mod core;
pub mod handler;
pub mod mesh;
pub mod pipeline;
pub mod prelude;
pub mod resources;
pub mod scene;
pub mod utils;
pub mod math;
/// Re-export of the high-level application facade for convenient top-level access.
/// Users create App instances via `AppBuilder`, then call `.run(handler)` to start the application.
@@ -41,3 +45,20 @@ pub use crate::app::App;
/// Re-export of the user-defined game logic interface for convenient top-level access.
/// Users implement this trait to define update/render callbacks injected into the render loop.
pub use crate::handler::AppHandler;
/// Re-export of the shadow mapping configuration for convenient top-level access.
/// Users tune shadow quality via `AppBuilder::with_shadow_config`.
pub use crate::core::ShadowConfig;
/// Re-export of the tone mapping curve selector for convenient top-level access.
/// Users enable HDR via `AppBuilder::with_hdr(ToneMapper::Aces)`.
pub use crate::core::ToneMapper;
/// Re-export of the geometry data type (positions, normals, UVs, indices).
pub use crate::core::Geometry;
/// Re-export of the per-entity transform (position + rotation + scale).
pub use crate::core::Transform;
/// Re-export of the axis-aligned bounding box.
pub use crate::core::BBox;
-28
View File
@@ -1,28 +0,0 @@
//! # Geometry Module
//!
//! Defines the `Geometry` struct for storing vertex data of 3D meshes.
//! This module handles the core geometric representation used by meshes.
//!
//! ## Usage
//! - Stores vertex attributes (positions, normals, UVs)
//! - Used by `Mesh` to define its vertex data
//! - Passed to shaders for rendering
//!
//! ## Related Types
//! - `Geometry`: Main struct for vertex data storage
//! - Fields: positions, normals, uvs, indices
/// Represents the geometric data of a 3D mesh.
///
/// This struct stores the core vertex attributes that define a mesh's shape.
#[derive(Debug, Clone)]
pub struct Geometry {
/// Vertex positions as an array of 3D coordinates
pub positions: Vec<[f32; 3]>,
/// Optional vertex normals for lighting calculations
pub normals: Option<Vec<[f32; 3]>>,
/// Optional texture coordinates for UV mapping
pub uvs: Option<Vec<[f32; 2]>>,
/// Optional indices for indexed rendering
pub indices: Option<Vec<u16>>,
}
-23
View File
@@ -1,23 +0,0 @@
//! # Math Module — Geometric and Transformation Utilities
//!
//! Provides core mathematical types and utilities for 3D graphics operations, including:
//! - `Transform` for object positioning, rotation, and scaling
//! - `Camera` for view and projection matrix calculations
//! - `Geometry` for mesh vertex data representation
//!
//! ## Interaction with Other Modules
//! - `scene::Scene` uses `Transform` to manage entity positions
//! - `renderer::Renderer` uses `Transform` and `Camera` to compute matrices for shaders
//! - `resources::Mesh` stores vertex data in `Geometry` format
//!
//! ## Files
//! - `transform.rs`: Defines the `Transform` struct and its conversion to matrix form
//! - `geometry.rs`: Defines the `Geometry` struct for mesh data storage
//! - `camera.rs`: Defines the `Camera` struct and view/projection matrix calculations
pub mod transform;
pub mod geometry;
// Re-exports
pub use transform::Transform;
pub use geometry::Geometry;
-45
View File
@@ -1,45 +0,0 @@
//! # Transform Module
//!
//! Defines the `Transform` struct for representing object transformations in 3D space,
//! including translation, rotation, and scale. Also provides functionality to convert
//! the transform into a 4x4 matrix for use in shaders.
//!
//! ## Usage
//! - Used by `Scene` entities to define their position in the world
//! - Converted to `Mat4` for MVP matrix calculations in the renderer
//!
//! ## Related Types
//! - `Transform`: Core struct for position/rotation/scale
//! - `to_matrix()`: Converts transform to a 4x4 matrix
use glam::{Vec3, Quat, Mat4};
/// Represents a 3D transformation with translation, rotation, and scale.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Transform {
/// Translation vector in 3D space
pub translation: Vec3,
/// Rotation as a quaternion
pub rotation: Quat,
/// Scale factors along X, Y, Z axes
pub scale: Vec3,
}
impl Transform {
/// Creates a new identity transform.
pub fn identity() -> Self {
Self {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
}
}
/// Converts the transform into a 4x4 transformation matrix.
///
/// # Returns
/// A `Mat4` representing the transformation matrix
pub fn to_matrix(&self) -> Mat4 {
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
}
}
+20
View File
@@ -0,0 +1,20 @@
//! glTF 2.0 / GLB loader.
//!
//! **Status: stub** — the full implementation requires the `gltf` crate and will
//! be added in a follow-up. For now, this module compiles (behind `feature = "import-gltf"`)
//! and returns a clear error.
use crate::core::geometry::Geometry;
use crate::mesh::import::MeshImportError;
use std::path::Path;
/// Loads a glTF 2.0 (.gltf JSON) or GLB (.glb binary) file.
///
/// # Errors
/// Always returns [`MeshImportError::Unsupported`] for now (implementation pending).
pub fn load_gltf(path: impl AsRef<Path>) -> Result<Vec<Geometry>, MeshImportError> {
let _ = path;
Err(MeshImportError::Unsupported(
"glTF import is not yet implemented (pending gltf crate wrapper)".into(),
))
}
+31
View File
@@ -0,0 +1,31 @@
//! File import loaders — each behind a feature flag.
//!
//! | Feature | Function | Format |
//!|---------|----------|--------|
//!| `import-obj` | `load_obj(path)` | Wavefront OBJ |
//!| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB |
#[cfg(feature = "import-obj")]
pub mod obj;
#[cfg(feature = "import-gltf")]
#[path = "gltf.rs"]
pub mod gltf_loader;
#[cfg(feature = "import-obj")]
pub use obj::{load_obj, parse_obj};
#[cfg(feature = "import-gltf")]
pub use gltf_loader::load_gltf;
/// Error type for mesh file import.
#[derive(thiserror::Error, Debug)]
pub enum MeshImportError {
/// The file could not be read (I/O error).
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
/// The file content is malformed or cannot be parsed.
#[error("parse error: {0}")]
Parse(String),
/// The file uses features not supported by this loader.
#[error("unsupported format: {0}")]
Unsupported(String),
}
+311
View File
@@ -0,0 +1,311 @@
//! Wavefront OBJ parser — minimal, dependency-free.
//!
//! Supports: `v` (position), `vn` (normal), `vt` (UV), `f` (face, 3-4 verts).
//! Quads are split into triangles via fan triangulation.
use crate::core::geometry::Geometry;
use crate::mesh::import::MeshImportError;
use std::path::Path;
/// Parses a Wavefront OBJ file and returns a single [`Geometry`].
///
/// Supported directives: `v`, `vn`, `vt`, `f` (3 or 4 vertices per face).
/// Vertex references in `f` use 1-based indices.
/// If no `vn` lines are present, normals are computed (area-weighted face normals).
/// If no `vt` lines are present, UVs are omitted.
///
/// # Errors
/// Returns [`MeshImportError::Io`] if the file cannot be read,
/// or [`MeshImportError::Parse`] on malformed input.
pub fn load_obj(path: impl AsRef<Path>) -> Result<Geometry, MeshImportError> {
let content = std::fs::read_to_string(path).map_err(MeshImportError::Io)?;
parse_obj(&content)
}
/// Parses OBJ content from a string. See [`load_obj`] for supported features.
pub fn parse_obj(content: &str) -> Result<Geometry, MeshImportError> {
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut file_normals: Vec<[f32; 3]> = Vec::new();
let mut file_uvs: Vec<[f32; 2]> = Vec::new();
// Unique vertex table: (pos_idx, opt_uv_idx, opt_norm_idx)
let mut vert_table: Vec<(usize, Option<usize>, Option<usize>)> = Vec::new();
let mut indices: Vec<u16> = Vec::new();
for (line_num, raw) in content.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
match parts[0] {
"v" => {
if parts.len() < 4 {
return Err(MeshImportError::Parse(format!(
"line {}: 'v' needs 3+ components",
line_num + 1
)));
}
let x = parts[1].parse::<f32>().map_err(|_| {
MeshImportError::Parse(format!("line {}: bad 'v' x = '{}'", line_num + 1, parts[1]))
})?;
let y = parts[2].parse::<f32>().map_err(|_| {
MeshImportError::Parse(format!("line {}: bad 'v' y = '{}'", line_num + 1, parts[2]))
})?;
let z = parts[3].parse::<f32>().map_err(|_| {
MeshImportError::Parse(format!("line {}: bad 'v' z = '{}'", line_num + 1, parts[3]))
})?;
positions.push([x, y, z]);
}
"vn" => {
if parts.len() < 4 {
return Err(MeshImportError::Parse(format!(
"line {}: 'vn' needs 3+ components",
line_num + 1
)));
}
let x = parts[1].parse::<f32>().map_err(|_| {
MeshImportError::Parse(format!("line {}: bad 'vn' x", line_num + 1))
})?;
let y = parts[2].parse::<f32>().map_err(|_| {
MeshImportError::Parse(format!("line {}: bad 'vn' y", line_num + 1))
})?;
let z = parts[3].parse::<f32>().map_err(|_| {
MeshImportError::Parse(format!("line {}: bad 'vn' z", line_num + 1))
})?;
file_normals.push([x, y, z]);
}
"vt" => {
if parts.len() < 3 {
return Err(MeshImportError::Parse(format!(
"line {}: 'vt' needs 2+ components",
line_num + 1
)));
}
let u = parts[1].parse::<f32>().map_err(|_| {
MeshImportError::Parse(format!("line {}: bad 'vt' u", line_num + 1))
})?;
let v = parts[2].parse::<f32>().map_err(|_| {
MeshImportError::Parse(format!("line {}: bad 'vt' v", line_num + 1))
})?;
file_uvs.push([u, v]);
}
"f" => {
if parts.len() < 4 {
return Err(MeshImportError::Parse(format!(
"line {}: 'f' needs 3+ vertices, got {}",
line_num + 1,
parts.len() - 1
)));
}
// Parse vertex references: "idx" or "idx/uv" or "idx/uv/norm"
let face_verts: Vec<(usize, Option<usize>, Option<usize>)> = parts[1..]
.iter()
.map(|tok| {
let mut fields = tok.split('/');
let idx_str = fields.next().unwrap_or("0");
let uv_str = fields.next();
let norm_str = fields.next();
let idx: usize = idx_str.parse().map_err(|_| {
MeshImportError::Parse(format!(
"line {}: bad face vertex index '{}'",
line_num + 1,
tok
))
})?;
if idx == 0 {
return Err(MeshImportError::Parse(format!(
"line {}: 0-based index not allowed in face",
line_num + 1
)));
}
let uv_idx = parse_opt_idx(uv_str, line_num, "uv")?;
let norm_idx = parse_opt_idx(norm_str, line_num, "norm")?;
Ok((idx - 1, uv_idx, norm_idx))
})
.collect::<Result<_, _>>()?;
// Map to unique vertex indices (dedup by pos+uv+norm tuple)
let mapped: Vec<u16> = face_verts
.iter()
.map(|&(pi, uvi, ni)| {
// Check if this combo already exists
if let Some(pos) = vert_table.iter().position(|&(ep, eu, en)| {
ep == pi && eu == uvi && en == ni
}) {
pos as u16
} else {
vert_table.push((pi, uvi, ni));
(vert_table.len() - 1) as u16
}
})
.collect();
// Fan triangulation
if mapped.len() == 3 {
indices.extend_from_slice(&mapped);
} else if mapped.len() > 3 {
for i in 1..mapped.len() - 1 {
indices.extend_from_slice(&[mapped[0], mapped[i], mapped[i + 1]]);
}
}
}
_ => {} // Ignore unknown directives
}
}
if positions.is_empty() {
return Err(MeshImportError::Parse("no vertices found".into()));
}
if vert_table.is_empty() {
return Err(MeshImportError::Parse("no faces found".into()));
}
// Build output vertex arrays from the table
let mut out_positions = Vec::with_capacity(vert_table.len());
let mut out_normals = Vec::with_capacity(vert_table.len());
let mut out_uvs = Vec::with_capacity(vert_table.len());
let mut has_any_uv = false;
for &(pi, uvi, ni) in &vert_table {
out_positions.push(positions[pi]);
if let Some(ni) = ni {
out_normals.push(file_normals[ni]);
} else {
out_normals.push([0.0, 0.0, 0.0]);
}
if let Some(uvi) = uvi {
out_uvs.push(file_uvs[uvi]);
has_any_uv = true;
} else {
out_uvs.push([0.0, 0.0]);
}
}
// Compute normals if file had none
if file_normals.is_empty() {
compute_normals(&out_positions, &indices, &mut out_normals);
}
let mut geo = Geometry::new(out_positions)
.with_normals(out_normals)
.with_indices(indices);
if has_any_uv {
geo = geo.with_uvs(out_uvs);
}
geo.validate()
.map_err(|e| MeshImportError::Parse(format!("validation failed: {e}")))?;
Ok(geo)
}
fn parse_opt_idx(
field: Option<&str>,
line_num: usize,
what: &str,
) -> Result<Option<usize>, MeshImportError> {
match field {
None | Some("") => Ok(None),
Some(s) => {
let idx: usize = s.parse().map_err(|_| {
MeshImportError::Parse(format!("line {}: bad {what} index '{s}'", line_num + 1))
})?;
if idx == 0 {
return Err(MeshImportError::Parse(format!(
"line {}: 0-based {what} index",
line_num + 1
)));
}
Ok(Some(idx - 1))
}
}
}
/// Computes area-weighted vertex normals from triangle faces.
fn compute_normals(positions: &[[f32; 3]], indices: &[u16], normals: &mut [[f32; 3]]) {
use glam::Vec3;
for n in normals.iter_mut() {
*n = [0.0, 0.0, 0.0];
}
for tri in indices.chunks(3) {
if tri.len() != 3 {
continue;
}
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
let pa = Vec3::from_array(positions[a]);
let pb = Vec3::from_array(positions[b]);
let pc = Vec3::from_array(positions[c]);
let fn_ = (pb - pa).cross(pc - pa);
for idx in [a, b, c] {
let n = &mut normals[idx];
n[0] += fn_.x;
n[1] += fn_.y;
n[2] += fn_.z;
}
}
for n in normals.iter_mut() {
let v = Vec3::from_array(*n);
*n = v.normalize().to_array();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_triangle() {
let content = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
let geo = parse_obj(content).unwrap();
assert_eq!(geo.positions.len(), 3);
assert_eq!(geo.indices.as_ref().unwrap().len(), 3);
geo.validate().unwrap();
}
#[test]
fn parse_quad_splits_to_two_tris() {
let content = "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n";
let geo = parse_obj(content).unwrap();
assert_eq!(geo.positions.len(), 4);
assert_eq!(geo.indices.as_ref().unwrap().len(), 6);
}
#[test]
fn parse_with_normals_and_uvs() {
let content = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nvt 0 0\nvt 1 0\nvt 0 1\nf 1/1/1 2/2/1 3/3/1\n";
let geo = parse_obj(content).unwrap();
assert!(geo.normals.is_some());
assert!(geo.uvs.is_some());
let n = geo.normals.as_ref().unwrap();
assert_eq!(n[0], [0.0, 0.0, 1.0]);
}
#[test]
fn parse_no_normals_computes_them() {
let content = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
let geo = parse_obj(content).unwrap();
let n = geo.normals.as_ref().unwrap();
assert!((n[0][2] - 1.0).abs() < 1e-4, "expected +Z normal, got {:?}", n[0]);
}
#[test]
fn parse_empty_fails() {
assert!(parse_obj("").is_err());
assert!(parse_obj("# just a comment\n").is_err());
}
#[test]
fn parse_malformed_fails() {
assert!(parse_obj("v 1 2\nf 1 2 3\n").is_err());
assert!(parse_obj("v 0 0 0\nv 1 0 0\nf 1 2\n").is_err());
}
#[test]
fn parse_shared_vertex_dedup() {
let content = "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3\nf 1 3 4\n";
let geo = parse_obj(content).unwrap();
assert_eq!(geo.positions.len(), 4);
assert_eq!(geo.indices.as_ref().unwrap().len(), 6);
}
}
+58
View File
@@ -0,0 +1,58 @@
//! # Mesh module — geometry sources for WSG
//!
//! This module is the single entry point for **where geometry data comes from**:
//!
//! - **`primitives`** — procedural generators (cube, sphere, torus, …), each behind a
//! feature flag so you only compile what you need.
//! - **`import`** — file loaders (OBJ, glTF), each behind a feature flag.
//!
//! All sources produce a [`Geometry`] (CPU-side vertex data: positions, normals, UVs,
//! indices). Turning that into a GPU renderable is the job of [`crate::scene::Scene::add_mesh`].
//!
//! ## Usage
//!
//! ```rust
//! use wsg_lib::mesh::cube;
//!
//! // Procedural (feature "prim-cube")
//! let geom = cube(2.0);
//! assert_eq!(geom.positions.len(), 24);
//! ```
//!
//! ## Features
//!
//! | Feature | Provides |
//! |---------|----------|
//! | `prim-cube` | `cube(size)` |
//! | `prim-sphere` | `uv_sphere(…)`, `icosphere(…)` |
//! | `prim-cylinder` | `cylinder(…)` |
//! | `prim-cone` | `cone(…)` |
//! | `prim-torus` | `torus(…)` |
//! | `prim-plane` | `plane(…)` |
//! | `all-prims` | all of the above |
//! | `import-obj` | `load_obj(path)` |
//! | `import-gltf` | `load_gltf(path)` |
pub mod primitives;
#[cfg(feature = "import-obj")]
pub mod import;
// Flat re-exports at the `wsg::mesh` level for convenience.
#[cfg(feature = "prim-cube")]
pub use primitives::cube;
#[cfg(feature = "prim-plane")]
pub use primitives::plane;
#[cfg(feature = "prim-sphere")]
pub use primitives::{icosphere, uv_sphere};
#[cfg(feature = "prim-cylinder")]
pub use primitives::cylinder;
#[cfg(feature = "prim-cone")]
pub use primitives::cone;
#[cfg(feature = "prim-torus")]
pub use primitives::torus;
#[cfg(feature = "import-obj")]
pub use import::load_obj;
#[cfg(feature = "import-gltf")]
pub use import::load_gltf;
+89
View File
@@ -0,0 +1,89 @@
//! Cone primitive — side (apex + base ring) + base cap.
use crate::core::geometry::Geometry;
use glam::Vec3;
/// Generates a cone of radius `radius` and height `height` (apex at +h/2, base at -h/2), closed by a
/// base, with `sectors` segments. Analytical side normals (tilted outward);
/// base normal −Y.
pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry {
let si = sectors.max(3);
let h = height * 0.5;
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
// Side elements: apex + base ring.
let apex = 0u16;
positions.push([0.0, h, 0.0]);
normals.push([0.0, 1.0, 0.0]); // shared apex; normal close to +Y by default
uvs.push([0.5, 1.0]);
let base_start = 1u16;
for s in 0..=si {
let u = s as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_t, cos_t) = theta.sin_cos();
positions.push([radius * cos_t, -h, radius * sin_t]);
// Side normal: normalize(h·cosθ, r, h·sinθ).
let n = Vec3::new(h * cos_t, radius, h * sin_t).normalize();
normals.push(n.to_array());
uvs.push([u, 0.0]);
}
for s in 0..si {
indices.extend_from_slice(&[apex, base_start + s as u16 + 1, base_start + s as u16]);
}
// Closed base (circle at -h/2, normal -Y).
let center = positions.len() as u16;
positions.push([0.0, -h, 0.0]);
normals.push([0.0, -1.0, 0.0]);
uvs.push([0.5, 0.5]);
let ring = positions.len() as u16;
for s in 0..=si {
let u = s as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_t, cos_t) = theta.sin_cos();
positions.push([radius * cos_t, -h, radius * sin_t]);
normals.push([0.0, -1.0, 0.0]);
uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]);
}
for s in 0..si {
let r = ring + s as u16;
indices.extend_from_slice(&[center, r, r + 1]);
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_valid(geo: &Geometry) {
geo.validate().expect("generated geometry must validate");
let positions = &geo.positions;
let normals = geo.normals.as_ref().expect("normals present");
let uvs = geo.uvs.as_ref().expect("uvs present");
let indices = geo.indices.as_ref().expect("indices present");
assert_eq!(normals.len(), positions.len());
assert_eq!(uvs.len(), positions.len());
for n in normals {
let len = Vec3::from_array(*n).length();
assert!((len - 1.0).abs() < 1e-3);
}
for &i in indices {
assert!((i as usize) < positions.len());
}
}
#[test]
fn cone_validate() {
assert_valid(&cone(0.5, 1.0, 16));
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Cube primitive — 24 vertices (4 per face) + 36 indices.
use crate::core::geometry::Geometry;
/// Generates a cube centered at the origin, edge `size`, with one normal and UVs per face.
/// 24 vertices (4 per face) + 36 indices.
pub fn cube(size: f32) -> Geometry {
let s = size * 0.5;
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
(
[0.0, 0.0, 1.0],
[[-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]],
),
(
[0.0, 0.0, -1.0],
[[s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]],
),
(
[1.0, 0.0, 0.0],
[[s, -s, -s], [s, s, -s], [s, s, s], [s, -s, s]],
),
(
[-1.0, 0.0, 0.0],
[[-s, -s, s], [-s, s, s], [-s, s, -s], [-s, -s, -s]],
),
(
[0.0, 1.0, 0.0],
[[-s, s, -s], [s, s, -s], [s, s, s], [-s, s, s]],
),
(
[0.0, -1.0, 0.0],
[[-s, -s, s], [s, -s, s], [s, -s, -s], [-s, -s, -s]],
),
];
let mut positions = Vec::with_capacity(24);
let mut normals = Vec::with_capacity(24);
let mut uvs = Vec::with_capacity(24);
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 (i, corner) in corners.iter().enumerate() {
positions.push(*corner);
normals.push(normal);
uvs.push(quad_uvs[i]);
}
}
let mut indices = Vec::with_capacity(36);
for face in 0..6u16 {
let b = face * 4;
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Vec3;
fn assert_valid(geo: &Geometry) {
geo.validate().expect("generated geometry must validate");
let positions = &geo.positions;
let normals = geo.normals.as_ref().expect("normals present");
let uvs = geo.uvs.as_ref().expect("uvs present");
let indices = geo.indices.as_ref().expect("indices present");
assert_eq!(normals.len(), positions.len(), "normals/positions count");
assert_eq!(uvs.len(), positions.len(), "uvs/positions count");
for n in normals {
let len = Vec3::from_array(*n).length();
assert!((len - 1.0).abs() < 1e-3, "unit normal, got {len}");
}
for &i in indices {
assert!((i as usize) < positions.len(), "index {i} in bounds");
}
}
#[test]
fn cube_counts() {
let g = cube(1.0);
assert_eq!(g.positions.len(), 24);
assert_eq!(g.indices.as_ref().unwrap().len(), 36);
assert_valid(&g);
let g2 = cube(2.0);
assert_eq!(
g2.positions,
g.positions
.iter()
.map(|p| [p[0] * 2.0, p[1] * 2.0, p[2] * 2.0])
.collect::<Vec<_>>()
);
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Cylinder primitive — side + top/bottom caps.
use crate::core::geometry::Geometry;
use glam::Vec3;
/// Generates a cylinder of radius `radius` and height `height` (along Y, centered), with
/// `sectors` segments. Parts: side (smooth radial normals), top cap (+Y), bottom base (−Y).
pub fn cylinder(radius: f32, height: f32, sectors: u32) -> Geometry {
let si = sectors.max(3);
let h = height * 0.5;
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
// Side: radial columns × 2 rows (bottom/top).
let side_base = 0u16;
for row in 0..=1 {
let y = if row == 0 { -h } else { h };
for s in 0..=si {
let u = s as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_t, cos_t) = theta.sin_cos();
let radial = Vec3::new(cos_t, 0.0, sin_t);
positions.push((radial * radius + Vec3::new(0.0, y, 0.0)).to_array());
normals.push(radial.to_array());
uvs.push([u, row as f32]);
}
}
for s in 0..si {
let a = side_base + s as u16;
let b = a + 1;
let c = side_base + (si as u16) + 1 + s as u16;
let d = c + 1;
indices.extend_from_slice(&[a, c, b, b, c, d]);
}
// Caps: center + ring at each end.
for (y, normal) in [(h, [0.0, 1.0, 0.0]), (-h, [0.0, -1.0, 0.0])] {
let center = positions.len() as u16;
positions.push([0.0, y, 0.0]);
normals.push(normal);
uvs.push([0.5, 0.5]);
let ring_start = positions.len() as u16;
for s in 0..=si {
let u = s as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_t, cos_t) = theta.sin_cos();
positions.push([radius * cos_t, y, radius * sin_t]);
normals.push(normal);
uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]);
}
for s in 0..si {
let a = ring_start + s as u16;
indices.extend_from_slice(&[center, a + 1, a]);
}
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_valid(geo: &Geometry) {
geo.validate().expect("generated geometry must validate");
let positions = &geo.positions;
let normals = geo.normals.as_ref().expect("normals present");
let uvs = geo.uvs.as_ref().expect("uvs present");
let indices = geo.indices.as_ref().expect("indices present");
assert_eq!(normals.len(), positions.len());
assert_eq!(uvs.len(), positions.len());
for n in normals {
let len = Vec3::from_array(*n).length();
assert!((len - 1.0).abs() < 1e-3);
}
for &i in indices {
assert!((i as usize) < positions.len());
}
}
#[test]
fn cylinder_validate() {
assert_valid(&cylinder(0.5, 1.0, 16));
let c = cylinder(0.5, 1.0, 8);
assert!(c.positions.iter().all(|p| p[1].abs() <= 0.5 + 1e-5));
}
}
+33
View File
@@ -0,0 +1,33 @@
//! Procedural mesh generators — each behind a feature flag.
//!
//! Enable features in `Cargo.toml`:
//! ```toml
//! wsg = { features = ["prim-cube", "prim-sphere"] }
//! ```
#[cfg(feature = "prim-cube")]
pub mod cube;
#[cfg(feature = "prim-plane")]
pub mod plane;
#[cfg(feature = "prim-sphere")]
pub mod sphere;
#[cfg(feature = "prim-cylinder")]
pub mod cylinder;
#[cfg(feature = "prim-cone")]
pub mod cone;
#[cfg(feature = "prim-torus")]
pub mod torus;
// Flat re-exports: `use wsg::mesh::primitives::cube` or `use wsg::mesh::cube`
#[cfg(feature = "prim-cube")]
pub use cube::cube;
#[cfg(feature = "prim-plane")]
pub use plane::plane;
#[cfg(feature = "prim-sphere")]
pub use sphere::{icosphere, uv_sphere};
#[cfg(feature = "prim-cylinder")]
pub use cylinder::cylinder;
#[cfg(feature = "prim-cone")]
pub use cone::cone;
#[cfg(feature = "prim-torus")]
pub use torus::torus;
+74
View File
@@ -0,0 +1,74 @@
//! Plane primitive — horizontal plane in XZ with subdivisions.
use crate::core::geometry::Geometry;
/// Generates a horizontal plane in the XZ plane (normal +Y), centered at (0, 0, 0), with
/// `width` × `depth` dimensions, subdivided into `seg_x` × `seg_z` cells.
pub fn plane(width: f32, depth: f32, seg_x: u32, seg_z: u32) -> Geometry {
let sx = seg_x.max(1);
let sz = seg_z.max(1);
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
for z in 0..=sz {
let vz = z as f32 / sz as f32;
for x in 0..=sx {
let vx = x as f32 / sx as f32;
positions.push([(vx - 0.5) * width, 0.0, (vz - 0.5) * depth]);
normals.push([0.0, 1.0, 0.0]);
uvs.push([vx, vz]);
}
}
for z in 0..sz {
for x in 0..sx {
let a = z * (sx + 1) + x;
let b = a + 1;
let c = (z + 1) * (sx + 1) + x;
let d = c + 1;
indices
.extend_from_slice(&[a as u16, c as u16, b as u16, b as u16, c as u16, d as u16]);
}
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Vec3;
fn assert_valid(geo: &Geometry) {
geo.validate().expect("generated geometry must validate");
let positions = &geo.positions;
let normals = geo.normals.as_ref().expect("normals present");
let uvs = geo.uvs.as_ref().expect("uvs present");
let indices = geo.indices.as_ref().expect("indices present");
assert_eq!(normals.len(), positions.len());
assert_eq!(uvs.len(), positions.len());
for n in normals {
let len = Vec3::from_array(*n).length();
assert!((len - 1.0).abs() < 1e-3);
}
for &i in indices {
assert!((i as usize) < positions.len());
}
}
#[test]
fn plane_counts() {
let g = plane(2.0, 3.0, 1, 1);
assert_eq!(g.positions.len(), 4);
assert_eq!(g.indices.as_ref().unwrap().len(), 6);
assert_valid(&g);
assert!(g.positions.iter().all(|p| p[1] == 0.0));
let g2 = plane(2.0, 3.0, 4, 5);
assert_eq!(g2.positions.len(), (4 + 1) * (5 + 1));
assert_valid(&g2);
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Sphere primitives — UV sphere (lat/long) + icosphere (subdivided icosahedron).
use crate::core::geometry::Geometry;
use glam::Vec3;
use std::collections::HashMap;
/// Generates a UV (latitude/longitude) sphere of radius `radius`, with `sectors` segments around
/// and `stacks` vertical rings. Smooth normals = normalized position; spherical UVs.
pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Geometry {
let si = sectors.max(3);
let st = stacks.max(3);
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
for stack in 0..=st {
let v = stack as f32 / st as f32;
let phi = v * std::f32::consts::PI;
for sector in 0..=si {
let u = sector as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_p, cos_p) = phi.sin_cos();
let (sin_t, cos_t) = theta.sin_cos();
let pos = Vec3::new(
radius * sin_p * cos_t,
radius * cos_p,
radius * sin_p * sin_t,
);
positions.push(pos.to_array());
normals.push(pos.normalize().to_array());
uvs.push([u, v]);
}
}
for stack in 0..st {
for sector in 0..si {
let k1 = stack * (si + 1) + sector;
let k2 = k1 + si + 1;
let (k1, k2) = (k1 as u16, k2 as u16);
indices.extend_from_slice(&[k1, k2, k1 + 1, k1 + 1, k2, k2 + 1]);
}
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
/// Generates an icosphere (subdivided icosahedron) of radius `radius`.
/// `subdivisions = 0` gives an icosahedron (12 verts / 20 faces); each subdivision refines ×4.
pub fn icosphere(radius: f32, subdivisions: u32) -> Geometry {
let t = (1.0 + 5.0_f32.sqrt()) * 0.5;
let mut positions: Vec<Vec3> = [
[-1.0, t, 0.0], [1.0, t, 0.0], [-1.0, -t, 0.0], [1.0, -t, 0.0],
[0.0, -1.0, t], [0.0, 1.0, t], [0.0, -1.0, -t], [0.0, 1.0, -t],
[t, 0.0, -1.0], [t, 0.0, 1.0], [-t, 0.0, -1.0], [-t, 0.0, 1.0],
]
.iter()
.map(|v| Vec3::from_array(*v).normalize())
.collect();
let mut faces: Vec<[u32; 3]> = [
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
]
.into_iter()
.collect();
for _ in 0..subdivisions {
let mut midpoint = HashMap::new();
let old_faces = std::mem::take(&mut faces);
for [a, b, c] in old_faces {
let ab = subdiv_midpoint(&mut positions, &mut midpoint, a, b);
let bc = subdiv_midpoint(&mut positions, &mut midpoint, b, c);
let ca = subdiv_midpoint(&mut positions, &mut midpoint, c, a);
faces.push([a, ab, ca]);
faces.push([ab, b, bc]);
faces.push([ca, bc, c]);
faces.push([ab, bc, ca]);
}
}
let mut normals = Vec::with_capacity(positions.len());
let mut uvs = Vec::with_capacity(positions.len());
for p in &positions {
let dir = p.normalize();
normals.push(dir.to_array());
uvs.push(spherical_uv(dir));
}
let scaled: Vec<[f32; 3]> = positions.iter().map(|p| (*p * radius).to_array()).collect();
let mut indices = Vec::with_capacity(faces.len() * 3);
for [a, b, c] in &faces {
indices.extend_from_slice(&[*a as u16, *b as u16, *c as u16]);
}
Geometry::new(scaled)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
fn subdiv_midpoint(
positions: &mut Vec<Vec3>,
cache: &mut HashMap<(u32, u32), u32>,
a: u32,
b: u32,
) -> u32 {
let key = if a < b { (a, b) } else { (b, a) };
if let Some(&i) = cache.get(&key) {
return i;
}
let mid = (positions[a as usize] + positions[b as usize]).normalize();
positions.push(mid);
let i = (positions.len() - 1) as u32;
cache.insert(key, i);
i
}
fn spherical_uv(dir: Vec3) -> [f32; 2] {
let u = 0.5 + (dir.z.atan2(dir.x) / (2.0 * std::f32::consts::PI));
let v = 0.5 - (dir.y.asin() / std::f32::consts::PI);
[u, v]
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_valid(geo: &Geometry) {
geo.validate().expect("generated geometry must validate");
let positions = &geo.positions;
let normals = geo.normals.as_ref().expect("normals present");
let uvs = geo.uvs.as_ref().expect("uvs present");
let indices = geo.indices.as_ref().expect("indices present");
assert_eq!(normals.len(), positions.len());
assert_eq!(uvs.len(), positions.len());
for n in normals {
let len = Vec3::from_array(*n).length();
assert!((len - 1.0).abs() < 1e-3);
}
for &i in indices {
assert!((i as usize) < positions.len());
}
}
#[test]
fn uv_sphere_counts_and_normals() {
let g = uv_sphere(1.0, 12, 8);
assert_eq!(g.positions.len(), (12 + 1) * (8 + 1));
assert_valid(&g);
for (p, n) in g.positions.iter().zip(g.normals.as_ref().unwrap()) {
let diff = (Vec3::from_array(*p) / 1.0 - Vec3::from_array(*n)).length();
assert!(diff < 1e-4);
}
}
#[test]
fn icosphere_grows_with_subdivision() {
let base = icosphere(1.0, 0);
assert_eq!(base.positions.len(), 12);
assert_eq!(base.indices.as_ref().unwrap().len(), 60);
assert_valid(&base);
let once = icosphere(1.0, 1);
assert!(once.positions.len() > base.positions.len());
assert_valid(&once);
for (p, n) in once.positions.iter().zip(once.normals.as_ref().unwrap()) {
let r = Vec3::from_array(*p).length();
assert!((r - 1.0).abs() < 1e-3);
let diff = (Vec3::from_array(*p).normalize() - Vec3::from_array(*n)).length();
assert!(diff < 1e-4);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Torus primitive — tube around a ring.
use crate::core::geometry::Geometry;
use glam::Vec3;
/// Generates a torus with major radius `major`, minor radius `minor`, with `major_segments`
/// segments around the ring and `minor_segments` around the tube cross-section.
pub fn torus(major: f32, minor: f32, major_segments: u32, minor_segments: u32) -> Geometry {
let mj = major_segments.max(3);
let mn = minor_segments.max(3);
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
for i in 0..=mj {
let u = i as f32 / mj as f32;
let ua = u * 2.0 * std::f32::consts::PI;
let (sin_u, cos_u) = ua.sin_cos();
for j in 0..=mn {
let v = j as f32 / mn as f32;
let va = v * 2.0 * std::f32::consts::PI;
let (sin_v, cos_v) = va.sin_cos();
let ring = Vec3::new(
(major + minor * cos_v) * cos_u,
minor * sin_v,
(major + minor * cos_v) * sin_u,
);
positions.push(ring.to_array());
let n = Vec3::new(cos_v * cos_u, sin_v, cos_v * sin_u).normalize();
normals.push(n.to_array());
uvs.push([u, v]);
}
}
for i in 0..mj {
for j in 0..mn {
let a = i * (mn + 1) + j;
let b = a + 1;
let c = a + mn + 1;
let d = c + 1;
indices
.extend_from_slice(&[a as u16, b as u16, c as u16, b as u16, d as u16, c as u16]);
}
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_valid(geo: &Geometry) {
geo.validate().expect("generated geometry must validate");
let positions = &geo.positions;
let normals = geo.normals.as_ref().expect("normals present");
let uvs = geo.uvs.as_ref().expect("uvs present");
let indices = geo.indices.as_ref().expect("indices present");
assert_eq!(normals.len(), positions.len());
assert_eq!(uvs.len(), positions.len());
for n in normals {
let len = Vec3::from_array(*n).length();
assert!((len - 1.0).abs() < 1e-3);
}
for &i in indices {
assert!((i as usize) < positions.len());
}
}
#[test]
fn torus_validate() {
let g = torus(1.0, 0.25, 24, 12);
assert_valid(&g);
assert_eq!(g.positions.len(), (24 + 1) * (12 + 1));
}
}
+2 -2
View File
@@ -6,11 +6,11 @@ The `pipeline` module contains the shader compilation cache that avoids duplicat
| File | Responsibility |
|------|---------------|
| **pipeline_cache** | PipelineCache — maps (shader_id, format) keys to compiled RenderPipelines. Loads WGSL from disk or falls back to embedded BASIC_SHADER constant. Creates pipelines on-demand via build_pipeline(). |
| **pipeline_cache** | PipelineCache — maps (shader_id, format) keys to compiled RenderPipelines. Loads WGSL from disk or falls back to embedded STANDARD_SHADER constant. Creates pipelines on-demand via build_pipeline(). |
## Interaction with Other Modules
- **utils::conf**: Provides BASIC_SHADER_PATH (disk path) and BASIC_SHADER (embedded fallback).
- **utils::conf**: Provides STANDARD_SHADER_PATH (disk path) and STANDARD_SHADER (embedded fallback).
- **resources::vertex**: Vertex struct field offsets define the CPU-side layout that build_pipeline() uses as the vertex buffer contract.
- **resources::material**: Material::new() calls get_or_create() during construction to obtain a shared RenderPipeline.
+6 -2
View File
@@ -6,9 +6,13 @@
//!
//! ## Interaction with Other Modules
//! - `material` calls `get_or_create()` during its own construction to obtain a shared RenderPipeline.
//! - `conf::BASIC_SHADER` provides fallback WGSL source when an external file is not found.
//! - `conf::STANDARD_SHADER` provides fallback WGSL source when an external file is not found.
//! - `vertex::Vertex` defines the CPU-side layout that `build_pipeline()` uses as the vertex buffer contract.
pub mod pipeline_cache;
// Re-exports
pub use pipeline_cache::PipelineCache;
pub use pipeline_cache::{
DEPTH_FORMAT, PipelineCache, build_shadow_pipeline, create_shadow_map_bind_group_layout,
create_shadow_uniform_layout, create_texture_bind_group_layout,
create_uniform_bind_group_layouts, vertex_buffer_layout,
};
+314 -41
View File
@@ -7,7 +7,7 @@
//!
//! ## Interaction with Other Modules
//! - **Material** calls `get_or_create()` during its own construction to obtain a shared RenderPipeline.
//! - **conf::BASIC_SHADER** provides fallback WGSL source when an external file is not found.
//! - **conf::STANDARD_SHADER** provides fallback WGSL source when an external file is not found.
//! - **vertex::Vertex** defines the CPU-side layout that `build_pipeline` uses as the vertex buffer contract.
//!
//! ## Technical Points
@@ -15,12 +15,179 @@
//! - 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::utils::BASIC_SHADER;
use crate::resources::{Texture, Vertex};
use crate::utils::STANDARD_SHADER;
use std::collections::HashMap;
use std::sync::Arc;
/// Creates the two bind group layouts shared by **every** pipeline (Step 3 — decision ratified
/// "a single layout for all"). Both buffers are `Uniform` and 16-byte aligned. Matching CPU
/// types: `FrameUniforms` (192 B) and `ObjectUniform` (64 B) in `resources::uniform`.
/// Returns `[frame_layout, object_layout]` in renderer binding order.
///
/// - `index 0`: per-frame uniforms (view/proj/light/options), visible in both shader stages. Static
/// (one shared `FrameUniforms` buffer per frame, no dynamic offset).
/// - `index 1`: per-object uniforms (model matrix), visible in the vertex stage only. **Dynamic**
/// (Phase 3, D12): the offset selects a 64-byte slice of the single GPU-written matrix buffer,
/// so every entity shares one buffer. The low-level `render` path passes offset 0.
pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGroupLayout; 2] {
[
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("frame_uniform_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
}),
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("object_uniform_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
// Phase 3 (D12): dynamic offset so every entity shares the single GPU-written
// matrix buffer (one 64-byte slice per slot), instead of a per-entity buffer.
// The low-level `render` path passes offset 0 (its identity object buffer).
has_dynamic_offset: true,
min_binding_size: None,
},
count: None,
}],
}),
]
}
/// Creates the texture bind group layout (group 2) shared by every pipeline (Step 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 »
/// (Step 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,
},
],
})
}
/// Creates the **shadow map** bind group layout (group 3) shared by every main pipeline (Step 14,
/// DRAFT D1/D5). Binds a **comparison** sampler + a depth texture so the fragment can run a PCF
/// `textureSampleCompare` against the shadow map. Added to every pipeline layout alongside groups
/// 0–2, keeping « un seul layout pour tous » — shadows are simply a no-op when disabled.
///
/// - `binding 0`: `sampler_comparison` (compare fn drives the shadow test, D5).
/// - `binding 1`: `texture_depth_2d` (the shadow map).
pub fn create_shadow_map_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("shadow_map_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
})
}
/// Creates the **shadow uniform** bind group layout (group 0 of the depth-only shadow pipeline,
/// Step 14, D4): a single uniform buffer holding the light's `view_proj` matrix. Read in the
/// **vertex** stage only (the shadow shader transforms vertices into light-clip space).
pub fn create_shadow_uniform_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("shadow_uniform_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
})
}
/// The shared GPU `Vertex`-buffer layout used by **every** pipeline that renders mesh geometry
/// (both the main `build_pipeline` and the depth-only shadow pipeline). The array stride equals
/// `size_of::<Vertex>()` so it matches the mesh vertex buffers exactly; the four attributes are
/// declared position (loc 0), normal (1), uv (2), color (3).
pub fn vertex_buffer_layout() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
}, // position
wgpu::VertexAttribute {
offset: 12,
shader_location: 1,
format: wgpu::VertexFormat::Float32x3,
}, // normal
wgpu::VertexAttribute {
offset: 24,
shader_location: 2,
format: wgpu::VertexFormat::Float32x2,
}, // uv
wgpu::VertexAttribute {
offset: 32,
shader_location: 3,
format: wgpu::VertexFormat::Float32x4,
}, // color
],
}
}
/// Depth texture format shared by the whole library (Step 9, D1 decision of 2026-09-18).
///
/// Single z-buffer format used for **both** the depth attachment textures (`Renderer`) and the
/// `DepthStencilState` of every pipeline (`build_pipeline`). Keeping them on the same constant
/// guarantees by construction that the pipeline depth format always matches the texture format
/// (wgpu validation error otherwise). `Depth32Float` = maximum precision (exact comparison),
/// with clear `1.0` (maximum depth far away), `depth_compare: Less`, write enabled.
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// Shader pipeline cache: maps (shader_id, format) keys to compiled RenderPipelines.
/// Ensures each unique shader+format combination is compiled at most once; subsequent requests return cached instances.
pub struct PipelineCache {
@@ -29,22 +196,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 (Step 10, DRAFT D1: "a single layout for all").
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-Step-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 (Step 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 BASIC_SHADER constant.
// 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. Step 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. Step 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 (Step 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.
@@ -101,13 +314,13 @@ impl PipelineCache {
pipeline_arc
}
/// Loads a WGSL shader module: reads from disk first, falls back to the embedded BASIC_SHADER constant.
/// Loads a WGSL shader module: reads from disk first, falls back to the embedded STANDARD_SHADER constant.
/// Inputs: device (GPU command source for shader compilation), path (file path or shader_id string).
/// Returns a compiled wgpu::ShaderModule. Called internally by `get_or_create()` when compiling a new pipeline.
fn load_shader(&self, device: &wgpu::Device, path: &str) -> wgpu::ShaderModule {
let source = std::fs::read_to_string(path).unwrap_or_else(|_| {
println!("Shader not found: {}, falling back to default", path);
BASIC_SHADER.to_string()
STANDARD_SHADER.to_string()
});
device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -129,39 +342,26 @@ impl PipelineCache {
) -> wgpu::RenderPipeline {
// Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs.
// Must match Vertex struct field offsets exactly.
let vertex_buffer_layout = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
}, // position
wgpu::VertexAttribute {
offset: 12,
shader_location: 1,
format: wgpu::VertexFormat::Float32x3,
}, // normal
wgpu::VertexAttribute {
offset: 24,
shader_location: 2,
format: wgpu::VertexFormat::Float32x2,
}, // uv
wgpu::VertexAttribute {
offset: 32,
shader_location: 3,
format: wgpu::VertexFormat::Float32x4,
}, // color
],
};
let vertex_buffer_layout = vertex_buffer_layout();
// Pipeline layout — defines bind group bindings (empty here; no uniform buffers used).
// wgpu 30: `immediate_size` replaces `push_constant_ranges`.
// Pipeline layout — the two uniform bind groups (frame @0 + object @1), the texture
// bind group (@2, Step 10 DRAFT D1) AND the shadow-map bind group (@3, Step 14 D5) are
// attached to EVERY pipeline (Step 3, decision ratified "a single layout for all"), 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 shadow_layout = create_shadow_map_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
Some(&shadow_layout), // shadow map @3
];
let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("render_pipeline_layout"),
bind_group_layouts: &[],
bind_group_layouts: &layout_refs,
immediate_size: 0, // no var<immediate> used
});
@@ -188,7 +388,17 @@ impl PipelineCache {
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
// Step 9 (DRAFT 9.3): depth test enabled on EVERY pipeline. The format must match
// the depth attachment (DEPTH_FORMAT) — guaranteed by the shared D1 constant.
// depth_write_enabled + depth_compare are Options in wgpu 30: Some(true) → the depth
// is written; Some(Less) → the fragment is kept if its z is closer.
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
// multiview → replaced by multiview_mask (NonZeroU32) and cache fields in wgpu 30.
multiview_mask: None,
@@ -198,8 +408,71 @@ impl PipelineCache {
/// Retrieves a cached RenderPipeline by shader_id without creating one.
/// Inputs: shader_id (unique key into the cache).
/// Returns Some(Arc<RenderPipeline>) if found, None otherwise. Called by renderer code for pipeline inspection.
/// Returns Some(`Arc<RenderPipeline>`) if found, None otherwise. Called by renderer code for pipeline inspection.
pub fn get(&self, shader_id: &str) -> Option<&Arc<wgpu::RenderPipeline>> {
self.pipelines.get(shader_id)
}
}
/// Builds the **depth-only shadow pipeline** (Step 14, D4): a vertex-only pipeline (no fragment
/// stage) that transforms each mesh vertex into the shadow-casting light's clip space, writing only
/// depth. Its layout is [`shadow_uniform_layout`] (group 0: light `view_proj`) + [`object_layout`]
/// (group 1: per-entity model matrix — the SAME layout/bind groups the main renderer already caches
/// per entity, so the shadow pass reuses them directly).
///
/// `depth_stencil` writes depth with a slope-scaled bias (D5) to suppress acne on surfaces nearly
/// parallel to the light. The vertex buffer layout is the shared [`vertex_buffer_layout`], so the
/// same mesh vertex/index buffers are reused.
///
/// Inputs: device (GPU), object_layout (the shared per-object bind group layout, group 1).
/// Returns the compiled shadow pipeline, ready to render into a depth attachment.
pub fn build_shadow_pipeline(
device: &wgpu::Device,
object_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
// Vertex-only shader: this pipeline sets `fragment: None`, so only the depth is produced.
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("shadow_shader"),
source: wgpu::ShaderSource::Wgsl(crate::utils::SHADOW_SHADER.into()),
});
let shadow_uniform_layout = create_shadow_uniform_layout(device);
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> =
vec![Some(&shadow_uniform_layout), Some(object_layout)];
let shadow_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("shadow_pipeline_layout"),
bind_group_layouts: &layout_refs,
immediate_size: 0,
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shadow Pipeline"),
layout: Some(&shadow_pipeline_layout),
// wgpu 30: vertex state requires `compilation_options`.
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[Some(vertex_buffer_layout())],
},
// Depth-only: no fragment state (no color output, no color target).
fragment: None,
primitive: wgpu::PrimitiveState::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
// Step 14 (D5): slope-scaled depth bias against acne — surfaces nearly parallel to
// the light are pushed back slightly in the shadow map so they do not self-shadow.
bias: wgpu::DepthBiasState {
constant: 2,
slope_scale: 2.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
}
+46
View File
@@ -0,0 +1,46 @@
//! # WSG Prelude
//!
//! Re-exports the most commonly used types in a single glob import:
//!
//! ```rust
//! use wsg_lib::prelude::*;
//!
//! let geom = cube(2.0);
//! assert_eq!(geom.positions.len(), 24);
//! let tf = Transform::identity();
//! ```
//!
//! This avoids long import paths for the types you touch every day.
// Core types
pub use crate::core::geometry::{BBox, Geometry};
pub use crate::core::transform::Transform;
pub use crate::core::{ShadowConfig, ToneMapper};
// App / handler (already at crate root, re-exported here for convenience)
pub use crate::app::AppBuilder;
pub use crate::handler::AppHandler;
// Primitives (available when the corresponding feature is enabled)
#[cfg(feature = "prim-cube")]
pub use crate::mesh::cube;
#[cfg(feature = "prim-sphere")]
pub use crate::mesh::{icosphere, uv_sphere};
#[cfg(feature = "prim-cylinder")]
pub use crate::mesh::cylinder;
#[cfg(feature = "prim-cone")]
pub use crate::mesh::cone;
#[cfg(feature = "prim-torus")]
pub use crate::mesh::torus;
#[cfg(feature = "prim-plane")]
pub use crate::mesh::plane;
// Import (available when the corresponding feature is enabled)
#[cfg(feature = "import-obj")]
pub use crate::mesh::load_obj;
#[cfg(feature = "import-gltf")]
pub use crate::mesh::load_gltf;
// Import error type
#[cfg(any(feature = "import-obj", feature = "import-gltf"))]
pub use crate::mesh::import::MeshImportError;
+8 -4
View File
@@ -2,16 +2,20 @@
## Overview
The `resources` module defines three immutable data types that flow through the rendering pipeline. These are created once during scene initialization and consumed by Renderer for draw calls every frame.
The `resources` module defines the data types that flow through the rendering pipeline. These are created once during scene initialization and consumed by Renderer for draw calls every frame.
| File | Responsibility |
|------|---------------|
| **vertex** | Vertex struct — CPU-side per-attribute tuple (position [f32;3], normal [f32;3], uv [f32;2], color [f32;4]). Must match PipelineCache::build_pipeline() vertex buffer layout byte-for-byte. |
| **mesh** | Mesh struct — persistent GPU geometry container with vertex_buffer (wgpu::Buffer), optional index_buffer, and draw call counters. Created via Mesh::new() which uploads data from CPU to GPU buffers. |
| **material** | Material struct — lightweight appearance descriptor pairing shader_id with a shared RenderPipeline Arc. Multiple Materials referencing the same shader_id point to the identical compiled GPU pipeline. |
| **mesh** | Mesh struct — persistent GPU geometry container with retained CPU `geometry: Arc<Geometry>` (Step 8), vertex_buffer (wgpu::Buffer), optional index_buffer, and draw call counters. Created via Mesh::from_geometry() which derives Vertex arrays from the Geometry and uploads them to GPU buffers. |
| **material** | Material struct — lightweight appearance descriptor pairing shader_id with a shared RenderPipeline Arc. Multiple Materials referencing the same shader_id point to the identical compiled GPU pipeline. Optionally holds a diffuse `Texture` (Step 10) plus its texture bind group. |
| **texture** | Texture struct *(Step 10)* — GPU 2D image (device, view, sampler) in `Rgba8UnormSrgb`. Constructors: `from_rgba8` (raw bytes), `from_bytes` (encoded, via the `image` crate: png/jpeg/...), `from_file`, and `white_placeholder` (1x1 white used when no texture is attached). Sampler is linear-filtered with repeat addressing. |
| **uniform** | `FrameUniforms` (per-frame uniforms: camera, ambient, global light list, options — 704 B, `Pod`) and `ObjectUniform` (per-entity model matrix — 64 B). Also `Light` (64 B, 4 × vec4, directional/point/spot) and `MAX_LIGHTS` (Phase 4.2, Steps 12–13). |
| **lights** | `Lights` — the scene's CPU-side global light list (directional + point + spot) and its `into_frame_array` packing (Phase 4.2, Steps 12–13). |
| **camera** | `Camera` (position/target/up + fov/near/far, `with_perspective`, `view_matrix`/`projection_matrix`) and `CameraController` (Step 15.C — orbital: yaw/pitch/distance/target, `orbit`/`zoom`/`reset`/`apply_to`). |
## Interaction with Other Modules
- **pipeline**: build_pipeline() reads Vertex field offsets to construct VertexBufferLayout attributes array.
- **scene**: Scene stores Arc<Mesh> and Arc<Material> instances keyed by identifier strings.
- **utils**: Mesh creation uses BASIC_SHADER fallback when external shader files are missing.
- **utils**: PipelineCache uses the embedded STANDARD_SHADER fallback when external shader files are missing.
+267 -15
View File
@@ -13,11 +13,20 @@
//! - `view_matrix()`: Computes the view matrix
//! - `projection_matrix()`: Computes the projection matrix
use glam::{Vec3, Mat4};
use glam::{Mat4, Vec3};
/// Default vertical field of view in radians (45°).
pub const DEFAULT_FOV: f32 = 45.0_f32.to_radians();
/// Near clipping plane distance used by the default perspective projection.
pub const DEFAULT_NEAR: f32 = 0.1;
/// Far clipping plane distance used by the default perspective projection.
pub const DEFAULT_FAR: f32 = 100.0;
/// Represents a 3D camera for viewing the scene.
///
/// The camera defines the viewpoint and projection settings for rendering.
/// The camera defines the viewpoint (position/target/up), the projection parameters (fov, near, far)
/// and can produce the view and projection matrices uploaded each frame to the `FrameUniforms` buffer
/// (Step 4.3). Use `Scene::set_camera` to install it as the scene's active camera.
#[derive(Debug, Clone)]
pub struct Camera {
/// Position of the camera in world space
@@ -26,33 +35,276 @@ pub struct Camera {
pub target: Vec3,
/// Up vector defining the camera's orientation
pub up: Vec3,
/// Vertical field of view in radians (used by the perspective projection).
pub fov: f32,
/// Near clipping plane distance (used by the perspective projection).
pub near: f32,
/// Far clipping plane distance (used by the perspective projection).
pub far: f32,
}
impl Default for Camera {
/// Default camera: positioned at (0, 0, 3) looking at the origin with a 45° vertical fov,
/// near 0.1 and far 100. Good enough to frame a unit-cube scene out of the box.
fn default() -> Self {
Self::new(Vec3::new(0.0, 0.0, 3.0), Vec3::ZERO, Vec3::Y)
}
}
impl Camera {
/// Creates a new camera with specified position, target, and up vector.
/// Creates a new perspective camera with the default fov/near/far.
/// Inputs: position (world-space eye point), target (world-space look-at point), up (view up vector).
/// Adjust the projection via [`Camera::with_perspective`] if the defaults don't fit.
pub fn new(position: Vec3, target: Vec3, up: Vec3) -> Self {
Self { position, target, up }
Self {
position,
target,
up,
fov: DEFAULT_FOV,
near: DEFAULT_NEAR,
far: DEFAULT_FAR,
}
}
/// Sets the perspective projection parameters and returns the camera for chaining.
/// Inputs: fov (vertical field of view in radians), near (near plane), far (far plane).
pub fn with_perspective(mut self, fov: f32, near: f32, far: f32) -> Self {
self.fov = fov;
self.near = near;
self.far = far;
self
}
/// Computes the view matrix for this camera.
///
/// # Returns
/// A `Mat4` representing the view transformation matrix
/// A `Mat4` representing the view transformation matrix (world → view space)
pub fn view_matrix(&self) -> Mat4 {
Mat4::look_at_rh(self.position, self.target, self.up)
glam::camera::rh::view::look_at_mat4(self.position, self.target, self.up)
}
/// Computes the projection matrix for this camera.
/// Computes the perspective projection matrix for this camera using its stored fov/near/far.
///
/// # Parameters
/// - `fov`: Field of view in radians
/// - `aspect`: Aspect ratio of the viewport
/// - `near`: Near clipping plane distance
/// - `far`: Far clipping plane distance
/// - `aspect`: Aspect ratio of the viewport (width / height)
///
/// # Returns
/// A `Mat4` representing the projection transformation matrix
pub fn projection_matrix(&self, fov: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
Mat4::perspective_rh_gl(fov, aspect, near, far)
/// A `Mat4` representing the projection transformation matrix (view → clip space)
pub fn projection_matrix(&self, aspect: f32) -> Mat4 {
// WebGPU expects NDC clip depth in [0,1]; glam's `opengl` module remaps to [-1,1], which
// would clip roughly the front half of the frustum in wgpu. The `directx` (WebGPU) module
// produces Y-up right-handed projections with depth already in [0,1], matching the shadow
// projections and the depth wgpu writes.
glam::camera::rh::proj::directx::perspective(self.fov, aspect, self.near, self.far)
}
}
}
/// Vertical pitch clamp (radians) applied by [`CameraController`] so the camera never flips over the
/// poles. Kept a little under ±90°.
pub const PITCH_LIMIT: f32 = 1.45; // ~83°
/// Orbital camera controller (Step 15, sub-step 15.C).
///
/// Represents the viewpoint spherically around a `target`: `yaw` (rotation around the world-up axis),
/// `pitch` (elevation above/below the horizontal), `distance` (radius) and the look-at `target`.
/// [`CameraController::apply_to`] writes these into a [`Camera`] each frame, so the controller stays
/// decoupled from `Camera`'s own position/target/up representation.
///
/// ```
/// # use wsg_lib::resources::{Camera, CameraController};
/// # use glam::Vec3;
/// let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::ZERO, Vec3::Y);
/// let mut ctrl = CameraController::from_camera(&cam);
/// ctrl.orbit(0.1, -0.05); // drag: yaw/pitch
/// ctrl.zoom(-1.0); // wheel: distance
/// let mut cam2 = cam;
/// ctrl.apply_to(&mut cam2); // write back into the active camera
/// ```
#[derive(Debug, Clone, Copy)]
pub struct CameraController {
/// Rotation around the world-up (+Y) axis, in radians.
pub yaw: f32,
/// Elevation angle above (+) / below (-) the horizontal, in radians, clamped to ±[`PITCH_LIMIT`].
pub pitch: f32,
/// Distance from the camera position to the `target` (orbit radius).
pub distance: f32,
/// World-space point the camera looks at and orbits around.
pub target: Vec3,
/// Orbit sensitivity (radians of yaw per pixel of mouse delta); see [`DEFAULT_ORBIT_SENSITIVITY`].
pub orbit_sensitivity: f32,
/// Multiplicative zoom factor applied per unit of vertical scroll; see [`DEFAULT_ZOOM_FACTOR`].
pub zoom_factor: f32,
}
impl Default for CameraController {
fn default() -> Self {
Self {
yaw: 0.0,
pitch: 0.0,
distance: 3.0,
target: Vec3::ZERO,
orbit_sensitivity: DEFAULT_ORBIT_SENSITIVITY,
zoom_factor: DEFAULT_ZOOM_FACTOR,
}
}
}
/// Sensitivity of the orbit drag (radians of yaw per pixel of horizontal mouse delta).
/// 0.005 gives ~110° per full window width — a comfortable default; raise it for smaller viewports.
pub const DEFAULT_ORBIT_SENSITIVITY: f32 = 0.005;
/// Multiplicative zoom factor applied per unit of vertical scroll (one wheel notch ≈ 1 unit after
/// `InputState` normalization). 0.9 → 10% distance change per notch.
pub const DEFAULT_ZOOM_FACTOR: f32 = 0.9;
impl CameraController {
/// Builds a controller that reproduces an existing camera's framing by extracting yaw/pitch/
/// distance from `position - target` in spherical coordinates.
pub fn from_camera(camera: &Camera) -> Self {
let offset = camera.position - camera.target;
let distance = offset.length().max(f32::EPSILON);
// Y-up convention: pitch = asin(y / r), yaw measured from +Z toward +X.
let pitch = offset
.y
.atan2((offset.x * offset.x + offset.z * offset.z).sqrt());
let yaw = offset.x.atan2(offset.z);
Self {
yaw,
pitch: pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT),
distance,
target: camera.target,
orbit_sensitivity: DEFAULT_ORBIT_SENSITIVITY,
zoom_factor: DEFAULT_ZOOM_FACTOR,
}
}
/// Computes the world-space eye position from the current yaw/pitch/distance around `target`.
pub fn position(&self) -> Vec3 {
let cp = self.pitch.cos();
let dir = Vec3::new(cp * self.yaw.sin(), self.pitch.sin(), cp * self.yaw.cos());
self.target + dir * self.distance
}
/// Writes the current framing into a [`Camera`]: sets its `position` (spherical away from
/// `target`), its look-at `target`, and forces `up` to world +Y so the horizon stays level.
pub fn apply_to(&self, camera: &mut Camera) {
camera.position = self.position();
camera.target = self.target;
camera.up = Vec3::Y;
}
/// Applies an orbit drag (mouse delta in pixels): `dx` rotates yaw, `dy` rotates pitch
/// (inverted so dragging up tilts the view up). Pitch is clamped to ±[`PITCH_LIMIT`]. The
/// rotation speed is scaled by `self.orbit_sensitivity`.
pub fn orbit(&mut self, dx: f32, dy: f32) {
self.yaw -= dx * self.orbit_sensitivity;
self.pitch = (self.pitch + dy * self.orbit_sensitivity).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
/// Zooms in/out by an exponential factor on the vertical wheel scroll (`scroll_y`, in wheel
/// notches): positive scroll zooms in (distance shrinks). Clamped to a sane `[0.1, 100]` range.
/// The per-notch factor is `self.zoom_factor`.
pub fn zoom(&mut self, scroll_y: f32) {
if scroll_y == 0.0 {
return;
}
let factor = self.zoom_factor.powf(scroll_y);
self.distance = (self.distance * factor).clamp(0.1, 100.0);
}
/// Resets the controller to its default framing (origin target, `distance` 3, level view).
pub fn reset(&mut self) {
*self = Self::default();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_positions_level_front() {
let ctrl = CameraController::default();
let p = ctrl.position();
assert!((p - Vec3::new(0.0, 0.0, 3.0)).length() < 1e-5);
}
#[test]
fn orbit_changes_yaw_and_clamps_pitch() {
let mut ctrl = CameraController::default();
ctrl.orbit(100.0, 0.0); // yaw rotation
let p1 = ctrl.position();
assert!((p1.x.abs()) > 0.1, "yaw should swing around +Y");
assert!(ctrl.pitch == 0.0);
// Pitch clamped to ±PITCH_LIMIT even with a huge drag.
ctrl.orbit(0.0, 1_000.0);
assert!((ctrl.pitch - PITCH_LIMIT).abs() < 1e-5);
ctrl.orbit(0.0, -2_000.0);
assert!((ctrl.pitch + PITCH_LIMIT).abs() < 1e-5);
}
#[test]
fn zoom_inout_clamped() {
let mut ctrl = CameraController::default();
ctrl.zoom(1.0);
assert!(ctrl.distance < 3.0, "positive scroll zooms in");
ctrl.zoom(-10.0);
assert!(ctrl.distance > 3.0);
ctrl.zoom(10_000.0);
assert!(ctrl.distance >= 0.1 - 1e-5);
ctrl.zoom(-10_000.0);
assert!(ctrl.distance <= 100.0 + 1e-5);
}
#[test]
fn roundtrip_from_camera_reproduces_framing() {
let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::new(1.0, 1.0, 0.0), Vec3::Y);
let ctrl = CameraController::from_camera(&cam);
let mut back = cam.clone();
ctrl.apply_to(&mut back);
// Target preserved; position matches up to float error for a non-pole framing.
assert!((back.target - cam.target).length() < 1e-4);
assert!((back.position - cam.position).length() < 1e-2);
}
#[test]
fn sensitivity_is_configurable() {
let mut slow = CameraController::default();
let mut fast = CameraController::default();
slow.orbit_sensitivity = 0.001; // one fifth of the default
fast.orbit_sensitivity = 0.02; // four times the default
slow.orbit(100.0, 0.0);
fast.orbit(100.0, 0.0);
assert!(
(slow.yaw - fast.yaw).abs() > 1.0,
"faster sensitivity must rotate more"
);
// Zoom factor: a gentler factor moves the distance less for the same scroll.
let mut gentle = CameraController::default();
gentle.zoom_factor = 0.99;
let mut aggressive = CameraController::default();
aggressive.zoom_factor = 0.8;
gentle.zoom(3.0);
aggressive.zoom(3.0);
assert!(gentle.distance > aggressive.distance);
}
#[test]
fn reset_restores_defaults() {
let mut ctrl = CameraController::default();
ctrl.orbit(100.0, 50.0);
ctrl.zoom(3.0);
assert!(ctrl.yaw != 0.0);
ctrl.reset();
assert!((ctrl.yaw).abs() < 1e-6);
assert!(ctrl.distance == 3.0);
assert!(ctrl.target == Vec3::ZERO);
}
#[test]
fn apply_to_enforces_world_up() {
let ctrl = CameraController::default();
let mut cam = Camera::new(Vec3::ZERO, Vec3::ZERO, Vec3::X); // odd up
ctrl.apply_to(&mut cam);
assert!(cam.up == Vec3::Y);
}
}
+221
View File
@@ -0,0 +1,221 @@
//! # Lights Module — CPU-side Global Light List (Phase 4.2, Steps 12–13)
//!
//! Holds the scene's global light list — directional, point and spot lights — in a CPU-side
//! [`Lights`] group. The list is uploaded into the per-frame [`FrameUniforms`] uniform array each
//! frame by `Renderer::write_frame_uniforms`. Lights are **global to the scene**: every entity is
//! lit by the same list (per-material lights are out of scope, a later performance/feature step).
//!
//! ## Rangement (no type flag)
//! Directional lights occupy indices `0..num_directional`; point lights occupy
//! `num_directional..num_directional + num_point`; spot lights occupy
//! `num_directional + num_point..`. The index alone disambiguates the type in the fragment shader,
//! so no type field is stored in [`Light`].
//!
//! ## Non-regression
//! [`Lights::default()`] = one white directional light along +Z, which (combined with a white
//! ambient) reproduces exactly the pre-multi-light rendering of `standard_shader.wgsl`.
use crate::resources::uniform::{Light, MAX_LIGHTS};
use glam::{Vec3, Vec4};
/// The scene's global light list: directional lights (first), point lights (middle), spot lights
/// (last). Total capacity is bounded by `MAX_LIGHTS`; adding beyond it is rejected by the `Scene`
/// API.
#[derive(Clone, PartialEq)]
pub struct Lights {
/// Directional lights (indices `0..len` in the frame array).
pub directional: Vec<Light>,
/// Point lights (indices `num_directional..` in the frame array).
pub point: Vec<Light>,
/// Spot lights (indices `num_directional + num_point..` in the frame array).
pub spot: Vec<Light>,
}
impl Lights {
/// Default = one white directional light along +Z (from surface toward light), no point or
/// spot lights. This reproduces the historical single-light look when combined with a white
/// ambient.
pub fn new() -> Self {
Self {
directional: vec![Light {
position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), // from surface toward light = +Z
color: Vec4::ONE,
radius: Vec4::ZERO,
dir_angle: Vec4::ZERO,
}],
point: Vec::new(),
spot: Vec::new(),
}
}
/// Total number of lights (directional + point + spot).
pub fn len(&self) -> usize {
self.directional.len() + self.point.len() + self.spot.len()
}
/// `true` when there are no lights at all.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Returns the light at a **packed-array index** (directionals first, then point lights, then
/// spot lights — the same order as `into_frame_array`). Used by the Renderer's shadow pass to
/// resolve the shadow-casting light by its packed index (`Scene::shadow_caster`, Step 14 D7).
pub fn get(&self, index: usize) -> Option<&Light> {
let n_dir = self.directional.len();
if index < n_dir {
return self.directional.get(index);
}
let index = index - n_dir;
let n_point = self.point.len();
if index < n_point {
return self.point.get(index);
}
self.spot.get(index - n_point)
}
/// Packs the lights into the GPU frame array: directionals first (`0..num_directional`), then
/// point lights, then spot lights. The tail is zero-filled. Returns
/// `(array, num_directional, num_point, num_spot)`. Caller must ensure `len() <= MAX_LIGHTS`
/// (the `Scene` API validates capacity).
pub fn into_frame_array(&self) -> ([Light; MAX_LIGHTS], u32, u32, u32) {
let empty = Light {
position_dir: Vec4::ZERO,
color: Vec4::ZERO,
radius: Vec4::ZERO,
dir_angle: Vec4::ZERO,
};
let mut array = [empty; MAX_LIGHTS];
for (i, l) in self.directional.iter().enumerate() {
array[i] = *l;
}
let n_dir = self.directional.len();
for (i, l) in self.point.iter().enumerate() {
array[n_dir + i] = *l;
}
let n_point = self.point.len();
for (i, l) in self.spot.iter().enumerate() {
array[n_dir + n_point + i] = *l;
}
(array, n_dir as u32, n_point as u32, self.spot.len() as u32)
}
}
impl Default for Lights {
/// `Lights::new()` — one white directional light along +Z (non-regression default).
fn default() -> Self {
Self::new()
}
}
/// Builds a directional [`Light`] from a direction (from surface toward the light), a color and
/// an intensity multiplier. Used by `Scene::add_directional_light`.
pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light {
Light {
position_dir: dir.extend(0.0),
color: Vec4::new(color[0], color[1], color[2], intensity),
radius: Vec4::ZERO,
dir_angle: Vec4::ZERO,
}
}
/// Builds a point [`Light`] from a world position, a color, an intensity multiplier and an
/// attenuation radius (linear falloff to zero at the radius). Used by `Scene::add_point_light`.
pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> Light {
Light {
position_dir: pos.extend(0.0),
color: Vec4::new(color[0], color[1], color[2], intensity),
radius: Vec4::new(radius, 0.0, 0.0, 0.0),
dir_angle: Vec4::ZERO,
}
}
/// Builds a spot [`Light`] from a world position, a cone axis (from the light toward the scene), a
/// color, an intensity multiplier, an attenuation radius and a half-angle in radians. Used by
/// `Scene::add_spot_light`. The half-angle is stored as its cosine in `dir_angle.w`.
pub fn spot_light(
pos: Vec3,
dir: Vec3,
color: [f32; 3],
intensity: f32,
radius: f32,
half_angle: f32,
) -> Light {
Light {
position_dir: pos.extend(0.0),
color: Vec4::new(color[0], color[1], color[2], intensity),
radius: Vec4::new(radius, 0.0, 0.0, 0.0),
dir_angle: dir.normalize().extend(half_angle.cos()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_has_one_directional() {
let lights = Lights::new();
assert_eq!(lights.directional.len(), 1);
assert_eq!(lights.point.len(), 0);
assert_eq!(lights.spot.len(), 0);
assert_eq!(lights.len(), 1);
}
#[test]
fn into_frame_array_packs_directional_point_then_spot() {
let mut lights = Lights::new(); // 1 directional
lights
.point
.push(point_light(Vec3::ONE, [1.0, 0.0, 0.0], 1.0, 2.0));
lights.spot.push(spot_light(
Vec3::new(2.0, 0.0, 0.0),
Vec3::new(-1.0, 0.0, 0.0),
[0.0, 1.0, 0.0],
1.0,
3.0,
0.3,
));
let (array, n_dir, n_point, n_spot) = lights.into_frame_array();
assert_eq!(n_dir, 1);
assert_eq!(n_point, 1);
assert_eq!(n_spot, 1);
// Directional first, point second, spot third.
assert_eq!(array[0].color, Vec4::ONE);
assert_eq!(array[1].color, Vec4::new(1.0, 0.0, 0.0, 1.0));
assert_eq!(array[2].color, Vec4::new(0.0, 1.0, 0.0, 1.0));
// Spot stores the cone axis (normalized) and the half-angle cosine.
assert_eq!(array[2].dir_angle.truncate(), Vec3::new(-1.0, 0.0, 0.0));
assert!((array[2].dir_angle.w - 0.3_f32.cos()).abs() < 1e-6);
}
#[test]
fn capacity_bounded_by_max_lights() {
assert!(MAX_LIGHTS >= 1);
}
/// Locks the spot sign convention used by the shader: for a surface point that lies on the
/// cone axis, the alignment between the "light -> point" direction (`-l`, where `l` points
/// from the surface toward the light) and the stored cone axis (`dir_angle.xyz`, from the
/// light toward the scene) must be **+1** (full cone), not −1. A regression to the wrong sign
/// would make every spot light contribute zero (black cube). Mirrors the WGSL spot loop.
#[test]
fn spot_cone_axis_alignment_is_positive() {
// Spot at (0,0,3), cone axis pointing toward the origin (light -> scene).
let light_pos = Vec3::new(0.0, 0.0, 3.0);
let surface_point = Vec3::ZERO;
let cone_axis = (surface_point - light_pos).normalize(); // (0,0,-1)
// Shader math: l points surface -> light; the cone test uses -l (light -> point).
let l = (light_pos - surface_point).normalize(); // (0,0,1)
let to_point = -l; // (0,0,-1)
let cone = to_point.dot(cone_axis);
assert!(
(cone - 1.0).abs() < 1e-6,
"on-axis point must align with the cone axis (got {cone}); if it is ~-1 the spot sign is wrong"
);
// Sanity: the buggy expression (dot of l with the axis) would be ~ -1.
assert!((l.dot(cone_axis) + 1.0).abs() < 1e-6);
}
}
+45 -8
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 Step 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.
//! during the render loop without borrow checker issues. The shader_id serves as the `Handle<T>` key.
//! - **Declaration Phase**: Materials are instantiated once in the declarative phase before the render loop begins.
//! - **Texture (Step 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,
}
}
}
+347 -34
View File
@@ -1,61 +1,374 @@
//! # Mesh Module — Persistent GPU Geometry Container
//!
//! Defines `Mesh`, a persistent GPU geometry container. Mesh data is uploaded to the GPU once at creation time
//! and remains valid across all frames until dropped. It holds no rendering knowledge—only raw geometric data.
//! and remains valid across all frames until dropped. Since Step 8 (DRAFT Step 8.3), a Mesh also retains the
//! CPU geometry it was built from (`geometry: Arc<Geometry>`), giving meshes a shared, readable source of truth
//! for phases such as bounding-box culling and UV access. Since Step 7, a Mesh may also hold a reference to the
//! `Material` that draws it — the appearance lives on the Mesh rather than on the `Entity`.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Identifiants**: Each Mesh is registered in Scene by string identifier, enabling dynamic access
//! during the render loop without borrow checker issues. The identifier serves as the Handle<T> key.
//! - **Phase de Déclaration**: Meshes are instantiated once in the declarative phase before the render loop begins.
//! during the render loop without borrow checker issues. The identifier serves as the `Handle<T>` key.
//! - **Declaration Phase**: Meshes are instantiated once in the declarative phase before the render loop begins.
//! - **Performance**: Multiple entities can reference the same Mesh, reducing memory footprint for repeated geometry.
//! - **CPU+GPU retention (DRAFT Step 8, D5)**: `geometry` (CPU) and the vertex/index buffers (GPU) coexist.
//! The GPU buffers are uploaded once at creation; the `Arc<Geometry>` is kept for CPU-side computations
//! without re-uploading per frame.
//! - **LOD packing (Step 19, D7)**: a multi-level mesh packs ALL its levels into **one** vertex buffer and
//! **one** index buffer (level k lives at a byte offset), because WebGPU forbids dynamic offsets on
//! vertex/index bindings — only the draw ARGS move per frame. The per-level offsets live in the
//! `LodRow`s (uploaded to the GPU LOD table); the shadow/main passes always bind level 0.
//!
//! ## Construction (DRAFT Step 8, D4; Step 19, D6/D7)
//! The single canonical constructor is [`Mesh::from_geometry_lod`] (levels + mode); [`Mesh::from_geometry`]
//! is its one-level convenience wrapper. The former `Mesh::new`/`Mesh::with_material`
//! (which took raw `&[Vertex]`) were removed in Step 8: the `Scene` declares meshes from a `Geometry`, and
//! `Mesh` derives its interleaved vertices internally via `Geometry::to_vertices()`.
use crate::resources::vertex::Vertex;
use crate::core::Geometry;
use crate::resources::Material;
use crate::resources::Vertex;
use crate::resources::uniform::LodRow;
use std::sync::Arc;
use wgpu::util::DeviceExt;
/// Persistent GPU geometry: vertex positions, optional indices, and draw call counters.
/// Created once via `Mesh::new()` during scene setup; referenced by Renderer for every frame.
/// How a mesh's LOD levels were produced (Step 19, D6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LodMode {
/// No LOD: a single level (the plain `create_mesh` path).
#[default]
Off,
/// Levels 1.. were auto-generated by quadric edge collapse (`Geometry::decimated`, D10).
Auto,
/// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`).
Explicit,
}
/// Persistent GPU geometry: vertex positions, optional indices, draw call counters, and the retained
/// CPU `Geometry` (Step 8). Created once via `Mesh::from_geometry()` during scene setup; referenced by
/// Renderer for every frame. A Mesh optionally references the `Material` used to render it (`Option<Arc<Material>>`).
/// When `material()` is `None`, the `Scene` supplies its default material at draw time (DRAFT Step 7.3.5).
///
/// Since Step 19 a Mesh may carry several LOD levels: they are packed into the single vertex/index
/// buffers (D7) and described by `lod_rows` (uploaded per mesh as a [`crate::resources::uniform::LodTable`]).
pub struct Mesh {
/// GPU buffer containing vertex attribute data (position, UV, color).
/// Shared CPU geometry of level 0 (the full mesh, Step 8, D5). Retained for CPU-side computation
/// (bounding boxes, UV access, normal queries) and shared across meshes with identical geometry.
geometry: Arc<Geometry>,
/// GPU buffer containing the packed vertex data of ALL levels (one `Vertex` per position, levels
/// concatenated in level order; level 0 first).
pub vertex_buffer: wgpu::Buffer,
/// Optional GPU buffer for indexed drawing. Present when the mesh uses index-based rendering instead of simple vertex iteration.
/// Optional GPU buffer with the packed indices of all indexed levels (rebased onto the packed
/// vertex layout). `None` when no level is indexed.
pub index_buffer: Option<wgpu::Buffer>,
/// Number of vertices in the mesh. Used as `0..num_vertices` for non-indexed draws.
/// Number of vertices of level 0. Used as `0..num_vertices` for non-indexed draws.
pub num_vertices: u32,
/// Number of indices in the index buffer. Used as `0..num_indices` for indexed draws.
/// Number of indices of level 0. Used as `0..num_indices` for indexed draws.
pub num_indices: u32,
/// The Material used to render this mesh. `None` until assigned; the Renderer falls back to the
/// Scene's default material when absent (DRAFT Step 7.3.5).
material: Option<Arc<Material>>,
/// How the levels were produced (Step 19, D6).
lod_mode: LodMode,
/// The CPU geometry of every level, level 0 first (all retained; `geometry` is `lod_levels[0]`).
lod_levels: Vec<Arc<Geometry>>,
/// The packed-buffer offsets per level (mirrors the uploaded per-mesh LOD table).
lod_rows: Vec<LodRow>,
}
/// Error returned by [`pack_levels`] when the packed vertex total exceeds the u16 index range.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PackError {
/// Total packed vertices (all levels) — must be < 65536 for u16 rebased indices.
pub total_vertices: u32,
}
impl std::fmt::Display for PackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"packed LOD vertex total {} exceeds the 65535 u16 index limit",
self.total_vertices
)
}
}
impl std::error::Error for PackError {}
/// Pure packing of LOD levels (Step 19, D7) — no GPU, unit-testable.
///
/// Concatenates every level's interleaved vertices into one flat slice (level 0 first) and rebases
/// every indexed level's indices onto the packed vertex layout. Returns the packed vertices, the
/// packed indices (`None` when no level is indexed), and one [`LodRow`] per level carrying its
/// **element** offsets + counts. Offsets are in ELEMENT units (vertex indices / index elements),
/// not bytes: the GPU cull pass copies them straight into the WebGPU indirect draw args, whose
/// `first_vertex`/`first_index` fields are element indices — the packed buffers are bound in full
/// (offset 0) and only the args' first_* fields move per level.
///
/// Fails with [`PackError`] when the **total** packed vertex count reaches 65536 (u16 rebased
/// indices cannot address it). Callers (the Scene APIs) validate the total beforehand and report
/// the error to the user; a single-level mesh can never fail (its indices are already u16).
pub(crate) fn pack_levels(
levels: &[Arc<Geometry>],
) -> Result<(Vec<Vertex>, Option<Vec<u16>>, Vec<LodRow>), PackError> {
let total: u32 = levels.iter().map(|l| l.positions.len() as u32).sum();
if total >= 65536 {
return Err(PackError {
total_vertices: total,
});
}
let mut vertices: Vec<Vertex> = Vec::new();
let mut indices: Vec<u16> = Vec::new();
let mut any_indexed = false;
let mut rows: Vec<LodRow> = Vec::with_capacity(levels.len());
let mut vertex_base = 0u32; // element (vertex index) base of the level in the packed buffer
let mut index_base = 0u32; // element (index element) base of the level in the packed buffer
for level in levels {
let level_vertices = level.to_vertices();
// Element units (NOT bytes): the row's offsets feed the WebGPU indirect draw args
// (first_vertex = vertex index, first_index = index element) — see the doc above.
let level_vertex_offset = vertex_base;
let level_vertex_count = level_vertices.len() as u32;
let (level_index_offset, level_index_count) = match level.indices() {
Some(data) => {
any_indexed = true;
for &idx in data {
// Safe: the total-vertex check above guarantees no u16 overflow.
indices.push(idx as u32 as u16 + vertex_base as u16);
}
(index_base, data.len() as u32)
}
None => (0, 0),
};
rows.push(LodRow::new(
level_vertex_offset,
level_vertex_count,
level_index_offset,
level_index_count,
));
vertex_base += level_vertex_count;
index_base += level_index_count;
vertices.extend(level_vertices);
}
Ok((vertices, any_indexed.then_some(indices), rows))
}
impl Mesh {
/// Creates a new Mesh by uploading vertex and optional index data to GPU buffers.
/// Inputs: device (GPU command source for buffer creation), vertices (CPU-side vertex array to upload),
/// indices (optional CPU-side index array for indexed drawing).
/// Returns a Mesh with two GPU buffers ready for rendering. Called at scene initialization time only.
/// Internal steps: 1) create_buffer_init for vertex data →
/// 2) if indices provided: create_buffer_init for index data and set num_indices = len →
/// else: set index_buffer = None and num_indices = 0.
pub fn new(device: &wgpu::Device, vertices: &[Vertex], indices: Option<&[u16]>) -> Self {
/// Canonical constructor (Step 8, D4, now D6/D7): builds the packed GPU buffers from a list of
/// LOD levels (level 0 = the full mesh, always present).
///
/// All levels are packed into ONE vertex buffer and ONE index buffer (D7 — WebGPU forbids dynamic
/// offsets on vertex/index bindings; only the draw args move). `num_vertices`/`num_indices`
/// describe **level 0** (the shadow and main passes always bind level 0); the per-level draw
/// arguments are emitted by the GPU cull pass from the uploaded LOD table.
pub fn from_geometry_lod(
device: &wgpu::Device,
levels: Vec<Arc<Geometry>>,
material: Option<Arc<Material>>,
mode: LodMode,
) -> Self {
assert!(!levels.is_empty(), "a mesh needs at least level 0");
// Packing can only fail when the packed vertex total reaches 65536; the Scene APIs
// validate that beforehand. A single level (from_geometry) can never fail.
let (vertices, indices, rows) =
pack_levels(&levels).expect("packed LOD vertex total exceeds the u16 limit");
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Vertex Buffer"),
contents: bytemuck::cast_slice(vertices),
usage: wgpu::BufferUsages::VERTEX,
label: Some("Mesh Vertex Buffer (packed LOD)"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_SRC,
});
// Create optional index buffer and count indices if provided
let (index_buffer, num_indices) = if let Some(data) = indices {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Index Buffer"),
usage: wgpu::BufferUsages::INDEX,
contents: bytemuck::cast_slice(data),
});
(Some(buffer), data.len() as u32)
} else {
(None, 0)
};
let index_buffer = indices.map(|data| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Index Buffer (packed LOD)"),
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_SRC,
contents: bytemuck::cast_slice(&data),
})
});
let l0 = &levels[0];
Self {
geometry: Arc::clone(l0),
vertex_buffer,
index_buffer,
num_vertices: vertices.len() as u32,
num_indices,
num_vertices: l0.positions.len() as u32,
num_indices: l0.indices().map(|i| i.len() as u32).unwrap_or(0),
material,
lod_mode: mode,
lod_levels: levels,
lod_rows: rows,
}
}
/// One-level convenience constructor (Step 8, D4): builds GPU buffers from a shared CPU
/// `Geometry` (no LOD — `LodMode::Off`, `lod_rows` has a single row).
pub fn from_geometry(
device: &wgpu::Device,
geometry: Arc<Geometry>,
material: Option<Arc<Material>>,
) -> Self {
Self::from_geometry_lod(device, vec![geometry], material, LodMode::Off)
}
/// Returns a reference to the level-0 CPU geometry this mesh was built from (Step 8, D5).
/// Read-only accessor for CPU-side queries (bounding boxes, UVs, normals).
pub fn geometry(&self) -> &Arc<Geometry> {
&self.geometry
}
/// Returns a reference to the Material attached to this mesh, if any.
/// When `None`, the Renderer falls back to the Scene's default material at draw time.
pub fn material(&self) -> Option<&Arc<Material>> {
self.material.as_ref()
}
/// Attaches (or replaces) the Material used to render this mesh.
/// Called by `Scene::create_mesh` during scene setup or by advanced users linking geometry manually.
pub fn set_material(&mut self, material: Arc<Material>) {
self.material = Some(material);
}
/// How this mesh's LOD levels were produced (Step 19, D6).
pub fn lod_mode(&self) -> LodMode {
self.lod_mode
}
/// Number of LOD levels (1 for a plain mesh).
pub fn num_lod_levels(&self) -> usize {
self.lod_rows.len()
}
/// The per-level packed-buffer rows (level 0 first).
pub fn lod_rows(&self) -> &[LodRow] {
&self.lod_rows
}
/// The CPU geometry of level k (0 = full mesh). Used by `Scene::add_mesh_lod` when
/// reconstructing a mesh with a modified level list.
pub fn lod_levels_arc(&self, k: usize) -> Arc<Geometry> {
Arc::clone(&self.lod_levels[k])
}
/// The per-mesh GPU LOD table (uploaded once by the Renderer; read by the GPU cull pass).
pub fn lod_table(&self) -> crate::resources::uniform::LodTable {
crate::resources::uniform::LodTable::from_rows(&self.lod_rows)
}
/// Whether **level 0** is indexed (the shadow/main passes always bind level 0).
pub fn l0_indexed(&self) -> bool {
self.lod_rows
.first()
.map(|r| r.index_count > 0)
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesh::primitives;
#[test]
fn pack_levels_offsets_and_rebasing() {
// Two levels: L0 = icosahedron (12 verts / 60 indices), L1 = decimated to 10 (welded).
let l0 = Arc::new(primitives::icosphere(1.0, 0));
let l1 = Arc::new(l0.decimated(10));
let (vertices, indices, rows) =
pack_levels(&[Arc::clone(&l0), Arc::clone(&l1)]).expect("small mesh packs");
// Packed vertices = L0 + L1 concatenated.
assert_eq!(vertices.len(), l0.positions.len() + l1.positions.len());
// Packed indices = 60 + L1's (rebased by L0's vertex count).
let packed_indices = indices.expect("both levels indexed");
assert_eq!(packed_indices.len(), 60 + l1.indices().unwrap().len());
// L0 indices unchanged (rebase base 0); L1 indices rebased by 12.
for (i, idx) in l0.indices().unwrap().iter().enumerate() {
assert_eq!(packed_indices[i], *idx);
}
for (j, idx) in l1.indices().unwrap().iter().enumerate() {
assert_eq!(packed_indices[60 + j], *idx + 12);
}
// Rows carry the ELEMENT offsets (vertex indices / index elements, not bytes).
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].vertex_offset, 0);
assert_eq!(rows[0].vertex_count, 12);
assert_eq!(rows[0].index_offset, 0);
assert_eq!(rows[0].index_count, 60);
assert_eq!(rows[1].vertex_offset, 12);
assert_eq!(rows[1].vertex_count, l1.positions.len() as u32);
assert_eq!(rows[1].index_offset, 60);
assert_eq!(rows[1].index_count, l1.indices().unwrap().len() as u32);
}
#[test]
fn pack_levels_mixed_indexedness() {
// Non-indexed L0 (6 verts) + indexed L1 (welded) — the Auto-mode case from DRAFT D7.
let l0 = Arc::new(
Geometry::new(vec![
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, 2.0, 0.0],
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, -2.0, 0.0],
])
.with_indices(vec![0, 1, 2, 3, 4, 5]),
);
// Force L0 non-indexed: strip the indices.
let l0_nonidx = Arc::new(Geometry::new(l0.positions.clone()));
let l1 = Arc::new(l0.decimated(1)); // welded + indexed
let (vertices, indices, rows) =
pack_levels(&[Arc::clone(&l0_nonidx), Arc::clone(&l1)]).expect("small mesh packs");
assert_eq!(vertices.len(), 6 + l1.positions.len());
let packed = indices.expect("an indexed level exists");
// L0 contributes no indices; L1's are rebased by 6.
assert_eq!(packed.len(), l1.indices().unwrap().len());
assert_eq!(rows[0].index_count, 0, "non-indexed L0 row");
assert_eq!(rows[1].index_offset, 0, "L1 is the first indexed level");
assert_eq!(rows[1].vertex_offset, 6, "element units, not bytes");
for (j, idx) in l1.indices().unwrap().iter().enumerate() {
assert_eq!(packed[j], *idx + 6);
}
}
#[test]
fn pack_levels_all_non_indexed() {
let l0 = Arc::new(Geometry::new(vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
]));
let (vertices, indices, rows) = pack_levels(&[l0.clone()]).expect("small mesh packs");
assert_eq!(vertices.len(), 3);
assert!(indices.is_none());
assert_eq!(rows[0].index_count, 0);
}
#[test]
fn lod_table_from_rows() {
let l0 = Arc::new(primitives::icosphere(1.0, 0));
let l1 = Arc::new(l0.decimated(10));
let (_, _, rows) = pack_levels(&[l0, l1]).expect("small mesh packs");
let table = crate::resources::uniform::LodTable::from_rows(&rows);
assert_eq!(table.count, 2);
assert_eq!(table.rows[0].index_count, 60);
assert_eq!(table.rows[1].vertex_count, rows[1].vertex_count);
}
#[test]
fn pack_levels_rejects_u16_overflow() {
// A level with 70k vertices (indexed) cannot be packed with u16 rebased indices.
let big = Arc::new(
Geometry::new(vec![[0.0, 0.0, 0.0]; 70_000]).with_indices(vec![0u16; 70_000 / 3 * 3]),
);
let err = pack_levels(&[big]).unwrap_err();
assert_eq!(err.total_vertices, 70_000);
}
}
+25 -6
View File
@@ -1,20 +1,39 @@
//! # Resources Module — Data Types
//!
//! Defines the three core data types that flow through the rendering pipeline: **Vertex** (CPU-side per-attribute
//! tuple), **Mesh** (GPU geometry container with vertex/index buffers), and **Material** (appearance descriptor
//! pairing shader ID with a compiled RenderPipeline). These are immutable after creation and consumed by Renderer
//! for draw calls.
//! Defines the core data types that flow through the rendering pipeline: **Geometry** (CPU-side scattered
//! vertex data, source of truth — re-exported here from `math` for convenience), **Vertex** (interleaved
//! CPU-side per-attribute tuple, the GPU upload contract), **Mesh** (GPU geometry container with vertex/index
//! buffers), and **Material** (appearance descriptor pairing shader ID with a compiled RenderPipeline).
//! These are immutable after creation and consumed by Renderer for draw calls.
//!
//! ## Interaction with Other Modules
//! - `pipeline_cache::build_pipeline()` reads Vertex field offsets to construct the vertex buffer layout.
//! - `mesh::new()` uploads Vertex arrays from CPU memory into GPU vertex buffers via DeviceExt::create_buffer_init().
//! - `mesh::from_geometry()` derives `Vertex` arrays from a `Geometry` and uploads them into GPU vertex
//! buffers via DeviceExt::create_buffer_init().
//! - `material::new()` requests RenderPipelines from PipelineCache during scene initialization.
pub mod camera;
pub mod lights;
pub mod material;
pub mod mesh;
pub mod texture;
pub mod uniform;
pub mod vertex;
// Re-exports
pub use camera::{Camera, CameraController, PITCH_LIMIT};
pub use lights::Lights;
pub use material::Material;
pub use mesh::Mesh;
pub use mesh::{LodMode, Mesh, PackError};
pub use texture::{Texture, TextureError};
pub use uniform::{
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, CullUniforms, DRAW_SLOT_SIZE, DrawSlot,
FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, Light, LightType, LodRow,
LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform,
SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE, TransformSlot,
};
pub use vertex::Vertex;
// Convenience re-export of `math::Geometry` (Step 8, D2) so examples can build meshes
// from `wsg_lib::resources::Geometry` without importing `math` separately.
pub use crate::core::Geometry;
+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 Step 10 (DRAFT D3) to texturize the standard shader via bind group `@group(2)`.
//!
//! ## Architecture Notes (per DRAFT Step 10, D3/D4)
//! - **Format**: `Rgba8UnormSrgb` (espace sRGB, correct pour une couleur diffuse).
//! - **Usage**: `TEXTURE_BINDING | COPY_DST` (sampled in the fragment, filled by CPU upload).
//! - **Mipmaps**: single level (`mip_level_count: 1` — YAGNI, no mipmap generation at this step).
//! - **Sampler**: `Linear` + `Repeat` (smooth filtering, classic UV coordinates).
//! - **Placeholder**: a white 1×1 texture (multiplicative-identity texel) serves the texture-less
//! `Material` — see `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 (Step 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: no mipmaps at this step (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)?;
// Normalize to RGBA8 (downsamples Luma8/Rgb8 to RGBA8, as Rgba8UnormSrgb requires).
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)
}
}
+590
View File
@@ -0,0 +1,590 @@
//! # Uniform Module — GPU Buffer Data Types
//!
//! Defines the CPU-side `Pod` (plain old data) structs that are uploaded to GPU uniform buffers.
//! Their memory layout must match **exactly** the WGSL uniforms declared in `standard_shader.wgsl`
//! (see the "Uniform Contract" section of that file) — 16-byte alignment (std140), no padding.
//!
//! Two bind groups are shared by every pipeline (single-layout decision, Step 3):
//! - `@group(0) @binding(0)`: `FrameUniforms` (per-frame: camera + lights + shadow) → 784 bytes
//! - `@group(1) @binding(0)`: `ObjectUniform` (per-entity model matrix) → 64 bytes
//!
//! ## Interaction with Other Modules
//! - `pipeline_cache::build_pipeline()` creates the two bind group layouts matching these types.
//! - `Renderer` allocates the buffers and `BindGroup`s from these types and writes them each frame.
//! - `standard_shader.wgsl` consumes them (layout identical to these structs).
use bytemuck::{Pod, Zeroable};
use glam::{Mat4, Vec4};
/// Byte size of the per-frame uniform buffer (`FrameUniforms`).
pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::<FrameUniforms>() as u64;
/// Byte size of the per-object uniform buffer (`ObjectUniform`).
pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::<ObjectUniform>() as u64;
/// Byte size of the shadow-pass uniform buffer (`ShadowUniform`, Step 14).
pub const SHADOW_UNIFORM_SIZE: u64 = std::mem::size_of::<ShadowUniform>() as u64;
/// Maximum number of lights stored in the per-frame uniform buffer.
/// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation).
pub const MAX_LIGHTS: usize = 8;
/// A single light, stored in the per-frame uniform array. One struct serves all three types; the
/// *position in the array* disambiguates:
/// - indices `0..num_directional` are **directional** (`position_dir.xyz` = direction
/// **from the surface toward the light**);
/// - indices `num_directional..num_directional + num_point` are **point**
/// (`position_dir.xyz` = world position);
/// - indices `num_directional + num_point..` are **spot** (`position_dir.xyz` = world position,
/// `dir_angle.xyz` = cone axis **from the light toward the scene**, `dir_angle.w` = cos of the
/// half-angle).
/// No type flag in the struct.
///
/// 4 × Vec4 = 64 bytes, 16-byte aligned (std140-compatible with the WGSL `struct Light`).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, PartialEq)]
pub struct Light {
/// xyz = direction from surface toward the light (directional) or world position (point/spot);
/// w = 0.
pub position_dir: Vec4,
/// rgb = color; a = intensity (multiplier).
pub color: Vec4,
/// x = attenuation radius (point/spot lights); 0 for directional.
pub radius: Vec4,
/// Spot only: xyz = cone axis (from the light toward the scene), w = cos of the half-angle.
/// Zero for directional and point lights.
pub dir_angle: Vec4,
}
/// The runtime-disambiguated type of a [`Light`] (Step 14, D6). Not stored in the struct (the array
/// position disambiguates on the GPU); used by CPU-side logic such as the shadow-pass light selection,
/// which must reject point lights (cubemap shadows are out of scope).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LightType {
/// Directional light (infinitely distant): `position_dir.xyz` = ray direction away from the
/// light, `radius.x` = 0, `dir_angle` = 0.
Directional,
/// Point (omnidirectional): `position_dir.xyz` = world position, `radius.x` = attenuation
/// radius, `dir_angle` = 0.
Point,
/// Spot: world position in `position_dir.xyz`, `radius.x` = attenuation radius, cone axis in
/// `dir_angle.xyz` and `dir_angle.w` = cos of the half-angle.
Spot,
}
impl Light {
/// Classifies the light for CPU-side logic. Query order is significant because a spot light
/// carries both a positive attenuation radius **and** a positive `dir_angle.w` (cos of a
/// sub-90° half-angle), so the cone flag is tested first, then the radius, and anything else is
/// the infinite directional light. Returns [`LightType::Directional`], [`LightType::Point`] or
/// [`LightType::Spot`].
pub fn light_type(&self) -> LightType {
if self.dir_angle.w > 0.0 {
LightType::Spot
} else if self.radius.x > 0.0 {
LightType::Point
} else {
LightType::Directional
}
}
}
/// Per-frame GPU uniforms: camera matrices + ambient + global light list + shadow data + options.
///
/// Mirrors the WGSL `FrameUniforms` struct in `standard_shader.wgsl` (offset table there).
/// 160 + 64·MAX_LIGHTS bytes for the camera header + lights, then the counters, the single shadow
/// light selection, the light view-projection matrix + shadow parameters, then options — total
/// **784 bytes** (Step 14, DRAFT 3.1), 16-byte aligned, `Pod` for direct `bytes_of` upload. The
/// bind-group layout uses `min_binding_size: None`, so extending this struct is transparent
/// (no relayout).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct FrameUniforms {
/// Camera view matrix (world → view space). Offset 0.
pub view: Mat4,
/// Camera projection matrix (view → clip space). Offset 64.
pub proj: Mat4,
/// Camera world position (`.xyz` used). Offset 128.
pub cam_pos: Vec4,
/// Ambient hemisphere color (`.rgb` used). Offset 144.
pub ambient: Vec4,
/// Global light list: directionals, then point, then spot. Offset 160.
pub lights: [Light; MAX_LIGHTS],
/// Number of active directional lights (indices `0..num_directional`).
/// Offset 160 + 64·MAX_LIGHTS.
pub num_directional: u32,
/// Number of active point lights (indices after the directionals).
pub num_point: u32,
/// Number of active spot lights (indices after the point lights).
pub num_spot: u32,
/// Index (in the packed frame array) of the single shadow-casting light (DRAFT Step 14, D1).
/// `MAX_LIGHTS` = sentinel meaning "no shadow" (shadows off). Offset 160 + 64·MAX_LIGHTS + 12.
pub shadow_light_index: u32,
/// View-projection matrix of the shadow-casting light (world → light clip space), used to
/// reproject fragments into the shadow map (DRAFT Step 14, D3). Offset 176 + 64·MAX_LIGHTS.
pub light_view_proj: Mat4,
/// Shadow sampling parameters (DRAFT Step 14, D5). `x` = shadow map size in pixels (for
/// texel-space PCF offsets), `y` = depth bias, `z`/`w` reserved. Offset 240 + 64·MAX_LIGHTS.
pub shadow_params: Vec4,
/// Options. `options[0]` = unlit flag (1 → flat color, no lighting);
/// `options[1]` = shadows enabled (1 → sample the shadow map, checked alongside
/// `shadow_light_index`). Offset 256 + 64·MAX_LIGHTS.
pub options: [u32; 4],
}
impl Default for FrameUniforms {
/// Sensible defaults: identity camera, white ambient, a single white directional light along
/// +Z (from surface toward light), *lit* mode — reproduces the pre-multi-light look exactly.
/// No point or spot lights.
fn default() -> Self {
Self {
view: Mat4::IDENTITY,
proj: Mat4::IDENTITY,
cam_pos: Vec4::ZERO,
ambient: Vec4::ONE,
lights: [Light {
position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), // from surface toward light = +Z
color: Vec4::ONE,
radius: Vec4::ZERO,
dir_angle: Vec4::ZERO,
}; MAX_LIGHTS],
num_directional: 1,
num_point: 0,
num_spot: 0,
// Shadows off by default (Step 14, D7 — non-regression): sentinel = MAX_LIGHTS.
shadow_light_index: MAX_LIGHTS as u32,
light_view_proj: Mat4::IDENTITY,
shadow_params: Vec4::ZERO,
options: [0, 0, 0, 0],
}
}
}
/// Per-object GPU uniforms: the entity's world-space model matrix.
///
/// Mirrors the WGSL `ObjectUniform` struct. 64 bytes, `Pod`.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Default)]
pub struct ObjectUniform {
/// Model matrix (object → world space). Offset 0.
pub model: Mat4,
}
/// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's
/// view-projection matrix. Mirrors the WGSL `ShadowUniform` struct in `shadow_shader.wgsl`.
/// 64 bytes, `Pod`, bound as group 0 of the shadow pipeline.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Default)]
pub struct ShadowUniform {
/// Light view-projection matrix (world → light clip space). Offset 0.
pub view_proj: Mat4,
}
// ============================================================================
// Phase 3 — GPU-driven entity slot buffers (Step 15)
// ============================================================================
//
// CPU-side `Pod` mirrors of the GPU buffer element types declared in `gpu_driven.wgsl` (see that
// file's "GPU buffer layouts" note). Their byte layout must match the WGSL structs **exactly** —
// including the 16-byte alignment of `vec3` (std140), which is why `vec3` fields below carry an
// explicit `_pad` so the Rust offsets line up with the WGSL ones.
//
// All buffers are fixed-capacity (`MAX_ENTITIES`), allocated once. Per frame the CPU rewrites only
// the transform slots + cull uniforms; the matrices and indirect draw args are written by the GPU.
/// Byte size of one GPU entity transform slot (`TransformSlot`).
pub const TRANSFORM_SLOT_SIZE: u64 = std::mem::size_of::<TransformSlot>() as u64;
/// Byte size of one GPU world-matrix slot (`MatSlot`).
pub const MAT_SLOT_SIZE: u64 = std::mem::size_of::<MatSlot>() as u64;
/// Byte size of one GPU local-space bounding box (`BBoxSlot`).
pub const BBOX_SLOT_SIZE: u64 = std::mem::size_of::<BBoxSlot>() as u64;
/// Byte size of one GPU indirect draw-args slot (`DrawSlot`).
pub const DRAW_SLOT_SIZE: u64 = std::mem::size_of::<DrawSlot>() as u64;
/// Byte size of the GPU cull/uniform block (`CullUniforms`).
pub const CULL_UNIFORMS_SIZE: u64 = std::mem::size_of::<CullUniforms>() as u64;
/// A packed entity transform slot (64 bytes) — the single CPU→GPU source of truth for world
/// matrices (Step 15, D13). Mirrors the WGSL `TransformSlot`.
///
/// Layout (std140, 16-byte aligned): translation (vec3 @0) + pad, flags (vec4 @16), rotation
/// (vec4 @32), scale (vec3 @48) + pad → 64 bytes.
///
/// `flags` packing: x = mesh index (stable index into the mesh list), y = active (0/1),
/// z = draw count (vertex or index count for this mesh), w = has_index (0/1).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct TransformSlot {
/// World translation (xyz). Offset 0.
pub translation: [f32; 3],
_pad0: [f32; 1],
/// Packed flags: x = mesh index, y = active, z = draw count, w = has_index. Offset 16.
pub flags: [f32; 4],
/// Rotation quaternion (x, y, z, w). Offset 32.
pub rotation: [f32; 4],
/// Non-uniform scale (xyz). Offset 48.
pub scale: [f32; 3],
_pad1: [f32; 1],
}
impl TransformSlot {
/// Builds an active transform slot from a CPU [`crate::core::Transform`] + the mesh's draw
/// metadata. `mesh_index` / `draw_count` are packed into `flags`; `active` is 1.
pub fn from_transform(
t: &crate::core::Transform,
mesh_index: u32,
draw_count: u32,
has_index: bool,
) -> Self {
Self {
translation: t.translation.to_array(),
_pad0: [0.0; 1],
flags: [
mesh_index as f32,
1.0,
draw_count as f32,
if has_index { 1.0 } else { 0.0 },
],
rotation: [t.rotation.x, t.rotation.y, t.rotation.z, t.rotation.w],
scale: t.scale.to_array(),
_pad1: [0.0; 1],
}
}
/// Builds an inactive (tombstone) slot: `active` = 0; the GPU writes identity and skips the draw.
pub fn inactive() -> Self {
Self {
translation: [0.0; 3],
_pad0: [0.0; 1],
flags: [0.0, 0.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
scale: [0.0; 3],
_pad1: [0.0; 1],
}
}
/// Stable index of the entity's mesh (from `flags.x`).
pub fn mesh_index(&self) -> u32 {
self.flags[0] as u32
}
/// Draw count (vertex or index count) packed in `flags.z`.
pub fn draw_count(&self) -> u32 {
self.flags[2] as u32
}
/// Whether the entity's mesh is indexed (from `flags.w`).
pub fn has_index(&self) -> bool {
self.flags[3] >= 0.5
}
/// Whether the slot is active (from `flags.y`).
pub fn is_active(&self) -> bool {
self.flags[1] >= 0.5
}
}
/// A 4x4 world matrix (64 B) followed by 192 B of padding = **256 B** total. The padding is
/// REQUIRED: the render pipelines read this slot through the `uniform` object group with a
/// per-slot dynamic offset, and WebGPU demands that offset be a multiple of
/// `min_uniform_buffer_offset_alignment` (256 B). A bare 64-byte matrix can never be individually
/// addressable via a uniform offset, so each slot is padded to a 256-byte boundary (capacity is
/// capped at 256 = 64 KB / 256 B). Derived on the GPU by `compute_matrices` (Step 15). Mirrors the
/// WGSL `MatSlot`. (No `Default`: the matrices are GPU-written, so the CPU never constructs a
/// `MatSlot` — this type exists only to fix the buffer's slot size. `pad` is `[f32; 48]`, beyond
/// the `N ≤ 32` bound of the array `Default` impl, so `Default` cannot be derived.)
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct MatSlot {
/// The world matrix (object → world), column-major. Offset 0.
pub m: Mat4,
/// 192 bytes of padding (alignment only, never read). Offset 64.
pub pad: [f32; 48],
}
/// A local-space axis-aligned bounding box (32 bytes), uploaded once per mesh (Step 15).
/// Mirrors the WGSL `BBoxSlot` (min vec3 @0 + pad, max vec3 @16 + pad).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct BBoxSlot {
/// Bounding box minimum corner (xyz). Offset 0.
pub min: [f32; 3],
_pad0: [f32; 1],
/// Bounding box maximum corner (xyz). Offset 16.
pub max: [f32; 3],
_pad1: [f32; 1],
}
impl BBoxSlot {
/// Builds a slot from a CPU [`crate::core::BBox`] (padding zeroed).
pub fn from_bbox(b: &crate::core::BBox) -> Self {
Self {
min: b.min,
max: b.max,
_pad0: [0.0; 1],
_pad1: [0.0; 1],
}
}
/// A degenerate (all-zero) box, used as the placeholder for a mesh with no bounding box.
pub fn empty() -> Self {
Self {
min: [0.0; 3],
max: [0.0; 3],
_pad0: [0.0; 1],
_pad1: [0.0; 1],
}
}
}
/// Indirect draw arguments for one entity (80 bytes = five u32 vec4s). The shader writes only `.a`;
/// the rest stays zero (the constant instance count of 1 lives in `.a.y`). Mirrors the WGSL `DrawSlot`.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Default)]
pub struct DrawSlot {
/// `.a` = (count, instance_count, first_vertex/first_index, base_vertex); `.b..e` = 0.
pub a: [u32; 4],
/// Reserved (first_instance for the indexed block). Kept zero.
pub b: [u32; 4],
/// Reserved padding; kept zero (part of the 80-byte slot).
pub c: [u32; 4],
/// Reserved padding; kept zero (part of the 80-byte slot).
pub d: [u32; 4],
/// Reserved padding; kept zero (part of the 80-byte slot).
pub e: [u32; 4],
}
/// Per-frame GPU cull/uniform block (112 bytes), rewritten by the CPU each frame (Step 15).
/// Mirrors the WGSL `CullUniforms`.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct CullUniforms {
/// Six frustum planes, unit (normal, d); inside the frustum iff dot(p, normal) + d >= 0.
pub planes: [[f32; 4]; 6],
/// Number of live entity slots (slots at/ beyond this are no-ops).
pub num_slots: u32,
/// 0 = culling disabled (every active entity draws), 1 = enabled (sphere test).
pub culling: u32,
_pad: [u32; 2],
}
impl CullUniforms {
/// Builds the cull block from six frustum planes (each a unit `[normal; d]` `[f32; 4]`) + the
/// control flags. `num_slots` = number of live entity slots.
pub fn new(planes: [[f32; 4]; 6], num_slots: u32, culling: bool) -> Self {
Self {
planes,
num_slots,
culling: if culling { 1 } else { 0 },
_pad: [0; 2],
}
}
/// Builds the cull block directly from a [`crate::core::Frustum`] (its six unit planes).
pub fn from_frustum(f: &crate::core::Frustum, num_slots: u32, culling: bool) -> Self {
Self::new(f.planes, num_slots, culling)
}
}
/// Size in bytes of one [`LodRow`] (16 B = 4 u32), matching WGSL `LodRow`.
pub const LOD_ROW_SIZE: u64 = 16;
/// Size in bytes of one [`LodTable`] (80 B = 5 × 16 B), matching WGSL `LodTable`.
pub const LOD_TABLE_SIZE: u64 = 80;
/// One LOD level of a mesh's **packed** vertex/index buffers (Step 19, D7) — the per-level
/// draw offsets the GPU cull pass needs to emit the level's indirect draw args.
/// Mirrors the WGSL `LodRow` (16 bytes: `vec4<u32>`).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
pub struct LodRow {
/// **Element** offset of this level's vertices within the mesh's packed vertex buffer
/// (a vertex index, not a byte offset — the WebGPU `drawIndirectNonIndexed` first_vertex
/// is a vertex index; it goes straight into the indirect args' `first_vertex`).
pub vertex_offset: u32,
/// Number of vertices of this level.
pub vertex_count: u32,
/// **Element** offset of this level's indices within the mesh's packed index buffer
/// (an index element, not a byte offset — the `drawIndirectIndexed` first_index is an
/// index element; 0 when the level is non-indexed or no earlier level has indices).
pub index_offset: u32,
/// Number of indices of this level (0 when non-indexed); the draw count.
pub index_count: u32,
}
impl LodRow {
/// Builds a row from the packed-buffer offsets the packer computed.
/// `index_offset`/`index_count` are 0 for a non-indexed level.
pub fn new(vertex_offset: u32, vertex_count: u32, index_offset: u32, index_count: u32) -> Self {
Self {
vertex_offset,
vertex_count,
index_offset,
index_count,
}
}
}
/// Per-mesh LOD table (Step 19, D7): one [`LodRow`] per level, uploaded once per mesh and
/// read by the GPU cull pass to map a CPU-decided level to indirect draw args. Level 0
/// **always** exists and is byte-exact with the full mesh. Mirrors the WGSL `LodTable`
/// (80 bytes = count @0 + 4 × 16-byte rows @16..80, each row a `vec4<u32>`).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
pub struct LodTable {
/// Number of valid levels (1 = no LOD, single level).
pub count: u32,
/// Padding so the rows start at byte 16 (WGSL `LodTable`: `count : u32` @0, a 12-byte pad,
/// then the `array<LodRow, 4>` @16 — the WGSL pad is an `array<u32, 3>` (alignment 4, NOT
/// `vec3<u32>` which would align to 16 and grow the struct to 96 bytes).
pub _pad: [u32; 3],
/// One 16-byte row per level (`LOD_ROW_SIZE`-spaced), zeroed beyond `count`.
pub rows: [LodRow; crate::utils::conf::MAX_LOD_LEVELS as usize],
}
impl LodTable {
/// Builds a table from up to `MAX_LOD_LEVELS` rows (row 0 = level 0 = full mesh).
pub fn from_rows(rows: &[LodRow]) -> Self {
let mut table = Self::default();
table._pad = [0; 3];
table.count = rows.len() as u32;
for (i, row) in rows
.iter()
.enumerate()
.take(crate::utils::conf::MAX_LOD_LEVELS as usize)
{
table.rows[i] = *row;
}
table
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::mem::offset_of;
use std::mem::{align_of, size_of};
#[test]
fn frame_uniforms_layout_matches_wgsl() {
// The offsets below must match the offset table in standard_shader.wgsl.
// Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters (4×u32 = 16),
// light_view_proj (64) + shadow_params (16) + options (16) = 112 after the counters.
// Total = 160 + 64·8 + 16 + 112 = 784 bytes.
assert_eq!(size_of::<FrameUniforms>(), 784);
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112);
assert_eq!(align_of::<FrameUniforms>(), 16);
let f = FrameUniforms::default();
assert_eq!(offset_of!(FrameUniforms, view), 0);
assert_eq!(offset_of!(FrameUniforms, proj), 64);
assert_eq!(offset_of!(FrameUniforms, cam_pos), 128);
assert_eq!(offset_of!(FrameUniforms, ambient), 144);
assert_eq!(offset_of!(FrameUniforms, lights), 160);
assert_eq!(
offset_of!(FrameUniforms, num_directional),
160 + 64 * MAX_LIGHTS
);
assert_eq!(
offset_of!(FrameUniforms, num_point),
160 + 64 * MAX_LIGHTS + 4
);
assert_eq!(
offset_of!(FrameUniforms, num_spot),
160 + 64 * MAX_LIGHTS + 8
);
assert_eq!(
offset_of!(FrameUniforms, shadow_light_index),
160 + 64 * MAX_LIGHTS + 12
);
assert_eq!(
offset_of!(FrameUniforms, light_view_proj),
160 + 64 * MAX_LIGHTS + 16
);
assert_eq!(
offset_of!(FrameUniforms, shadow_params),
160 + 64 * MAX_LIGHTS + 80
);
assert_eq!(
offset_of!(FrameUniforms, options),
160 + 64 * MAX_LIGHTS + 96
);
// Default is lit mode (unlit flag cleared), one directional light, no point/spot lights,
// shadows off (sentinel = MAX_LIGHTS).
assert_eq!(f.options[0], 0);
assert_eq!(f.num_directional, 1);
assert_eq!(f.num_point, 0);
assert_eq!(f.num_spot, 0);
assert_eq!(f.shadow_light_index, MAX_LIGHTS as u32);
}
#[test]
fn object_uniform_layout_matches_wgsl() {
assert_eq!(size_of::<ObjectUniform>(), 64);
assert_eq!(align_of::<ObjectUniform>(), 16);
assert_eq!(offset_of!(ObjectUniform, model), 0);
}
#[test]
fn gpu_slot_layouts_match_wgsl() {
// TransformSlot: translation @0 (vec3+pad), flags @16, rotation @32, scale @48 (vec3+pad) -> 64.
assert_eq!(size_of::<TransformSlot>(), 64);
assert_eq!(offset_of!(TransformSlot, translation), 0);
assert_eq!(offset_of!(TransformSlot, flags), 16);
assert_eq!(offset_of!(TransformSlot, rotation), 32);
assert_eq!(offset_of!(TransformSlot, scale), 48);
// MatSlot: one 4x4 column-major matrix (64 B) + 192 B padding -> 256 (padded to the
// uniform offset alignment; see the struct doc). m @0, pad @64.
assert_eq!(size_of::<MatSlot>(), 256);
assert_eq!(align_of::<MatSlot>(), 16);
assert_eq!(offset_of!(MatSlot, m), 0);
assert_eq!(offset_of!(MatSlot, pad), 64);
// BBoxSlot: min @0 (vec3+pad), max @16 (vec3+pad) -> 32.
assert_eq!(size_of::<BBoxSlot>(), 32);
assert_eq!(offset_of!(BBoxSlot, min), 0);
assert_eq!(offset_of!(BBoxSlot, max), 16);
// DrawSlot: five u32 vec4s -> 80 (a multiple of both 16 and 20, per the WebGPU indirect rule).
assert_eq!(size_of::<DrawSlot>(), 80);
// CullUniforms: 6 planes (96) + num_slots @96 + culling @100 + pad -> 112.
assert_eq!(size_of::<CullUniforms>(), 112);
assert_eq!(offset_of!(CullUniforms, planes), 0);
assert_eq!(offset_of!(CullUniforms, num_slots), 96);
assert_eq!(offset_of!(CullUniforms, culling), 100);
}
#[test]
fn lod_layouts_match_wgsl() {
// LodRow: four u32 -> 16 (a vec4<u32> in WGSL).
assert_eq!(size_of::<LodRow>(), 16);
assert_eq!(LOD_ROW_SIZE, 16);
assert_eq!(offset_of!(LodRow, vertex_offset), 0);
assert_eq!(offset_of!(LodRow, vertex_count), 4);
assert_eq!(offset_of!(LodRow, index_offset), 8);
assert_eq!(offset_of!(LodRow, index_count), 12);
// LodTable: count @0 + 4 rows @16 -> 80.
assert_eq!(size_of::<LodTable>(), 80);
assert_eq!(LOD_TABLE_SIZE, 80);
assert_eq!(offset_of!(LodTable, count), 0);
assert_eq!(offset_of!(LodTable, rows), 16);
// from_rows: count + rows filled, rest zeroed.
let table = LodTable::from_rows(&[LodRow {
vertex_offset: 0,
vertex_count: 100,
index_offset: 0,
index_count: 300,
}]);
assert_eq!(table.count, 1);
assert_eq!(table.rows[0].vertex_count, 100);
assert_eq!(table.rows[1], LodRow::default());
// MAX_LOD_LEVELS rows fit exactly.
let table = LodTable::from_rows(&vec![
LodRow::default();
crate::utils::conf::MAX_LOD_LEVELS as usize
]);
assert_eq!(table.count, crate::utils::conf::MAX_LOD_LEVELS);
}
}
+4 -4
View File
@@ -15,13 +15,13 @@
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Vertex {
/// XYZ coordinates of this vertex in world space. Offset: 0 bytes (12 bytes total as [f32;3]).
/// XYZ coordinates of this vertex in world space. Offset: 0 bytes (12 bytes total as `[f32; 3]`).
pub position: [f32; 3],
/// XYZ coordinates of the vertex normal. Offset: 12 bytes (12 bytes total as [f32;3]).
/// XYZ coordinates of the vertex normal. Offset: 12 bytes (12 bytes total as `[f32; 3]`).
pub normal: [f32; 3],
/// UV texture coordinates. Offset: 24 bytes (8 bytes total as [f32;2]).
/// UV texture coordinates. Offset: 24 bytes (8 bytes total as `[f32; 2]`).
pub uv: [f32; 2],
/// RGBA color values. Offset: 32 bytes (16 bytes total as [f32;4]).
/// RGBA color values. Offset: 32 bytes (16 bytes total as `[f32; 4]`).
pub color: [f32; 4],
}
+1 -1
View File
@@ -19,4 +19,4 @@ The `scene` module defines Scene, the declarative layer of the WSG architecture.
## Architecture Note
Per [ARCHI_APP](../../docs/ARCHI_APP.md), Scene is one half of the "App" facade pattern. It enables a declarative workflow where all resources are declared before the render loop begins, while keeping the freedom to build the engine "brick by brick" through direct Context/PipelineCache/Renderer manipulation.
Per [ARCHI_APP](../../../docs/tech/ARCHI_APP.md), Scene is one half of the "App" facade pattern. It enables a declarative workflow where all resources are declared before the render loop begins, while keeping the freedom to build the engine "brick by brick" through direct Context/PipelineCache/Renderer manipulation.
+53
View File
@@ -0,0 +1,53 @@
//! # Entity Module
//!
//! Defines `Entity`, the renderable association between a Mesh and its own world-space `Transform`.
//! Since Step 7 (DRAFT Step 7.3) the appearance (Material) lives **on the Mesh**, so an `Entity` only
//! references the mesh by identifier and carries the per-entity placement. Each entry of `Scene::entities`
//! is an `Entity`.
//!
//! ## Interaction with Other Modules
//! - `scene::Scene` stores entities in a `HashMap<String, Entity>` keyed by label.
//! - `math::Transform` provides the placement (translation / rotation / scale) converted to a
//! matrix during rendering.
//! - `resources::Mesh` is the referenced render resource, resolved by `Scene`; its Material is read by the Renderer.
use crate::core::Transform;
/// A renderable entity: a mesh (with its own material) and a world-space transform.
///
/// Entities are created through [`crate::scene::Scene::add_entity`] (identity transform) or
/// [`crate::scene::Scene::add_entity_with_transform`]. Fields are exposed via accessors.
#[derive(Debug, Clone)]
pub struct Entity {
/// Identifier of the referenced Mesh resource.
mesh_id: String,
/// World-space placement of this entity.
transform: Transform,
}
impl Entity {
/// Creates a new entity referencing a mesh under the given transform.
/// Called internally by `Scene::add_entity*` after resource existence is validated.
pub fn new(mesh_id: impl Into<String>, transform: Transform) -> Self {
Self {
mesh_id: mesh_id.into(),
transform,
}
}
/// Returns the identifier of the referenced Mesh resource.
pub fn mesh_id(&self) -> &str {
&self.mesh_id
}
/// Returns a reference to this entity's world-space transform.
pub fn transform(&self) -> &Transform {
&self.transform
}
/// Sets this entity's world-space transform.
/// Called by `Scene::set_entity_transform` during dynamic updates.
pub fn set_transform(&mut self, transform: Transform) {
self.transform = transform;
}
}
+3 -1
View File
@@ -16,7 +16,9 @@
//! all resources are declared before the render loop begins, while keeping the freedom to build the engine
//! "brick by brick" through direct Context/PipelineCache/Renderer manipulation.
pub mod entity;
pub mod scene;
// Re-export
pub use scene::Scene;
pub use entity::Entity;
pub use scene::{Scene, SlotDraw};
+848 -31
View File
@@ -4,39 +4,582 @@
//! the render loop starts, then associate entities via labels. At runtime, Scene provides immutable access to these resources without exposing raw wgpu handles.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **La Recette**: Scene is central to the "App" facade workflow. In the Phase de Déclaration, users call add_mesh(), add_material(), and add_entity()
//! to build the resource depot. During Phase d'Exécution, Renderer iterates Scene entities for rendering.
//! - **Identifiants**: All resource registration uses string identifiers (Handle<T>/String pattern), guaranteeing memory safety
//! - **The Recipe**: Scene is central to the "App" facade workflow. In the Declaration Phase, users call add_mesh(), add_material(), and add_entity()
//! to build the resource depot. During the Execution Phase, Renderer iterates Scene entities for rendering.
//! - **Identifiants**: All resource registration uses string identifiers (`Handle<T>`/String pattern), guaranteeing memory safety
//! and avoiding borrow checker issues during dynamic updates.
//! - **Ergonomie**: Users interact only with entity-level operations (add/remove/get) rather than wgpu buffers/pipelines directly.
//!
//! ## Step 7 — Pipeline context owned by the Scene (DRAFT Step 7.1)
//! Since Step 7 the Scene owns the GPU-facing material pipeline context (`SceneGpu`: device + format + `PipelineCache`)
//! instead of `App`. It can therefore build materials and meshes itself (`add_material_shader`, `create_mesh`) and inject
//! a default material for meshes that carry none (`default_material`).
use crate::resources::{Material, Mesh};
use crate::core::{Geometry, Transform};
use crate::pipeline::PipelineCache;
use crate::resources::{BBoxSlot, Camera, Lights, Material, Mesh, Texture, TransformSlot};
use crate::scene::Entity;
use glam::Vec3;
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
/// GPU-facing context the Scene needs to build materials and meshes by itself. Held in
/// `Scene.gpu` and populated once by `Scene::init_gpu` after the `Context`/`Renderer` exist
/// (during `AppRunner::resumed`, before `AppHandler::setup`). The `cache` is interior-mutable
/// (`RefCell`) so a default material can be built lazily from an immutable `&Scene` at render time.
struct SceneGpu {
/// Shared GPU device used to create mesh buffers and compile pipelines.
device: Arc<wgpu::Device>,
/// Surface texture output format, required to build fragment pipelines.
format: wgpu::TextureFormat,
/// Shader compilation cache: compiles/caches RenderPipelines keyed by shader_id + format.
cache: RefCell<PipelineCache>,
}
/// A stable, append-only slot for an entity in the GPU-driven slot buffers (Phase 3, Step 15).
/// Slots are **never freed**: removing an entity leaves a tombstone (its label drops out of the
/// `entities` map) so that slot indices stay stable across frames and the fixed-capacity GPU buffers
/// can be indexed by a constant slot index. The transform itself is read from the `entities` map
/// (by `label`) at pack time; the slot only carries the mesh identity + draw metadata, which are
/// stable once the entity is (re-)added.
#[derive(Clone)]
struct EntitySlot {
/// Entity label (key into the `entities` map; the transform is read from there each frame).
label: String,
/// Stable index of the entity's mesh (position in `mesh_order`) — indexes the GPU bbox buffer.
mesh_index: u32,
/// Draw count for the entity's mesh (vertex count, or index count when indexed) → packed into
/// the transform slot's `flags.z`.
draw_count: u32,
/// Whether the entity's mesh is indexed → packed into the transform slot's `flags.w`.
has_index: bool,
}
/// Per-slot draw descriptor for the GPU-driven render loop (Phase 3). Carries everything the
/// renderer needs to issue one indirect draw: the slot index (→ indirect-args + matrix buffer
/// offset), whether the slot is active (tombstones are skipped on the CPU), the mesh, and whether
/// it is indexed. The world matrix is **not** carried here — it is derived on the GPU (Step 15.5)
/// and read from the matrix buffer by the render pipeline.
#[derive(Clone)]
pub struct SlotDraw {
/// Stable slot index (offset into the indirect-args and matrix buffers, in slot units).
pub slot_index: usize,
/// Whether the slot is active (false = tombstone; the CPU skips it, the GPU zeros its draw args).
pub active: bool,
/// The entity's mesh (vertex/index buffers + material).
pub mesh: Arc<Mesh>,
/// Whether the mesh is indexed (`draw_indexed_indirect` vs `draw_indirect`).
pub has_index: bool,
}
/// Resource depot and entity graph. Stores Meshes and Materials keyed by identifier strings,
/// and maps entity labels to their associated mesh+material pairs for rendering iteration.
/// maps entity labels to their associated `Entity` (mesh + transform) for rendering iteration,
/// and holds the scene's active `Camera` used to build the per-frame view/projection matrices (Step 4.3).
/// Created once during application setup; entities are added before the render loop starts.
pub struct Scene {
/// Map of mesh identifiers to owned Arc<Mesh> instances. Populated via `add_mesh()`.
/// Map of mesh identifiers to owned `Arc<Mesh>` instances. Populated via `add_mesh()`.
meshes: HashMap<String, Arc<Mesh>>,
/// Map of material identifiers to owned Arc<Material> instances. Populated via `add_material()`.
/// Map of material identifiers to owned `Arc<Material>` instances. Populated via `add_material()`.
materials: HashMap<String, Arc<Material>>,
/// Map of entity labels to (mesh_id, material_id) associations. Populated via `add_entity()`.
entities: HashMap<String, (String, String)>,
/// Map of diffuse texture identifiers to owned `Arc<Texture>` instances (Step 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
/// view/projection matrices written into the frame uniform buffer. Replaced via `set_camera()`.
camera: Camera,
/// Owned pipeline context (device + format + cache), `None` until `init_gpu` is called.
gpu: Option<SceneGpu>,
/// Lazily-built default `standard` material, cached so `default_material` costs O(1) after the
/// first call. Interior-mutable so it can be filled from an immutable `&Scene` (used by the Renderer).
default_material: RefCell<Option<Arc<Material>>>,
/// Global light list (directional + point), uploaded into the frame uniforms each frame
/// (Phase 4.2, Step 12). Default = one white directional light along +Z (non-regression).
lights: Lights,
/// Ambient hemisphere color (rgb) used by the `standard` shader. Default = white.
ambient: [f32; 3],
/// Optional shadow-casting light index (DRAFT Step 14, D1): the index (in the packed frame
/// array: directionals, then points, then spots) of the single light that casts a shadow.
/// `None` = shadows off (default, non-regression). Read each frame by `Renderer::render_scene`
/// to compute the light `view_proj` and enable shadow sampling.
shadow_caster: Option<usize>,
/// Stable, append-only entity slots for the GPU-driven buffers (Phase 3). Grows only; removed
/// entities leave tombstones so slot indices stay stable.
entity_slots: Vec<EntitySlot>,
/// Map of entity label to slot index (O(1) lookup so a re-added label reuses its slot).
slot_of_label: HashMap<String, usize>,
/// Ordered mesh identifiers (index = the stable `mesh_index` used by slots and the bbox buffer).
mesh_order: Vec<String>,
}
impl Scene {
/// Creates an empty scene with no registered resources or entities.
/// Called at application startup before any resource registration.
/// Creates an empty scene with no registered resources or entities and a default camera
/// (`Camera::default()`: position (0,0,3), looking at origin, 45° perspective).
/// Called at application startup before any resource registration. The GPU pipeline context is
/// empty (`gpu: None`) until `init_gpu` is called once the `Context`/`Renderer` exist.
pub fn new() -> Self {
Self {
meshes: HashMap::new(),
materials: HashMap::new(),
textures: HashMap::new(),
entities: HashMap::new(),
camera: Camera::default(),
gpu: None,
default_material: RefCell::new(None),
lights: Lights::new(),
ambient: [1.0, 1.0, 1.0],
shadow_caster: None,
entity_slots: Vec::new(),
slot_of_label: HashMap::new(),
mesh_order: Vec::new(),
}
}
/// 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/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(), queue.clone());
self.gpu = Some(SceneGpu {
device,
format,
cache: RefCell::new(cache),
});
self
}
/// Returns the owned GPU pipeline context, panicking with a clear message if it has not been
/// initialized yet. Called internally whenever the Scene builds materials/meshes.
fn gpu(&self) -> &SceneGpu {
self.gpu
.as_ref()
.expect("scene pipeline not initialized yet — call Scene::init_gpu once after the GPU context is ready")
}
/// Returns the Scene's shared GPU device (used to create mesh buffers and compile pipelines).
/// Panics if the Scene's pipeline context has not been initialized (i.e. outside `resumed`).
pub fn device(&self) -> &wgpu::Device {
self.gpu().device.as_ref()
}
/// Returns the surface texture output format used to build fragment pipelines.
/// Panics if the Scene's pipeline context has not been initialized.
pub fn format(&self) -> wgpu::TextureFormat {
self.gpu().format
}
/// Registers an external WGSL shader file path under a shader id in the Scene's pipeline cache.
/// Sugar for `cache().register_shader(id, path)` so callers never touch the cache directly.
/// Returns Ok(id) or Err(String) if the id is already registered.
pub fn register_shader(&mut self, id: &str, path: &str) -> Result<String, String> {
self.gpu().cache.borrow_mut().register_shader(id, path)
}
/// Builds and registers a Material from a shader id, using the Scene's pipeline context
/// (format + cache). This is the declarative way to declare an appearance without touching
/// `Material::new` or the `PipelineCache` directly. Returns Ok(id) or Err(String) if the id exists.
pub fn add_material_shader(&mut self, id: &str, shader_id: &str) -> Result<String, String> {
if self.materials.contains_key(id) {
return Err(format!("Material ID '{}' already exists.", id));
}
let mut cache = self.gpu().cache.borrow_mut();
let material = Arc::new(Material::new(self.gpu().format, shader_id, &mut cache));
drop(cache);
self.materials.insert(id.to_string(), material);
Ok(id.to_string())
}
/// Registers a diffuse texture in the Scene's resource depot under a unique identifier, so
/// materials can reference it declaratively (Step 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). Step 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` (Step 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 Step 8 the mesh is declared from a CPU `Geometry` (DRAFT Step 8, D4) instead of raw
/// `&[Vertex]`. This builds the shared `Arc<Geometry>` and creates the GPU buffers via
/// `Mesh::from_geometry(device, arc, ...)`, then — if `material` is `Some(name)` — resolves that
/// material id and attaches it to the mesh (`Mesh::set_material`). When `material` is `None`, the
/// mesh carries no material and the Scene's `default_material` is used at draw time.
/// Returns Ok(id) or Err(String) if the id exists or the named material does not.
pub fn create_mesh(
&mut self,
id: &str,
geometry: Geometry,
material: Option<&str>,
) -> Result<String, String> {
if self.meshes.contains_key(id) {
return Err(format!("Mesh ID '{}' already exists.", id));
}
let mut mesh = Mesh::from_geometry(self.device(), Arc::new(geometry), None);
if let Some(name) = material {
let mat = self
.materials
.get(name)
.ok_or_else(|| format!("Material '{}' does not exist.", name))?
.clone();
mesh.set_material(mat);
}
self.meshes.insert(id.to_string(), Arc::new(mesh));
self.mesh_order.push(id.to_string());
Ok(id.to_string())
}
/// Builds, (optionally) links to a Material, and registers a **multi-level** Mesh in one
/// declarative call (Step 19, D6/D7). Level 0 is `geometry` (byte-exact); levels 1.. are
/// auto-generated by quadric edge collapse (`Geometry::generate_lod_levels`, D10) at halving
/// targets. All levels are packed into the mesh's single vertex/index buffers (D7), and the
/// per-level offsets are uploaded per frame as the mesh's LOD table for the GPU cull pass.
///
/// Inputs: id (unique mesh id), geometry (level 0 — the full mesh), material (optional
/// material id, same rule as [`create_mesh`]), levels (2..=MAX_LOD_LEVELS).
/// Returns `Err` if the id exists, the material is unknown, `levels` is out of range, or the
/// packed vertex total exceeds the u16 index limit (65535).
pub fn create_mesh_with_lod(
&mut self,
id: &str,
geometry: Geometry,
material: Option<&str>,
levels: u8,
) -> Result<String, String> {
use crate::utils::conf::MAX_LOD_LEVELS;
if self.meshes.contains_key(id) {
return Err(format!("Mesh ID '{}' already exists.", id));
}
if levels < 2 || u32::from(levels) > MAX_LOD_LEVELS {
return Err(format!(
"LOD levels must be in 2..={MAX_LOD_LEVELS} (got {levels})."
));
}
let lod_levels: Vec<Arc<Geometry>> = geometry
.generate_lod_levels(levels)
.into_iter()
.map(Arc::new)
.collect();
let total: u32 = lod_levels.iter().map(|l| l.positions.len() as u32).sum();
if total >= 65536 {
return Err(format!(
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit; use fewer levels or a smaller mesh."
));
}
let mut mesh = Mesh::from_geometry_lod(
self.device(),
lod_levels,
None,
crate::resources::LodMode::Auto,
);
if let Some(name) = material {
let mat = self
.materials
.get(name)
.ok_or_else(|| format!("Material '{}' does not exist.", name))?
.clone();
mesh.set_material(mat);
}
self.meshes.insert(id.to_string(), Arc::new(mesh));
self.mesh_order.push(id.to_string());
Ok(id.to_string())
}
/// Adds (or replaces) an **explicitly provided** LOD level on an existing mesh (Step 19, D6).
/// The level is packed into the mesh's buffers alongside the others (D7) and the per-mesh LOD
/// table is updated (it is re-uploaded every frame, so the change takes effect next frame).
///
/// Inputs: id (existing mesh id), level (index ≥ 1; must be ≤ the current level count —
/// append at the end or replace in place), geometry (the level's geometry).
/// Validation: the level must validate, carry the **same attribute set and indexedness** as
/// level 0, keep the packed vertex total under 65536, and stay within `MAX_LOD_LEVELS`.
pub fn add_mesh_lod(&mut self, id: &str, level: u8, geometry: Geometry) -> Result<(), String> {
use crate::utils::conf::MAX_LOD_LEVELS;
let current = self
.meshes
.get(id)
.ok_or_else(|| format!("Mesh '{}' does not exist.", id))?;
let levels_count = current.num_lod_levels();
if level < 1 || u32::from(level) > MAX_LOD_LEVELS {
return Err(format!(
"LOD level must be in 1..={MAX_LOD_LEVELS} (got {level})."
));
}
if level as usize > levels_count {
return Err(format!(
"Mesh '{}' has {levels_count} level(s); level {level} does not exist and the next free level is {levels_count}.",
id
));
}
let l0 = current.geometry();
let attr = |g: &Geometry| (g.normals.is_some(), g.uvs.is_some(), g.colors.is_some());
if attr(&geometry) != attr(l0) {
return Err(format!(
"LOD level {level} of mesh '{}' must have the same attribute set (normals/UVs/colors) as level 0.",
id
));
}
if geometry.indices().is_some() != l0.indices().is_some() {
return Err(format!(
"LOD level {level} of mesh '{}' must have the same indexedness as level 0.",
id
));
}
geometry
.validate()
.map_err(|e| format!("LOD level {level} of mesh '{}': {e}", id))?;
let mut new_levels: Vec<Arc<Geometry>> = (0..levels_count)
.map(|i| current.lod_levels_arc(i))
.collect();
if level as usize == levels_count {
if levels_count >= MAX_LOD_LEVELS as usize {
return Err(format!(
"Mesh '{}' already has the maximum of {MAX_LOD_LEVELS} LOD levels.",
id
));
}
new_levels.push(Arc::new(geometry)); // append the next level (L_k at vec index k)
} else {
new_levels[level as usize] = Arc::new(geometry); // replace L_k in place (vec index k)
}
let total: u32 = new_levels.iter().map(|l| l.positions.len() as u32).sum();
if total >= 65536 {
return Err(format!(
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit."
));
}
let material = current.material().cloned();
let mesh = Mesh::from_geometry_lod(
self.device(),
new_levels,
material,
crate::resources::LodMode::Explicit,
);
// Entities reference the mesh by (stable) index, not by Arc — swapping the Arc is safe.
self.meshes.insert(id.to_string(), Arc::new(mesh));
Ok(())
}
/// The per-mesh LOD tables in `mesh_order` order (one 80-byte table per mesh, level 0 first)
/// — the payload of the GPU `lod_tables` buffer, uploaded every frame (Step 19, D7).
pub fn mesh_lod_tables(&self) -> Vec<crate::resources::LodTable> {
self.mesh_order
.iter()
.map(|name| self.meshes[name].lod_table())
.collect()
}
/// Returns the Scene's default material: the `standard` shader pipeline, built lazily on first
/// call and cached afterwards. Used by `Renderer::render_scene` for meshes that carry no material.
/// Note: the flat (unlit) look is *not* a property of this material — it is driven by the
/// orthogonal `Renderer::set_unlit` flag (DRAFT Step 7.3.5).
pub fn default_material(&self) -> Arc<Material> {
if let Some(m) = self.default_material.borrow().as_ref() {
return m.clone();
}
let material = Arc::new(Material::new(
self.gpu().format,
"standard",
&mut self.gpu().cache.borrow_mut(),
));
*self.default_material.borrow_mut() = Some(material.clone());
material
}
/// Replaces the scene's active camera. The new camera is used from the next frame onward by
/// `Renderer::render_scene` to build the view/projection matrices and the camera position.
/// Inputs: camera — the new camera configuration. Call during setup or `AppHandler::update`
/// to move/re-orient the view (e.g. orbit or FPS controls).
pub fn set_camera(&mut self, camera: Camera) {
self.camera = camera;
}
/// Returns a reference to the scene's active camera.
/// Called by users to read the current camera (e.g. to move it based on input) and internally by
/// `Renderer::render_scene` to upload its matrices.
pub fn camera(&self) -> &Camera {
&self.camera
}
/// Returns a mutable reference to the scene's active camera, for in-place per-frame edits
/// (e.g. [`CameraController::apply_to`](crate::resources::CameraController) during `update`).
pub fn camera_mut(&mut self) -> &mut Camera {
&mut self.camera
}
/// Adds a directional light (direction **from the surface toward the light**, color, intensity).
/// Lights are global to the scene and uploaded into the frame uniforms each frame (Phase 4.2,
/// Step 12). Returns `Err` if the scene would exceed `MAX_LIGHTS` (capacity is bounded; no
/// dynamic UBO allocation). Inputs: dir (direction toward the light source), color (rgb),
/// intensity (multiplier).
pub fn add_directional_light(
&mut self,
dir: Vec3,
color: [f32; 3],
intensity: f32,
) -> Result<(), String> {
if self.lights.len() >= crate::resources::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS
));
}
self.lights
.directional
.push(crate::resources::lights::directional_light(
dir, color, intensity,
));
Ok(())
}
/// Adds a point light (world position, color, intensity, attenuation radius).
/// Returns `Err` if the scene would exceed `MAX_LIGHTS`. Inputs: pos (world position of the
/// light), color (rgb), intensity (multiplier), radius (linear falloff to zero at this distance).
pub fn add_point_light(
&mut self,
pos: Vec3,
color: [f32; 3],
intensity: f32,
radius: f32,
) -> Result<(), String> {
if self.lights.len() >= crate::resources::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS
));
}
self.lights
.point
.push(crate::resources::lights::point_light(
pos, color, intensity, radius,
));
Ok(())
}
/// Adds a spot light (world position, cone axis from the light toward the scene, color,
/// intensity, attenuation radius, half-angle in radians). Returns `Err` if the scene would
/// exceed `MAX_LIGHTS`. Inputs: pos (world position of the light), dir (cone axis, from the
/// light toward the scene), color (rgb), intensity (multiplier), radius (linear falloff to
/// zero at this distance), half_angle (cone half-angle in radians).
pub fn add_spot_light(
&mut self,
pos: Vec3,
dir: Vec3,
color: [f32; 3],
intensity: f32,
radius: f32,
half_angle: f32,
) -> Result<(), String> {
if self.lights.len() >= crate::resources::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS
));
}
self.lights.spot.push(crate::resources::lights::spot_light(
pos, dir, color, intensity, radius, half_angle,
));
Ok(())
}
/// Replaces the scene's global light list. The `Scene` keeps ownership; the list is uploaded
/// into the frame uniforms each frame. Used to reset or bulk-configure lighting.
pub fn set_lights(&mut self, lights: Lights) {
self.lights = lights;
}
/// Returns a reference to the scene's global light list (directional + point).
/// Read by `Renderer::render_scene` each frame to upload the light array.
pub fn lights(&self) -> &Lights {
&self.lights
}
/// Selects the single shadow-casting light by **its index in the packed frame array**
/// (directionals first, then point lights, then spots — same order as
/// `Lights::into_frame_array`). `None` disables shadows (default, non-regression, Step 14 D7).
/// The light must be **directional or spot**; a point light index disables the shadow pass
/// (cubemap shadows are out of scope, D6). Inputs: index — the light's packed-array index, or
/// `None` to turn shadows off.
pub fn set_shadow_caster(&mut self, index: Option<usize>) {
self.shadow_caster = index;
}
/// Returns the index of the scene's shadow-casting light (`None` = shadows off).
/// Read by `Renderer::render_scene` each frame to decide whether to run the shadow pass.
pub fn shadow_caster(&self) -> Option<usize> {
self.shadow_caster
}
/// Removes all lights (directional, point and spot). The fragment shader then contributes
/// only the ambient term. Useful for flat look without toggling `unlit`.
pub fn clear_lights(&mut self) {
self.lights = Lights {
directional: Vec::new(),
point: Vec::new(),
spot: Vec::new(),
};
}
/// Sets the ambient hemisphere color (rgb). Default is white.
pub fn set_ambient(&mut self, color: [f32; 3]) {
self.ambient = color;
}
/// Returns the scene's ambient hemisphere color (rgb).
pub fn ambient(&self) -> [f32; 3] {
self.ambient
}
/// Registers a Mesh in the scene under a unique identifier.
/// Inputs: id (unique key), mesh (Arc-wrapped Mesh instance). Returns Ok(id) on success or Err(String) if already exists.
/// Called during scene initialization when building the resource depot.
@@ -45,6 +588,7 @@ impl Scene {
return Err(format!("Mesh ID '{}' already exists.", id));
}
self.meshes.insert(id.to_string(), mesh);
self.mesh_order.push(id.to_string());
Ok(id.to_string())
}
@@ -59,28 +603,67 @@ impl Scene {
Ok(id.to_string())
}
/// Associates an entity label with a mesh and material pair for rendering iteration.
/// Inputs: label (entity identifier string), mesh_id (key into meshes map), material_id (key into materials map).
/// Returns Ok(label) on success or Err(String) if either referenced resource does not exist.
/// Associates an entity label with a mesh for rendering iteration, using an identity transform.
/// The appearance (Material) is read from the Mesh itself (or the Scene's default), so no
/// material_id is needed here (DRAFT Step 7.3).
/// Inputs: label (entity identifier string), mesh_id (key into meshes map).
/// Returns Ok(label) on success or Err(String) if the referenced mesh does not exist.
/// Called during scene initialization to build the renderable entity graph.
/// Internal steps: 1) validate mesh_id exists → 2) validate material_id exists →
/// 3) insert association into entities HashMap.
pub fn add_entity(
/// Internal steps: 1) validate mesh_id exists → 2) insert an `Entity` with identity transform.
pub fn add_entity(&mut self, label: &str, mesh_id: &str) -> Result<String, String> {
self.add_entity_with_transform(label, mesh_id, Transform::identity())
}
/// Associates an entity label with a mesh together with an explicit world-space transform.
/// Inputs: label (entity identifier string), mesh_id (key into meshes map),
/// transform (world-space placement). Returns Ok(label) on success or Err(String) if the
/// referenced mesh does not exist. Called during scene initialization to build the renderable entity graph.
/// Internal steps: 1) validate mesh_id exists → 2) insert the `Entity` into the entities HashMap.
pub fn add_entity_with_transform(
&mut self,
label: &str,
mesh_id: &str,
material_id: &str,
transform: Transform,
) -> Result<String, String> {
if !self.meshes.contains_key(mesh_id) {
return Err(format!("Mesh '{}' does not exist.", mesh_id));
}
if !self.materials.contains_key(material_id) {
return Err(format!("Material '{}' does not exist.", material_id));
}
self.entities.insert(
label.to_string(),
(mesh_id.to_string(), material_id.to_string()),
);
self.entities
.insert(label.to_string(), Entity::new(mesh_id, transform));
// Keep the stable slot in sync (Phase 3): a re-added label reuses its slot (stable index);
// a new label appends a slot. The mesh index + draw metadata are read from the mesh.
let mesh_index = self
.mesh_order
.iter()
.position(|id| id == mesh_id)
.expect("mesh validated above") as u32;
let mesh = &self.meshes[mesh_id];
let has_index = mesh.index_buffer.is_some();
let draw_count = if has_index {
mesh.num_indices
} else {
mesh.num_vertices
};
let slot_index = match self.slot_of_label.get(label) {
Some(&i) => i,
None => {
let i = self.entity_slots.len();
self.slot_of_label.insert(label.to_string(), i);
self.entity_slots.push(EntitySlot {
label: label.to_string(),
mesh_index: 0,
draw_count: 0,
has_index: false,
});
i
}
};
self.entity_slots[slot_index] = EntitySlot {
label: label.to_string(),
mesh_index,
draw_count,
has_index,
};
Ok(label.to_string())
}
@@ -96,16 +679,36 @@ impl Scene {
self.materials.get(id)
}
/// Iterates all entity associations, yielding (label, mesh_ref, material_ref) triples.
/// Iterates all entity associations, yielding (label, mesh_ref, transform_ref) tuples.
/// The Material is **not** yielded here: since Step 7 it is resolved from the Mesh
/// (`mesh.material()`) or the Scene's default at draw time (DRAFT Step 7.3.4).
/// Called by the orchestrator during each render pass to draw every entity in order.
pub fn iter_entities(&self) -> impl Iterator<Item = (&str, &Arc<Mesh>, &Arc<Material>)> + '_ {
self.entities.iter().map(|(label, (mesh_id, mat_id))| {
let mesh = self.meshes.get(mesh_id).unwrap(); // safe: add_entity validates existence
let mat = self.materials.get(mat_id).unwrap(); // same invariant
(label.as_str(), mesh, mat)
pub fn iter_entities(&self) -> impl Iterator<Item = (&str, &Arc<Mesh>, &Transform)> + '_ {
self.entities.iter().map(|(label, entity)| {
let mesh = self.meshes.get(entity.mesh_id()).unwrap(); // safe: add_entity validates existence
(label.as_str(), mesh, entity.transform())
})
}
/// Returns a reference to the transform of the entity with the given label, if it exists.
/// Called by the user to read an entity's current placement during updates.
pub fn entity_transform(&self, label: &str) -> Option<&Transform> {
self.entities.get(label).map(|e| e.transform())
}
/// Overwrites the transform of the entity with the given label.
/// Returns true if the entity existed and was updated, false otherwise.
/// Called by the user to move/rotate/scale an entity during `AppHandler::update`.
pub fn set_entity_transform(&mut self, label: &str, transform: Transform) -> bool {
match self.entities.get_mut(label) {
Some(entity) => {
entity.set_transform(transform);
true
}
None => false,
}
}
/// Removes an entity from the graph without freeing its underlying resources.
/// The referenced Mesh and Material remain registered; only the association is dropped.
/// Called during dynamic updates when an entity should be hidden or removed temporarily.
@@ -118,4 +721,218 @@ impl Scene {
pub fn entity_count(&self) -> usize {
self.entities.len()
}
// ========================================================================
// Phase 3 — GPU-driven entity slot accessors (Step 15)
// ========================================================================
// These expose the stable slot system to the Renderer: the packed transform slots (uploaded
// to the GPU each frame), the per-slot draw descriptors (for the indirect render loop), the
// per-mesh bounding boxes (uploaded once), and the slot/mesh counts. The world matrices are
// derived on the GPU; these methods only feed the CPU→GPU inputs and the draw-loop metadata.
/// Packs the stable entity slots into GPU [`TransformSlot`]s (one per slot; tombstones →
/// inactive). The renderer uploads this to the transform buffer each frame (Phase 3, Step 15).
pub fn packed_transform_slots(&self) -> Vec<TransformSlot> {
self.entity_slots
.iter()
.map(|slot| match self.entities.get(&slot.label) {
Some(entity) => TransformSlot::from_transform(
entity.transform(),
slot.mesh_index,
slot.draw_count,
slot.has_index,
),
None => TransformSlot::inactive(),
})
.collect()
}
/// Iterates the stable entity slots as per-slot draw descriptors (Phase 3). Each item carries
/// the slot index (→ indirect-args/matrix buffer offset), whether the slot is active (tombstones
/// are skipped on the CPU), the mesh, and whether it is indexed. Used by the indirect render loop.
pub fn iter_slot_draws(&self) -> impl Iterator<Item = SlotDraw> + '_ {
self.entity_slots
.iter()
.enumerate()
.map(|(i, slot)| SlotDraw {
slot_index: i,
active: self.entities.contains_key(&slot.label),
mesh: self.meshes[&self.mesh_order[slot.mesh_index as usize]].clone(),
has_index: slot.has_index,
})
}
/// The local-space bounding boxes for all registered meshes, in `mesh_index` order (one per
/// mesh). Uploaded once to the GPU bbox buffer (Phase 3). Meshes without a bounding box get a
/// degenerate (all-zero) box, which the cull pass treats as a zero-radius sphere.
pub fn mesh_bboxes(&self) -> Vec<BBoxSlot> {
self.mesh_order
.iter()
.map(|id| {
self.meshes[id]
.geometry()
.bbox()
.map(|b| BBoxSlot::from_bbox(&b))
.unwrap_or_else(BBoxSlot::empty)
})
.collect()
}
/// Stable index of `mesh_id` in the ordered mesh list (its slot in the GPU bbox buffer), if
/// the mesh is registered.
pub fn mesh_index_of(&self, mesh_id: &str) -> Option<u32> {
self.mesh_order
.iter()
.position(|id| id == mesh_id)
.map(|p| p as u32)
}
/// The registered mesh at a stable `mesh_index` (panics if the index is out of range; in
/// practice it is always valid, being derived from `mesh_order` positions).
pub fn mesh_by_index(&self, index: u32) -> &Arc<Mesh> {
&self.meshes[&self.mesh_order[index as usize]]
}
/// Number of entity slots (tombstones included) — the `num_slots` written to the cull uniforms
/// (slots at/beyond this are no-ops on the GPU).
pub fn num_slots(&self) -> usize {
self.entity_slots.len()
}
/// Number of *active* entity slots (tombstones excluded) — the live entity count.
pub fn num_active_slots(&self) -> usize {
self.entity_slots
.iter()
.filter(|s| self.entities.contains_key(&s.label))
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_scene_is_empty() {
let scene = Scene::new();
assert_eq!(scene.entity_count(), 0);
assert_eq!(scene.iter_entities().count(), 0);
assert!(scene.get_mesh("nope").is_none());
assert!(scene.get_material("nope").is_none());
assert_eq!(scene.shadow_caster(), None);
assert_eq!(scene.ambient(), [1.0, 1.0, 1.0]);
}
#[test]
fn default_camera_looks_at_origin_from_plus_z() {
let scene = Scene::new();
let cam = scene.camera();
assert_eq!(cam.position, Vec3::new(0.0, 0.0, 3.0));
assert_eq!(cam.target, Vec3::ZERO);
}
#[test]
fn camera_set_and_get_roundtrip() {
let mut scene = Scene::new();
let cam = Camera::new(Vec3::new(5.0, 5.0, 5.0), Vec3::ZERO, Vec3::Y)
.with_perspective(1.0, 0.5, 50.0);
scene.set_camera(cam.clone());
assert_eq!(scene.camera().position, Vec3::new(5.0, 5.0, 5.0));
scene.camera_mut().target = Vec3::new(1.0, 0.0, 0.0);
assert_eq!(scene.camera().target, Vec3::new(1.0, 0.0, 0.0));
}
#[test]
fn default_light_list_has_one_directional() {
let scene = Scene::new();
assert_eq!(scene.lights().len(), 1);
assert_eq!(scene.lights().directional.len(), 1);
assert_eq!(scene.lights().point.len(), 0);
assert_eq!(scene.lights().spot.len(), 0);
}
#[test]
fn add_lights_until_capacity_then_rejected() {
let mut scene = Scene::new(); // starts with the default directional (1 light)
scene
.add_point_light(Vec3::ZERO, [1.0, 1.0, 1.0], 1.0, 5.0)
.unwrap();
while scene.lights().len() < crate::resources::MAX_LIGHTS {
scene
.add_directional_light(Vec3::Z, [1.0, 1.0, 1.0], 1.0)
.unwrap();
}
assert_eq!(scene.lights().len(), crate::resources::MAX_LIGHTS);
assert!(
scene
.add_spot_light(Vec3::Z, Vec3::NEG_Z, [1.0, 1.0, 1.0], 1.0, 5.0, 0.5)
.is_err()
);
}
#[test]
fn clear_lights_keeps_ambient() {
let mut scene = Scene::new();
scene.set_ambient([0.5, 0.2, 0.1]);
scene.clear_lights();
assert!(scene.lights().is_empty());
assert_eq!(scene.ambient(), [0.5, 0.2, 0.1]);
}
#[test]
fn shadow_caster_set_and_get() {
let mut scene = Scene::new();
scene.set_shadow_caster(Some(0));
assert_eq!(scene.shadow_caster(), Some(0));
scene.set_shadow_caster(None);
assert_eq!(scene.shadow_caster(), None);
}
#[test]
fn add_entity_rejects_unknown_mesh() {
let mut scene = Scene::new();
assert!(scene.add_entity("e1", "missing_mesh").is_err());
assert!(!scene.set_entity_transform("missing", Transform::identity()));
assert!(!scene.remove_entity("missing"));
}
#[test]
fn gpu_driven_slot_bookkeeping_is_empty_when_no_entities() {
// The GPU-driven slot system starts empty; the full slot/mesh interplay requires a
// wgpu device (to build meshes) and is validated by the examples.
let scene = Scene::new();
assert_eq!(scene.num_slots(), 0);
assert_eq!(scene.num_active_slots(), 0);
assert!(scene.packed_transform_slots().is_empty());
assert!(scene.mesh_bboxes().is_empty());
assert!(scene.iter_slot_draws().next().is_none());
assert!(scene.mesh_index_of("nope").is_none());
}
#[test]
fn packed_slot_roundtrips_transform_and_is_inactive_when_tombstoned() {
// Exercises the CPU→GPU packing (GPU-independent): an active slot packs the transform +
// mesh index + draw count + index flag; a tombstoned slot packs to `inactive()`.
let t = Transform {
translation: glam::Vec3::new(1.0, 2.0, 3.0),
..Transform::identity()
};
let slot = TransformSlot::from_transform(&t, 7, 36, true);
assert_eq!(slot.mesh_index(), 7, "mesh index packed in flags.x");
assert!(slot.is_active(), "active = 1");
assert_eq!(slot.draw_count(), 36, "draw count packed in flags.z");
assert!(slot.has_index(), "indexed flag packed in flags.w");
assert!(
slot.translation
.iter()
.zip([1.0, 2.0, 3.0].iter())
.all(|(a, b)| (a - b).abs() < 1e-5)
);
let inactive = TransformSlot::inactive();
assert!(!inactive.is_active(), "inactive active-flag = 0");
assert_eq!(inactive.mesh_index(), 0);
assert_eq!(inactive.draw_count(), 0);
assert!(!inactive.has_index());
}
}
+34 -11
View File
@@ -2,27 +2,50 @@
## Overview
Contains WGSL shader source files used by the PipelineCache module. These are loaded at runtime from disk when referenced by their registered ID in PipelineCache.register_shader(). If a file is missing, PipelineCache falls back to the embedded BASIC_SHADER constant defined in utils::conf.
Contains WGSL shader source files used by the PipelineCache module. These are loaded at runtime from
disk when referenced by their registered ID in PipelineCache.register_shader(). If a file is missing,
PipelineCache falls back to the embedded STANDARD_SHADER constant defined in utils::conf.
The directory contains a **single shader**: `standard_shader.wgsl` (Phong). Flat 2D rendering is
the **unlit variant** of `standard`.
## Files
| File | Purpose |
|------|---------|
| **basic_shader.wgsl** | Default vertex/fragment shader pair (vs_main / fs_main entry points) with position, uv, and color attributes. |
| **standard_shader.wgsl** | Standard (Phong) vertex/fragment shader — ambient + multi-light (directional + point + spot) diffuse with an explicit unlit mode. Carries the full uniform contract (frame @group(0) + object @group(1) + texture @group(2)). |
## Shader Contract (basic_shader.wgsl)
## Shader Contract (standard_shader.wgsl)
The WGSL shader defines:
`standard_shader.wgsl` is WSG's unified (Phong) shader. It exposes the two bind groups shared
by every material (Step 3: a single layout for all).
- `@vertex fn vs_main(model: VertexInput) -> VertexOutput` — vertex entry point
- `@fragment fn fs_main(in: VertexOutput) -> @location(0) vec4<f32>` — fragment entry point writing RGBA output
### Vertex Input Layout
### Vertex Input Layout (56-byte stride — matches `resources::Vertex`)
| Location | Attribute | Type | Offset (bytes) |
|----------|-----------|------|----------------|
| 0 | position | vec3<f32> | 0 |
| 1 | uv | vec2<f32> | 12 |
| 2 | color | vec3<f32> | 24 |
| 1 | normal | vec3<f32> | 12 |
| 2 | uv | vec2<f32> | 24 |
| 3 | color | vec4<f32> | 32 |
**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.
### Uniforms (bind groups)
| Group / Binding | Struct | Content |
|-----------------|--------|---------|
| `@group(0) @binding(0)` | `FrameUniforms` (704 B) | `view`, `proj`, `cam_pos`, `ambient`, `lights[8]`, `num_directional`, `num_point`, `num_spot`, `options` (.x = unlit flag) |
| `@group(1) @binding(0)` | `ObjectUniform` (64 B) | `model` (the entity's model matrix) |
`FrameUniforms` carries a **global light list** (Steps 12–13, Phase 4.2): `lights[0..num_directional]`
are **directional** lights (`position_dir.xyz` = direction from the surface toward the light),
`lights[num_directional..num_directional + num_point]` are **point** lights
(`position_dir.xyz` = world position, `radius.x` = linear attenuation radius), and
`lights[num_directional + num_point..]` are **spot** lights (`position_dir.xyz` = world position,
`dir_angle.xyz` = light cone axis toward the scene, `dir_angle.w` = cos of the half-angle).
The index disambiguates the type — no flag. `ambient` is the color of the hemispherical ambient term.
### Unlit mode
The `options.x != 0` flag disables **all lights** and returns the vertex color as-is
(flat color). On the API side, `Renderer::set_unlit(true)` (or `app.renderer_mut().set_unlit(true)`)
sets this flag in the frame uniforms. Flat 2D rendering is thus a **special case** of lit 3D.
-42
View File
@@ -1,42 +0,0 @@
//! # Basic Shader Module
//!
//! Default vertex/fragment shader pair used by PipelineCache when no external .wgsl file is found.
//! This shader implements a simple unlit rendering path: passes through position and color attributes
//! from VertexInput to fragment output, producing flat-colored geometry without lighting calculations.
//!
//! ## Shader Contract
//! Must define entry points matching PipelineCache::build_pipeline():
//! - @vertex fn vs_main(model: VertexInput) -> VertexOutput
//! - model.position → @location(0), vec3<f32>, offset 0 bytes in vertex buffer
//! - model.uv → @location(1), vec2<f32>, offset 12 bytes in vertex buffer
//! - model.color → @location(2), vec3<f32>, offset 24 bytes in vertex buffer
//! - @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4<f32>
//! - Writes RGBA output where alpha is hardcoded to 1.0 (fully opaque).
//!
//! ## Technical Notes
//! - No normal or UV interpolation — this is an unlit shader that directly outputs the per-vertex color.
//! - The clip_position is computed as vec4<f32>(position, 1.0), assuming position is already in NDC space.
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec3<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec3<f32>,
};
@vertex
fn vs_main(model: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4<f32>(model.position, 1.0);
out.color = model.color; // On transmet la couleur au fragment shader
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}
+239
View File
@@ -0,0 +1,239 @@
// # GPU-driven rendering compute shader (Phase 3, Step 15)
//
// Two compute entry points run sequentially in a single command encoder, before the render passes:
// 1. `compute_matrices` derives each entity's world matrix on the GPU from its transform slot.
// 2. `cull` decides per-entity visibility (bounding sphere vs frustum) and fills the indirect
// draw arguments (the vertex/index count, zeroed when the entity is culled or inactive).
//
// The main and shadow render passes are then 100% indirect: they read the draw slots (zero count
// = no-op) instead of a CPU-side per-entity loop.
//
// All buffers are fixed-capacity (MAX_ENTITIES = 256, see ARCHI_CPU_GPU.md D12) and are
// allocated once. Each frame the CPU rewrites the transform slots, cull uniforms, LOD levels
// and LOD tables; everything else is GPU-driven.
//
// GPU buffer layouts mirror the bytemuck structs in `resources::uniform` (byte-for-byte):
// TransformSlot (64B), MatSlot (256B), BBoxSlot (32B), DrawSlot (80B), CullUniforms (112B),
// LodRow (16B), LodTable (80B).
//
// Step 19 (LOD): the CPU decides each entity's level (screen-space size + hysteresis, D8);
// the cull pass maps it to the packed level's draw args through the per-mesh LOD table
// (binding 4) and per-slot level array (binding 3). With LOD disabled the CPU writes level 0
// everywhere and the args are byte-identical to the pre-LOD behavior.
//
// GOTCHA — WGSL `select` argument order: `select(reject, accept, cond)` returns the SECOND
// argument when `cond` is true and the FIRST when false (the reverse of HLSL's
// `select(trueVal, falseVal, cond)`). The original cull pass wrote
// `select(u32(t.flags.z), 0u, visible)`, which zeroed the count of every VISIBLE entity (and
// would have drawn the culled ones) — the source of the black-window bug.
// 64 bytes: packed TRS, the single source of truth for world matrices.
struct TransformSlot {
translation : vec3f,
flags : vec4f, // x = mesh index, y = active, z = draw count, w = has_index
rotation : vec4f,
scale : vec3f,
};
// 256 bytes: a 64-byte world matrix followed by 192 bytes of padding. The padding is REQUIRED —
// the render pipelines read this slot through the `uniform` object group with a per-slot dynamic
// offset, and WebGPU demands that offset be a multiple of `min_uniform_buffer_offset_alignment`
// (256 bytes). A bare 64-byte matrix can never be individually addressable via a uniform offset,
// so each slot is padded to a 256-byte boundary (capacity is capped at 256 = 64 KB / 256 B).
struct MatSlot {
m : mat4x4f,
pad : array<vec4f, 12>, // 192 bytes, alignment only — never read
};
// 32 bytes: local-space axis-aligned bounding box (uploaded once per mesh).
struct BBoxSlot {
min : vec3f,
max : vec3f,
};
// 80 bytes: indirect draw arguments for one entity (a 4-u32 non-indexed / 5-u32 indexed block).
// Only `.a` is written by the shader; the rest stays zero (instance count is the constant 1 in `.a.y`).
struct DrawSlot {
a : vec4u,
b : vec4u,
c : vec4u,
d : vec4u,
e : vec4u,
};
// 112 bytes: frustum planes + control flags, rewritten by the CPU each frame.
struct CullUniforms {
planes : array<vec4f, 6>, // unit (normal, d); inside iff dot(p, normal) + d >= 0
num_slots : u32, // number of live entity slots
culling : u32, // 0 = culling disabled, 1 = enabled
_pad : vec2u,
};
// 16 bytes: one LOD level's draw offsets (Step 19, D7). ELEMENT units, not bytes: x is a
// vertex index (drawIndirectNonIndexed first_vertex) and z an index element (drawIndirectIndexed
// first_index) — the packed vertex/index buffers are bound in full (offset 0), only these
// first_* values move per level. w = 0 marks a non-indexed level.
struct LodRow {
o : vec4u, // x = first_vertex, y = vertex_count, z = first_index, w = index_count
};
// 80 bytes: the per-mesh LOD table (Step 19, D7). count @0, 12-byte pad (an array<u32,3> —
// NOT vec3u, which would align to 16 and grow the struct to 96 B), rows @16..80.
struct LodTable {
count : u32, // number of valid levels (1 = no LOD)
_pad : array<u32, 3>,
rows : array<LodRow, 4>, // MAX_LOD_LEVELS = 4; zeroed beyond count
};
// ---- Bind groups (Step 15.5) ----
// Group 0 (transforms) is shared by both entry points; group 1 (matrices) by `compute_matrices`;
// group 2 (cull uniforms + bboxes + draw args) by `cull`. Each pipeline infers the subset it uses.
@group(0) @binding(0) var<storage, read> transforms : array<TransformSlot>;
@group(1) @binding(0) var<storage, read_write> matrices : array<MatSlot>;
@group(2) @binding(0) var<uniform> cull_u : CullUniforms;
@group(2) @binding(1) var<storage, read> bboxes : array<BBoxSlot>;
@group(2) @binding(2) var<storage, read_write> draw_args : array<DrawSlot>;
// Step 19: per-slot CPU-decided LOD level (one u32 per entity slot) and the per-mesh LOD tables
// (one 80-byte LodTable per mesh, mesh_order order — same indexing as `bboxes`).
@group(2) @binding(3) var<storage, read> lod_levels : array<u32>;
@group(2) @binding(4) var<storage, read> lod_tables : array<LodTable>;
// ---- Shared helpers ----
// Builds a rotation mat4x4f from a quaternion (x, y, z, w) in column-major form.
fn quat_to_mat4(q : vec4f) -> mat4x4f {
let x = q.x;
let y = q.y;
let z = q.z;
let w = q.w;
return mat4x4f(
vec4f(1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + w * z), 2.0 * (x * z - w * y), 0.0),
vec4f(2.0 * (x * y - w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + w * x), 0.0),
vec4f(2.0 * (x * z + w * y), 2.0 * (y * z - w * x), 1.0 - 2.0 * (x * x + y * y), 0.0),
vec4f(0.0, 0.0, 0.0, 1.0)
);
}
// Rotates a local-space vector by a quaternion (via the rotation matrix).
fn rotate_by_quat(v : vec3f, q : vec4f) -> vec3f {
let m = quat_to_mat4(q);
return (m * vec4f(v, 0.0)).xyz;
}
// World matrix = T * R * S, column-major (matches the CPU `Transform::to_matrix`, D13).
// Returns just the 4x4 matrix; the caller stores it in `matrices[i].m` (the slot's 192-byte pad
// is left at its zero-initialised value).
fn world_matrix(t : TransformSlot) -> mat4x4f {
let r = quat_to_mat4(t.rotation);
return mat4x4f(
r[0] * t.scale.x,
r[1] * t.scale.y,
r[2] * t.scale.z,
vec4f(t.translation.x, t.translation.y, t.translation.z, 1.0)
);
}
// The identity world matrix (used for inactive slots so any stale read is harmless).
fn identity_mat() -> mat4x4f {
return mat4x4f(
vec4f(1.0, 0.0, 0.0, 0.0),
vec4f(0.0, 1.0, 0.0, 0.0),
vec4f(0.0, 0.0, 1.0, 0.0),
vec4f(0.0, 0.0, 0.0, 1.0)
);
}
// Fills a draw slot with a non-zero (visible) count of `count`, or zero (culled / inactive).
// The count lands in `.a.x`; `.a.y` (instance count) is the constant 1; the rest stays zero.
// (A level-0 draw produced by `write_level_args` is byte-identical to this: first_* = 0.)
fn set_draw_count(i : u32, count : u32) {
draw_args[i].a = vec4u(count, 1u, 0u, 0u);
}
// Fills the slot's indirect args with the draw command of LOD level `row` (Step 19, D7):
// indexed levels use the 5-field layout (index_count, instances, first_index, base_vertex,
// base_instance) and non-indexed levels the 4-field layout (vertex_count, instances,
// first_vertex, base_instance). The element-unit offsets in the row become the first_* fields.
fn write_level_args(i : u32, row : LodRow) {
if (row.o.w > 0u) {
draw_args[i].a = vec4u(row.o.w, 1u, row.o.z, 0u);
} else {
draw_args[i].a = vec4u(row.o.y, 1u, row.o.x, 0u);
}
}
// ---- Pass 1: derive world matrices (Step 15.5) ----
// Dispatched for MAX_ENTITIES; inactive slots get the identity matrix (a harmless stale read).
@compute
@workgroup_size(64)
fn compute_matrices(@builtin(global_invocation_id) gid : vec3u) {
let i = gid.x;
let t = transforms[i];
if (t.flags.y < 0.5) {
matrices[i].m = identity_mat();
} else {
matrices[i].m = world_matrix(t);
}
}
// ---- Pass 2: LOD level → draw args + cull (Steps 15.6 + 19) ----
// Dispatched for MAX_ENTITIES. Slots at or beyond `num_slots` (and inactive slots) are zeroed so
// the indirect render passes skip them. For visible slots the CPU-decided LOD level (binding 3)
// is mapped through the mesh's LOD table (binding 4) to the level's draw command; culling
// (when enabled) zeroes it against the frustum planes.
@compute
@workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid : vec3u) {
let i = gid.x;
let t = transforms[i];
// Beyond the live slots: zero the count so the indirect draw is a no-op.
if (i >= cull_u.num_slots) {
set_draw_count(i, 0u);
return;
}
// Inactive slot (tombstone): no draw.
if (t.flags.y < 0.5) {
set_draw_count(i, 0u);
return;
}
// LOD (Step 19, D8): the CPU decides the level per slot (projected size + hysteresis);
// the GPU only maps it to the packed level's draw args. Clamped to the mesh's level count
// (the CPU clamps too — defense in depth against a stale level after an add_mesh_lod).
let table = lod_tables[u32(t.flags.x)];
var level = lod_levels[i];
if (level >= table.count) {
level = table.count - 1u;
}
let row = table.rows[level].o;
// Culling: test the entity's world bounding sphere against the frustum planes.
var visible = true;
if (cull_u.culling == 1u) {
let b = bboxes[u32(t.flags.x)];
let center_local = (b.min + b.max) * 0.5;
// World center = translation + rotation * local center (no scale; the radius carries it).
let center_world = t.translation + rotate_by_quat(center_local, t.rotation);
let half_extents = (b.max - b.min) * 0.5;
let radius = length(half_extents) * max(t.scale.x, max(t.scale.y, t.scale.z));
for (var p = 0u; p < 6u; p = p + 1u) {
let plane = cull_u.planes[p];
let dist = dot(plane.xyz, center_world) + plane.w;
if (dist < -radius) {
visible = false;
break;
}
}
}
if (!visible) {
set_draw_count(i, 0u);
return;
}
// Visible: the chosen level's draw command (level 0 is byte-identical to the pre-LOD args).
write_level_args(i, LodRow(row));
}
+48
View File
@@ -0,0 +1,48 @@
//! # Shadow Shader (Étape 14, Phase 4.2 — depth-only pass)
//!
//! Minimal vertex shader used for the **shadow map pass** (DRAFT Étape 14, D4). It transforms each
//! vertex into the light's clip space and lets the depth write happen — no fragment stage, no color
//! output, no lighting : the rasterizer only records the depth (D2).
//!
//! Only the `position` attribute (location 0) is consumed, so this pipeline needs no normal/uv/color
//! buffers and is as cheap as possible.
//!
//! ## Uniform Contract (this pipeline's own layout — independent of the main pipeline)
//! - `@group(0) @binding(0)` : `ShadowUniform` — the light's `view_proj` matrix (world → light clip).
//! - `@group(1) @binding(0)` : `ObjectUniform` — the entity's per-entity model matrix (shared with
//! the main pipeline, so the Renderer reuses its per-entity object bind groups).
//!
//! The light VP is passed as a group-0 uniform rather than reusing the camera `FrameUniforms`
//! because the shadow pass is rendered from the light's point of view, not the camera's.
struct ShadowUniform {
view_proj: mat4x4<f32>,
};
struct ObjectUniform {
model: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> shadow: ShadowUniform;
@group(1) @binding(0) var<uniform> object: ObjectUniform;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
};
// Output carries only the clip position; any attribute interpolated without a fragment stage is
// still fine (it is simply discarded). Keeping just the position minimizes the vertex output size.
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world = object.model * vec4<f32>(input.position, 1.0);
out.clip_position = shadow.view_proj * world;
return out;
}
+260
View File
@@ -0,0 +1,260 @@
//! # 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
//! Four bind groups, shared by every material (one single pipeline layout — voir Étape 3) :
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow) [784 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)
//! - `@group(3) @binding(0)` : `shadow_sampler` (sampler_comparison) (Étape 14)
//! - `@group(3) @binding(1)` : `shadow_map` (texture_depth_2d) (Étape 14)
//!
//! `FrameUniforms` layout (std140 — each element 16-byte aligned) :
//! | Offset | Field | Type | Meaning |
//! |-----------------------|-------------------|---------------|----------------------------------|
//! | 0 | view | mat4x4<f32> | Camera view matrix |
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
//! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
//! | 160 | lights[0..MAX] | array<Light> | Global light list |
//! | 160 + 64·MAX_LIGHTS | num_directional | u32 | # directional (indices 0..n) |
//! | | num_point | u32 | # point (indices n..) |
//! | | num_spot | u32 | # spot (indices after point) |
//! | | shadow_light_index| u32 | packed index of shadow light |
//! | 160 + 64·MAX_LIGHTS+16| light_view_proj | mat4x4<f32> | world → light clip space (D3) |
//! | | shadow_params | vec4<f32> | .x = map size, .y = depth bias |
//! | | options | vec4<u32> | .x = unlit ; .y = shadows on |
//!
//! `MAX_LIGHTS = 8`. `struct Light` is 64 bytes (4 × vec4). Directional lights occupy
//! `lights[0..num_directional]` (`position_dir.xyz` = direction **from the surface toward the
//! light**); point lights occupy `lights[num_directional..num_directional + num_point]`
//! (`position_dir.xyz` = world position, `radius.x` = linear attenuation radius); spot lights
//! occupy `lights[num_directional + num_point..]` (`position_dir.xyz` = world position,
//! `dir_angle.xyz` = cone axis from the light toward the scene, `dir_angle.w` = cos of the
//! half-angle). No type flag — the index disambiguates (Étapes 12–13).
//!
//! ## 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) |
//! |----------|-----------|----------|----------------|
//! | 0 | position | vec3<f32>| 0 |
//! | 1 | normal | vec3<f32>| 12 |
//! | 2 | uv | vec2<f32>| 24 |
//! | 3 | color | vec4<f32>| 32 |
//!
//! ## Entry Points
//! - `@vertex vs_main` : world = model * position ; clip = proj * view * world.
//! - `@fragment fs_main` : base = texel * vertex color; × (ambient + diffuse) when lit, or base when unlit.
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
};
// Étape 12 (Phase 4.2) : maximum number of lights in the per-frame array. Must match
// `wsg_lib::resources::MAX_LIGHTS`.
const MAX_LIGHTS: u32 = 8u;
// A single light (64 bytes = 4 × vec4). Directional: `position_dir.xyz` = direction from the
// surface toward the light. Point: `position_dir.xyz` = world position, `radius.x` = linear
// attenuation radius. Spot: `position_dir.xyz` = world position, `dir_angle.xyz` = cone axis
// (from the light toward the scene), `dir_angle.w` = cos of the half-angle. The array index
// disambiguates the type (no flag stored).
struct Light {
position_dir: vec4<f32>,
color: vec4<f32>, // rgb = color; a = intensity
radius: vec4<f32>, // x = point/spot attenuation radius
dir_angle: vec4<f32>, // spot: xyz = cone axis, w = cos(half-angle)
};
struct FrameUniforms {
view: mat4x4<f32>,
proj: mat4x4<f32>,
cam_pos: vec4<f32>,
ambient: vec4<f32>, // .rgb = ambient hemisphere color
lights: array<Light, MAX_LIGHTS>, // directional, then point, then spot
num_directional: u32,
num_point: u32,
num_spot: u32,
shadow_light_index: u32, // packed index of the shadow light ; MAX_LIGHTS = off
light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3)
shadow_params: vec4<f32>, // .x = map size, .y = constant bias, .z = slope bias
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
};
struct ObjectUniform {
model: mat4x4<f32>,
};
@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>;
// Étape 14 (DRAFT D1/D5) : groupe ombre — comparaison sampler (0) + carte de profondeur (1).
// Toujours lié (layout unifié) ; inutilisé tant que `options.y == 0` (ombres désactivées).
@group(3) @binding(0) var shadow_sampler: sampler_comparison;
@group(3) @binding(1) var shadow_map: texture_depth_2d;
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
};
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world = object.model * vec4<f32>(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<f32>(
object.model[0].xyz,
object.model[1].xyz,
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> {
// É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 vec4<f32>(base, in.color.a);
}
let n = normalize(in.normal);
// Ambient hémisphérique : dépend de la composante verticale de la normale (couleur venue
// de frame.ambient, Étape 12 — était codée en dur via la couleur de la lumière avant).
let sky = max(n.y, 0.0);
let ambient = frame.ambient.rgb * (0.3 + 0.4 * sky);
var diffuse = vec3<f32>(0.0);
// Lumières directionnelles (indices 0..num_directional). `position_dir` pointe de la surface
// vers la lumière, donc on l'utilise tel quel pour le terme N·L (dot(n, direction vers la
// lumière) > 0 = face éclairée).
for (var i = 0u; i < frame.num_directional; i++) {
let l = normalize(frame.lights[i].position_dir.xyz);
let ndotl = max(dot(n, l), 0.0);
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl;
}
// Lumières ponctuelles (indices num_directional..num_directional + num_point). Atténuation
// linéaire dans le rayon (zéro au-delà).
for (var i = frame.num_directional; i < frame.num_directional + frame.num_point; i++) {
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
let dist = length(to_light);
let l = to_light / max(dist, 1e-4);
let ndotl = max(dot(n, l), 0.0);
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff;
}
// Lumières spot (indices num_directional + num_point..num_directional + num_point +
// num_spot). Cône orienté : on teste l'alignement de la direction **de la lumière vers le
// point** de la surface (-l, car l pointe de la surface vers la lumière) avec l'axe du cône
// (dir_angle.xyz, de la lumière vers la scène). Pénombre lissée entre le demi-angle intérieur
// (dir_angle.w) et un liseré extérieur (demi-angle − 0.1 rad), plus atténuation linéaire.
let spot_base = frame.num_directional + frame.num_point;
for (var i = spot_base; i < spot_base + frame.num_spot; i++) {
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
let dist = length(to_light);
let l = to_light / max(dist, 1e-4); // surface -> lumière
let ndotl = max(dot(n, l), 0.0);
// direction lumière -> point de la surface = -l ; alignée avec l'axe du cône (dir_angle.xyz).
let to_point = -l;
let cone = dot(to_point, normalize(frame.lights[i].dir_angle.xyz));
let cos_inner = frame.lights[i].dir_angle.w;
let cos_outer = cos_inner - 0.1;
let spot_factor = clamp((cone - cos_outer) / max(cos_inner - cos_outer, 1e-4), 0.0, 1.0);
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor;
}
let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos, n);
return vec4<f32>(lit, in.color.a);
}
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
// into the shadow light's clip space, converts to depth-map UVs + normalized depth, then averages
// a 3×3 `textureSampleCompare` neighborhood using the comparison sampler (LessEqual). Returns
// 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow.
//
// Bias strategy : **slope-scaled** — the reference depth is pulled toward the viewer by
// `max(constant_bias, slope_bias * (1.0 - abs(dot(n, light_dir))))`. The slope term grows as the
// surface becomes perpendicular to the light (grazing angle), where acne is worst. This prevents
// the large black patches that a constant bias alone cannot suppress on large flat surfaces.
fn compute_shadow(world_pos: vec3<f32>, normal: vec3<f32>) -> f32 {
// Shadows off (options.y == 0) or no valid caster (sentinel = MAX_LIGHTS) → fully lit.
if (frame.options.y == 0u || frame.shadow_light_index == MAX_LIGHTS) {
return 1.0;
}
let light_clip = frame.light_view_proj * vec4<f32>(world_pos, 1.0);
// Perspective divide then map NDC [-1,1] → UV [0,1]. Orthographic depth is linear in the map.
let shadow_ndc = light_clip.xyz / max(light_clip.w, 1e-6);
var shadow_uv = shadow_ndc.xy * 0.5 + 0.5;
shadow_uv = vec2<f32>(shadow_uv.x, 1.0 - shadow_uv.y); // flip V for texture coordinates
// The light projection is built with the WebGPU `[0,1]` clip-depth convention (glam
// directx/WebGPU module), so NDC z is already in [0,1]: no extra remap is needed.
let current_depth = shadow_ndc.z;
let texel = 1.0 / max(frame.shadow_params.x, 1.0);
// Slope-scaled bias (fixes the large acne patches on surfaces at grazing angles to the light).
// Direction from surface toward the shadow-casting light:
// directional → position_dir.xyz (already the surface→light direction)
// spot → normalize(light_position - world_pos)
let sl_idx = frame.shadow_light_index;
let sl = frame.lights[sl_idx];
let is_dir = (sl_idx < frame.num_directional);
var light_dir: vec3<f32>;
if (is_dir) {
light_dir = normalize(sl.position_dir.xyz);
} else {
light_dir = normalize(sl.position_dir.xyz - world_pos);
}
// The slope factor: 0 when the normal faces the light (no bias needed), 1 when perpendicular.
let slope = 1.0 - abs(dot(normalize(normal), light_dir));
let bias = max(frame.shadow_params.y, frame.shadow_params.z * slope);
// 3×3 PCF : average of the comparison results around the fragment's texel.
var lit_count = 0.0;
for (var ox = -1i; ox <= 1; ox++) {
for (var oy = -1i; oy <= 1; oy++) {
let offset = vec2<f32>(f32(ox), f32(oy)) * texel;
lit_count += textureSampleCompare(
shadow_map, shadow_sampler, shadow_uv + offset, current_depth - bias);
}
}
return lit_count / 9.0;
}
+105
View File
@@ -0,0 +1,105 @@
// Tone mapping fullscreen pass shader (Étape 20).
//
// Renders a fullscreen triangle (no vertex buffer — position derived from vertex_index)
// that samples the HDR texture, applies exposure + tone mapping curve, and writes the
// result to the sRGB surface. The hardware handles the linear→sRGB gamma conversion
// automatically (the surface is Rgba8UnormSrgb).
//
// Two fragment entry points: `fs_aces` (ACES Filmic, Narkowicz 2015) and `fs_reinhard`
// (simple Reinhard). The pipeline is compiled with the appropriate entry point at
// construction time.
struct TmUniforms {
exposure: f32,
pad: vec3<f32>,
}
@group(0) @binding(0) var u_hdr_texture: texture_2d<f32>;
@group(0) @binding(1) var u_hdr_sampler: sampler;
@group(0) @binding(2) var<uniform> u_params: TmUniforms;
// Fullscreen triangle vertex shader: generates three vertices covering the entire
// NDC viewport. The triangle is (-1,-1), (3,-1), (-1,3) — the fourth NDC corner (1,1)
// is outside the triangle and gets clipped away; the visible portion exactly covers [-1,1]².
// The fragment shader derives UVs from the built-in position (window coords).
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
switch vid {
case 0u {
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
}
case 1u {
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
}
default {
return vec4<f32>(-1.0, 3.0, 0.0, 1.0);
}
}
}
// --- ACES Filmic tone curve (Narkowicz 2015) ---
fn aces(x: f32) -> f32 {
let a = 2.51;
let b = 0.03;
let c = 2.43;
let d = 0.59;
let e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
// --- Reinhard tone curve ---
fn reinhard(x: f32) -> f32 {
return clamp(x / (1.0 + x), 0.0, 1.0);
}
// Convert window-space position to texture UVs [0,1]².
// @builtin(position) in a fragment shader is in window coordinates (pixels, top-left origin).
// We need the draw size to normalize; pass it via a uniform or use the known viewport.
// Here we use a simpler trick: the NDC position is available via the vertex interpolation,
// but since we only output @builtin(position), we derive UVs in the fragment from
// @builtin(position) / viewport. The viewport is the full window, so we normalize by
// the known draw size.
//
// Actually, the simplest correct approach: since the triangle covers the full viewport,
// we can use `@builtin(position)` (in pixels) and normalize by the viewport size.
// But we don't have the viewport size as a binding here...
//
// Alternative: use a second vertex output for UVs. Since tuple returns aren't supported
// in this naga version, we use a different trick — the UVs are linearly interpolated
// from the vertex positions. We compute them as (ndc + 1) / 2 in the vertex shader
// and pass them through an @location. But we can only have one return value...
//
// Simplest fix: just use @builtin(position) in the fragment and divide by the
// viewport size (stored in the uniform).
// We add viewport size to the uniform (reusing the _pad field).
// _pad.xy = viewport size in pixels (width, height).
// _pad.z = unused, _pad.w = unused.
@fragment
fn fs_aces(
@builtin(position) frag_pos: vec4<f32>,
) -> @location(0) vec4<f32> {
let uv = frag_pos.xy / u_params.pad.xy;
let color = textureSample(u_hdr_texture, u_hdr_sampler, uv).rgb * u_params.exposure;
return vec4<f32>(
aces(color.r),
aces(color.g),
aces(color.b),
1.0,
);
}
@fragment
fn fs_reinhard(
@builtin(position) frag_pos: vec4<f32>,
) -> @location(0) vec4<f32> {
let uv = frag_pos.xy / u_params.pad.xy;
let color = textureSample(u_hdr_texture, u_hdr_sampler, uv).rgb * u_params.exposure;
return vec4<f32>(
reinhard(color.r),
reinhard(color.g),
reinhard(color.b),
1.0,
);
}
+2 -2
View File
@@ -6,11 +6,11 @@ The `utils` module defines two leaf concepts that other modules consume but have
| File | Responsibility |
|------|---------------|
| **conf** | Shared constants for shader paths (BASIC_SHADER_PATH) and embedded WGSL source code (BASIC_SHADER). Centralized here so all submodules import from one place instead of duplicating literal strings. Enables PipelineCache to fall back to an embedded default shader when the file-based one is missing. |
| **conf** | Shared constants for shader paths (STANDARD_SHADER_PATH) and embedded WGSL source code (STANDARD_SHADER). Centralized here so all submodules import from one place instead of duplicating literal strings. Enables PipelineCache to fall back to an embedded default shader when the file-based one is missing. |
| **error** | WsgError enum — application-level error type mapping specific wgpu failure modes to user-friendly messages via thiserror. Every variant maps a GPU initialization or rendering failure to a recoverable or fatal outcome. |
## Interaction with Other Modules
- **pipeline::pipeline_cache**: load_shader() reads BASIC_SHADER_PATH from disk; falls back to BASIC_SHADER if unreadable.
- **pipeline::pipeline_cache**: load_shader() reads STANDARD_SHADER_PATH from disk; falls back to STANDARD_SHADER if unreadable.
- **core::context**: Returns WsgError variants from all fallible methods (new, configure, begin_frame).
- **core::renderer**: Does not use errors directly — panics on invalid state rather than returning Result.
+93 -6
View File
@@ -6,16 +6,103 @@
//! Also provides application defaults for window title, width, and height used by AppBuilder.
//!
//! ## Interaction with Other Modules
//! - **pipeline_cache::load_shader()** reads `BASIC_SHADER_PATH` from disk; if unreadable, falls back to `BASIC_SHADER`.
//! - **pipeline_cache::load_shader()** reads `STANDARD_SHADER_PATH` from disk; if unreadable, falls back to `STANDARD_SHADER`.
//! - Both constants are compile-time values (`include_str!`) ensuring the fallback shader is always available even without external files.
//! - **app::AppBuilder** reads APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH, and APP_DEFAULT_HEIGHT for default window configuration.
/// Path to the default WGSL shader file on disk (runtime). Used by PipelineCache::load_shader() for file-based loading.
pub const BASIC_SHADER_PATH: &str = "assets/shaders/basic_shader.wgsl";
/// Path to the standard (Phong) WGSL shader file on disk (runtime). Used by PipelineCache::load_shader()
/// for file-based loading. This is the unified pipeline shader (a single layout for all):
/// 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.
///
/// 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 basic WGSL shader source code, embedded at compile time via `include_str!`.
/// Serves as a fallback when `BASIC_SHADER_PATH` cannot be read at runtime.
pub const BASIC_SHADER: &str = include_str!("../shaders/basic_shader.wgsl");
/// The standard (Phong) WGSL shader source code, embedded at compile time via `include_str!`.
/// Serves as the fallback when `STANDARD_SHADER_PATH` cannot be read at runtime. Because every
/// 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 (Step 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!`
/// (Step 14, D4). Serves as the fallback when `SHADOW_SHADER_PATH` cannot be read.
pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl");
/// The GPU-driven rendering compute shader source (Phase 3, Step 15), embedded at compile time.
/// It carries two compute entry points — `compute_matrices` (derive world matrices) and `cull`
/// (per-entity visibility + indirect draw args) — compiled directly by the renderer (internal to
/// the library; no external file is read).
pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl");
/// The tone mapping fullscreen pass shader source (Étape 20), embedded at compile time.
/// Carries one vertex entry point (`vs_main`, fullscreen triangle) and two fragment entry
/// points (`fs_aces`, `fs_reinhard`). Compiled directly by the renderer when HDR is enabled.
pub const TONEMAP_SHADER: &str = include_str!("../shaders/tonemap.wgsl");
/// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and
/// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU
/// rewrites only the transform slots and the cull uniforms.
///
/// **Why 256 (and why the matrix slot is padded to 256 B):** the matrix buffer is bound to the
/// render pipeline's `uniform` object slot (group 1), which imposes TWO limits:
/// (1) a single uniform binding is capped at `max_uniform_buffer_binding_size` (64 KB on most
/// backends), and (2) a uniform offset must be a multiple of `min_uniform_buffer_offset_alignment`
/// (256 B). A 64-byte matrix can therefore never be individually addressable via a per-slot
/// dynamic offset, so each matrix slot is padded to 256 B (see `MatSlot`); with 256-byte slots,
/// 256 slots × 256 B is exactly 64 KB — the largest the single-buffer / dynamic-offset design
/// can address. (The transform / bbox / draw-args buffers are `storage` bindings with a 128 MB
/// limit and no 256-B offset rule, so they keep their natural 64 / 32 / 80 B slot sizes.)
/// 256 is a multiple of the 64-wide workgroup size, giving a whole number of workgroups.
pub const MAX_ENTITIES: u32 = 256;
/// Maximum number of LOD levels per mesh (Étape 19, D8). The per-mesh LOD table
/// (`LodTable`, 80 bytes) carries one 16-byte row per level — 4 rows + the count header.
pub const MAX_LOD_LEVELS: u32 = 4;
/// Default LOD thresholds in **pixels** of projected bounding-sphere radius (Étape 19, D4/D8):
/// `thresholds[k]` is the radius *above which* level k+1 is required (descending). With these
/// values: `r > 48` → L0, `12 < r ≤ 48` → L1, `r ≤ 12` → L2+ (clamped). Constants in v1
/// (per-scene/mesh configurability is a follow-up); the hysteresis dead band (×0.8 to go
/// coarser) lives in `math::lod::lod_level`.
pub const LOD_THRESHOLDS: [f32; 2] = [48.0, 12.0];
/// Workgroup size of the GPU-driven compute shaders (matches the `@workgroup_size` in
/// `gpu_driven.wgsl`). The compute dispatch is `MAX_ENTITIES / WORKGROUP_SIZE` workgroups.
pub const GPU_WORKGROUP_SIZE: u32 = 64;
/// Default shadow-map resolution in pixels per side (square, D2). A 1024² depth map is a good
/// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes.
pub const SHADOW_MAP_SIZE: u32 = 1024;
/// Default shadow constant bias (Step 14, D5) subtracted from the reference depth before the
/// comparison, to suppress acne without killing contact shadows. This is the minimum bias;
/// the slope-scaled term (SHADOW_SLOPE_BIAS) adds more for surfaces at grazing angles.
pub const SHADOW_DEPTH_BIAS: f32 = 0.002;
/// Slope-scaled bias coefficient (Étape 14 fix, 2026-09-24). The effective bias is
/// `max(SHADOW_DEPTH_BIAS, SHADOW_SLOPE_BIAS * (1.0 - |dot(N, L)|))` — it grows as the surface
/// normal becomes perpendicular to the light direction, where shadow acne is worst. A value of
/// 0.004 works well for a 1024² map with a 10-unit ortho frustum; tune per scene scale.
pub const SHADOW_SLOPE_BIAS: f32 = 0.006;
/// Default half-extent (world units) of the orthographic shadow frustum around the scene center
/// for a directional light (D3). Chosen to comfortably frame the unit-cube scene of the examples.
pub const SHADOW_SCENE_RADIUS: f32 = 5.0;
/// Default world-space scene center used to place the shadow light for the examples (D3).
pub const SHADOW_SCENE_CENTER: [f32; 3] = [0.0, 0.0, 0.0];
/// Maximum number of lights in the packed frame light array (re-exported from the uniform layout
/// so upper layers can address the shadow light safely, Step 14 D7). Also used as the no-caster
/// sentinel for `FrameUniforms.shadow_light_index`.
pub use crate::resources::uniform::MAX_LIGHTS;
/// Default application title displayed in the OS taskbar/window decorations.
pub const APP_DEFAULT_TITLE: &str = "WSG App";
+1 -1
View File
@@ -8,7 +8,7 @@
//! ## Interaction with Other Modules
//! - **context** uses WsgError as return types for `new()`, `configure()`, and `begin_frame()`.
//! - **renderer** does not use errors directly (render panics on invalid state rather than returning Result).
//! - **frame** does not use errors — Frame::new() panics while Frame::try_new() returns Option<Self>.
//! - **frame** does not use errors — Frame::new() panics while Frame::try_new() returns `Option<Self>`.
use thiserror::Error;
+5 -3
View File
@@ -5,7 +5,7 @@
//! Both are consumed by other modules but have no internal dependencies on them.
//!
//! ## Interaction with Other Modules
//! - `pipeline_cache` loads shaders from disk using conf::BASIC_SHADER_PATH; falls back to BASIC_SHADER.
//! - `pipeline_cache` loads shaders from disk using conf::STANDARD_SHADER_PATH; falls back to STANDARD_SHADER.
//! - `context` returns WsgError variants from all fallible methods (new, configure, begin_frame).
//! - `renderer` does not use errors directly (panics on invalid state rather than returning Result).
@@ -13,6 +13,8 @@ pub mod conf;
pub mod error;
// Re-exports
pub use conf::BASIC_SHADER;
pub use conf::BASIC_SHADER_PATH;
pub use conf::{
SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, SHADOW_SHADER, SHADOW_SHADER_PATH,
STANDARD_SHADER, STANDARD_SHADER_PATH,
};
pub use error::WsgError;
+118
View File
@@ -0,0 +1,118 @@
//! # Validation WGSL (naga)
//!
//! The `standard_shader.wgsl` shader is not yet loaded by a `RenderPipeline` (see Steps 3–5):
//! this offline validation via `wgpu::naga` is therefore the **only** guarantee of its validity until
//! it is wired in. It protects against future regressions (shader re-edits, layout changes)
//! without requiring a GPU context.
//!
//! No new dependency is introduced: `wgpu` re-exports `naga`, already a `wsg-lib` dependency.
use wgpu::naga;
/// Parses and fully validates the embedded `standard_shader.wgsl` shader via naga.
/// A failure here means the shader would be rejected by `Device::create_shader_module` at Step 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: parsing error: {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: validation failed: {e:?}"));
// Contract: exactly the two expected entry points vs_main / fs_main.
assert!(module.entry_points.len() >= 2, "vs_main + fs_main expected");
}
/// Parses and fully validates the embedded `shadow_shader.wgsl` shader (Step 14, D4) via naga.
/// The "shadow" pipeline is wired directly by `build_shadow_pipeline` (bypassing the
/// PipelineCache), so this offline validation is the guarantee of its validity. The contract
/// expects a single entry point (`vs_main` — pipeline with no fragment stage).
#[test]
fn shadow_shader_is_valid_wgsl() {
let src = include_str!("../src/shaders/shadow_shader.wgsl");
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("shadow_shader.wgsl: parsing error: {e:?}"));
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.unwrap_or_else(|e| panic!("shadow_shader.wgsl: validation failed: {e:?}"));
let entry_names: Vec<&str> = module
.entry_points
.iter()
.map(|ep| ep.name.as_str())
.collect();
assert_eq!(entry_names, vec!["vs_main"], "only vs_main expected");
}
/// Parses and fully validates the embedded `gpu_driven.wgsl` compute shader (Phase 3, Step 15)
/// via naga. The renderer compiles it directly into two `ComputePipeline`s (one per entry point),
/// so this offline validation is the guarantee of its validity. The contract expects exactly the
/// two compute entry points: `compute_matrices` and `cull`.
#[test]
fn gpu_driven_shader_is_valid_wgsl() {
let src = include_str!("../src/shaders/gpu_driven.wgsl");
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("gpu_driven.wgsl: parsing error: {e:?}"));
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.unwrap_or_else(|e| panic!("gpu_driven.wgsl: validation failed: {e:?}"));
let mut entry_names: Vec<&str> = module
.entry_points
.iter()
.map(|ep| ep.name.as_str())
.collect();
entry_names.sort();
assert_eq!(
entry_names,
vec!["compute_matrices", "cull"],
"the two compute entry points are expected"
);
}
/// Parses and fully validates the embedded `tonemap.wgsl` shader (Étape 20) via naga.
/// The renderer compiles it into one `RenderPipeline` (vertex `vs_main` + one of the two
/// fragment entry points `fs_aces` / `fs_reinhard`), so this offline validation is the
/// guarantee of its validity. The contract expects three entry points.
#[test]
fn tonemap_shader_is_valid_wgsl() {
let src = include_str!("../src/shaders/tonemap.wgsl");
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("tonemap.wgsl: parsing error: {e:?}"));
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.unwrap_or_else(|e| panic!("tonemap.wgsl: validation failed: {e:?}"));
let mut entry_names: Vec<&str> = module
.entry_points
.iter()
.map(|ep| ep.name.as_str())
.collect();
entry_names.sort();
assert_eq!(
entry_names,
vec!["fs_aces", "fs_reinhard", "vs_main"],
"the three entry points are expected"
);
}