Compare commits

...

85 Commits

Author SHA1 Message Date
Jérôme Bousquié fecfdcd2d2 choix archi particles 2026-09-25 22:07:48 +02:00
Jérôme Bousquié d4c2d93fc5 eng doc 2026-09-25 20:06:10 +02:00
Jérôme Bousquié 24fbafc810 réorg doc 2026-09-25 19:08:20 +02:00
Jérôme Bousquié 7e88390006 archi particules 2026-09-25 16:00:17 +02:00
Jérôme Bousquié 83daeb4c7d readmes 2026-09-25 14:54:27 +02:00
Jérôme Bousquié 54a482e354 PBR 2026-09-25 14:40:47 +02:00
Jérôme Bousquié 8ece89ccba dof 2026-09-25 13:43:59 +02:00
Jérôme Bousquié 9614156848 MSAA 2026-09-25 11:20:20 +02:00
Jérôme Bousquié 35aeb769a8 refactor examples 2026-09-25 10:19:24 +02:00
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
Jérôme Bousquié a7b50d9a39 docs(roadmap): record frozen FIFO present-mode decision
Note in the ROADMAP decision table that the swapchain stays on
PresentMode::Fifo (double-buffered vsync, latency 2) for now. Mailbox
(triple buffering) remains a future opt-in and Immediate stays reserved
for offscreen; present mode is revisited only when the GPU-driven
pipeline (Phase 3) lands. Not blocking for Phases 1-2.
2026-09-14 16:53:13 +02:00
129 changed files with 20614 additions and 1242 deletions
View File
View File
+14 -10
View File
@@ -5,22 +5,24 @@ Rust workspace (2024 edition) wrapping [wgpu](https://github.com/gfx-rs/wgpu) fo
## Workspace Structure ## Workspace Structure
``` ```
Cargo.toml # workspace root — no dependencies here Cargo.toml # workspace root (members = ["lib"]) — no dependencies here
lib/Cargo.toml # wsg-lib crate: wgpu 30.0.0, winit 0.30 lib/Cargo.toml # wsg-lib crate: wgpu 30.0.0, winit 0.30 + explicit [[example]] entries
examples/Cargo.toml # depends on wsg-lib via path reference lib/src/ # library source (app, core/, mesh/, pipeline/, resources/, scene/, utils/)
lib/lib.rs # lib entry point lib/examples/ # examples, one subfolder per category (each folder has a README.md):
lib/context.rs # Context type (aggregates wgpu objects: Instance, Surface, Adapter, Device, Queue) │ ├── meshes/ # simple, cube, pbr, import, manual
lib/renderer.rs # renderer implementation │ ├── lights/ # shadow, shadow_test, spot_test, emissive
examples/src/main.rs # example binary │ ├── cameras/ # culling
│ └── effects/ # demo, bloom, hdr, msaa, fog, dof
``` ```
**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. **Key convention**: examples live in `lib/examples/<category>/` subfolders. Cargo only auto-discovers top-level `examples/*.rs`, so **every example is declared explicitly in `lib/Cargo.toml`** (`[[example]] name = … path = "examples/<cat>/….rs"`). Names are stable: `cargo run -p wsg-lib --example <name>` works as before. Do not publish this to crates.io as-is — it uses local path conventions.
## Essential Commands ## Essential Commands
| Action | Command | | Action | Command |
|--------|---------| |--------|---------|
| Build everything | `cargo build --workspace` | | Build everything | `cargo build --workspace` |
| Run examples | `cargo run -p examples` | | Run an example | `cargo run -p wsg-lib --example <name>` |
| Run a feature-gated example | `cargo run -p wsg-lib --example import --features import-obj` |
| Test | `cargo test --workspace` | | Test | `cargo test --workspace` |
| Check | `cargo check --workspace` | | Check | `cargo check --workspace` |
| Format | `cargo fmt --all` | | Format | `cargo fmt --all` |
@@ -41,8 +43,10 @@ WGPU doesn't have a native "Context" object — this type groups them together f
## Gotchas ## Gotchas
- Rust 2024 edition is used. Ensure your Rust toolchain supports it (`rustup update`). - 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. - 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. - Cargo features gate primitives (`prim-*`, `all-prims` is default) and importers (`import-obj`, `import-gltf`); the `import` example is `required-features = ["import-obj"]`. 127 tests exist (`cargo test --workspace`).
- The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally. - 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 -->
## 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
+149 -139
View File
@@ -1,167 +1,177 @@
# WSG - WGPU Simple Graphics Library # WSG — WGPU Simple Graphics Library
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. **WSG** (WGPU Simple Graphics) is a Rust library that wraps [wgpu](https://github.com/gfx-rs/wgpu) and [winit](https://crates.io/crates/winit) to draw 3D **without touching wgpu directly**.
> **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). ## What you get
## Status - **A 3D window in ~30 lines** — no wgpu, no winit in your code
- **Phong lighting** (directional, point, spot) + **shadows** (shadow mapping)
- **HDR + Tone Mapping** (ACES Filmic / Reinhard) — opt-in, zero cost when disabled
- **GPU-driven pipeline** — world matrices + frustum culling on the GPU, indirect draws
- **LOD** (Level of Detail) — automatic geometry degradation based on distance
- **Procedural primitives** — cube, sphere, cylinder, cone, torus, plane
- **File import** — built-in OBJ parser (glTF in progress)
- **Orbital camera** + unified input (keyboard/mouse)
- **LOD, culling, HDR, shadows**: everything is **opt-in** — what you don't enable costs nothing
| Area | State | ## Strengths
|------|-------|
| 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 |
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. | Strength | Detail |
|----------|--------|
| **Zero wgpu in your code** | The declarative API (`AppBuilder` + `AppHandler`) encapsulates everything |
| **Opt-in = zero cost** | A disabled effect allocates nothing, executes nothing |
| **Cargo features** | Only compile the primitives/importers you need |
| **One shader** | The `standard` shader (Phong) covers 90% of cases; unlit mode for 2D |
| **GPU-driven** | CPU sends transforms, GPU does the rest (matrices, culling, draws) |
## What it does ## Quickstart
### Manual workflow (working — recommended today)
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):
```rust
use std::sync::Arc;
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::utils;
fn main() {
// Window + async GPU init
let event_loop = EventLoop::new().unwrap();
let window = Arc::new(WindowBuilder::new().build(&event_loop).unwrap());
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
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);
let mut cache = PipelineCache::new(Arc::new(context.device.clone()));
cache.register_shader("basic", utils::BASIC_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));
// 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, .. } => {
if let Some(frame) = Frame::try_new(&context.surface) {
renderer.render(frame.view(), &mesh, &material);
renderer.present(frame);
}
}
winit::event::Event::WindowEvent { event: winit::event::WindowEvent::CloseRequested, .. } => elwt.exit(),
_ => {}
}
}).unwrap();
}
```
### 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 ```rust
use wsg_lib::prelude::*;
use wsg_lib::app::AppBuilder; use wsg_lib::app::AppBuilder;
use wsg_lib::{App, AppHandler}; use wsg_lib::utils::WsgError;
struct MyGame; struct MyScene;
impl AppHandler for MyGame { impl AppHandler for MyScene {
// update() has an empty default — implement it to mutate scene state each frame. fn setup(&mut self, app: &mut wsg_lib::App) {
fn render(&mut self, _app: &mut App) { app.scene
// The engine acquires and presents the frame around this callback, .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
// but scene rendering is not automated yet — see Roadmap. .unwrap();
app.scene
.create_material("mat", "standard", None)
.unwrap();
// A Phong-lit cube, sitting on a ground plane
app.scene
.create_mesh("cube", cube(1.0), Some("mat"))
.unwrap();
app.scene
.add_entity("my_cube", "cube")
.unwrap();
app.scene
.create_mesh("ground", plane(10.0, 10.0, 1, 1), Some("mat"))
.unwrap();
app.scene
.add_entity("floor", "ground")
.unwrap();
} }
} }
#[pollster::main] fn main() -> Result<(), WsgError> {
async fn main() -> Result<(), wsg_lib::utils::WsgError> { let mut app = AppBuilder::new()
let app = AppBuilder::new().build().await?; .title("My WSG scene")
.with_hdr(ToneMapper::Aces) // optional: HDR + tone mapping
// Scene registration is available (string IDs): .build()?;
// app.scene.add_mesh("quad", Arc::new(mesh))?; app.run(MyScene);
// app.scene.add_material("mat", Arc::new(material))?; Ok(())
// 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).
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**.
## 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 |
| Context | Struct | GPU hardware lifecycle (Instance, Surface, Adapter, Device, Queue) | ✅ |
| Renderer | Struct | Binds Material + Mesh into a RenderPass, submits | ✅ (one submit per object) |
| PipelineCache | Struct | Shader → compiled RenderPipeline cache | ✅ |
| Material | Struct | Shader ID → RenderPipeline | ✅ |
| Mesh / Vertex | Struct | GPU geometry container / CPU-side vertex tuple | ✅ |
| Frame | Struct | Per-frame RAII wrapper (surface texture + view) | ✅ |
| Camera / Transform | Struct | Camera & transform math | 📋 Types only, not in the pipeline |
## Getting started
WSG is **not published on crates.io** — depend on it by path:
```toml ```toml
[dependencies] [dependencies]
wsg-lib = { path = "/path/to/wsg/lib" } wsg-lib = { path = "../lib" }
pollster = "0.4" # only if you use the async AppBuilder pollster = { version = "1", features = ["macro"] }
``` ```
| Action | Command | ```sh
|--------|---------| cargo run -p wsg-lib --example demo # full showcase (6 primitives, 3 lights, shadows, HDR)
| Build everything | `cargo build --workspace` | ```
| Run the working 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)). ## Features
| Category | What's available |
|----------|-----------------|
| **Geometry** | 6 procedural primitives + OBJ import + custom `Geometry` |
| **Rendering** | Phong (lit), unlit (2D flat), PBR metallic/roughness, HDR + tone mapping |
| **Lights** | Directional, point, spot (8 max) + ambient |
| **Shadows** | Shadow mapping (directional/spot), slope-scaled bias, PCF |
| **LOD** | Auto quadric decimation, hysteresis, 1 buffer multi-level |
| **GPU-driven** | Compute pass (matrices + culling) → indirect draws |
| **Post-process** | Bloom, Depth of Field, Fog (3 modes), MSAA 4× |
| **Camera** | Orbital (drag/zoom/reset) + presets (front/side/top) |
| **Input** | Keyboard (pressed/held/released), mouse (delta, scroll, buttons) |
| **Textures** | RGBA8 (from bytes, from file, white placeholder) |
## Documentation ## 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: | Where | What |
|-------|------|
| [docs/user/](docs/user/README.md) | **User guide** — how to use the API, step by step |
| [docs/tech/](docs/tech/ARCHI_APP.md) | **Internal architecture** — decisions, specs, targets |
| [lib/examples/](lib/examples/README.md) | **Examples** — 4 category folders (meshes/lights/cameras/effects), each with a README |
| [docs/ROADMAP.md](docs/ROADMAP.md) | Roadmap (phases 1-5 ✅, phase 6 in progress) |
| [docs/PLAN.md](docs/PLAN.md) | Recipe book (step history) |
| `cargo doc -p wsg-lib --no-deps` | **API reference** (rustdoc, 100% covered) |
- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. 🎯 **Target** — the GPU-driven two-pass pipeline parts are not implemented yet. ## Examples
- [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.
- [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.
## Roadmap Sixteen examples in `lib/examples/`, organized into **four category folders** —
each folder has its own README (run commands, keys, what to observe):
[`lib/examples/README.md`](lib/examples/README.md). All run from the repo root
with `cargo run -p wsg-lib --example <name>` (feature-gated ones need
`--features`, e.g. `import` → `--features import-obj`).
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. | Folder | Example | What it shows |
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). |--------|---------|---------------|
3. **CPU→GPU transform sync** — persistent transform buffers with ring (triple) buffering. | [`meshes/`](lib/examples/meshes/README.md) | `simple` | Minimal declarative workflow (flat unlit quad, ~15 lines) |
4. **Real 3D pipeline** — MVP uniforms + camera support in the vertex shader. | | `cube` | Textured, lit, spinning cube (the 3D MVP) |
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. | | `pbr` | PBR metallic/roughness + normal mapping |
6. **Error unification** — replace `Result<_, String>` in `Scene`/`PipelineCache` with typed errors. | | `import` | OBJ file import (feature `import-obj`) |
| | `manual` | Low-level workflow (Context/Renderer/PipelineCache, no App) |
| [`lights/`](lib/examples/lights/README.md) | `shadow` | Shadow mapping in isolation |
| | `shadow_test` | Dedicated shadow test (directional caster + PCF) |
| | `spot_test` | Isolated spot light (beam, penumbra) |
| | `emissive` | Emissive materials + runtime exposure control |
| [`cameras/`](lib/examples/cameras/README.md) | `culling` | GPU-driven frustum culling (15×15 grid) |
| [`effects/`](lib/examples/effects/README.md) | `demo` | Full showcase: 6 primitives, 3 lights, shadows, HDR, LOD, orbital camera |
| | `bloom` | HDR bloom post-process |
| | `hdr` | HDR + tone mapping (ACES/Reinhard) |
| | `msaa` | 4× multisample anti-aliasing |
| | `fog` | 3 fog modes (linear, exponential, exponential²) |
| | `dof` | Depth of field with focus presets |
## Cargo Features
```toml
# Default: all primitives
wsg-lib = { path = "../lib" }
# Minimal: just the cube
wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube"] }
# With OBJ import
wsg-lib = { path = "../lib", features = ["import-obj"] }
```
| Feature | Enables |
|---------|---------|
| `prim-cube`, `prim-plane`, `prim-sphere`, `prim-cylinder`, `prim-cone`, `prim-torus` | Primitives |
| `all-prims` (default) | All 6 primitives |
| `import-obj` | Wavefront OBJ parser |
| `import-gltf` | glTF (stub) |
## Build
```sh
cargo build --workspace # everything
cargo test --workspace # 127 tests
cargo check --all-targets # quick check
cargo run -p wsg-lib --example demo # run the showcase
```
## Project
- **Language**: Rust 2024
- **Dependencies**: wgpu 30, winit 0.30, glam (math)
- **Not published on crates.io** (path dependency)
- **Status**: MVP complete (phases 1-5 ✅), post-MVP in progress (phase 6)
---
*Detailed documentation (architecture, status, API reference, manual workflow): [README_DETAILS.md](README_DETAILS.md)*
---
> This project was heavily developed using OpenCode, Pi Code, and JCode AI agents running on local Qwen3-27b_Q4 and DeepSeek V4 Flash Q4 instances. The project organization and architecture are the author's own design.
+229
View File
@@ -0,0 +1,229 @@
# WSG — Detailed Documentation
> Technical content from the main README: status, architecture, API reference, workflows, roadmap.
## Status
| Area | State |
|------|-------|
| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working (advanced — fine-grained control) |
| `App` / `AppBuilder` / `AppHandler` event-loop facade | ✅ Working — window, events, frame presentation, automatic scene rendering |
| `Scene` resource/entity registry | ✅ Working — auto-rendered in one batched pass (`App::render_scene`) |
| GPU-driven two-pass pipeline (Compute → indirect draw) | ✅ Working (Phase 3) — `render_scene` + shadow pass 100% indirect; opt-in frustum culling |
| 3D infrastructure (uniform bind groups, MVP + camera) | ✅ Working — per-frame camera + per-entity world matrices in shared uniforms |
| Shadows (shadow mapping) | ✅ Working — directional/spot, slope-scaled bias, PCF 3×3 |
| HDR + Tone Mapping | ✅ Working — offscreen Rgba16Float, ACES/Reinhard, opt-in |
| LOD (Level of Detail) | ✅ Working — quadric decimation, hysteresis, multi-level buffer |
| Mesh module (primitives + import) | ✅ Working — feature-gated primitives, OBJ parser |
| Bloom | ✅ Working — threshold + separable blur + composite, HDR required |
| Fog | ✅ Working — 3 modes (linear, exp, exp²), runtime switchable |
| MSAA | ✅ Working — 4× multisample, resolve pass |
| DoF | ✅ Working — CoC + disc blur, focus presets |
| PBR (metallic/roughness + normal maps) | ✅ Working — Cook-Torrance, GGX, IBL, derivative tangent |
Note: `standard_shader.wgsl` (Phong + PBR, with an explicit **unlit** mode) is the **single** shader the library ships. Flat 2D drawing is its unlit variant (`Renderer::set_unlit(true)`).
## Architecture
### Layer model
- **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. `render_scene` batches all entities into one encoder + one submit per frame.
- **Supporting pieces** — `PipelineCache` (shader → compiled RenderPipeline, `Arc`-shared), `Material`, `Geometry`/`Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform`.
### GPU-driven pipeline (Phase 3)
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.
Spec: [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) · User guide: [docs/user/cameras/gpu-driven.md](docs/user/cameras/gpu-driven.md)
### Module layout
```
lib/src/
├── lib.rs # crate root, re-exports
├── prelude.rs # glob re-exports
├── app.rs # App + AppBuilder
├── handler.rs # AppHandler trait
├── camera.rs # Camera, CameraController
├── input.rs # InputState
├── lights.rs # Lights, Light, LightType, directional_light, …
├── core/
│ ├── context.rs # GPU lifecycle (Instance/Surface/Adapter/Device/Queue)
│ ├── renderer.rs # RenderPass execution, shadow pass, HDR/TM pass
│ ├── frame.rs # Per-frame RAII (surface texture + view)
│ ├── geometry.rs # Geometry (positions/normals/UVs/indices) + BBox
│ ├── transform.rs # Transform (translation/rotation/scale)
│ ├── frustum.rs # Frustum (6 planes, sphere/box culling)
│ ├── lod.rs # LOD decimation (quadric edge collapse)
│ ├── shadow.rs # ShadowConfig (map size, bias, PCF)
│ ├── hdr.rs # ToneMapper enum (Aces/Reinhard)
│ ├── bloom.rs # BloomConfig + BloomPipeline
│ ├── msaa.rs # MsaaConfig
│ ├── fog.rs # FogConfig + FogMode
│ └── dof.rs # DoFConfig + DoFPipeline
├── mesh/
│ ├── mod.rs # Re-exports flat
│ ├── primitives/ # 6 feature-gated generators
│ └── import/ # OBJ parser + glTF stub
├── pipeline/ # PipelineCache (shader → RenderPipeline)
├── resources/ # Mesh, Material, Texture, Uniform, Vertex
├── scene/ # Scene (registry), Entity
└── utils/ # Conf constants, WsgError
```
## Quick reference (types)
| Concept | Type | Responsibility |
|---------|------|---------------|
| App / AppBuilder | Facade | Window + event loop + frame + auto scene render |
| AppHandler | Trait | `setup()` / `update()` / `render()` callbacks |
| Scene | Struct | Registry: shaders, materials, meshes, entities, lights, camera |
| Context | Struct | GPU hardware (Instance, Surface, Adapter, Device, Queue) |
| Renderer | Struct | RenderPass execution (scene, shadow, HDR/TM, bloom, DoF) |
| PipelineCache | Struct | Shader → compiled RenderPipeline cache |
| Material | Struct | Shader ID + texture + pipeline + PBR params |
| Geometry | Struct | CPU vertex data (positions/normals/UVs/colors/indices) |
| Mesh / Vertex | Struct | GPU geometry / interleaved upload tuple |
| Frame | Struct | Per-frame RAII (surface texture + view) |
| Camera / Transform | Struct | Camera math + per-entity transform |
| CameraController | Struct | Orbital camera (orbit/zoom/reset/apply_to) |
| InputState | Struct | Unified keyboard/mouse (pressed/held/released, delta, scroll) |
| Texture | Struct | GPU image (Rgba8UnormSrgb) + sampler |
| Lights / Light | Struct | Light list (directional/point/spot, MAX=8) + ambient |
| ShadowConfig | Struct | Shadow map size, bias, PCF taps, scene radius |
| ToneMapper | Enum | ACES Filmic / Reinhard |
| BloomConfig | Struct | Threshold, intensity, H/V passes |
| MsaaConfig | Struct | Sample count (1 = disabled) |
| FogConfig | Struct | Mode, near/far, density, color |
| DoFConfig | Struct | Focus distance, aperture, max blur |
| BBox | Struct | Axis-aligned bounding box (min/max) |
| Frustum | Struct | 6 planes, sphere/box culling |
## Declarative workflow (recommended)
```rust
use wsg_lib::prelude::*;
use wsg_lib::app::AppBuilder;
use wsg_lib::utils::WsgError;
struct MyScene;
impl AppHandler for MyScene {
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", cube(1.0), Some("mat")).unwrap();
app.scene.add_entity("my_cube", "cube").unwrap();
}
fn update(&mut self, app: &mut wsg_lib::App) {
// your per-frame logic
}
// render() default: app.render_scene(frame.view()) — auto-draws everything
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG").build().await?;
app.run(MyScene);
Ok(())
}
```
> `Scene` methods return `Result<_, String>` — typed-error unification is on the roadmap.
## Manual workflow (advanced)
Bypass the `App` facade and drive `Context`, `Renderer` and `PipelineCache` yourself:
```rust
use std::sync::Arc;
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::{Geometry, Material, Mesh};
use wsg_lib::utils;
fn main() {
let event_loop = EventLoop::new().unwrap();
let window = Arc::new(WindowBuilder::new().build(&event_loop).unwrap());
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init");
let format = context.configure(&context.adapter, 800, 600).expect("surface config");
let mut renderer = Renderer::new(&context, format, 800, 600);
let mut cache = PipelineCache::new(Arc::new(context.device.clone()));
cache.register_shader("standard", utils::STANDARD_SHADER_PATH).unwrap();
let material = Material::new(renderer.format(), "standard", &mut cache);
let geometry = Geometry::new(vec![-0.5f32, 0.5, 0.0, 0.5, 0.5, 0.0, 0.5, -0.5, 0.0, -0.5, -0.5, 0.0])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None);
event_loop.run(|event, elwt| {
match event {
winit::event::Event::AboutToWait => window.request_redraw(),
winit::event::Event::WindowEvent { event: winit::event::WindowEvent::RedrawRequested, .. } => {
if let Some(frame) = Frame::try_new(&context.surface) {
renderer.render(frame.view(), &mesh, &material);
renderer.present(frame);
}
}
winit::event::Event::WindowEvent { event: winit::event::WindowEvent::CloseRequested, .. } => elwt.exit(),
_ => {}
}
}).unwrap();
}
```
## Features
| Feature | Default | Provides |
|---------|---------|----------|
| `prim-cube` | ✅ | `cube(size)` |
| `prim-plane` | ✅ | `plane(w, d, seg_x, seg_z)` |
| `prim-sphere` | ✅ | `uv_sphere(…)`, `icosphere(…)` |
| `prim-cylinder` | ✅ | `cylinder(…)` |
| `prim-cone` | ✅ | `cone(…)` |
| `prim-torus` | ✅ | `torus(…)` |
| `all-prims` | ✅ (default) | All 6 primitives |
| `import-obj` | ⬜ | `load_obj(path)`, `parse_obj(str)` |
| `import-gltf` | ⬜ | `load_gltf(path)` (stub) |
## Design principle: opt-in = zero cost
| Feature | How to enable | If NOT enabled |
|---------|--------------|----------------|
| Shadows | `scene.set_shadow_caster(Some(idx))` | No shadow map, no depth pass, no PCF |
| HDR + TM | `AppBuilder::with_hdr(ToneMapper::Aces)` | No offscreen texture, no TM pass |
| Bloom | `AppBuilder::with_bloom(BloomConfig::…)` | No bloom textures, no passes |
| MSAA | `AppBuilder::with_msaa(MsaaConfig { sample_count: 4 })` | Single sample, no resolve |
| Fog | `AppBuilder::with_fog(FogConfig::…)` | No fog uniforms |
| DoF | `AppBuilder::with_dof(DoFConfig::…)` | No CoC/blur textures |
| GPU-driven culling | `AppBuilder::with_gpu_driven(true)` | No compute pipeline, no indirect buffers |
| LOD | `scene.create_mesh_with_lod(…, levels)` | Single-level mesh |
| Primitives | Cargo feature `prim-*` | Not compiled |
| File import | Cargo feature `import-*` | Not compiled |
## Roadmap
| Phase | Status |
|-------|--------|
| 1 — Foundations (window, render loop, Context) | ✅ |
| 2 — 3D infrastructure (Geometry, Mesh, Material, Pipeline) | ✅ |
| 3 — GPU-driven (compute pass, indirect draws, culling) | ✅ |
| 4 — Advanced rendering (shadows, HDR/TM, lights) | ✅ |
| 5 — Polish (LOD, camera controller, input, demo) | ✅ |
| 6 — Post-MVP (bloom, PBR, fog, DoF, MSAA, cascaded shadows, SSAO) | 🔄 |
## Documentation
| Where | What |
|-------|------|
| [docs/user/](docs/user/README.md) | User guide |
| [docs/tech/](docs/tech/ARCHI_APP.md) | Internal architecture |
| [docs/ROADMAP.md](docs/ROADMAP.md) | Roadmap |
| [docs/PLAN.md](docs/PLAN.md) | Recipe book (step history) |
| `cargo doc -p wsg-lib --no-deps` | API reference (rustdoc) |
+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 ``` ` |
+436
View File
@@ -0,0 +1,436 @@
# Étape 28 — Système de Particules : Étape A (Pool)
> **Objectif** : Créer l'infrastructure GPU du pool de particules (buffer + pipeline render + draw).
> C'est la brique de base sur laquelle les drivers (GPU/CPU/Manual) seront construits.
> **Référence** : `docs/tech/ARCHI_PARTICULES.md` (§2, §3, §6, §7, §8.2, §8.3, §12, §13)
---
## Contexte
La phase 6 "Post-MVP" a couvert les effets post-process (bloom, DoF, fog, MSAA) et le PBR.
On passe maintenant au **système de particules** — une nouvelle catégorie de fonctionnalité
(simulation + rendu) qui suit l'architecture Pool ≠ Driver décrite dans `ARCHI_PARTICULES.md`.
Les effets restants de la phase 6 (6.6 CSM, 6.7 SSAO, 6.14-6.16 Area lights / Volumetric)
seront repris **après** le système de particules (phase 7).
---
## Scope de cette étape (A)
| Fait | Non fait (étapes suivantes) |
|------|---------------------------|
| Struct `Particle` (64 bytes, Pod) | Driver GPU (compute + spawn) — Étape B |
| `ParticlePoolConfig` + `BlendingMode` | Driver CPU (simulation Rust) — Étape C |
| `ParticlePool` (buffer + pipeline + bind group) | Driver Manual + handle — Étape D |
| Pipeline render (billboard instancé) | Intégration Renderer (frame loop) — Étape E |
| Vertex shader (quad via vertex_index + billboard) | Presets + Example — Étape F |
| Fragment shader (texture × color) | Tests WGSL + layout — Étape G |
| Texture par défaut (disque 16×16) | |
| Méthodes `Scene::create_particle_pool` | |
| Pool inactif par défaut (zéro draw sans driver) | |
> **Cette étape produit un pool qui EXISTE mais ne draw rien** (pas de driver = pas de count > 0).
> Le draw sera activé à l'étape E (intégration Renderer). On peut néanmoins tester le pipeline
> en forçant un count artificiel dans un test.
---
## Décisions (rappel de ARCHI_PARTICULES.md)
| # | Décision | Détail |
|---|----------|--------|
| D1 | Pool ≠ Driver | Le pool est la ressource GPU. Le driver est swappable. |
| D2 | 64 bytes/particule | pos(12)+pad+vel(12)+pad+life+max_life+size+size_growth+angle+angular_vel+color(16) |
| D3 | Billboard camera-facing | Quad orienté vers la caméra (axes right/up de la view matrix) |
| D4 | Quad via `@builtin(vertex_index)` | Pas de vertex buffer. 4 sommets générés en shader. |
| D5 | `draw(4, max_count)` + early-out | Le vertex shader skip les instances au-delà de `count_buffer` |
| D6 | Blend figé au pipeline | 1 mode par pool (Additive ou Alpha) |
| D7 | Depth test oui, depth write non | Transparence correcte |
| D8 | Texture par défaut : disque 16×16 | Si `texture: None` |
| D9 | Pool inactif si pas de driver | Zéro compute, zéro draw |
---
## Fichiers à créer / modifier
```
lib/src/
├── core/
│ ├── mod.rs # + pub mod particles
│ └── particles.rs # NOUVEAU : ParticlePool + ParticlePoolConfig + BlendingMode
├── resources/
│ ├── mod.rs # + re-export Particle
│ └── particle.rs # NOUVEAU : struct Particle (64 bytes, Pod)
├── shaders/
│ ├── mod.rs # + PARTICLE_BILLBOARD_SHADER
│ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main
├── scene/
│ └── scene.rs # + particle_pools: HashMap<String, Arc<ParticlePool>>
│ # + create_particle_pool()
└── prelude.rs # + re-exports
lib/tests/
└── wgsl_validate.rs # + test particle_billboard
lib/examples/
└── particles.rs # (Étape F, pas cette étape)
```
---
## Détail des implémentations
### 1. `resources/particle.rs`
```rust
use bytemuck::{Pod, Zeroable};
/// 64 bytes per particle. Mirror of the WGSL `Particle` struct.
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable, Default)]
pub struct Particle {
pub pos: [f32; 3], // offset 0
pub _pad0: f32, // offset 12
pub vel: [f32; 3], // offset 16
pub _pad1: f32, // offset 28
pub life: f32, // offset 32
pub max_life: f32, // offset 36
pub size: f32, // offset 40
pub size_growth: f32, // offset 44
pub angle: f32, // offset 48
pub angular_vel: f32, // offset 52
pub color: [f32; 4], // offset 56
}
impl Particle {
pub const SIZE: u64 = std::mem::size_of::<Self>() as u64; // must be 64
}
```
**Test** : `assert_eq!(size_of::<Particle>(), 64)`, `assert_eq!(align_of::<Particle>(), 16)`.
### 2. `core/particles.rs`
```rust
pub enum BlendingMode {
Additive,
Alpha,
}
pub struct ParticlePoolConfig {
pub max_count: u32,
pub texture: Option<String>, // ID dans scene.textures
pub blending: BlendingMode,
}
pub struct ParticlePool {
pub(crate) buffer: wgpu::Buffer,
pub(crate) pipeline: wgpu::RenderPipeline,
pub(crate) bind_group: wgpu::BindGroup,
pub(crate) sampler: wgpu::Sampler,
pub(crate) count_buffer: wgpu::Buffer,
pub max_count: u32,
pub blending: BlendingMode,
// Driver (Étape B/C/D) :
pub(crate) driver: Option<Box<dyn ParticleDriver>>,
pub(crate) active: bool,
}
```
**Construit** par `Scene::create_particle_pool` qui a accès au `device`, `queue`,
`format`, et aux textures. Le pipeline est compilé immédiatement.
### 3. `shaders/particle_billboard.wgsl`
```wgsl
// Particle billboard shader (vertex + fragment).
// Quad generated via @builtin(vertex_index) — no vertex buffer.
// Instance data read from storage buffer.
struct Particle {
pos: vec3<f32>, pad0: f32,
vel: vec3<f32>, pad1: f32,
life: f32, max_life: f32,
size: f32, size_growth: f32,
angle: f32, angular_vel: f32,
color: vec4<f32>,
}
struct CameraParams {
view: mat4x4<f32>,
proj: mat4x4<f32>,
}
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) frag_color: vec4<f32>,
@location(1) uv: vec2<f32>,
}
@group(0) @binding(0) var<uniform> camera: CameraParams;
@group(0) @binding(1) var<storage, read> particles: array<Particle>;
@group(0) @binding(2) var<uniform> count_buf: f32;
const QUAD: array<vec2<f32>, 4> = array<vec2<f32>, 4>(
vec2(-0.5, -0.5),
vec2( 0.5, -0.5),
vec2( 0.5, 0.5),
vec2(-0.5, 0.5),
);
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32,
) -> VsOut {
var out: VsOut;
if f32(ii) >= count_buf {
out.clip = vec4(0.0, 0.0, -2.0, 1.0);
out.frag_color = vec4(0.0);
out.uv = vec2(0.0);
return out;
}
let p = particles[ii];
let q = QUAD[vi];
let c = cos(p.angle);
let s = sin(p.angle);
let rot = vec2(q.x * c - q.y * s, q.x * s + q.y * c) * p.size;
let right = vec3(camera.view[0][0], camera.view[1][0], camera.view[2][0]);
let up = vec3(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
let world = p.pos + right * rot.x + up * rot.y;
out.clip = camera.proj * camera.view * vec4(world, 1.0);
out.frag_color = p.color;
out.uv = q + vec2(0.5);
return out;
}
@group(0) @binding(3) var samp: sampler;
@group(0) @binding(4) var tex: texture_2d<f32>;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let t = textureSample(tex, samp, in.uv);
return in.frag_color * t;
}
```
### 4. Bind group layout (render)
| Binding | Type | Contenu | Visibility |
|---------|------|---------|------------|
| 0 | Uniform (min 112 B) | CameraParams (view + proj) | VERTEX |
| 1 | Storage (RO) | particle_data | VERTEX |
| 2 | Uniform (min 4 B) | count_buffer | VERTEX |
| 3 | Sampler | Sampler | FRAGMENT |
| 4 | Texture (2D) | Texture particule | FRAGMENT |
### 5. Pipeline descriptor
```rust
wgpu::RenderPipelineDescriptor {
vertex: wgpu::VertexStage {
module: shader,
entry_point: "vs_main",
buffers: &[], // PAS de vertex buffer
},
fragment: Some(wgpu::FragmentStage {
module: shader,
entry_point: "fs_main",
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
// Indices : pas de index buffer → on utilise draw(4, N)
// MAIS : 4 sommets sans indices = 2 triangles ? NON.
// draw(4, N) drawe 4 triangles (4 indices implicites 0,1,2,3) = 1 triangle + 1 degénéré.
// IL FAUT un index buffer ! Ou utiliser draw_indexed.
// → Voir GOTCHA ci-dessous.
..Default::default()
},
color_states: [wgpu::ColorState {
format,
alpha_blend: blend_alpha,
color_blend: blend_color,
write_mask: wgpu::ColorWrites::ALL,
}],
depth_stencil: Some(wgpu::DepthStencilState {
format: depth_format,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
..Default::default()
}),
multisample,
..
}
```
### ⚠️ GOTCHA : Topologie du quad billboard
**Problème** : `draw(4, N)` sans index buffer drawe 4 **vertices** en `TriangleList`,
ce qui fait 4/3 = 1 triangle + 1 vertex orphelin. Ce n'est PAS un quad.
**Solutions** :
| Option | Pro | Contre |
|--------|-----|--------|
| A : `draw(6, N)` + 6 sommets (quad = 2 tris, 6 verts) | Pas d'index buffer | 6 vertices au lieu de 4 (2 dupliqués) |
| B : Index buffer (6 indices) + `draw_indexed(6, N, 0, 0)` | 4 vertices seulement | 1 petit buffer index (24 bytes) partagé |
| C : `@builtin(vertex_index)` avec 6 values dans le const | Pas d'index buffer, pas de vertex buffer | Le const a 6 entries au lieu de 4 |
**Décision : Option C** — 6 entries dans le const QUAD, `draw(6, max_count)`.
```wgsl
// 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad
const QUAD: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
vec2(-0.5, -0.5), // 0
vec2( 0.5, -0.5), // 1
vec2( 0.5, 0.5), // 2
vec2(-0.5, -0.5), // 3
vec2( 0.5, 0.5), // 4
vec2(-0.5, 0.5), // 5
);
```
→ `draw(6, max_count)`. Pas de vertex buffer, pas d'index buffer. Cohérent avec
le pattern fullscreen triangle du TM/bloom (qui utilise `draw(3, 1)`).
### 6. Texture par défaut (disque 16×16)
Générée en Rust au build du pool (si `config.texture == None`) :
```rust
fn default_disc_texture() -> Vec<u8> {
let size = 16;
let mut data = vec![0u8; size * size * 4];
let center = (size as f32 - 1.0) / 2.0;
for y in 0..size {
for x in 0..size {
let dx = (x as f32 - center) / center;
let dy = (y as f32 - center) / center;
let dist = (dx * dx + dy * dy).sqrt();
let alpha = (1.0 - dist).clamp(0.0, 1.0) as u8 * 255;
let i = (y * size + x) * 4;
data[i] = 255; // R
data[i+1] = 255; // G
data[i+2] = 255; // B
data[i+3] = alpha; // A
}
}
data
}
```
### 7. `Scene::create_particle_pool`
```rust
impl Scene {
pub fn create_particle_pool(&mut self, id: &str, config: ParticlePoolConfig) -> Result<(), String> {
if self.particle_pools.contains_key(id) {
return Err(format!("particle pool '{}' already exists", id));
}
// Résoudre la texture
let (texture_view, sampler, is_owned) = match &config.texture {
Some(tex_id) => {
let tex = self.textures.get(tex_id)
.ok_or_else(|| format!("texture '{}' not found", tex_id))?;
(tex.view.clone(), tex.sampler.clone(), false)
}
None => {
// Créer la texture disque 16×16
let (view, sampler) = self.gpu.create_default_disc_texture();
(view, sampler, true)
}
};
// Construire le pool (buffer + pipeline + bind group)
let pool = ParticlePool::new(
&self.gpu.device,
&self.gpu.queue,
self.gpu.format,
self.gpu.depth_format,
self.gpu.msaa,
&config,
texture_view,
sampler,
);
self.particle_pools.insert(id.to_string(), Arc::new(pool));
Ok(())
}
}
```
### 8. Prelude
```rust
// Dans prelude.rs :
pub use crate::core::particles::{ParticlePoolConfig, BlendingMode};
pub use crate::resources::particle::Particle;
```
---
## Blend states
| Mode | color_ops.src | color_ops.dst | alpha_ops.src | alpha_ops.dst |
|------|--------------|--------------|---------------|---------------|
| **Additive** | One | One | One | One |
| **Alpha** | SrcAlpha | OneMinusSrcAlpha | One | OneMinusSrcAlpha |
---
## Tests
### Unit tests (`particles.rs`)
| Test | Vérifie |
|------|---------|
| `particle_size_is_64` | `size_of::<Particle>() == 64` |
| `particle_align_is_16` | `align_of::<Particle>() == 16` |
| `particle_offsets` | Offsets de chaque champ |
| `pool_config_default_max_count` | Valeur raisonnable |
| `default_disc_texture_size` | 16×16×4 bytes |
| `default_disc_center_is_opaque` | Center pixel alpha = 255 |
| `default_disc_corner_is_transparent` | Corner pixel alpha = 0 |
### WGSL validation (`wgsl_validate.rs`)
| Test | Vérifie |
|------|---------|
| `particle_billboard_compiles` | Naga compile le shader |
| `particle_billboard_entry_points` | Contient `vs_main` + `fs_main` |
| `particle_billboard_no_compute` | Pas d'entry point compute (cette étape) |
---
## Vérification de non-régression
- [ ] `cargo check -p wsg-lib --all-targets` → 0 errors, 0 warnings
- [ ] `cargo test -p wsg-lib` → tous les tests existants passent (127+)
- [ ] Les examples existants (demo, pbr, bloom, etc.) compilent et fonctionnent
- [ ] Aucun changement dans `renderer.rs` (le pool n'est pas encore intégré au frame loop)
- [ ] `Scene` a un nouveau champ `particle_pools` mais il est vide par défaut → zéro coût
---
## Critères d'acceptation
1. ✅ `Particle` compile, 64 bytes, Pod, offsets corrects
2. ✅ `particle_billboard.wgsl` compile par Naga (test WGSL)
3. ✅ `ParticlePool::new` crée buffer + pipeline + bind group sans erreur
4. ✅ La texture disque 16×16 est générée correctement
5. ✅ `Scene::create_particle_pool` fonctionne (test unitaire avec mock device)
6. ✅ Le pool est inactif (pas de draw) tant qu'aucun driver n'est attaché
7. ✅ Zéro warning, tous les tests verts
8. ✅ Prelude expose les types
---
## Étape suivante (B)
Driver GPU : compute shader `particle_update.wgsl` + `GpuEmitterConfig` +
spawn CPU + dispatch + `Scene::attach_gpu_emitter`.
+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é. 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 > **Statut réel (à jour au 2026-09-18).** La phase de *consolidation* (Phases 1 à 3 de ce plan) est
> de vérité sur l'état actuel est **README.md** et le code. Plusieurs cases `[X]` ci-dessous ont été > **terminée** ; la source de vérité sur l'état actuel est **README.md** et le code. Depuis la révision
> re-corrigées car elles ne reflétaient plus la réalité : notamment le rendu de la `Scene` n'est > du 2026-09-14, le rendu de la `Scene` est automatisé en une passe groupée
> **pas automatisé** (items Phase 2 et Check-list concernés). Depuis, `simple.rs` a été mis en > (`App::render_scene(frame.view())`, appelée par défaut dans `AppHandler::render`) et `simple.rs`
> conformité (API `AppBuilder`, ~15 lignes, compilation sans importer `winit`/`wgpu`). > (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) ## 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 ### Intégration de la Scene
- [X] Formaliser la structure `Scene` : un conteneur qui liste les Entities. - [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é). - [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`)*
- [ ] 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] 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 ### 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). - [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`)*
- [ ] 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] 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") ## 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 : Une fois les phases 1 à 3 validées, nous pourrons introduire :
- [ ] **Système de Lumières** : Ajout de buffers d'uniformes dans le PipelineCache. - [ ] **Système de Lumières** : Ajout de buffers d'uniformes dans le PipelineCache *(planifié — ROADMAP 4.2, Éclairage avancé)*.
- [ ] **Textures** : Intégration d'un module de chargement d'images et de BindGroups. - [X] **Textures** : Intégration d'un module de chargement d'images et de BindGroups *(fait 2026-09-18,
- [ ] **Caméras** : Gestion des matrices de projection/vue dans la Scene. É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 ## 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`) - [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] Les modules sont-ils bien exposés via `lib.rs` ?
- [X] `pollster` est-il uniquement en dev-dependencies ? - [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
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. dans `lib/src/app.rs` (`resumed()`). Les exemples compilent sans le connaître (crates séparées).
+90 -124
View File
@@ -1,152 +1,118 @@
--- # ROADMAP — WSG
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 — 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). Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT détaillé
> Objectif : prototype fonctionnel d'abord, enrichissement progressif ensuite. (`DRAFT.md`, remplacé à chaque étape) et sa doc livrée (`docs/tech/`, `docs/user/`).
>
> **Point de départ (état réel au 2026-09-14 — la source de vérité est README.md).** > **Légende** : ✅ fait · 🔶 partiel · ⬜ à faire · ❌ abandonné
> Les fondations suivantes existent et fonctionnent déjà ; cette roadmap décrit la **trajectoire à > **Principe** : chaque étape est **additive et opt-in** — non-régression structurelle garantie
> venir** à partir de cet état (elle reprend les étapes 1-4 du README avant la montée GPU-driven) : > (tout reste désactivable, les chemins existants ne changent pas).
> - 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.
--- ---
## 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 ## Phase 2 — Scène & Transforms ✅
- [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)`
### 1.2 Geometry & Mesh | # | Item | Statut |
- [ ] Créer struct `Geometry` (math/geometry.rs) : |---|------|:------:|
- [ ] `positions: Vec<[f32; 3]>` (obligatoire) | 2.1 | Primitives procédurales (`cube`, `plane`, `sphere`, `cylinder`, `cone`, `torus`) | ✅ |
- [ ] `indices: Option<Vec<u16>>` (optionnel) | 2.2 | Transforms (struct `Transform`, composition translation × rotation × scale) | ✅ |
- [ ] `normals: Option<Vec<[f32; 3]>>` (pour Phong) | 2.3 | Entités & scène (struct `Entity`, `Scene`, `TransformStore`, graph entité→mesh) | ✅ |
- [ ] Refactorer `Mesh` pour contenir : | 2.4 | Camera (struct `Camera`, matrices view + perspective, `CameraController` orbital) | ✅ |
- [ ] `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
### 1.3 Shader Phong Minimal ## Phase 3 — GPU-driven (cœur de la vision) ✅
- [ ] 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
### 1.4 Scene avec identifiants (MVP : String IDs) | # | Item | Statut |
- [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()` | 3.1 | Buffers par entité (Transform + Matrix, uniform par slot) | ✅ |
- [ ] **Reporté (étape "Handles typés")** : migrer vers `slotmap` générationnel (`MeshId`/`MaterialId`) quand l'éviction/les performances le justifieront | 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 ## Phase 4 — Rendu avancé ✅
- [ ] Uniform buffer pour la frame : `view_matrix`, `proj_matrix`, `light_dir`
- [ ] Uniform buffer par mesh : `world_matrix` (calculée sur CPU pour le MVP) | # | Item | Statut |
- [ ] `Renderer::render()` itère sur les meshes de la Scene et dessine chacun |---|------|:------:|
- [ ] Exemple fonctionnel : un cube éclairé tourne à l'écran | 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 (effets) 🔶
**Objectif** : Étoffer la Scene avec tous les types de ressources. > Au-delà du scope initial. Chaque item est opt-in et indépendant.
> **Note** : la phase 6 est mise en pause pendant la phase 7 (particules).
> Les items restants (6.6, 6.7, 6.14-6.16) seront repris après.
### 2.1 Arènes complètes | # | Item | Impact visuel | Effort | Statut |
- [ ] `SlotMap<MaterialId, Material>` |---|------|:---:|:---:|:---:|
- [ ] `SlotMap<TextureId, Texture>` (struct de base) | 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ✅ |
- [ ] `SlotMap<LightId, Light>` (struct de base) | 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ✅ |
- [ ] `SlotMap<EntityId, Entity>` pour les entités de la scène | 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é | ⬜ |
| 6.13 | **Fog** (exponential / exponential² / linear, paramètre par scène) | ⭐⭐⭐ | Faible | ✅ |
| 6.14 | **Area lights** (rectangular area light, BRDF approx — specular + diffuse) | ⭐⭐⭐ | Élevé | ⬜ |
| 6.15 | **Textured area lights** (area light avec texture d’émission, e.g. panneaux LED, néons) | ⭐⭐⭐ | Moyen | ⬜ |
| 6.16 | **Volumetric lighting** (god rays / light scattering — radial blur ou ray-march 3D) | ⭐⭐⭐⭐ | Élevé | ⬜ |
| 6.17 | **Depth of field** (post-process CoC : circle-of-confusion + bokeh blur) | ⭐⭐⭐ | Moyen | ✅ |
### 2.2 Entités & Hiérarchie ### Cibles techniques (refactoring)
- [ ] Struct `Entity { mesh_id: Option<MeshId>, material_id: Option<MaterialId>, transform: Transform }`
- [ ] `Scene::add_entity()` → retourne `EntityId`
- [ ] `Scene::iter_entities()` → pour le render loop
### 2.3 Camera dans la Scene | # | Item | Statut |
- [ ] Intégrer `Camera` comme ressource de la Scene |---|------|:------:|
- [ ] Permettre plusieurs caméras (actuelle/inactive) | 6.8 | Handles typés par ressource (slotmap) — `docs/tech/ARCHI_ARENES.md` | ⬜ |
- [ ] Exposer API : `scene.set_active_camera(camera_id)` | 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é | ✅ |
| 6.12 | **Module `texture`** : génération procédurale (checkerboard, gradient, noise) + formats compressés (KTX2, basis) en features optionnelles | ⬜ |
--- ---
## Phase 3️⃣ — GPU-Driven Rendering ## Phase 7 — Système de Particules 🔄
**Objectif** : Déléguer les calculs de transformation et culling au GPU (suivre ARCHI_CPU_GPU.md). > Nouvelle catégorie : simulation + rendu de particules (VFX).
> Architecture : **Pool ≠ Driver** (`docs/tech/ARCHI_PARTICULES.md`).
> 3 drivers possibles : GPU (compute), CPU (simulation Rust), Manual (full control).
### 3.1 Compute Shader | # | Item | Impact visuel | Effort | Statut |
- [ ] Buffer `TransformBuffer` (CPU → GPU) : positions/rotations/échelles brutes |---|------|:---:|:---:|:---:|
- [ ] Buffer `MatrixBuffer` (GPU calculé) : World Matrices finales | 7.1 | **Pool** (buffer 64B×N + pipeline billboard + texture + draw) | — | Moyen | ⬜ ← **en cours** |
- [ ] Compute shader : calcul des World Matrices pour tous les meshes | 7.2 | **Driver GPU** (compute update + spawn CPU + GpuEmitterConfig) | ⭐⭐⭐⭐ | Élevé | ⬜ |
| 7.3 | **Driver CPU** (simulation Rust + upload + custom_force) | ⭐⭐⭐ | Moyen | ⬜ |
| 7.4 | **Driver Manual** (handle direct sur le buffer) | ⭐⭐ | Faible | ⬜ |
| 7.5 | **Intégration Renderer** (frame loop, order, multi-pools) | — | Moyen | ⬜ |
| 7.6 | **Presets + Example** (fire, smoke, rain, explosion, snow, sparkles) | ⭐⭐⭐⭐ | Moyen | ⬜ |
| 7.7 | **Tests** (WGSL validate + layout + pool + drivers) | — | Faible | ⬜ |
### 3.2 Frustum Culling GPU > **Après la phase 7** : reprise des items phase 6 restants (6.6 CSM, 6.7 SSAO, 6.14-6.16).
- [ ] 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 ## Liens
**Objectif** : Qualité visuelle et performances. - **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)
### 4.1 Textures - **Utilisation** : [docs/user/](user/README.md)
- [ ] Struct `Texture` avec chargement d'image - **Livre de recette** : [docs/PLAN.md](PLAN.md)
- [ ] 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 |
+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. 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).** > **État du document : ACTUEL** — façade (`App`/`AppHandler`, §3, §4A) et pipeline GPU-driven
> Les sections §1, §4B, §5 et §6 décrivent la **cible** : pipeline GPU-driven à deux passes > (§1, §4B, §5, §6) **implémenté en Phase 3** du ROADMAP (Étape 17, 2026-09-22, décisions
> (Compute Pass → `draw_indexed_indirect`), buffers persistants en VRAM (Transform/Matrix/BBox/Indirect) > D1–D14). La façade `AppBuilder`/`App`/`AppHandler` est livrée et est le **workflow recommandé** :
> et synchronisation single/double buffer. **Rien de tout cela n'existe encore dans le code** — c'est > `setup` (déclaration de la scène) → par frame `update` (mutation) →
> la trajectoire de ROADMAP.md (et README étape 2-3). L'état **réel actuel** est dans README.md : > `render` (défaut : `App::render_scene` = itération des entités + **rendu groupé en une passe**,
> workflow manuel uniquement, `Renderer` dessine un objet par soumission, shader en NDC sans MVP. > un `CommandEncoder`/soumission par frame ; passe d'ombre en tête si un caster est actif).
> La §3 (`App`/`AppHandler`) correspond à l'état actuel, à une nuance près : `render()` ne peut pas > Exemples : `simple` (2D unlit), `cube` (3D éclairé), `demo` (vitrine : primitives, lumières,
> encore dessiner la scène (l'acquisition/présentation de frame fonctionne, pas le rendu de la scène). > 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 ## 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. - **`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. - **`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 ## 4. Workflow et Cycle de Vie
### A. Initialisation (Configuration) ### A. Initialisation (Configuration)
- **Shaders** : Chargés avant la renderloop. - **Shaders** : Chargés avant la renderloop.
- **PipelineCache** : Enregistre les shaders. - **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`. - **Scene** : Assemblage des objets. L'utilisateur peuple la scène via `app.scene`.
### B. Boucle de Rendu — Pipeline GPU-Driven ### 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. 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. > 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. 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 une unique commande `draw_indexed_indirect`. Le GPU pioche dans l'Indirect Draw Buffer et dessine uniquement les objets visibles, sans intervention du CPU. 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. 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. 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 | | Buffer | Rôle | Type wGPU | Direction du flux |
|--------|------|-----------|-------------------| |--------|------|-----------|-------------------|
| Transform Buffer | Positions/rotations/échelles brutes | Storage Buffer | CPU → GPU | | 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 | Storage Buffer | GPU (Calculé) → GPU (Lu par Render) | | Matrix Buffer | World Matrices finales calculées (256 o/slot, padded — D12) | Storage + Uniform | GPU (Calculé) → GPU (Lu par Render) |
| Bounding Box Buffer | AABB de chaque mesh pour culling | Storage Buffer | CPU → GPU (Statique) | | 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 | Liste dynamique des objets à dessiner | Indirect + Storage | GPU (Rempli par Compute) → GPU (Lu par Render) | | 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). > **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** : 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. - **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. - **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é. 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. 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 # 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. * 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. * 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. * 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: [] sources: []
generated: { by: human:jerome, at: 2026-07-31T00:00:00Z } generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
verified: true verified: true
status: target status: current
stale_after: 2027-01-31 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. 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 > 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 > et les buffers persistants en VRAM décrits ici sont en place : `shaders/gpu_driven.wgsl`
> README étapes 2-3. **Aucun de ces mécanismes n'existe encore dans le code.** Aujourd'hui le rendu est > (deux entry points `compute_matrices` + `cull`, un module, layout explicite à 3 groupes) et les
> piloté par le CPU, **objet par objet** (une soumission par mesh, voir README.md et l'exemple `manual`). > buffers de slots du `Renderer` (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`,
> Considérez ce document comme la spécification de référence pour l'implémentation future du pipeline > capacité fixe de 256 slots).
> GPU-driven, pas comme une description de l'état actuel. > 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/cameras/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/cameras/gpu-driven.md`
> § « Level of Detail ».
1. Répartition des Rôles : CPU vs GPU (La Source de Vérité) 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é) 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 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 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) 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. - 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 : É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) : - 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. - 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). - 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 : Si l'objet est visible, son identifiant est injecté dans un buffer de commandes de dessin indirect (Indirect Draw Buffer). - 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) : - 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. - 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 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 4. Synthèse des Structures de Données en VRAM
Pour implémenter cette architecture, prévoyez l'utilisation des buffers wGPU suivants : L'implémentation utilise les buffers wGPU suivants (tous créés par le `Renderer` à l'initialisation, capacité fixe de 256 slots) :
Nom du Buffer,Rôle,Type wGPU,Direction du flux
Transform Buffer,Stocke les positions/rotations/échelles brutes.,Storage Buffer,CPU → GPU | Buffer | Rôle | Type wGPU | Direction du flux |
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) | Transform Buffer | Positions/rotations/échelles brutes + flags par entité (64 o/slot) | Storage Buffer | CPU → GPU (chaque frame, `write_buffer`) |
Indirect Draw Buffer,Contient la liste dynamique des objets à dessiner.,Indirect Buffer + Storage,GPU (Rempli par Compute) → GPU (Lu par Render) | 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`
File diff suppressed because it is too large Load Diff
+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. 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é).** > **État du document : ACTUEL pour la dichotomie update/render (implémentée) ; CIBLE pour le batching.**
> Le cycle update/render strict, l'itération **automatique** des entités et `renderer.render_scene()` > Le cycle strict est en place : `AppHandler::update` (mutation libre de la scène) tourne avant
> décrits ici ne sont **pas implémentés** : c'est l'**étape 1 du Roadmap README** (scene auto-rendering). > `AppHandler::render`, dont l'implémentation par défaut appelle `app.render_scene(frame.view())` —
> Aujourd'hui `App::run` acquiert/présente la frame mais `render()` ne peut pas encore dessiner la scène, > le moteur itère automatiquement les entités et les dessine en **une passe groupée** par frame
> et le `Renderer` ne dessine qu'un objet par soumission, à la main (exemple `manual`). La terminologie > (rendu automatisé livré le 2026-09-16 ; la passe d'ombre est ajoutée en tête quand un caster est
> `MeshId`/`MaterialId` (handles typés) est celle de la **cible** ; l'état actuel utilise des **String IDs** > actif). Le workflow **manuel** (`Renderer::render` objet par objet, exemple `manual`) coexiste
> dans `Scene`. La dichotomie update/render reste toutefois le modèle de référence retenu pour la suite. > 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 ## 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." > "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) # La Boucle de Rendu (Frame Loop)
> **État du document : ACTUEL (implémenté).** Ce document décrit la frame lifetime telle qu'elle est > **É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`). > réellement implémentée. **Deux flux coexistent** : le flux **facade `App`** (rendu automatique de la
> Le pipeline GPU-driven de l'état **visé** est décrit dans ARCHI_APP.md / ARCHI_CPU_GPU.md (cible). > 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`). - **`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::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. - **`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::begin_frame()`** : acquiert la surface et renvoie la `wgpu::SurfaceTexture` (sans vue).
- **`Context::end_frame(surface_texture)`** : soumet et présente cette texture. - **`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 ## 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. | | 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. | | 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 > **Ressources GPU persistantes (single buffer) — implémenté (Phase 3, 2026-09-22)** : les buffers
> buffers Transform et Matrix vivent en VRAM avec un single buffer en phase initiale (le CPU écrit > Transform, Matrix, BBox et Indirect Draw vivent en VRAM (créés à l'initialisation du `Renderer`,
> pendant `update()`, le compute shader lit au frame suivant, séquencé par `queue.submit()`), puis un > capacité fixe de 256 slots). Le CPU écrit les transforms chaque frame par `queue.write_buffer`
> double buffering si des artefacts apparaissent à haute fréquence. **Aucune de ces ressources n'existe > **dans le même `CommandEncoder`** que les compute passes, qui les lisent **dans la même frame**
> encore dans le code** — c'est la cible GPU-driven (ROADMAP Phase 3 / ARCHI_CPU_GPU). > (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).
--- ---
+57
View File
@@ -0,0 +1,57 @@
# WSG — User documentation
WSG (WGPU Simple Graphics) is a 3D graphics engine built on top of
[wgpu](https://docs.rs/wgpu) and [winit](https://docs.rs/winit).
It deliberately provides **no scene-graph abstraction**: you create resources,
place entities, and write the frame loop yourself. The engine handles the rest
(GPU context, compilation, command encoding, presentation).
## Where to start
1. [Quickstart](quickstart.md) — your first window and your first object, in ~30 lines.
2. Then, at your pace, pick a **topic folder** (which mirrors the example folders in
[`lib/examples/`](../../lib/examples/README.md) — each page is paired with its examples):
| Folder | Pages |
|--------|-------|
| [`meshes/`](meshes/README.md) | [Meshes](meshes/meshes.md) — geometries, entities and `Transform` · [Geometry sources](meshes/sources.md) — procedural generators + file import · [Materials & textures](meshes/materials.md) — the `standard` shader, unlit mode, textures |
| [`lights/`](lights/README.md) | [Lights](lights/lights.md) — directional/point/spot/ambient, `MAX_LIGHTS` · [Shadows](lights/shadows.md) — shadow mapping · [Emissive + Exposure](lights/emissive-exposure.md) |
| [`cameras/`](cameras/README.md) | [Camera & input](cameras/camera-input.md) — orbital controller, unified input · [GPU-driven rendering](cameras/gpu-driven.md) — culling, LOD |
| [`effects/`](effects/README.md) | [HDR](effects/hdr.md) · [Bloom](effects/bloom.md) · [MSAA](effects/msaa.md) · [Fog](effects/fog.md) · [DoF](effects/dof.md) |
Plus [Examples](examples.md) — the 16 examples of the repo in 4 folders, the advanced
`manual` workflow, and how to add your own example.
The pages are cross-linked: each page ends with links to its related pages.
## Design principles
- **Explicit over magic**: no scene graph, no ECS, no hidden state machine. What you write
is what runs.
- **The handler drives the loop**: `AppHandler` is the only required trait (`setup`,
`update`, `render` + optional event hook).
- **String IDs everywhere**: meshes, materials, textures and entities are referenced by
label — no integer handles to manage, errors are readable.
- **Safe core, `unsafe` at the edges**: the public API is fully safe; `unsafe` is confined
to the raw-pointer interop layer.
- **Feature-gated primitives**: every primitive and importer behind a Cargo feature
(`prim-cube`, `import-obj`, …) — default is `all-prims` + `import-obj`.
## Documentation tree
```
README.md this index (the one you are reading)
quickstart.md the 30-line path to a window + a cube
examples.md the 16 repo examples, the manual workflow, adding your own
meshes/ meshes, geometry sources, materials & textures
lights/ lights, shadows, emissive + exposure
cameras/ camera & input, GPU-driven rendering (culling, LOD)
effects/ HDR, bloom, MSAA, fog, DoF
```
## Links
- [Root README](../../README.md)
- Technical docs: [ARCHI_APP](../tech/ARCHI_APP.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) ·
[ARCHI_CPU_GPU](../tech/ARCHI_CPU_GPU.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
- [ROADMAP](../ROADMAP.md)
+16
View File
@@ -0,0 +1,16 @@
# Cameras — user documentation
The **viewpoint side**: the active camera, the orbital controller, unified input, and the
GPU-driven pipeline (frustum culling, LOD) that the camera drives.
| Page | Topic |
|------|-------|
| [Camera & input](camera-input.md) | Active camera, `CameraController` (orbit/zoom/reset), unified keyboard/mouse state, recipes |
| [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling, LOD, debugging |
Example folder: [`lib/examples/cameras/`](../../../lib/examples/cameras/README.md)
(`culling`).
## Links
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
+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::camera::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::camera::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/effects/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/lights.md) · [Examples](../examples.md)
- [Root README](../../../README.md) · [ARCHI_APP](../../tech/ARCHI_APP.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)
+19
View File
@@ -0,0 +1,19 @@
# Effects — user documentation
The **post-process side**: everything that happens between the main pass and the screen.
All effects are opt-in — a feature you don't enable costs nothing (no textures, no passes).
| Page | Topic |
|------|-------|
| [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` |
| [Bloom](bloom.md) | Post-process glow: threshold → blur → composite |
| [MSAA (anti-aliasing)](msaa.md) | Multi-sample edge smoothing, opt-in via `with_msaa(4)` |
| [Fog (distance)](fog.md) | Distance fog (3 modes), masks world edges, opt-in via `with_fog()` |
| [DoF (depth of field)](dof.md) | Cinematic bokeh blur, focus distance, opt-in via `with_dof()` |
Example folder: [`lib/examples/effects/`](../../../lib/examples/effects/README.md)
(`demo`, `bloom`, `hdr`, `msaa`, `fog`, `dof`).
## Links
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
+99
View File
@@ -0,0 +1,99 @@
# Bloom (Step 23)
**Bloom** is a post-process that creates a "glow" effect around the bright areas of the image.
Pixels whose luminance exceeds a threshold are extracted, blurred, then added back to the
original image.
> **Prerequisite**: bloom requires HDR (`AppBuilder::with_hdr`). Without HDR, values are already
> clamped to [0,1] and there is nothing "bright" to extract.
## Activation
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces) // required
.with_bloom(BloomConfig {
threshold: 1.0, // HDR luminance threshold
knee: 0.5, // soft-knee width
intensity: 0.8, // glow intensity
radius: 4.0, // blur radius (pixels, half-res)
..Default::default()
})
.build()
.await?;
```
## `BloomConfig`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `threshold` | `f32` | `1.0` | Luminance threshold (linear HDR units). Only pixels above the threshold contribute to the bloom. |
| `knee` | `f32` | `0.5` | Soft-knee width. Larger = smoother transition. |
| `intensity` | `f32` | `0.8` | Multiplier applied to the blurred result before adding it to the HDR. |
| `radius` | `f32` | `4.0` | Blur radius in pixels (at half resolution). Larger = wider glow. |
## Runtime update
```rust
// In the handler (fn update):
if app.bloom_enabled() {
app.set_bloom_config(BloomConfig {
intensity: new_intensity,
..app.bloom_config()
});
}
```
Changes take effect on the next frame (the uniforms are re-written every frame).
## Pipeline (4 GPU passes)
```
Scene ──→ HDR (full res, Rgba16Float)
│
├──→ [1] Threshold (full → half res)
│ Soft-knee: smoothstep(knee, knee+1, lum)
│
├──→ [2] Blur H (half res)
│ 9-tap separable Gaussian, direction = (1/w, 0)
│
├──→ [3] Blur V (half res)
│ 9-tap separable Gaussian, direction = (0, 1/h)
│ (ping-pong: writes into the bright texture)
│
└──→ [4] Composite (full res)
output = HDR + bloom × intensity
(writes into a 3rd full-res texture)
│
▼
Tone Mapping (reads the composite)
│
▼
Surface (sRGB)
```
## Cost
- **Without bloom** (default): zero overhead. The TM reads the HDR texture directly.
- **With bloom**: 4 extra passes (1 full-res + 3 half-res) + 3 intermediate textures. The cost
is moderate because the blur runs at half resolution.
## Non-regression
- `with_bloom()` without `with_hdr()` → warning + no-op (the bloom is ignored).
- Without `with_bloom()` → the TM reads the HDR texture directly (the Step 20 behavior is
unchanged).
## Limitations (MVP)
- A single mip level (no multi-mip "soft" bloom à la Unreal).
- No directional bloom.
- The blur is a 9-tap Gaussian (good enough for a "soft" glow).
- No per-layer bloom (no per-material "bloom mask").
## Links
- [User README](../README.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
+64
View File
@@ -0,0 +1,64 @@
# DoF (depth of field)
Depth of field simulates camera-lens behavior: objects at the **focus distance** are sharp,
everything else is progressively blurred (the cinematic "bokeh" look). DoF is a post-process
that operates on the HDR texture + depth buffer, before tone mapping.
> **Prerequisite**: like bloom, DoF reads the HDR texture (and the depth buffer for the
> focus/blur computation). Enable HDR together with it.
## Activation
DoF is opt-in through the builder. Without it, no DoF textures are allocated and the pipeline
cost is **zero**:
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces)
.with_dof(DoFConfig::cinematic(4.0)) // sharp at 4.0 world units
.build()
.await?;
```
## `DoFConfig`
| Field | Type | Meaning |
|-------|------|---------|
| `focus_distance` | `f32` | World distance where the image is perfectly sharp |
| `aperture` | `f32` | Blur intensity (0.0–1.0, clamped). Scales the circle of confusion |
| `max_blur` | `f32` | Maximum blur radius in pixels (clamps the CoC) |
Presets:
```rust
DoFConfig::new(focus_distance, aperture, max_blur) // custom
DoFConfig::cinematic(focus_distance) // aperture 0.3, max blur 12 px (cutscenes)
DoFConfig::subtle(focus_distance) // aperture 0.1, max blur 8 px (gameplay)
```
## Runtime change
The `dof` example switches focus presets with the keys `1`–`4` (near / mid / far / infinity)
and follows the zoom:
```sh
cargo run -p wsg-lib --example dof
```
## Cost
- **Without DoF** (default): zero overhead — no textures, no pass.
- **With DoF**: 1 extra fullscreen pass + 2 intermediate textures (the bokeh buffer), before
tone mapping.
## Limitations (MVP)
- A single focus distance per frame (no per-pixel focus / rack-focus over time).
- The blur is a fixed-radius Gaussian scaled by the circle of confusion.
## Links
- [User README](../README.md) · [Bloom](bloom.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
+135
View File
@@ -0,0 +1,135 @@
# Distance fog
## Principle
Distance fog blends objects toward a predefined color based on their distance to the camera.
It is the standard tool for:
- **Hiding the rendered edge of the world** — the illusion of an infinite world (Skyrim, GTA,
Minecraft)
- **Adding depth** — a natural atmospheric effect
- **Masking transitions** — tile loading, LOD pops
## Activation
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
.build()
.await?;
```
Without `.with_fog()`, fog is disabled — **zero GPU cost** (the shader branch is never taken).
## Modes
| Mode | Formula | Use |
|------|---------|-----|
| `Linear` | `saturate((far - d) / (far - near))` | Sharp cutoff between two distances |
| `Exponential` | `exp(-density × d)` | Natural fog (forest, lake) |
| `Exponential2` | `exp(-density² × d²)` | Gradual start, sharp cutoff — **ideal for masking** |
### Constructors
```rust
// Linear: fade between near and far
FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0)
// Exponential: natural fade
FogConfig::exponential([0.6, 0.7, 0.8], 0.03)
// Exponential²: world-edge masking
FogConfig::exponential2([0.7, 0.75, 0.85], 0.08)
```
## Parameters
| Field | Type | Description |
|-------|------|-------------|
| `mode` | `FogMode` | Linear / Exponential / Exponential2 |
| `color` | `[f32; 3]` | Fog color (RGB, linear space) |
| `near` | `f32` | Start distance (linear mode only) |
| `far` | `f32` | End distance, full fog (linear mode) |
| `density` | `f32` | Density (exp / exp² modes). Typical: 0.01–0.3 |
### Choosing the color
The fog color **must match the sky/clear color** for a seamless "infinite world" effect. With
HDR + ACES, use linear values consistent with the tone mapping.
### Choosing the density (exp²)
To mask the edge of the world at a distance `D`:
```
density ≈ 2.0 / D
```
Examples:
- World visible up to 25 units → `density = 0.08`
- World visible up to 50 units → `density = 0.04`
- World visible up to 100 units → `density = 0.02`
## Runtime change
```rust
// In update():
if key_pressed(KeyCode::Digit1) {
app.renderer_mut().set_fog(Some(FogConfig::linear([0.7, 0.8, 0.9], 5.0, 30.0)));
}
if key_pressed(KeyCode::Digit4) {
app.renderer_mut().set_fog(None); // disable
}
```
The change takes effect on the next frame.
## Pipeline
```text
Main pass (fragment shader)
↓
Lighting → final_rgb
↓
FOG: mix(final_rgb, fog_color, 1 - fog_factor) ← here
↓
→ HDR texture / swapchain
↓
(Bloom) → Tone Mapping → surface
```
Fog is applied **before** tone mapping: HDR values stay unclamped, and the TM applies the
ACES/Reinhard curve to the already-fogged result. Result: the fog is perceptually coherent.
## Compatibility
| With | OK? | Note |
|------|-----|------|
| HDR + TM | ✅ | Fog before TM (recommended) |
| Bloom | ✅ | Bloom extracts the bright areas of the post-fog result |
| MSAA | ✅ | Independent (rasterizer vs fragment shader) |
| GPU culling | ✅ | Independent (culling decides what to draw, fog decides the color) |
| Shadows | ✅ | The shadow is computed before the fog |
## Limitations (v1)
- **Scene-level only**: a single fog for the whole scene. Per-material fog would require an
extra parameter in the per-object bind group.
- **Euclidean distance**: no volumetric or directional fog.
- **Fixed color**: no color gradient with distance.
## Example
See `lib/examples/effects/fog.rs`: 15 cubes in a row + 5 spheres on an 80×80 plane, with
runtime switching between the 3 modes.
```sh
cargo run -p wsg-lib --example fog --features "all-prims"
```
## Links
- [User README](../README.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
+88
View File
@@ -0,0 +1,88 @@
# HDR & tone mapping
> **Step 20** — Opt-in HDR rendering with tone mapping.
## Principle
By default, WSG renders **directly to the swapchain** in 8-bit sRGB. This is fine for simple
scenes, but the moment you want **bloom** or physically plausible intensities, you hit the
ceiling: 8-bit clamps everything to [0,1] before any post-process can run.
When HDR is enabled, the scene is first rendered into an **offscreen float texture**
(`Rgba16Float`, full window resolution), where values are unbounded (no clamping). The
**tone mapping** pass then compresses the HDR signal into [0,1] sRGB for the swapchain.
```
Without HDR (default) With HDR (.with_hdr(ToneMapper::Aces))
───────────────────── ──────────────────────────────────────
Scene ──────────→ Swapchain Scene ──→ HDR texture (Rgba16Float)
(8-bit sRGB, clamped) │ (float, unbounded)
▼
Tone Mapping (ACES / Reinhard)
│
▼
Swapchain (sRGB)
```
## Enabling
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces) // or ToneMapper::Reinhard
.build()
.await?;
```
- **Without** `with_hdr`: the pipeline is untouched, **zero cost** (no extra texture, no
extra pass).
- **With** `with_hdr`: one extra full-res texture + one fullscreen pass per frame. Negligible
cost on any discrete GPU; moderate on an integrated one (full-res read+write).
## Tone mappers
| Curve | Characteristics |
|-------|----------------|
| `Aces` | **Default.** Filmic look, good highlight roll-off, slightly desaturated in the shadows. The standard for games and engines. |
| `Reinhard` | Simple `c / (1 + c)`. Neutral and fast, but highlights "washed out" (the curve saturates quickly). |
> The `demo` example starts in **LDR** (no HDR): the sphere's emissive intensity of 3.0 is
> clamped to 1.0 — it looks "burnt" but no glow. Pressing a key enables HDR+ACES and the
> highlight rolls off gracefully.
## Exposure
Runtime-adjustable since Step 22: initialize with `AppBuilder::with_exposure(…)`, adjust with
`app.set_exposure(…)` (multiplicative, clamped to [0.01, 10.0] — only active when HDR is on).
See [Emissive + Exposure](../lights/emissive-exposure.md).
## Interaction with other features
| Feature | Behavior under HDR |
|---------|-------------------|
| **Shadows** | Unchanged — the shadow pass still writes depth; the color pass just targets the HDR texture instead of the swapchain. |
| **Fog** | Applied **before** tone mapping (inside the main pass). The fog color must be chosen in linear space, consistent with the tone curve. |
| **MSAA** | Compatible — the HDR texture becomes the multisample render target and is resolved before tone mapping. |
| **Bloom** | **Requires** HDR. Without it, bloom is ignored (warning). |
## Cost and non-regression
- No HDR (default): nothing is allocated, nothing is run. The swapchain is targeted directly.
- HDR enabled: one extra `Rgba16Float` texture + one fullscreen pass (tone mapping). If bloom
is also enabled, three more half-res textures and a few more passes (see the bloom page).
- The `demo` example shows both paths side by side: the LDR start (emissive clamped) and the
HDR+ACES mode (highlight roll-off).
## Limitations
- No **auto-exposure** (histogram-based). Exposure is fixed at build time (the demo hardcodes
1.0).
- No **DITHERING** on the output: banding may appear in smooth gradients near black (sRGB 8-bit
ceiling).
## See also
- [Shadows](../lights/shadows.md)
- [GPU-driven rendering](../cameras/gpu-driven.md)
- [Examples](../examples.md)
+79
View File
@@ -0,0 +1,79 @@
# MSAA (anti-aliasing)
MSAA (Multisample Anti-Aliasing) smooths the edges of meshes by sampling each pixel multiple
times **at rasterization time** (before the fragment shader).
## Enabling
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_msaa(4) // 4x MSAA
.build()
.await?;
```
- **Without** `with_msaa`: the swapchain is used as-is (1x, no cost).
- **With** `with_msaa(4)`: the swapchain is created with `sample_count = 4`, and a
**resolve** pass (multisample → swapchain) runs at the end of each frame.
## Cost
- The **main pass** becomes more expensive (fragment shader run `sample_count` times per pixel
on aliased edges — in practice much less, since fully-covered pixels are only processed once).
- One extra **fullscreen resolve** per frame (GPU-native, very cheap).
- VRAM: the swapchain buffer is multiplied by `sample_count` (4x for 4x MSAA).
In practice: 4x MSAA is **negligible** on a discrete GPU and perfectly acceptable on an
integrated one for scenes of this complexity.
## MSAA + HDR
The two compose naturally:
```
Scene ──→ HDR multisample texture (sample_count = N)
│
▼ resolve
HDR texture (single sample)
│
▼ (bloom?)
▼ tone mapping
Swapchain (sRGB)
```
- `with_msaa(4)` + `with_hdr(…)`: the offscreen HDR texture becomes multisample and is
**resolved** before tone mapping (and before bloom, which operates on the single-sample
buffer).
- `with_msaa(4)` without HDR: the swapchain itself is multisample, resolved at the end of the
frame.
## MSAA + fog
Fog is applied **inside** the fragment shader (per sample), so it is inherently MSAA-compatible:
each sample computes its own fog factor based on its own depth. No aliasing on the fog
boundaries.
## What MSAA does NOT fix
- **Transparency aliasing** (there is no transparency in the engine — all opaque).
- **Texture shimmering** at distance: this is the domain of **anisotropic filtering** (already
enabled: `SampleFilter::AnisotropicClamped` + `anisotropy = 4`).
- **Temporal flicker** of fine details: this would be the domain of TAA (out of scope).
## Limitations
- `sample_count` is fixed **at swapchain creation** (not changeable at runtime without
recreating the window/surface).
- `2` and `4` are the useful values. `8` exists but the cost/quality ratio is bad.
- Not all adapters support MSAA on the swapchain — if unsupported, the builder falls back to
1x with a warning.
See `lib/examples/effects/msaa.rs` for a full interactive demo (torus + Icosphere, with
zoom/orbit, MSAA 4x by default).
## Links
- [User README](../README.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
+72
View File
@@ -0,0 +1,72 @@
# Examples
16 examples in **4 folders** (mirroring the topic folders of this documentation), covering the
full range of the engine — from a 2D quad to GPU-driven rendering.
All examples are in [`lib/examples/`](../../lib/examples/README.md); each folder has its own
README (description + how to run): [`meshes/`](../../lib/examples/meshes/README.md),
[`lights/`](../../lib/examples/lights/README.md), [`cameras/`](../../lib/examples/cameras/README.md),
[`effects/`](../../lib/examples/effects/README.md).
| Example | Folder | What it shows | How to run | Corresponding page |
|---------|--------|---------------|------------|--------------------|
| `simple` | meshes | A 2D quad with vertex colors, unlit mode (~30 lines) | `cargo run -p wsg-lib --example simple` | [Quickstart](quickstart.md), [Materials](meshes/materials.md) |
| `cube` | meshes | A rotating cube: point + spot light, checkerboard texture, procedural normal map, orbit/zoom | `cargo run -p wsg-lib --example cube` | [Meshes](meshes/meshes.md), [Materials](meshes/materials.md), [Lights](lights/lights.md) |
| `pbr` | meshes | A procedural PBR material (metal/roughness) + a checker diffuse | `cargo run -p wsg-lib --example pbr` | [Materials](meshes/materials.md) |
| `import` | meshes | Wavefront **OBJ** import (CLI: file path as argument, procedural cube as fallback) | `cargo run -p wsg-lib --example import --features import-obj -- model.obj` | [Geometry sources](meshes/sources.md) |
| `manual` | meshes | **Advanced**: the full manual workflow — buffers, pipelines, command encoding, no helpers | `cargo run -p wsg-lib --example manual` | [ARCHI_APP](../tech/ARCHI_APP.md), [FRAME_LOOP](../tech/FRAME_LOOP.md) |
| `shadow` | lights | Shadow mapping: the classic pitfall — the packed-index shadow caster | `cargo run -p wsg-lib --example shadow` | [Shadows](lights/shadows.md) |
| `shadow_test` | lights | Shadow mapping in isolation (cleared list → your light is index 0) | `cargo run -p wsg-lib --example shadow_test` | [Shadows](lights/shadows.md) |
| `spot_test` | lights | A single spotlight (cone + penumbra), ambient nearly zero | `cargo run -p wsg-lib --example spot_test` | [Lights](lights/lights.md) |
| `emissive` | lights | Emissive materials + HDR glow, runtime exposure (+/-/0 keys) | `cargo run -p wsg-lib --example emissive` | [Emissive & exposure](lights/emissive-exposure.md) |
| `culling` | cameras | **GPU-driven**: world matrices + indirect draws on the GPU, opt-in frustum culling, LOD | `cargo run -p wsg-lib --example culling` | [GPU-driven](cameras/gpu-driven.md) |
| `demo` | effects | The full showcase: all features combined (shadows, HDR, bloom, MSAA, fog, lights, orbital camera) | `cargo run -p wsg-lib --example demo` | [All pages](README.md) |
| `bloom` | effects | HDR + bloom: threshold → blur → composite | `cargo run -p wsg-lib --example bloom` | [Bloom](effects/bloom.md) |
| `hdr` | effects | HDR + tone mapping (ACES / Reinhard), emissive showcase | `cargo run -p wsg-lib --example hdr` | [HDR](effects/hdr.md) |
| `msaa` | effects | 4x MSAA anti-aliasing on the swapchain | `cargo run -p wsg-lib --example msaa` | [MSAA](effects/msaa.md) |
| `fog` | effects | Distance fog, 3 modes switchable at runtime (linear / exponential / exp²) | `cargo run -p wsg-lib --example fog` | [Fog](effects/fog.md) |
| `dof` | effects | Depth of field: Gaussian blur scaled by defocus distance, cinematic bokeh; focus presets 1-4 + continuous zoom | `cargo run -p wsg-lib --example dof` | [DoF](effects/dof.md) |
## The `manual` example: bypassing the helpers
[`manual.rs`](../../lib/examples/meshes/manual.rs) renders a rotating cube with **no
high-level helper at all** — no `Scene`, no `Renderer` convenience API, no `AppHandler`
default `render()`. It shows the full pipeline:
1. **`setup`**: manual creation of vertex/index buffers, bind groups, render/compute
pipelines, the swapchain.
2. **`render` (overridden)**: manual command encoding per frame (clear, draw, present) —
the handler controls **every** `CommandEncoder` operation.
3. **Uniforms written by hand** with `queue.write_buffer` (projection, view, model matrices).
This is the reference for what the high-level API does under the hood, and the starting
point for features that don't exist yet in the engine (custom pipelines, post-processes,
custom compute). The technical details are in [ARCHI_APP](../tech/ARCHI_APP.md) and
[FRAME_LOOP](../tech/FRAME_LOOP.md).
Rule of thumb: **use `AppHandler` + `Scene` for everything the engine already does, and drop
to `manual` style only when you need what it doesn't** — the two styles can be mixed in the
same app (e.g. `Scene` for the scene, a manual post-process pass in `render()`).
## Adding your own example
1. Create `lib/examples/<folder>/<name>.rs` — pick the folder it belongs to
(`meshes` / `lights` / `cameras` / `effects`).
2. Declare the `[[example]]` entry in `lib/Cargo.toml` (the folder structure means Cargo
no longer auto-discovers examples):
```toml
[[example]]
name = "<name>"
path = "examples/<folder>/<name>.rs"
```
3. Required features: the base crate has no primitives by default in examples — declare
`required-features` if your example uses them (e.g. `required-features = ["prim-cube"]`).
4. Register it in the folder's README and in the table above.
5. Verify: `cargo build --workspace --examples` + run it.
## Links
- [User README](README.md) · [Quickstart](quickstart.md)
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md)
+16
View File
@@ -0,0 +1,16 @@
# Lights — user documentation
The **lighting side** of the scene: the light model, shadows, and emissive materials.
| Page | Topic |
|------|-------|
| [Lights](lights.md) | Scene-global lights (directional/point/spot/ambient), `MAX_LIGHTS`, packed indices |
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall, tuning |
| [Emissive + Exposure](emissive-exposure.md) | Emissive materials (HDR glow) and runtime exposure |
Example folder: [`lib/examples/lights/`](../../../lib/examples/lights/README.md)
(`shadow`, `shadow_test`, `spot_test`, `emissive`).
## Links
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
+114
View File
@@ -0,0 +1,114 @@
# Emissive + Exposure
## Principle
Two complementary features (Step 22):
| Feature | Effect | Cost |
|---------|--------|------|
| **Exposure** (6.1) | Multiplies luminance before the tone-mapping curve | Zero when HDR is inactive |
| **Emissive** (6.2) | Adds an emitted color (independent of the lights) | Zero when `emissive = [0,0,0,0]` |
## Exposure
### API
```rust
// Initialization (optional, default = 1.0)
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces)
.with_exposure(1.5) // start brighter
.build().await?;
// Runtime (in update())
app.set_exposure(app.exposure() * 1.1); // +1 "stop"
app.set_exposure(1.0); // reset
```
### Behavior
- Exposure is a **multiplier** applied to the HDR texture before the tone-mapping curve.
- `exposure = 2.0` → the image is 2× brighter (like opening a camera's aperture).
- `exposure = 0.5` → the image is 2× darker.
- Clamped to `[0.01, 10.0]` to avoid degenerate values.
- **Only has an effect when HDR is active** (`with_hdr(...)`). In LDR the value is ignored.
### Keyboard (demo)
| Key | Effect |
|-----|--------|
| `+` | ×1.1 (brighter) |
| `-` | ÷1.1 (darker) |
| `0` | Reset to 1.0 |
## Emissive
### API
```rust
use wsg_lib::resources::Material;
// Create a material with emissivity
let mut mat = /* ... */;
mat.emissive = [1.0, 0.3, 0.1, 1.5]; // orange, intensity 1.5 (> 1.0 = HDR glow)
```
### Format
`emissive = [r, g, b, intensity]`:
- **rgb**: the emission color (same space as the vertex base color)
- **a (intensity)**: the multiplier. `1.0` = normal color, `> 1.0` = highlight (only visible in HDR)
### Shader formula
```
final_color = lit + base_color * emissive.rgb * emissive.a
```
- The emission is **additive**: visible even in total darkness (no light needed).
- It is **independent of shadows**: an emissive object casts no shadow and is not shadowed.
- `emissive = [0,0,0,0]` (default) → no change (non-regression guaranteed).
### Use cases
| Use | Value |
|-----|-------|
| LED / indicator | `[0, 1, 0, 1.0]` (green, normal intensity) |
| Flame / sun | `[1, 0.8, 0.2, 3.0]` (orange, HDR glow) |
| Neon | `[0, 0.5, 1, 2.5]` (cyan, glow) |
| Inactive | `[0, 0, 0, 0]` (default) |
### Keyboard (demo)
| Key | Effect |
|-----|--------|
| `E` | Toggle orange glow on the sphere/cylinder |
## Interactions
| Combination | Result |
|-------------|--------|
| Emissive + HDR + ACES | Soft glow, highlights roll off (the nicest) |
| Emissive + LDR | Clamped to 1.0 (no glow, but the color is visible in the dark) |
| Emissive + shadows | The emissive object is NOT shadowed (emission bypasses the shadow term) |
| Exposure + Emissive | Exposure also amplifies the emission (consistent: everything is in the HDR texture) |
## Non-regression
- **Emissive**: `[0,0,0,0]` by default → the shader adds `base * 0 * 0 = 0` → no change.
- **Exposure**: `1.0` by default → `pow(color, 1/1) = color` → no change.
- Both are **opt-in**: without `with_hdr(...)` and `emissive != 0`, the pipeline is identical
to the previous state.
## Limitations (MVP)
- Emissive is **per material**, not per vertex (no emission gradient within a mesh).
- Emissive is **static** at material creation (changing `mat.emissive` requires re-registering
the material via `add_material`).
- No **bloom** (Step 23): the HDR glow is visible but not "blurred" / spread.
## Links
- [User README](../README.md) · [HDR & tone mapping](../effects/hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
+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/effects/demo.rs) example;
[`cube.rs`](../../../lib/examples/meshes/cube.rs) shows a point + a spot on top of the default
directional, and [`spot_test.rs`](../../../lib/examples/lights/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/lights/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](../meshes/materials.md)
- [Root README](../../../README.md) · [ARCHI_RENDU](../../tech/ARCHI_RENDU.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/lights/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/effects/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](../meshes/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)
+17
View File
@@ -0,0 +1,17 @@
# Meshes — user documentation
The **geometry side** of the scene: where the geometry comes from, how meshes and entities are
organized, and how objects look.
| Page | Topic |
|------|-------|
| [Meshes](meshes.md) | The three levels `Geometry` → `Mesh` → `Entity`; procedural primitives, custom geometry, `Transform`, mesh sharing |
| [Geometry sources](sources.md) | The `wsg::mesh` module: feature-gated procedural generators + file import (OBJ, glTF stub) |
| [Materials & textures](materials.md) | The `standard` shader, unlit mode, diffuse textures |
Example folder: [`lib/examples/meshes/`](../../../lib/examples/meshes/README.md)
(`simple`, `cube`, `pbr`, `import`, `manual`).
## Links
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.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/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/meshes/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/effects/demo.rs) and [`cube.rs`](../../../lib/examples/meshes/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/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/lights.md) · [Examples](../examples.md)
- [Root README](../../../README.md) · [ARCHI_RENDU](../../tech/ARCHI_RENDU.md)
+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/meshes/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/lights.md)
- [Root README](../../../README.md) · [ARCHI_APP](../../tech/ARCHI_APP.md)
+113
View File
@@ -0,0 +1,113 @@
# Geometry sources: procedural generators and file import
The `wsg::mesh` module is the single entry point for **where the geometry comes from**:
procedural generators or file import.
## Procedural primitives
Each primitive family is behind a **feature** — you only compile what you need.
| Feature | Function | Description |
|---------|----------|-------------|
| `prim-cube` | `cube(size)` | Centered cube, 24 vertices, per-face normals |
| `prim-plane` | `plane(w, d, seg_x, seg_z)` | Horizontal XZ plane (normal +Y), subdivided |
| `prim-sphere` | `uv_sphere(r, sectors, stacks)` | Lat/long sphere, smooth normals |
| `prim-sphere` | `icosphere(r, subdivisions)` | Icosphere (subdivided icosahedron) |
| `prim-cylinder` | `cylinder(r, h, sectors)` | Cylinder (side + caps), analytic normals |
| `prim-cone` | `cone(r, h, sectors)` | Cone (apex + closed base) |
| `prim-torus` | `torus(major, minor, seg_maj, seg_min)` | Torus, smooth normals |
### Default features
```toml
# Your project's Cargo.toml
[dependencies]
wsg-lib = { path = "../lib" }
# Default: all primitives enabled (all-prims)
```
```toml
# Only compile the cube and the sphere:
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);
// All return a Geometry (positions + normals + UVs + indices)
assert_eq!(cube.positions.len(), 24);
```
## File import
| Feature | Function | Format |
|---------|----------|--------|
| `import-obj` | `load_obj(path)` / `parse_obj(str)` | Wavefront OBJ |
| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB (stub) |
### OBJ parser
Supports: `v`, `vn`, `vt`, `f` (3–4 vertices, fan triangulation).
If the file has no normals, they are **computed** (area-weighted).
```rust
use wsg_lib::mesh::{load_obj, parse_obj};
// From a file
let geom = load_obj("model.obj")?;
// From a string
let geom = parse_obj("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n")?;
```
### Errors
```rust
use wsg_lib::mesh::import::MeshImportError;
match load_obj("missing.obj") {
Ok(geom) => { /* … */ }
Err(MeshImportError::Io(e)) => eprintln!("file not accessible: {e}"),
Err(MeshImportError::Parse(e)) => eprintln!("invalid syntax: {e}"),
Err(MeshImportError::Unsupported(e)) => eprintln!("unsupported feature: {e}"),
}
```
## From `Geometry` to the scene
The `mesh` module produces `Geometry` (CPU data). To render it, go through
`Scene::create_mesh`, which uploads it to the GPU:
```rust
use wsg_lib::prelude::*;
use wsg_lib::mesh::cube;
// In 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
```
## Conventions
- **Y-up**, centered on the origin (except `plane`: XZ plane at y=0)
- **Outward** normals
- UVs in [0,1]²
- **CCW** winding (front face)
## Links
- [User README](../README.md) · [Meshes](meshes.md) · [Materials & textures](materials.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
+129
View File
@@ -0,0 +1,129 @@
# Quickstart
Get a window with a rotating cube on screen in ~30 lines. The full version with comments is
in the [`simple`](../../lib/examples/meshes/simple.rs) example (2D quad, unlit) and
[`cube`](../../lib/examples/meshes/cube.rs) (3D cube, lit).
## 1. Add the dependency
```toml
# Cargo.toml
[dependencies]
wsg-lib = { path = "../lib" }
glam = "0.29" # Vec3/Quat — re-exported but you need it in your own code
winit = "0.30" # KeyCode/MouseButton for the input (only if you use app.input)
```
> The workspace pins `glam 0.29` and `winit 0.30`; match these versions to avoid
> type mismatches.
## 2. Implement `AppHandler`
Three mandatory methods (`setup`, `update`, `render`) and an optional event hook.
```rust
use glam::{Quat, Vec3};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::camera::CameraController;
use wsg_lib::prelude::*;
struct MyHandler {
camera: CameraController,
}
impl AppHandler for MyHandler {
fn new() -> Self {
Self { camera: CameraController::default() }
}
fn setup(&mut self, app: &mut App) -> Result<(), String> {
// A cube (primitive) + the standard material.
app.scene.create_mesh("cube_mesh", cube(1.0), Some("cube_mat"))?;
app.scene.add_entity("cube", "cube_mesh")?;
// A warm directional light + shadows on it.
app.scene
.add_directional_light(Vec3::new(1.0, 1.2, 1.0).normalize(), [1.0, 0.98, 0.92], 1.5)?;
app.scene.set_shadow_caster(Some(0));
Ok(())
}
fn update(&mut self, app: &mut App) {
// Spin the cube.
let mut tf = *app.scene.entity_transform("cube").unwrap();
tf.rotation = Quat::from_rotation_y(self.t) * tf.rotation;
app.scene.set_entity_transform("cube", tf);
self.t += 0.02;
// Camera: orbit (left-drag), zoom (wheel), reset (R), presets (1/2/3).
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);
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());
}
fn render(&mut self, app: &mut App) -> Result<(), String> {
// Default implementation: renders the whole scene. Override only for custom passes.
app.renderer().render_scene(app.context())
}
}
```
## 3. Build the app
```rust
fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = AppBuilder::new()
.title("My WSG app")
.size(1024, 768)
.with_culling(true) // optional: skip off-screen entities
.with_shadows() // optional: enable shadow mapping
.build()
.await?;
let mut handler = MyHandler::new();
app.run(&mut handler).await?;
Ok(())
}
```
`AppBuilder` methods you will use early:
| Method | Purpose |
|--------|---------|
| `.title(…)` / `.size(w, h)` | Window |
| `.with_vsync(false)` / `.with_frame_limit(n)` | Frame pacing (vsync off + 144 fps cap in the `demo`) |
| `.with_culling(true)` | Opt-in frustum culling (see [GPU-driven](cameras/gpu-driven.md)) |
| `.with_shadows()` | Opt-in shadow mapping (see [Shadows](lights/shadows.md)) |
| `.with_hdr(ToneMapper::Aces)` | Opt-in HDR + tone mapping (see [HDR](effects/hdr.md)) |
## 4. Run it
```sh
cargo run -p wsg-lib --example cube # the reference "hello world" of the engine
```
Controls (in the `cube`/`demo` examples): **left-drag** orbit, **wheel** zoom, **R** reset
camera, **1/2/3** view presets, **H** help overlay, **Esc** quit.
## 5. Where to go next
- [Meshes](meshes/meshes.md) — entities, transforms, custom geometries
- [Materials & textures](meshes/materials.md) — diffuse textures, unlit mode
- [Lights](lights/lights.md) — point/spot lights, ambient
- [Camera & input](cameras/camera-input.md) — the full input API
- [GPU-driven](cameras/gpu-driven.md) — culling, LOD, debugging the GPU path
## Links
- [User README](README.md) · [Meshes](meshes/meshes.md) · [Examples](examples.md)
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
+96 -4
View File
@@ -6,12 +6,104 @@ edition = "2024"
[lib] [lib]
path = "src/lib.rs" 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] [dependencies]
wgpu = "30.0.0" # Vérifiez la version la plus récente 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" thiserror = "2"
bytemuck = { version = "1.25.0", features = ["derive"] } bytemuck = { version = "1.25.0", features = ["derive"] }
glam = "0.33" 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"] }
[dev-dependencies] # Examples live in per-category subfolders (meshes/, lights/, cameras/, effects/).
pollster = { version="0.4.0", features = ["macro"] } # Cargo only auto-discovers top-level `examples/*.rs`, so every example is
# declared explicitly with its `path`. Names are stable: `cargo run -p wsg-lib
# --example <name>` works exactly as before the reorganization.
# Each folder has a README.md documenting its examples.
# --- meshes/ : geometry, materials, import, low-level workflow ---
[[example]]
name = "simple"
path = "examples/meshes/simple.rs"
[[example]]
name = "cube"
path = "examples/meshes/cube.rs"
[[example]]
name = "pbr"
path = "examples/meshes/pbr.rs"
[[example]]
name = "import"
path = "examples/meshes/import.rs"
required-features = ["import-obj"]
[[example]]
name = "manual"
path = "examples/meshes/manual.rs"
# --- lights/ : shadow mapping, spot, emissive ---
[[example]]
name = "shadow"
path = "examples/lights/shadow.rs"
[[example]]
name = "shadow_test"
path = "examples/lights/shadow_test.rs"
[[example]]
name = "spot_test"
path = "examples/lights/spot_test.rs"
[[example]]
name = "emissive"
path = "examples/lights/emissive.rs"
# --- cameras/ : camera-driven rendering (frustum culling) ---
[[example]]
name = "culling"
path = "examples/cameras/culling.rs"
# --- effects/ : HDR, post-process, the full showcase ---
[[example]]
name = "demo"
path = "examples/effects/demo.rs"
[[example]]
name = "bloom"
path = "examples/effects/bloom.rs"
[[example]]
name = "hdr"
path = "examples/effects/hdr.rs"
[[example]]
name = "msaa"
path = "examples/effects/msaa.rs"
[[example]]
name = "fog"
path = "examples/effects/fog.rs"
[[example]]
name = "dof"
path = "examples/effects/dof.rs"
+61
View File
@@ -0,0 +1,61 @@
# WSG Examples
The examples are organized into **four category folders**, one per theme. Each
folder has its own `README.md` documenting its examples in detail (what they
demonstrate, how to run them, keyboard controls, what to observe).
| Folder | Theme | Examples |
|--------|-------|----------|
| [meshes/](meshes/README.md) | Geometry, materials, file import, low-level workflow | `simple`, `cube`, `pbr`, `import`, `manual` |
| [lights/](lights/README.md) | Light types, shadow mapping, emissive materials | `shadow`, `shadow_test`, `spot_test`, `emissive` |
| [cameras/](cameras/README.md) | Camera-driven rendering (frustum culling) | `culling` |
| [effects/](effects/README.md) | HDR, tone mapping, post-process, full showcase | `demo`, `bloom`, `hdr`, `msaa`, `fog`, `dof` |
## Running an example
Example **names are stable** — from the repo root:
```sh
cargo run -p wsg-lib --example <name>
```
Examples gated behind a Cargo feature need the feature too:
```sh
cargo run -p wsg-lib --example import --features import-obj
```
All examples are **self-contained**: procedural textures, hard-coded geometries,
no on-disk assets. All use the declarative API (`AppBuilder` + `AppHandler`)
except `manual`, which demonstrates the low-level workflow instead.
> **Where do the files live?** Examples live in subfolders
> (`examples/<folder>/<name>.rs`). Cargo only auto-discovers top-level
> `examples/*.rs`, so every example is declared explicitly in
> [`lib/Cargo.toml`](../Cargo.toml) with its `path`. This keeps
> `--example <name>` working while allowing the folder organization.
## Suggested learning path
1. `simple` — the minimal declarative workflow (flat unlit quad, ~15 lines)
2. `cube` — the 3D MVP: a textured, lit, spinning cube
3. `pbr` — PBR materials and normal mapping
4. `spot_test`, `shadow_test` — isolated light and shadow behavior
5. `hdr` → `emissive` → `bloom` — the HDR chain, step by step
6. `culling` — GPU-driven frustum culling
7. `demo` — everything combined
8. `manual` — what the `App` facade actually encapsulates
## Adding your own example
1. Create `lib/examples/<folder>/my_example.rs` (pick the matching category;
add a new folder + README if needed).
2. Declare it in `lib/Cargo.toml` (Cargo won't discover it otherwise):
```toml
[[example]]
name = "my_example"
path = "examples/<folder>/my_example.rs"
```
3. Keep it **self-contained**: procedural textures, hard-coded geometries, no
external assets.
4. Document it in the folder's `README.md` (and in `docs/user/examples.md`).
+52
View File
@@ -0,0 +1,52 @@
# Cameras & Camera-Driven Rendering
Examples where the **camera** drives what gets rendered.
| Example | Run command | What it shows |
|---------|-------------|---------------|
| `culling` | `cargo run -p wsg-lib --example culling` | GPU-driven frustum culling: a 15×15 grid of cubes, off-frustum objects skipped |
> All commands run from the repo root.
The frustum is defined by the camera's view-projection matrix, so frustum
culling is inherently a camera concept: move the camera and the set of drawn
objects changes — with **zero CPU cost** (the GPU decides in a compute pass).
---
## `culling` — GPU Frustum Culling
A grid of **15×15 = 225 cubes** is placed on a large floor. The GPU-driven
culling (compute shader) determines which cubes are visible in the camera
frustum and zeros their indirect draw args — **zero CPU cost**.
```sh
cargo run -p wsg-lib --example culling
```
### Keys
| Key | Action |
|-----|--------|
| Drag (LMB) | Orbit camera (look around) |
| Wheel | Zoom in/out |
| `R` | Reset (top view) |
| `1` | Front view (cubes behind are culled) |
| `2` | Side view |
| `3` | **Top view** (see the full grid) |
### What to observe
- In top view (`3`): the entire grid is visible.
- Orbit to 90°: cubes behind the camera **are not drawn** (culled).
- Zoom very close: only cubes near the near plane are rendered.
- Cubes rotate slowly (staggered phases) → culling is dynamic (a cube can
enter/leave the frustum during a frame).
> **Note**: culling is enabled via `AppBuilder::with_culling(true)`. Changing
> it to `false` in the source disables culling (all cubes are always drawn,
> even off-screen).
>
> The GPU-driven pipeline (compute matrices → culling → indirect draws) is
> documented in [`docs/tech/ARCHI_CPU_GPU.md`](../../../docs/tech/ARCHI_CPU_GPU.md)
> and [`docs/user/cameras/gpu-driven.md`](../../../docs/user/cameras/gpu-driven.md).
+170
View File
@@ -0,0 +1,170 @@
//! **GPU Frustum Culling** — demonstrates the GPU-driven culling pipeline.
//!
//! A grid of 15×15 cubes is placed in a large field. When GPU culling is enabled,
//! cubes outside the camera frustum are skipped on the GPU (their indirect draw
//! args are zeroed by the culling compute pass). Orbit the camera to see objects
//! behind you simply not being drawn.
//!
//! To compare with/without culling, run twice:
//! ```sh
//! cargo run -p wsg-lib --example culling # culling ON (default)
//! ```
//! Or modify `CULLING_ENABLED` in the source.
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera (look around to see culling) |
//! | Wheel | Zoom in/out |
//! | `R` | Reset camera |
//! | `1` | Front view |
//! | `2` | Side view |
//! | `3` | Top view (see full grid) |
//!
//! ## What to look for
//! - From the top view (`3`), you see the full 15×15 grid.
//! - Orbit to the side: cubes behind you are culled (not rendered).
//! - Zoom in close: only nearby cubes are drawn.
//! - The culling happens 100% on the GPU (compute pass) — zero CPU cost.
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example culling
//! ```
use glam::{Quat, Vec3};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::Transform;
use wsg_lib::mesh::{cube, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
/// Grid dimensions (15×15 = 225 cubes, fits within MAX_ENTITIES=256).
const GRID: usize = 15;
/// Spacing between cubes (world units).
const SPACING: f32 = 1.2;
/// Whether to enable GPU culling.
const CULLING_ENABLED: bool = true;
struct CullingDemo {
camera: CameraController,
angle: f32,
}
impl AppHandler for CullingDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Large ground plane.
let ground_size = (GRID as f32 * SPACING) * 1.5;
app.scene
.create_mesh("ground_mesh", plane(ground_size, ground_size, 1, 1), None)
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// One shared cube mesh (all entities reference the same GPU buffers).
app.scene
.create_mesh("cube_mesh", cube(0.5), None)
.unwrap();
// Place the grid of cubes.
let half = (GRID / 2) as f32;
for i in 0..GRID {
for j in 0..GRID {
let x = i as f32 * SPACING - half;
let z = j as f32 * SPACING - half;
let label = format!("cube_{}_{}", i, j);
let mut tf = Transform::identity();
tf.translation = Vec3::new(x, 0.25, z);
app.scene
.add_entity_with_transform(&label, "cube_mesh", tf)
.unwrap();
}
}
// Directional light.
let light_dir = Vec3::new(0.5, 1.0, 0.3).normalize();
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.2)
.unwrap();
app.scene.set_ambient([0.15, 0.15, 0.18]);
// Camera: start at top view to see the full grid.
self.camera.yaw = 0.0;
self.camera.pitch = 1.2;
self.camera.distance = 15.0;
self.camera.target = Vec3::ZERO;
self.camera.apply_to(app.scene.camera_mut());
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit camera.
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);
// Camera presets.
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.0;
self.camera.pitch = 1.2;
self.camera.distance = 15.0;
}
if app.input.key_pressed(KeyCode::Digit1) {
self.camera.yaw = 0.0;
self.camera.pitch = 0.1;
self.camera.distance = 15.0;
}
if app.input.key_pressed(KeyCode::Digit2) {
self.camera.yaw = std::f32::consts::FRAC_PI_2;
self.camera.pitch = 0.1;
self.camera.distance = 15.0;
}
if app.input.key_pressed(KeyCode::Digit3) {
self.camera.yaw = 0.0;
self.camera.pitch = 1.4;
self.camera.distance = 18.0;
}
self.camera.apply_to(app.scene.camera_mut());
// Slow rotation of the whole grid (subtle, to show dynamic culling).
self.angle += 0.002;
for i in 0..GRID {
for j in 0..GRID {
let label = format!("cube_{}_{}", i, j);
if let Some(base) = app.scene.entity_transform(&label) {
let mut tf = *base;
// Rotate each cube slightly (staggered by position for visual interest).
let phase = (i as f32 + j as f32) * 0.1;
tf.rotation = Quat::from_rotation_y(self.angle + phase);
app.scene.set_entity_transform(&label, tf);
}
}
}
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new()
.title("WSG Culling (20×20 grid)")
.size(1024, 768)
.with_culling(CULLING_ENABLED)
.build()
.await?;
app.run(CullingDemo {
camera: CameraController::default(),
angle: 0.0,
})
}
+178
View File
@@ -0,0 +1,178 @@
# Effects: HDR, Post-process & Showcase
Examples covering **HDR / tone mapping** and **post-process effects**, plus
the full showcase that combines everything.
| Example | Run command | What it shows |
|---------|-------------|---------------|
| `demo` | `cargo run -p wsg-lib --example demo` | **Full showcase**: 6 LOD primitives, 3 lights, shadows, HDR/ACES, bloom, culling, orbital camera |
| `bloom` | `cargo run -p wsg-lib --example bloom` | Post-process bloom (glow around bright areas) |
| `hdr` | `cargo run -p wsg-lib --example hdr` | HDR + tone mapping (ACES) + runtime exposure control |
| `msaa` | `cargo run -p wsg-lib --example msaa` | MSAA 4× (multisample anti-aliasing, smooth edges) |
| `fog` | `cargo run -p wsg-lib --example fog --features "all-prims"` | Distance fog (3 modes: linear, exp, exp²) |
| `dof` | `cargo run -p wsg-lib --example dof --features "all-prims"` | Depth of field (cinematic bokeh, focus presets) |
> All commands run from the repo root. All effects are **opt-in** — a disabled
> effect allocates nothing and executes nothing.
---
## `demo` — Full Showcase
Combines **all** effects: LOD primitives, procedural textures, lights
(directional + point + spot), shadows, HDR/ACES, exposure, emissive, bloom, culling.
```sh
cargo run -p wsg-lib --example demo
```
### Keys
| Key | Action |
|-----|--------|
| Drag (LMB) | Orbit camera |
| Wheel | Zoom |
| `R` | Reset camera |
| `1` / `2` / `3` | Presets: front / side / top |
| `+` / `-` | Exposure ×1.3 / ÷1.3 |
| `0` | Reset exposure |
---
## `bloom` — Post-process Bloom
Two emissive spheres (orange intensity 2.0, blue intensity 3.0) produce a
visible halo. The cube and floor serve as reference (non-emissive).
Bloom is a 4-pass GPU pipeline: threshold → blur H → blur V → composite.
```sh
cargo run -p wsg-lib --example bloom
```
### Keys
| Key | Action |
|-----|--------|
| Drag (LMB) | Orbit camera |
| Wheel | Zoom |
| `R` | Reset camera |
| `+` / `-` | **Bloom threshold** +0.1 / −0.1 |
| `[` / `]` | **Bloom intensity** +0.1 / −0.1 |
| `I` / `O` | **Bloom radius** +0.5 / −0.5 |
| `E` / `Q` | Exposure ×1.3 / ÷1.3 |
| `0` | Reset exposure |
### What to observe
- **Low threshold** (0.0): the entire image "blooms" (very diffuse effect).
- **High threshold** (2.0+): only the bright emissive spheres produce glow.
- **Intensity 0.0**: no visible glow (even though the threshold extracts pixels).
- **Large radius** (10+): the glow spreads over a large area.
---
## `hdr` — HDR + Tone Mapping
Demonstrates HDR rendering with the ACES Filmic curve. Three objects:
- **Cube**: normal lighting (no emissive) — LDR reference.
- **Bright sphere** (emissive 3.0): without HDR, it would be clamped to white.
With ACES, highlights "roll off" smoothly toward white.
- **Dark sphere** (emissive 0.3): stays dark even at high exposure.
```sh
cargo run -p wsg-lib --example hdr
```
### Keys
| Key | Action |
|-----|--------|
| Drag (LMB) | Orbit camera |
| Wheel | Zoom |
| `R` | Reset camera |
| `E` | **Exposure ×1.3** (brighter) |
| `Q` | **Exposure ÷1.3** (darker) |
| `0` | Reset exposure to 1.0 |
### What to observe
- At exposure 1.0: the bright sphere is white but with detail (ACES rolloff).
- At high exposure (E×E×E): the scene brightens, the bright sphere stays white
(saturated), but the cube gains detail.
- At low exposure (Q×Q): everything darkens, the bright sphere becomes orange
(HDR values > 1.0 are compressed).
> **Note**: the tone mapper is compiled into the pipeline at build time. To
> compare ACES vs Reinhard, change `ToneMapper::Aces` → `ToneMapper::Reinhard`
> in the source.
---
## `msaa` — MSAA 4× (Anti-aliasing)
Demonstrates multisample anti-aliasing: object edges (cube, sphere) are smooth
instead of "stair-stepped". The scene contains a cube (sharp edges), a sphere
(curved silhouette), and a small cube near the camera (maximum aliasing).
```sh
cargo run -p wsg-lib --example msaa
```
### Keys
| Key | Action |
|-----|--------|
| Drag (LMB) | Orbit camera |
| Wheel | Zoom |
| `R` | Reset camera |
| `M` | Show sample count |
### To compare with/without MSAA
Remove the `.with_msaa(4)` line in the source and recompile: the scene is
identical, only the edges differ (stair-stepped vs smooth).
> **Note**: MSAA is a build-time setting (multisample texture allocation). It
> works independently of HDR: with HDR, the MSAA texture is `Rgba16Float` and
> resolves into the HDR texture before bloom/TM.
---
## `fog` — Distance Fog
Demonstrates the 3 fog modes: **linear**, **exponential**, **exponential²**.
The scene contains a row of cubes receding into the distance and scattered
spheres on a large floor plane. Fog blends objects toward a background color,
creating the illusion of an infinite world.
```sh
cargo run -p wsg-lib --example fog --features "all-prims"
```
**Keys**: `1` = linear, `2` = exp, `3` = exp², `4` = off, `R` = reset.
> Fog is applied in the main fragment shader (after lighting, before tone
> mapping). It uses the Euclidean distance from the fragment to the camera.
---
## `dof` — Depth of Field (Cinematic Bokeh)
Demonstrates depth of field blur: an object at the focus plane stays sharp
while foreground and background blur according to their distance from the
focus plane. Creates a natural attention effect (cinematic style).
The scene contains 20 cubes in a row along Z (z=3 to z=-25.5) and 5 spheres to
the sides, on a floor plane. Focus presets at 3 m / 8 m / 15 m.
```sh
cargo run -p wsg-lib --example dof --features "all-prims"
```
**Keys**: `1` = cinematic, `2` = subtle, `3` = focus 3 m, `4` = focus 15 m,
`5` = off, `R` = reset.
> DoF operates in linear HDR (after bloom, before tone mapping). Two passes:
> CoC (depth → per-pixel blur radius) then 12-tap disc blur with variable radius.
+217
View File
@@ -0,0 +1,217 @@
//! **Bloom** — demonstrates the bloom post-process with emissive materials.
//!
//! A glowing sphere (emissive intensity 2.0) produces a visible halo. The scene
//! also contains a lit ground plane and a cube for reference.
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `R` | Reset camera |
//! | `+` / `-` | Bloom threshold up/down |
//! | `[` / `]` | Bloom intensity up/down |
//! | `I` / `O` | Bloom radius up/down |
//! | `E` | Exposure up (×1.3) |
//! | `Q` | Exposure down (÷1.3) |
//! | `0` | Reset exposure |
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example bloom
//! ```
use glam::{Quat, Vec3};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::{BloomConfig, ToneMapper, Transform};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
struct BloomDemo {
camera: CameraController,
angle: f32,
/// Runtime bloom config (mirrors the App's internal state for display/adjustment).
bloom: BloomConfig,
}
impl AppHandler for BloomDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Ground plane.
app.scene
.create_mesh("ground_mesh", plane(8.0, 8.0, 1, 1), None)
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// Cube (lit, non-emissive — reference).
app.scene
.create_mesh("cube_mesh", cube(0.7), None)
.unwrap();
let mut cube_tf = Transform::identity();
cube_tf.translation = Vec3::new(1.5, 0.35, 0.0);
app.scene
.add_entity_with_transform("cube_e", "cube_mesh", cube_tf)
.unwrap();
// Glowing sphere (emissive intensity 2.0 → HDR bloom).
app.scene
.add_material_shader("glow_mat", "standard")
.unwrap();
app.scene
.set_material_emissive("glow_mat", [1.0, 0.3, 0.05, 2.0])
.unwrap();
app.scene
.create_mesh("glow_mesh", icosphere(0.35, 3), Some("glow_mat"))
.unwrap();
let mut glow_tf = Transform::identity();
glow_tf.translation = Vec3::new(0.0, 0.5, 0.0);
app.scene
.add_entity_with_transform("glow_e", "glow_mesh", glow_tf)
.unwrap();
// Second glow (blue, higher intensity for more dramatic bloom).
app.scene
.add_material_shader("blue_glow_mat", "standard")
.unwrap();
app.scene
.set_material_emissive("blue_glow_mat", [0.2, 0.5, 1.0, 3.0])
.unwrap();
app.scene
.create_mesh("blue_glow_mesh", icosphere(0.25, 3), Some("blue_glow_mat"))
.unwrap();
let mut blue_tf = Transform::identity();
blue_tf.translation = Vec3::new(-1.5, 0.4, 0.0);
app.scene
.add_entity_with_transform("blue_glow_e", "blue_glow_mesh", blue_tf)
.unwrap();
// Directional light (warm, from above-right).
let light_dir = Vec3::new(1.0, 1.5, 0.8).normalize();
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.2)
.unwrap();
app.scene.set_ambient([0.12, 0.12, 0.15]);
// Camera.
self.camera.yaw = 0.4;
self.camera.pitch = 0.3;
self.camera.distance = 5.0;
self.camera.target = Vec3::new(0.0, 0.5, 0.0);
self.camera.apply_to(app.scene.camera_mut());
// Sync bloom config from the App.
if let Some(cfg) = app.bloom_config() {
self.bloom = cfg.clone();
}
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit camera.
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);
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.4;
self.camera.pitch = 0.3;
self.camera.distance = 5.0;
}
self.camera.apply_to(app.scene.camera_mut());
// Bloom threshold (+/-).
if app.input.key_pressed(KeyCode::Equal) {
self.bloom.threshold += 0.1;
app.set_bloom_config(self.bloom.clone());
eprintln!("bloom threshold = {:.2}", self.bloom.threshold);
}
if app.input.key_pressed(KeyCode::Minus) {
self.bloom.threshold = (self.bloom.threshold - 0.1).max(0.0);
app.set_bloom_config(self.bloom.clone());
eprintln!("bloom threshold = {:.2}", self.bloom.threshold);
}
// Bloom intensity ([/]).
if app.input.key_pressed(KeyCode::BracketRight) {
self.bloom.intensity += 0.1;
app.set_bloom_config(self.bloom.clone());
eprintln!("bloom intensity = {:.2}", self.bloom.intensity);
}
if app.input.key_pressed(KeyCode::BracketLeft) {
self.bloom.intensity = (self.bloom.intensity - 0.1).max(0.0);
app.set_bloom_config(self.bloom.clone());
eprintln!("bloom intensity = {:.2}", self.bloom.intensity);
}
// Bloom radius (I/O).
if app.input.key_pressed(KeyCode::KeyI) {
self.bloom.radius += 0.5;
app.set_bloom_config(self.bloom.clone());
eprintln!("bloom radius = {:.1}", self.bloom.radius);
}
if app.input.key_pressed(KeyCode::KeyO) {
self.bloom.radius = (self.bloom.radius - 0.5).max(0.5);
app.set_bloom_config(self.bloom.clone());
eprintln!("bloom radius = {:.1}", self.bloom.radius);
}
// Exposure (E/Q/0).
if app.input.key_pressed(KeyCode::KeyE) {
app.set_exposure(app.exposure() * 1.3);
eprintln!("exposure = {:.2}", app.exposure());
}
if app.input.key_pressed(KeyCode::KeyQ) {
app.set_exposure(app.exposure() / 1.3);
eprintln!("exposure = {:.2}", app.exposure());
}
if app.input.key_pressed(KeyCode::Digit0) {
app.set_exposure(1.0);
eprintln!("exposure reset to 1.0");
}
// Slow rotation of the glow spheres.
self.angle += 0.01;
let mut tf = *app
.scene
.entity_transform("glow_e")
.expect("glow entity present");
tf.rotation = Quat::from_rotation_y(self.angle);
app.scene.set_entity_transform("glow_e", tf);
let mut tf2 = *app
.scene
.entity_transform("blue_glow_e")
.expect("blue glow entity present");
tf2.rotation = Quat::from_rotation_y(-self.angle * 0.7);
app.scene.set_entity_transform("blue_glow_e", tf2);
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new()
.title("WSG Bloom")
.size(960, 640)
.with_hdr(ToneMapper::Aces)
.with_bloom(BloomConfig::default())
.build()
.await?;
app.run(BloomDemo {
camera: CameraController::default(),
angle: 0.0,
bloom: BloomConfig::default(),
})
}
+328
View File
@@ -0,0 +1,328 @@
//! **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.
//! * **Exposure** (Étape 22, 6.1): keys `+` / `-` adjust the tone mapping exposure live
//! (×1.3 / ÷1.3 per press), `0` resets to 1.0.
//! * **Emissive** (Étape 22, 6.2): a small glowing orange sphere sits at the center
//! (emissive intensity 2.0 → HDR glow, visible even in shadow).
//!
//! 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::BloomConfig;
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::camera::CameraController;
use wsg_lib::resources::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);
// 4b. Étape 22 (6.2): emissive demo — a small glowing sphere at the center.
// The material has emissive = [1.0, 0.3, 0.05, 2.0] (orange, intensity 2.0 = HDR glow).
// IMPORTANT: set emissive BEFORE create_mesh (the mesh captures the Arc at creation).
app.scene
.add_material_texture("glow_mat", "standard", "checker_texture")
.unwrap();
app.scene
.set_material_emissive("glow_mat", [1.0, 0.3, 0.05, 2.0])
.unwrap();
app.scene
.create_mesh("glow_mesh", icosphere(0.3, 3), Some("glow_mat"))
.unwrap();
let mut glow_tf = Transform::identity();
glow_tf.translation = Vec3::new(0.0, 0.5, 0.0);
app.scene
.add_entity_with_transform("glow_e", "glow_mesh", glow_tf)
.unwrap();
// 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());
// ---- Étape 22 (6.1): exposure control ----
// `+` / `-`: multiply/divide by 1.3 (visible step). `0`: reset to 1.0.
if app.input.key_pressed(KeyCode::Equal) {
app.set_exposure(app.exposure() * 1.3);
}
if app.input.key_pressed(KeyCode::Minus) {
app.set_exposure(app.exposure() / 1.3);
}
if app.input.key_pressed(KeyCode::Digit0) {
app.set_exposure(1.0);
}
// ---- 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/cameras/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)
.with_bloom(BloomConfig::default())
.build()
.await?;
app.run(Demo {
camera: CameraController::default(),
angle: 0.0,
dbg: 0,
})
}
+175
View File
@@ -0,0 +1,175 @@
//! # Depth of Field Example (Étape 26)
//!
//! Demonstrates cinematic DoF: a row of cubes receding into the distance,
//! with the focus plane at a configurable depth. Cubes at the focus distance
//! stay sharp; those closer or farther blur proportionally.
//!
//! ## Pipeline
//! DoF operates in linear HDR space **after** bloom and **before** tone mapping:
//! 1. CoC pass: reads the depth buffer, linearizes to world distance, computes
//! per-pixel blur radius.
//! 2. Blur pass: 12-tap disc blur with variable radius (from CoC), producing
//! natural circular bokeh.
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `1` | Cinematic preset (focus=8m, strong blur) |
//! | `2` | Subtle preset (focus=8m, gentle blur) |
//! | `3` | Focus at 3m (near cubes sharp, far blurred) |
//! | `4` | Focus at 15m (far cubes sharp, near blurred) |
//! | `5` | DoF OFF |
//! | `R` | Reset camera |
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example dof --features "all-prims"
//! ```
use glam::Vec3;
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::{DoFConfig, ToneMapper, Transform};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
struct DoFDemo {
camera: CameraController,
}
impl AppHandler for DoFDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Ground plane.
app.scene
.create_mesh("ground_mesh", plane(80.0, 80.0, 1, 1), None)
.unwrap();
app.scene
.add_entity_with_transform(
"ground",
"ground_mesh",
Transform::identity(),
)
.unwrap();
// Row of cubes receding along -Z (distance ≈ 2 to 25 from camera at dist=8).
app.scene
.create_mesh("cube_mesh", cube(1.0), None)
.unwrap();
for i in 0..20 {
let z = 3.0 - i as f32 * 1.5; // from z=3 (close) to z=-25.5 (far)
let mut tf = Transform::identity();
tf.translation = Vec3::new(0.0, 0.5, z);
app.scene
.add_entity_with_transform(&format!("cube_{i}"), "cube_mesh", tf)
.unwrap();
}
// A few spheres scattered to the sides for visual interest.
app.scene
.create_mesh("sphere_mesh", icosphere(0.7, 3), None)
.unwrap();
let sphere_positions = [
Vec3::new(2.5, 0.7, -2.0),
Vec3::new(-3.0, 0.7, -6.0),
Vec3::new(3.5, 0.7, -10.0),
Vec3::new(-2.0, 0.7, -14.0),
Vec3::new(2.0, 0.7, -18.0),
];
for (i, pos) in sphere_positions.iter().enumerate() {
let mut tf = Transform::identity();
tf.translation = *pos;
app.scene
.add_entity_with_transform(&format!("sphere_{i}"), "sphere_mesh", tf)
.unwrap();
}
// Directional light.
let light_dir = Vec3::new(-0.4, -1.0, -0.3).normalize();
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.2)
.unwrap();
app.scene.set_ambient([0.08, 0.08, 0.1]);
// Camera — positioned to look down the row of cubes.
self.camera.yaw = 0.0;
self.camera.pitch = 0.1;
self.camera.distance = 8.0;
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
self.camera.apply_to(app.scene.camera_mut());
eprintln!("[DoF] Initial: Cinematic (focus=8m, aperture=0.3, max_blur=12)");
eprintln!("[DoF] Keys: 1=cinematic 2=subtle 3=focus 3m 4=focus 15m 5=off R=reset");
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit camera.
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);
// DoF presets.
if app.input.key_pressed(KeyCode::Digit1) {
app.renderer_mut()
.set_dof(Some(DoFConfig::cinematic(8.0)));
eprintln!("[DoF] → Cinematic (focus=8m, aperture=0.3, max_blur=12)");
}
if app.input.key_pressed(KeyCode::Digit2) {
app.renderer_mut()
.set_dof(Some(DoFConfig::subtle(8.0)));
eprintln!("[DoF] → Subtle (focus=8m, aperture=0.1, max_blur=8)");
}
if app.input.key_pressed(KeyCode::Digit3) {
app.renderer_mut()
.set_dof(Some(DoFConfig::new(3.0, 0.3, 12.0)));
eprintln!("[DoF] → Focus 3m (near sharp, far blurred)");
}
if app.input.key_pressed(KeyCode::Digit4) {
app.renderer_mut()
.set_dof(Some(DoFConfig::new(15.0, 0.3, 12.0)));
eprintln!("[DoF] → Focus 15m (far sharp, near blurred)");
}
if app.input.key_pressed(KeyCode::Digit5) {
app.renderer_mut().set_dof(None);
eprintln!("[DoF] → OFF");
}
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.0;
self.camera.pitch = 0.1;
self.camera.distance = 8.0;
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
}
self.camera.apply_to(app.scene.camera_mut());
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new()
.title("WSG — Depth of Field (Étape 26)")
.size(1280, 720)
.with_hdr(ToneMapper::Aces)
.with_dof(DoFConfig::cinematic(8.0))
.build()
.await?;
app.run(DoFDemo {
camera: CameraController::default(),
})
}
+163
View File
@@ -0,0 +1,163 @@
//! # Fog Example (Étape 25)
//!
//! Demonstrates distance fog: objects fade into the fog color as they recede,
//! creating the illusion of an infinite world (Skyrim/GTA pattern).
//!
//! The scene has a row of cubes receding into the distance and scattered spheres,
//! all sitting on a large ground plane. Switch fog modes with number keys to
//! compare the three attenuation curves.
//!
//! ## Pipeline
//! Fog is applied in the main pass fragment shader (after lighting, before tone
//! mapping). It uses the fragment's world-space distance to the camera and
//! blends the final color toward `fog_color`.
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `1` | Linear fog (near=5, far=30) |
//! | `2` | Exponential fog (density=0.04) |
//! | `3` | Exponential² fog (density=0.06) — best for masking |
//! | `4` | Fog OFF |
//! | `R` | Reset camera |
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example fog --features "all-prims"
//! ```
use glam::Vec3;
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::{FogConfig, ToneMapper, Transform};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
struct FogDemo {
camera: CameraController,
}
impl AppHandler for FogDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Large ground plane — will fade into fog at distance.
app.scene
.create_mesh("ground_mesh", plane(80.0, 80.0, 1, 1), None)
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// Row of cubes receding into the distance.
app.scene
.create_mesh("cube_mesh", cube(1.0), None)
.unwrap();
for i in 0..15 {
let z = -2.0 - i as f32 * 2.5;
let mut tf = Transform::identity();
tf.translation = Vec3::new(0.0, 0.5, z);
app.scene
.add_entity_with_transform(&format!("cube_{i}"), "cube_mesh", tf)
.unwrap();
}
// Scattered spheres at various distances.
app.scene
.create_mesh("sphere_mesh", icosphere(0.8, 3), None)
.unwrap();
let positions = [
Vec3::new(3.0, 0.8, -5.0),
Vec3::new(-4.0, 0.8, -10.0),
Vec3::new(5.0, 0.8, -15.0),
Vec3::new(-3.0, 0.8, -20.0),
Vec3::new(0.0, 0.8, -30.0),
];
for (i, pos) in positions.iter().enumerate() {
let mut tf = Transform::identity();
tf.translation = *pos;
app.scene
.add_entity_with_transform(&format!("sphere_{i}"), "sphere_mesh", tf)
.unwrap();
}
// Directional light.
let light_dir = Vec3::new(-0.5, -1.0, -0.3).normalize();
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.2)
.unwrap();
app.scene.set_ambient([0.08, 0.08, 0.1]);
// Camera — positioned to look down the row of cubes.
self.camera.yaw = 0.0;
self.camera.pitch = 0.15;
self.camera.distance = 8.0;
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
self.camera.apply_to(app.scene.camera_mut());
// Print initial fog status.
eprintln!("[Fog] Initial: Exponential² (density=0.06)");
eprintln!("[Fog] Keys: 1=linear 2=exp 3=exp² 4=off R=reset");
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit camera.
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);
// Fog mode switching.
if app.input.key_pressed(KeyCode::Digit1) {
app.renderer_mut()
.set_fog(Some(FogConfig::linear([0.7, 0.75, 0.85], 5.0, 30.0)));
eprintln!("[Fog] → Linear (near=5, far=30)");
}
if app.input.key_pressed(KeyCode::Digit2) {
app.renderer_mut()
.set_fog(Some(FogConfig::exponential([0.7, 0.75, 0.85], 0.04)));
eprintln!("[Fog] → Exponential (density=0.04)");
}
if app.input.key_pressed(KeyCode::Digit3) {
app.renderer_mut()
.set_fog(Some(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06)));
eprintln!("[Fog] → Exponential² (density=0.06)");
}
if app.input.key_pressed(KeyCode::Digit4) {
app.renderer_mut().set_fog(None);
eprintln!("[Fog] → OFF");
}
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.0;
self.camera.pitch = 0.15;
self.camera.distance = 8.0;
}
self.camera.apply_to(app.scene.camera_mut());
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new()
.title("WSG — Fog (3 modes)")
.size(1024, 640)
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
.with_hdr(ToneMapper::Aces)
.build()
.await?;
app.run(FogDemo {
camera: CameraController::default(),
})
}
+170
View File
@@ -0,0 +1,170 @@
//! **HDR + Tone Mapping** — demonstrates HDR rendering with exposure control.
//!
//! Shows the difference between ACES and Reinhard tone mapping curves, and how
//! exposure affects the final image. A bright emissive sphere (intensity 3.0)
//! demonstrates highlight rolloff: without HDR it would clip to white, with
//! ACES it rolls off smoothly.
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `R` | Reset camera |
//! | `E` | Exposure up (×1.3) |
//! | `Q` | Exposure down (÷1.3) |
//! | `0` | Reset exposure to 1.0 |
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example hdr
//! ```
//!
//! Note: tone mapper is selected at build time (pipeline compiled once). To compare
//! ACES vs Reinhard, run twice with different flags or modify the source.
use glam::{Quat, Vec3};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::{ToneMapper, Transform};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
struct HdrDemo {
camera: CameraController,
angle: f32,
}
impl AppHandler for HdrDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Ground.
app.scene
.create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None)
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// Lit cube (normal brightness, no emissive).
app.scene
.create_mesh("cube_mesh", cube(0.8), None)
.unwrap();
let mut cube_tf = Transform::identity();
cube_tf.translation = Vec3::new(1.5, 0.4, 0.0);
app.scene
.add_entity_with_transform("cube_e", "cube_mesh", cube_tf)
.unwrap();
// Bright sphere (emissive 3.0 — demonstrates HDR highlight rolloff).
app.scene
.add_material_shader("bright_mat", "standard")
.unwrap();
app.scene
.set_material_emissive("bright_mat", [1.0, 0.9, 0.7, 3.0])
.unwrap();
app.scene
.create_mesh("bright_mesh", icosphere(0.4, 3), Some("bright_mat"))
.unwrap();
let mut bright_tf = Transform::identity();
bright_tf.translation = Vec3::new(0.0, 0.5, 0.0);
app.scene
.add_entity_with_transform("bright_e", "bright_mesh", bright_tf)
.unwrap();
// Dim sphere (emissive 0.3 — stays dark even at high exposure).
app.scene
.add_material_shader("dim_mat", "standard")
.unwrap();
app.scene
.set_material_emissive("dim_mat", [0.2, 0.4, 1.0, 0.3])
.unwrap();
app.scene
.create_mesh("dim_mesh", icosphere(0.3, 3), Some("dim_mat"))
.unwrap();
let mut dim_tf = Transform::identity();
dim_tf.translation = Vec3::new(-1.5, 0.4, 0.0);
app.scene
.add_entity_with_transform("dim_e", "dim_mesh", dim_tf)
.unwrap();
// Strong directional light.
let light_dir = Vec3::new(0.5, 1.0, 0.5).normalize();
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 2.0)
.unwrap();
app.scene.set_ambient([0.1, 0.1, 0.12]);
// Camera.
self.camera.yaw = 0.3;
self.camera.pitch = 0.25;
self.camera.distance = 5.0;
self.camera.target = Vec3::new(0.0, 0.4, 0.0);
self.camera.apply_to(app.scene.camera_mut());
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit camera.
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);
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.3;
self.camera.pitch = 0.25;
self.camera.distance = 5.0;
}
self.camera.apply_to(app.scene.camera_mut());
// Exposure control.
if app.input.key_pressed(KeyCode::KeyE) {
app.set_exposure(app.exposure() * 1.3);
eprintln!("exposure = {:.3}", app.exposure());
}
if app.input.key_pressed(KeyCode::KeyQ) {
app.set_exposure(app.exposure() / 1.3);
eprintln!("exposure = {:.3}", app.exposure());
}
if app.input.key_pressed(KeyCode::Digit0) {
app.set_exposure(1.0);
eprintln!("exposure reset to 1.0");
}
// Rotate the bright sphere to show specular highlights.
self.angle += 0.008;
let mut tf = *app
.scene
.entity_transform("bright_e")
.expect("bright entity present");
tf.rotation = Quat::from_rotation_y(self.angle);
app.scene.set_entity_transform("bright_e", tf);
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
// ACES Filmic tone mapping — cinematic contrast with smooth highlight rolloff.
// Change to ToneMapper::Reinhard to compare (flatter, less contrast).
let app = AppBuilder::new()
.title("WSG HDR (ACES)")
.size(960, 640)
.with_hdr(ToneMapper::Aces)
.with_exposure(1.0)
.build()
.await?;
app.run(HdrDemo {
camera: CameraController::default(),
angle: 0.0,
})
}
+151
View File
@@ -0,0 +1,151 @@
//! **MSAA (Multi-Sample Anti-Aliasing)** — demonstrates 4× MSAA edge smoothing.
//!
//! Shows how MSAA eliminates the jagged "staircase" artifacts (aliasing) along
//! sharp edges. The scene contains a cube (sharp edges), a sphere (curved surface),
//! and a ground plane — all with high-contrast edges where aliasing is most visible.
//!
//! To compare with/without MSAA: remove the `.with_msaa(4)` line from the builder
//! below and rebuild. The scene and lighting are identical — only the edge
//! smoothness differs.
//!
//! ## Pipeline (MSAA + HDR)
//! ```text
//! Main pass → MSAA texture (4 samples, Rgba16Float)
//! ↓ resolve (average 4 samples → 1)
//! HDR texture (single sample)
//! ↓
//! Tone Mapping → surface
//! ```
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `R` | Reset camera |
//! | `M` | Toggle MSAA info (shows sample count) |
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example msaa
//! ```
use glam::Vec3;
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::{Transform, ToneMapper};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
struct MsaaDemo {
camera: CameraController,
show_info: bool,
}
impl AppHandler for MsaaDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Ground plane.
app.scene
.create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None)
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// Cube — sharp edges make aliasing very visible.
app.scene
.create_mesh("cube_mesh", cube(1.0), None)
.unwrap();
let mut cube_tf = Transform::identity();
cube_tf.translation = Vec3::new(1.5, 0.5, 0.0);
app.scene
.add_entity_with_transform("cube", "cube_mesh", cube_tf)
.unwrap();
// Sphere — curved surface, aliasing visible on the silhouette.
app.scene
.create_mesh("sphere_mesh", icosphere(0.6, 3), None)
.unwrap();
let mut sphere_tf = Transform::identity();
sphere_tf.translation = Vec3::new(-1.5, 0.6, 0.0);
app.scene
.add_entity_with_transform("sphere", "sphere_mesh", sphere_tf)
.unwrap();
// Small cube near the camera — very close edges, maximum aliasing.
app.scene
.create_mesh("small_cube_mesh", cube(0.3), None)
.unwrap();
let mut small_tf = Transform::identity();
small_tf.translation = Vec3::new(0.0, 0.15, 1.5);
app.scene
.add_entity_with_transform("small_cube", "small_cube_mesh", small_tf)
.unwrap();
// Directional light (strong, creates high-contrast edges).
let light_dir = Vec3::new(0.5, 1.0, 0.3).normalize();
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.5)
.unwrap();
app.scene.set_ambient([0.08, 0.08, 0.1]);
// Camera.
self.camera.yaw = 0.4;
self.camera.pitch = 0.2;
self.camera.distance = 4.0;
self.camera.target = Vec3::new(0.0, 0.4, 0.0);
self.camera.apply_to(app.scene.camera_mut());
// Print MSAA status.
let sc = app.renderer().msaa_sample_count();
eprintln!("[MSAA] sample_count = {} ({})", sc, if sc > 1 { "active" } else { "disabled" });
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit camera.
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);
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.4;
self.camera.pitch = 0.2;
self.camera.distance = 4.0;
}
self.camera.apply_to(app.scene.camera_mut());
// Toggle info display.
if app.input.key_pressed(KeyCode::KeyM) {
self.show_info = !self.show_info;
let sc = app.renderer().msaa_sample_count();
eprintln!("[MSAA] {}× {}", sc, if sc > 1 { "enabled" } else { "disabled (single sample)" });
}
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new()
.title("WSG MSAA 4×")
.size(960, 640)
.with_msaa(4) // ← Enable 4× MSAA (remove for comparison)
.with_hdr(ToneMapper::Aces) // MSAA works with or without HDR
.build()
.await?;
app.run(MsaaDemo {
camera: CameraController::default(),
show_info: false,
})
}
+124
View File
@@ -0,0 +1,124 @@
# Lights, Shadows & Emissive
Examples covering **lighting**: shadow mapping, isolated light types, and
emissive materials.
| Example | Run command | What it shows |
|---------|-------------|---------------|
| `shadow` | `cargo run -p wsg-lib --example shadow` | Shadow mapping in isolation (directional light, 4 objects on a floor) |
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Dedicated shadow test: one directional caster, cube on a ground slab, PCF-softened |
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Isolated spot light: directed beam, penumbra, attenuation |
| `emissive` | `cargo run -p wsg-lib --example emissive` | Emissive materials (increasing intensities 0 → 4.0) |
> All commands run from the repo root.
---
## `shadow` — Shadow Mapping
Four objects (cube, sphere, cone, cylinder) on a floor, lit by a directional
light that casts shadows. Shadow quality is controlled by `ShadowConfig`
(map size, anti-acne bias).
```sh
cargo run -p wsg-lib --example shadow
```
### Keys
| Key | Action |
|-----|--------|
| Drag (LMB) | Orbit camera |
| Wheel | Zoom |
| `R` | Reset camera |
| `1` | Front view |
| `2` | Side view |
| `3` | **Top view** (see shadow shapes clearly) |
| `L` | Change light direction (3 presets) |
### What to observe
- The cube rotates slowly → its shadow moves on the floor.
- The sphere has a smooth shadow/light transition (soft terminator).
- The cone produces a distinct triangular shadow.
- In top view (`3`), you see the exact shape of projected shadows.
- Shadow map size (1024 default) determines resolution: modify
`SHADOW_MAP_SIZE` at the top of the file to test 256 (pixelated) or 2048 (sharp).
---
## `shadow_test` — Dedicated Shadow Mapping Test
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 the shadow runs clearly across the ground to
the left of the cube,
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.
```sh
cargo run -p wsg-lib --example shadow_test
```
---
## `spot_test` — Isolated Spot Light
**Only** a spot light is on (the default directional light is removed via
`clear_lights()`) and the ambient is deliberately **very low**. The rotating
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).
```sh
cargo run -p wsg-lib --example spot_test
```
---
## `emissive` — Emissive Materials
Five spheres in a row with increasing emissive intensities:
| Sphere | Color | Intensity | Effect |
|--------|-------|-----------|--------|
| 1 | Gray | 0.0 | No glow (reference) |
| 2 | Orange | 0.5 | Slight glow |
| 3 | Yellow | 1.0 | Visible glow |
| 4 | Green | 2.0 | HDR glow (beyond 1.0) |
| 5 | Blue | 4.0 | Intense glow (saturation) |
With HDR, intensities > 1.0 produce a true "glow" (values exceed [0,1] in
linear space). Without HDR, they would be clamped to white.
```sh
cargo run -p wsg-lib --example emissive
```
### Keys
| Key | Action |
|-----|--------|
| Drag (LMB) | Orbit camera |
| Wheel | Zoom |
| `R` | Reset camera |
| `E` / `Q` | Exposure ×1.3 / ÷1.3 |
| `0` | Reset exposure |
| `C` | **Cycle emissive multiplier** (1× → 2× → 0.5× → …) |
### What to observe
- Sphere 1 (intensity 0) is simply lit by the directional light.
- Spheres 2-5 glow with their own light, independent of scene lighting.
- `C` doubles or halves all intensities simultaneously (to see the HDR effect).
+197
View File
@@ -0,0 +1,197 @@
//! **Emissive Materials** — demonstrates the emissive property of the standard material.
//!
//! Shows objects with varying emissive intensities. Without HDR, emissive values > 1.0
//! are clamped to white (LDR). With HDR, they produce true "glow" that can feed the
//! bloom post-process.
//!
//! The scene contains 5 spheres with increasing emissive intensity (0.0 → 4.0),
//! arranged in a row. A lit cube serves as a non-emissive reference.
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `R` | Reset camera |
//! | `E` | Exposure up (×1.3) |
//! | `Q` | Exposure down (÷1.3) |
//! | `0` | Reset exposure |
//! | `C` | Cycle emissive intensity (re-applies to all glow spheres) |
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example emissive
//! ```
//!
//! Run with `--features all-prims` if you don't have the default features.
use glam::{Quat, Vec3};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::{ToneMapper, Transform};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
/// Emissive intensities for the 5 glow spheres (left to right).
const INTENSITIES: [f32; 5] = [0.0, 0.5, 1.0, 2.0, 4.0];
/// RGB colors for the 5 glow spheres (rainbow-ish).
const COLORS: [[f32; 3]; 5] = [
[0.5, 0.5, 0.5], // gray (no glow)
[1.0, 0.3, 0.1], // orange
[1.0, 0.8, 0.0], // yellow
[0.2, 1.0, 0.4], // green
[0.3, 0.5, 1.0], // blue
];
struct EmissiveDemo {
camera: CameraController,
angle: f32,
/// Which intensity preset to apply (0-4 maps to a multiplier).
cycle_idx: usize,
}
impl AppHandler for EmissiveDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Ground.
app.scene
.create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None)
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// Reference cube (non-emissive).
app.scene
.create_mesh("cube_mesh", cube(0.6), None)
.unwrap();
let mut cube_tf = Transform::identity();
cube_tf.translation = Vec3::new(0.0, 0.3, 1.5);
app.scene
.add_entity_with_transform("cube_e", "cube_mesh", cube_tf)
.unwrap();
// 5 glow spheres in a row.
for i in 0..5 {
let mat_id = format!("glow_mat_{}", i);
let mesh_id = format!("glow_mesh_{}", i);
let entity_id = format!("glow_e_{}", i);
app.scene.add_material_shader(&mat_id, "standard").unwrap();
let c = COLORS[i];
let intensity = INTENSITIES[i];
app.scene
.set_material_emissive(&mat_id, [c[0], c[1], c[2], intensity])
.unwrap();
app.scene
.create_mesh(&mesh_id, icosphere(0.3, 3), Some(&mat_id))
.unwrap();
let x = (i as f32 - 2.0) * 0.9;
let mut tf = Transform::identity();
tf.translation = Vec3::new(x, 0.4, 0.0);
app.scene
.add_entity_with_transform(&entity_id, &mesh_id, tf)
.unwrap();
}
// Directional light.
let light_dir = Vec3::new(0.5, 1.0, 0.5).normalize();
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.0)
.unwrap();
app.scene.set_ambient([0.15, 0.15, 0.18]);
// Camera.
self.camera.yaw = 0.0;
self.camera.pitch = 0.2;
self.camera.distance = 5.5;
self.camera.target = Vec3::new(0.0, 0.3, 0.0);
self.camera.apply_to(app.scene.camera_mut());
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit camera.
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);
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.0;
self.camera.pitch = 0.2;
self.camera.distance = 5.5;
}
self.camera.apply_to(app.scene.camera_mut());
// Exposure.
if app.input.key_pressed(KeyCode::KeyE) {
app.set_exposure(app.exposure() * 1.3);
eprintln!("exposure = {:.2}", app.exposure());
}
if app.input.key_pressed(KeyCode::KeyQ) {
app.set_exposure(app.exposure() / 1.3);
eprintln!("exposure = {:.2}", app.exposure());
}
if app.input.key_pressed(KeyCode::Digit0) {
app.set_exposure(1.0);
eprintln!("exposure reset to 1.0");
}
// C: cycle emissive intensity multiplier (1x → 2x → 0.5x → back).
if app.input.key_pressed(KeyCode::KeyC) {
self.cycle_idx = (self.cycle_idx + 1) % 3;
let multiplier = match self.cycle_idx {
0 => 1.0,
1 => 2.0,
_ => 0.5,
};
for i in 0..5 {
let mat_id = format!("glow_mat_{}", i);
let c = COLORS[i];
let intensity = INTENSITIES[i] * multiplier;
if let Ok(()) = app.scene.set_material_emissive(&mat_id, [c[0], c[1], c[2], intensity]) {
eprintln!("emissive multiplier = {:.1}x", multiplier);
}
}
}
// Slow rotation.
self.angle += 0.01;
for i in 0..5 {
let entity_id = format!("glow_e_{}", i);
if let Some(base) = app.scene.entity_transform(&entity_id) {
let mut tf = *base;
tf.rotation = Quat::from_rotation_y(self.angle * (1.0 + i as f32 * 0.2));
app.scene.set_entity_transform(&entity_id, tf);
}
}
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
// HDR enabled so emissive > 1.0 produces true glow (not clamped to white).
let app = AppBuilder::new()
.title("WSG Emissive")
.size(960, 640)
.with_hdr(ToneMapper::Aces)
.build()
.await?;
app.run(EmissiveDemo {
camera: CameraController::default(),
angle: 0.0,
cycle_idx: 0,
})
}
+201
View File
@@ -0,0 +1,201 @@
//! **Shadow Mapping** — demonstrates the directional shadow map system.
//!
//! A cube and a sphere sit on a ground plane, lit by a directional light that
//! casts shadows. The shadow quality is controlled by `ShadowConfig` (map size,
//! depth/slope bias, ortho frustum radius).
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `R` | Reset camera |
//! | `1` | Front view |
//! | `2` | Side view |
//! | `3` | Top view (see shadow shape clearly) |
//! | `L` | Move light (cycles 3 directions) |
//!
//! ## Shadow Config
//! The shadow map parameters are set at build time (the shadow map texture is
//! allocated once). To test different resolutions, modify `SHADOW_MAP_SIZE` below
//! and re-run.
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example shadow
//! ```
use glam::{Quat, Vec3};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::{ShadowConfig, Transform};
use wsg_lib::mesh::{cone, cube, cylinder, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
/// Shadow map size — change to test quality (256, 512, 1024, 2048).
const SHADOW_MAP_SIZE: u32 = 1024;
/// Light directions to cycle through (normalized at runtime).
fn light_dirs() -> [Vec3; 3] {
[
Vec3::new(1.0, 1.2, 0.8).normalize(),
Vec3::new(-0.8, 1.0, 0.5).normalize(),
Vec3::new(0.3, 0.6, -1.0).normalize(),
]
}
struct ShadowDemo {
camera: CameraController,
angle: f32,
light_idx: usize,
}
impl AppHandler for ShadowDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Large ground plane (receives shadows).
app.scene
.create_mesh("ground_mesh", plane(8.0, 8.0, 1, 1), None)
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// Cube (casts + receives shadow).
app.scene
.create_mesh("cube_mesh", cube(0.8), None)
.unwrap();
let mut cube_tf = Transform::identity();
cube_tf.translation = Vec3::new(0.8, 0.4, 0.0);
app.scene
.add_entity_with_transform("cube_e", "cube_mesh", cube_tf)
.unwrap();
// Sphere (smooth shadow terminator).
app.scene
.create_mesh("sphere_mesh", icosphere(0.45, 3), None)
.unwrap();
let mut sphere_tf = Transform::identity();
sphere_tf.translation = Vec3::new(-0.8, 0.45, 0.3);
app.scene
.add_entity_with_transform("sphere_e", "sphere_mesh", sphere_tf)
.unwrap();
// Cone (distinctive shadow shape).
app.scene
.create_mesh("cone_mesh", cone(0.4, 0.8, 24), None)
.unwrap();
let mut cone_tf = Transform::identity();
cone_tf.translation = Vec3::new(0.0, 0.4, -0.9);
app.scene
.add_entity_with_transform("cone_e", "cone_mesh", cone_tf)
.unwrap();
// Cylinder.
app.scene
.create_mesh("cyl_mesh", cylinder(0.3, 0.7, 24), None)
.unwrap();
let mut cyl_tf = Transform::identity();
cyl_tf.translation = Vec3::new(-0.5, 0.35, -0.7);
app.scene
.add_entity_with_transform("cyl_e", "cyl_mesh", cyl_tf)
.unwrap();
// Directional light (shadow caster).
let dirs = light_dirs();
let light_dir = dirs[0];
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.5)
.unwrap();
// The light is at index 1 (index 0 is the default +Z light from Lights::new()).
app.scene.set_shadow_caster(Some(1));
app.scene.set_ambient([0.15, 0.15, 0.18]);
// Camera.
self.camera.yaw = 0.5;
self.camera.pitch = 0.4;
self.camera.distance = 5.0;
self.camera.target = Vec3::ZERO;
self.camera.apply_to(app.scene.camera_mut());
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit camera.
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);
// Camera presets.
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.5;
self.camera.pitch = 0.4;
self.camera.distance = 5.0;
}
if app.input.key_pressed(KeyCode::Digit1) {
self.camera.yaw = 0.0;
self.camera.pitch = 0.2;
self.camera.distance = 5.0;
}
if app.input.key_pressed(KeyCode::Digit2) {
self.camera.yaw = std::f32::consts::FRAC_PI_2;
self.camera.pitch = 0.15;
self.camera.distance = 5.0;
}
if app.input.key_pressed(KeyCode::Digit3) {
self.camera.yaw = 0.0;
self.camera.pitch = 1.4;
self.camera.distance = 6.0;
}
self.camera.apply_to(app.scene.camera_mut());
// L: cycle light direction.
if app.input.key_pressed(KeyCode::KeyL) {
let dirs = light_dirs();
self.light_idx = (self.light_idx + 1) % dirs.len();
let new_dir = dirs[self.light_idx];
eprintln!("light direction: {:?}", new_dir);
// Note: changing the light direction at runtime requires re-packing
// the lights buffer. For this demo, we just print the direction —
// the shadow frustum is computed from the light each frame.
}
// Slow rotation of the cube to show shadow movement.
self.angle += 0.005;
if let Some(base) = app.scene.entity_transform("cube_e") {
let mut tf = *base;
tf.rotation = Quat::from_rotation_y(self.angle);
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());
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
// Shadow config: 1024² map, default biases.
// Try map_size = 256 to see blocky shadows, or 2048 for sharper ones.
let app = AppBuilder::new()
.title("WSG Shadow")
.size(960, 640)
.with_shadow_config(ShadowConfig {
map_size: SHADOW_MAP_SIZE,
..Default::default()
})
.build()
.await?;
app.run(ShadowDemo {
camera: CameraController::default(),
angle: 0.0,
light_idx: 0,
})
}
+153
View File
@@ -0,0 +1,153 @@
//! 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::camera::Camera;
use wsg_lib::resources::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)
}
+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,
})
}
-101
View File
@@ -1,101 +0,0 @@
use std::sync::Arc;
use winit::event_loop::EventLoop;
use winit::window::WindowBuilder;
use wsg_lib::core::Context;
use wsg_lib::core::Frame;
use wsg_lib::core::Renderer;
use wsg_lib::pipeline::PipelineCache;
use wsg_lib::resources::Material;
use wsg_lib::resources::Mesh;
use wsg_lib::resources::Vertex;
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());
// 1. Initialisation
let context = pollster::block_on(Context::new(window.clone())).expect("Échec init GPU");
// Configuration de la surface et récupération du format
let format = context
.configure(&context.adapter, 800, 600)
.expect("Échec configuration");
// 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();
let renderer = Renderer::new(&context, format);
// 3. Material : On utilise renderer.device() et renderer.format()
let material = Material::new(renderer.format(), "basic", &mut cache);
// 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));
// 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,
..
} => {
if let Some(frame) = Frame::try_new(&context.surface) {
// 1. Rendu (plus d'arguments device/queue inutiles)
renderer.render(frame.view(), &mesh, &material);
// 2. Présentation
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();
}
+118
View File
@@ -0,0 +1,118 @@
# Meshes, Materials & Import
Examples covering **geometry and materials**: the minimal workflow, the 3D MVP,
PBR shading, file import, and the low-level (non-`App`) workflow.
| Example | Run command | What it shows |
|---------|-------------|---------------|
| `simple` | `cargo run -p wsg-lib --example simple` | The minimal declarative workflow: a flat two-tone quad, **unlit**, rendered automatically |
| `cube` | `cargo run -p wsg-lib --example cube` | The 3D MVP: a textured (checkerboard) cube, lit (directional + point + spot), spinning |
| `pbr` | `cargo run -p wsg-lib --example pbr` | PBR metallic/roughness + normal mapping |
| `import` | `cargo run -p wsg-lib --example import --features import-obj` | OBJ file import (non-graphical, prints stats to stdout) |
| `manual` | `cargo run -p wsg-lib --example manual` | The **advanced** workflow: `Context`/`Renderer`/`PipelineCache` driven by hand, without the `App` facade |
> All commands run from the repo root. The `import` example additionally
> requires the `import-obj` Cargo feature.
---
## `simple` — Minimal Declarative Workflow
The "15 lines, no wgpu" model: `AppBuilder` creates the window + GPU, and
`App::run` drives the update → render → present loop. The scene renders
**automatically** — the default `AppHandler::render` calls
`app.render_scene(frame.view())`.
The mesh is a flat two-tone quad declared from a `Geometry` (per-vertex
positions + colors) and drawn with the `standard` shader in **unlit** mode
(`renderer.set_unlit(true)`): flat 2D is a special case of 3D, one pipeline for all.
```sh
cargo run -p wsg-lib --example simple
```
No keys — static render.
---
## `cube` — The 3D MVP
A lit unit cube that rotates, **textured** with a procedural 8×8 checkerboard
via the diffuse path (bind group `@group(2)`). Follows the declarative workflow
(like `simple`): `AppBuilder` + automatic scene, **no wgpu import**. The texture
is generated procedurally (RGBA bytes → `Texture::from_rgba8`) to stay
self-contained; the default camera at (0, 0, 3) frames the cube, and
`update()` rotates the entity via `set_entity_transform` each frame.
```sh
cargo run -p wsg-lib --example cube
```
No keys — the cube spins on its own.
---
## `pbr` — PBR Metallic/Roughness + Normal Mapping
Demonstrates the Cook-Torrance PBR workflow: GGX distribution + Smith geometry +
Schlick Fresnel + hemispheric IBL + normal mapping.
```sh
cargo run -p wsg-lib --example pbr
```
| Key | Action |
|-----|--------|
| Drag (LMB) | Orbit camera |
| Wheel | Zoom |
| `R` | Reset camera |
Scene: 6 PBR materials (mirror metal, smooth plastic, rusty metal, ceramic,
bump map, matte floor). The bump-map cube shows procedural sin-wave surface
detail.
---
## `import` — OBJ File Import
**Non-graphical** example: parses a `.obj` file and prints statistics
(vertex count, normals, UVs, indices, bounding box) to stdout.
```sh
# With a file:
cargo run -p wsg-lib --example import --features import-obj -- /path/to/model.obj
# Without argument (demo triangle):
cargo run -p wsg-lib --example import --features import-obj
```
No keys — runs and exits.
---
## `manual` — Low-level Workflow (no `App` facade)
Demonstrates the API **without** the `App` facade: direct use of `Context`,
`Renderer`, `PipelineCache`, `Mesh`, `Material`. Renders a colored quad (unlit).
Useful for understanding what the `App` facade encapsulates:
- `Context` (*Manager* layer): GPU lifecycle — `Instance`/`Surface`/`Adapter`/
`Device`/`Queue`, `configure()` for the swapchain, per-frame surface texture.
- `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`). The two-layer architecture is detailed in
[`docs/tech/ARCHI_APP.md`](../../../docs/tech/ARCHI_APP.md) and
[`FRAME_LOOP.md`](../../../docs/tech/FRAME_LOOP.md).
```sh
cargo run -p wsg-lib --example manual
```
No keys — static render (unlit quad, 4 colors).
> **Tip**: start with the declarative workflow. The manual workflow doesn't
> render more pixels — it gives more control over command encoding.
+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 })
}
+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!();
}
+147
View File
@@ -0,0 +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::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, ShadowConfig};
use wsg_lib::pipeline::PipelineCache;
use wsg_lib::resources::{Geometry, Material, Mesh};
use wsg_lib::utils;
/// 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>,
}
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);
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());
// 1. Initialisation
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
// Surface configuration and format retrieval
let format = context
.configure(&context.adapter, 800, 600)
.expect("configuration failed");
// 2. Renderer initialization (it retrieves everything it needs)
let device = Arc::new(context.device.clone());
let mut cache = PipelineCache::new(device, context.queue.clone(), 1);
cache
.register_shader("standard", utils::STANDARD_SHADER_PATH)
.unwrap();
// 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, None, None, None, None);
renderer.set_unlit(true);
// 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. Render (no more useless device/queue arguments)
renderer.render(frame.view(), mesh, material);
// 2. Present
renderer.present(frame);
}
}
}
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();
}
+194
View File
@@ -0,0 +1,194 @@
//! # Exemple PBR — Metallic/Roughness + Normal Mapping (Étape 27)
//!
//! Démonstration du workflow PBR Cook-Torrance (GGX + Smith + Schlick) avec IBL hémisphérique.
//!
//! ## Scène
//! - Sol : plan 20×20, PBR matte (metallic=0, roughness=0.8)
//! - Cube métal : metallic=1.0, roughness=0.1 → reflet spéculaire net (miroir)
//! - Cube plastique : metallic=0.0, roughness=0.4 → spéculaire large et doux
//! - Cube rouillé : metallic=0.8, roughness=0.7 → métal rugueux
//! - Sphere céramique : metallic=0.3, roughness=0.3
//! - Cube normal map : bump procédural (sin wave)
//!
//! ## Contrôles
//! | Touche | Action |
//! |--------|--------|
//! | Drag (LMB) | Orbite caméra |
//! | Molette | Zoom |
//! | `R` | Reset caméra |
//!
//! ## Lancement
//! ```bash
//! cargo run -p wsg-lib --example pbr
//! ```
use glam::Vec3;
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::{ToneMapper, Transform};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::resources::Texture;
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
struct PbrDemo {
camera: CameraController,
}
impl Default for PbrDemo {
fn default() -> Self {
Self {
camera: CameraController::default(),
}
}
}
impl AppHandler for PbrDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Normal map procédurale 256×256 : bump sin(x)*sin(y).
let bump_map = make_bump_normal_map(&app.context().device, &app.context().queue);
app.scene.add_texture("bump_nm", bump_map).unwrap();
// Matériaux PBR.
app.scene.add_material_pbr("floor", "standard", 0.0, 0.8).unwrap();
app.scene.add_material_pbr("metal", "standard", 1.0, 0.1).unwrap();
app.scene.add_material_pbr("plastic", "standard", 0.0, 0.4).unwrap();
app.scene.add_material_pbr("rust", "standard", 0.8, 0.7).unwrap();
app.scene.add_material_pbr("ceramic", "standard", 0.3, 0.3).unwrap();
app.scene
.add_material_pbr_textured("bump", "standard", 0.0, 0.5, None, Some("bump_nm"))
.unwrap();
// Sol (plan 20×20).
app.scene
.create_mesh("floor_mesh", plane(1.0, 1.0, 1, 1), Some("floor"))
.unwrap();
{
let mut tf = Transform::identity();
tf.translation = Vec3::new(0.0, 0.0, 0.0);
tf.scale = Vec3::new(20.0, 1.0, 20.0);
app.scene.add_entity_with_transform("floor", "floor_mesh", tf).unwrap();
}
// Cubes.
app.scene.create_mesh("cube_mesh", cube(1.0), None).unwrap();
let cubes: [(&str, &str, Vec3); 4] = [
("c_metal", "metal", Vec3::new(-3.0, 0.5, 0.0)),
("c_plastic", "plastic", Vec3::new(-1.0, 0.5, 0.0)),
("c_rust", "rust", Vec3::new(1.0, 0.5, 0.0)),
("c_bump", "bump", Vec3::new(3.0, 0.5, 0.0)),
];
for (id, mat, pos) in &cubes {
app.scene
.create_mesh(&format!("{id}_mesh"), cube(1.0), Some(mat))
.unwrap();
let mut tf = Transform::identity();
tf.translation = *pos;
app.scene
.add_entity_with_transform(id, &format!("{id}_mesh"), tf)
.unwrap();
}
// Sphere céramique.
app.scene
.create_mesh("sphere_mesh", icosphere(0.5, 4), Some("ceramic"))
.unwrap();
{
let mut tf = Transform::identity();
tf.translation = Vec3::new(0.0, 0.5, -3.0);
app.scene
.add_entity_with_transform("s_ceramic", "sphere_mesh", tf)
.unwrap();
}
// Lumières.
app.scene
.add_directional_light(Vec3::new(-1.0, 2.0, 1.0).normalize(), [1.0, 0.95, 0.9], 2.0)
.unwrap();
app.scene
.add_point_light(Vec3::new(0.0, 3.0, 2.0), [0.3, 0.5, 1.0], 8.0, 5.0)
.unwrap();
// Ambiance (IBL hémisphérique).
app.scene.set_ambient([0.3, 0.35, 0.4]);
// Caméra.
self.camera.yaw = 0.0;
self.camera.pitch = 0.3;
self.camera.distance = 8.0;
self.camera.target = Vec3::new(0.0, 0.5, 0.0);
self.camera.apply_to(app.scene.camera_mut());
eprintln!("[PBR] Scene: 6 PBR materials (metal/plastic/rust/ceramic/bump/floor)");
eprintln!("[PBR] Drag=orbit, Wheel=zoom, R=reset");
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbite caméra.
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);
self.camera.apply_to(app.scene.camera_mut());
// R = reset.
if app.input.key_pressed(KeyCode::KeyR) {
self.camera = CameraController::default();
self.camera.target = Vec3::new(0.0, 0.5, 0.0);
self.camera.apply_to(app.scene.camera_mut());
}
}
}
/// Génère une normal map procédurale 256×256 : pattern sin(x*freq)*sin(y*freq) → bump.
/// Chaque pixel : normale perturbée encodée en RGB (nx*0.5+0.5, ny*0.5+0.5, nz*0.5+0.5) * 255.
fn make_bump_normal_map(device: &wgpu::Device, queue: &wgpu::Queue) -> Texture {
let size = 256u32;
let freq = 8.0;
let mut pixels: Vec<u8> = vec![0u8; (size * size * 4) as usize];
for y in 0..size {
for x in 0..size {
let u = x as f32 / size as f32;
let v = y as f32 / size as f32;
let h = (u * freq * std::f32::consts::PI).sin()
* (v * freq * std::f32::consts::PI).sin();
let eps = 1.0 / size as f32;
let hx = ((u + eps) * freq * std::f32::consts::PI).sin()
* (v * freq * std::f32::consts::PI).sin();
let hy = (u * freq * std::f32::consts::PI).sin()
* ((v + eps) * freq * std::f32::consts::PI).sin();
let dhdx = (hx - h) / eps;
let dhdy = (hy - h) / eps;
let n = Vec3::new(-dhdx, -dhdy, 1.0).normalize();
let idx = ((y * size + x) * 4) as usize;
pixels[idx] = ((n.x * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
pixels[idx + 1] = ((n.y * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
pixels[idx + 2] = ((n.z * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
pixels[idx + 3] = 255;
}
}
Texture::from_rgba8(device, queue, size, size, &pixels, "bump_normal_map")
.expect("bump normal map creation failed")
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new()
.title("WSG — PBR Metallic/Roughness")
.size(1280, 720)
.with_hdr(ToneMapper::Aces)
.build()
.await?;
app.run(PbrDemo::default())
}
+53
View File
@@ -0,0 +1,53 @@
//! 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;
struct MonQuad;
impl AppHandler for MonQuad {
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]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Simple").build().await?;
app.run(MonQuad)
}
-19
View File
@@ -1,19 +0,0 @@
//! 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.
use wsg_lib::app::AppBuilder;
use wsg_lib::utils::WsgError;
use wsg_lib::{App, AppHandler};
struct MonQuad;
impl AppHandler for MonQuad {
fn render(&mut self, _app: &mut App) {}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Simple").build().await?;
app.run(MonQuad)
}
+7 -5
View File
@@ -2,14 +2,16 @@
## Overview ## 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 | | Module | Responsibility |
|--------|---------------| |--------|---------------|
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration | | **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), Material (appearance descriptor) | | **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 | | **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 | | **utils** | Configuration constants and WsgError type |
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation | | **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 | | **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: 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. - **Manual**: Manipulate Context, Renderer, and PipelineCache directly for fine-grained control.
## Dependency Flow ## Dependency Flow
+483 -75
View File
@@ -8,82 +8,225 @@
//! ## Interaction with Other Modules //! ## Interaction with Other Modules
//! - **core::context**: Consumes GPU hardware lifecycle via Context; acquires frames for rendering. //! - **core::context**: Consumes GPU hardware lifecycle via Context; acquires frames for rendering.
//! - **core::renderer**: Delegates draw call execution to Renderer per frame. //! - **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. //! - **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. //! - **utils::conf**: Provides default window title, dimensions, and embedded WGSL source.
//! - **handler**: Defines the AppHandler trait that users implement for custom logic. //! - **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::AppHandler;
use crate::core::{Context, Renderer}; use crate::core::{BloomConfig, Context, MsaaConfig, Renderer, ShadowConfig, ToneMapper};
use crate::pipeline::PipelineCache; use crate::input::InputState;
use crate::scene::Scene; use crate::scene::Scene;
use crate::utils::WsgError; use crate::utils::WsgError;
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH}; use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
use std::sync::Arc; use std::sync::Arc;
use winit::event_loop::EventLoop; use winit::application::ApplicationHandler;
use winit::window::Window; 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. /// 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. /// 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. /// 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 { pub struct App {
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle. /// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
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.
pub scene: Scene, 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>,
/// Bloom post-process (Étape 23). `None` = no bloom (default, zero overhead).
/// Only active when HDR is also enabled.
pub(crate) bloom_config: Option<BloomConfig>,
/// Exposure multiplier (Étape 22, 6.1). Applied in the tone mapping pass before the curve.
/// Default 1.0. Adjustable at runtime via `set_exposure` or keyboard (+/-).
pub exposure: f32,
/// MSAA configuration (Étape 24). `None` = no MSAA (default, zero overhead);
/// `Some(config)` activates multi-sample anti-aliasing.
msaa: Option<MsaaConfig>,
/// Fog configuration (Étape 25). `None` = no fog (default, zero overhead).
fog: Option<super::core::FogConfig>,
/// DoF configuration (Étape 26). `None` = no DoF (default, zero overhead). Requires HDR.
dof: Option<super::core::DoFConfig>,
/// 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. /// The OS-level window backing this application. Shared via Arc for multi-owner access.
pub window: Arc<Window>, window: Option<Arc<Window>>,
} }
impl App { 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. /// Runs the application's main loop: processes events, updates logic per frame, renders, and presents.
/// Inputs: handler — user-provided AppHandler implementation containing game logic. /// Inputs: handler — user-provided AppHandler implementation containing game logic.
/// Returns Ok(()) on success or Err(WsgError::WindowSystem) if the event loop exits abnormally. /// 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. /// 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 → /// Internal steps: 1) take EventLoop from Option → 2) build an `AppRunner` around the handler →
/// 3a) on AboutToWait: call handler.update() + request_redraw → /// 3) on resumed: create window/context/renderer/cache and call handler.setup() →
/// 3b) on RedrawRequested: acquire frame → call handler.render() → present frame → /// 4) on about_to_wait: call handler.update() + request_redraw →
/// 3c) on CloseRequested: exit event loop. /// 5) on RedrawRequested: acquire frame → call handler.render() → present frame →
pub fn run<H: AppHandler + 'static>(mut self, mut handler: H) -> Result<(), WsgError> { /// 6) on CloseRequested: exit the event loop.
// On extrait l'event_loop de manière sûre grâce au Option pub fn run<H: AppHandler + 'static>(mut self, handler: H) -> Result<(), WsgError> {
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // Erreur si déjà pris // 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,
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
msaa: self.msaa.clone(),
fog: self.fog.clone(),
dof: self.dof.clone(),
handler,
app: None,
};
event_loop event_loop
.run(move |event, elwt| { .run_app(&mut runner)
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();
}
_ => {}
}
})
.map_err(|_| WsgError::WindowSystem) .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, self.exposure);
}
/// Sets the exposure multiplier (Étape 22, 6.1). Clamped to [0.01, 10.0].
/// Takes effect on the next frame's tone mapping pass.
pub fn set_exposure(&mut self, value: f32) {
self.exposure = value.clamp(0.01, 10.0);
}
/// Returns the current exposure multiplier.
pub fn exposure(&self) -> f32 {
self.exposure
}
/// Returns `true` if bloom is active (Étape 23). Requires HDR to be enabled.
pub fn bloom_enabled(&self) -> bool {
self.bloom_config.is_some() && self.hdr.is_some()
}
/// Returns the current bloom configuration (Étape 23). `None` if bloom is not enabled.
pub fn bloom_config(&self) -> Option<&BloomConfig> {
self.bloom_config.as_ref()
}
/// Updates the bloom configuration at runtime (Étape 23).
/// Takes effect on the next frame (uniforms are re-written each frame).
/// No-op if bloom is not enabled.
pub fn set_bloom_config(&mut self, config: BloomConfig) {
if self.bloom_config.is_some() {
self.bloom_config = Some(config.clone());
if let Some(renderer) = &mut self.renderer {
renderer.set_bloom_config(&config);
}
}
}
/// 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());
let sc = self.renderer_mut().msaa_sample_count();
self.scene
.init_gpu(device, self.context().queue.clone(), new_format, sc);
}
Ok(())
}
} }
/// Builder for constructing a configured `App` instance with custom title and dimensions. /// Builder for constructing a configured `App` instance with custom title and dimensions.
@@ -95,6 +238,24 @@ pub struct AppBuilder {
width: u32, width: u32,
/// Window height in pixels. /// Window height in pixels.
height: u32, 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>,
/// Bloom post-process (Étape 23). `None` = no bloom (default); `Some(c)` activates
/// the 4-pass bloom when HDR is also enabled.
bloom_config: Option<BloomConfig>,
/// Initial exposure multiplier (Étape 22, 6.1). Default 1.0.
exposure: f32,
/// MSAA configuration (Étape 24). `None` = no MSAA (default).
msaa: Option<MsaaConfig>,
/// Fog configuration (Étape 25). `None` = no fog (default).
fog: Option<super::core::FogConfig>,
/// DoF configuration (Étape 26). `None` = no DoF (default). Requires HDR.
dof: Option<super::core::DoFConfig>,
} }
impl AppBuilder { impl AppBuilder {
@@ -105,6 +266,14 @@ impl AppBuilder {
title: APP_DEFAULT_TITLE.to_string(), title: APP_DEFAULT_TITLE.to_string(),
width: APP_DEFAULT_WIDTH, width: APP_DEFAULT_WIDTH,
height: APP_DEFAULT_HEIGHT, height: APP_DEFAULT_HEIGHT,
culling: false,
shadow_config: ShadowConfig::default(),
hdr: None,
bloom_config: None,
exposure: 1.0,
msaa: None,
fog: None,
dof: None,
} }
} }
/// Sets the window title to display in the OS taskbar/window decorations. /// Sets the window title to display in the OS taskbar/window decorations.
@@ -120,34 +289,273 @@ impl AppBuilder {
self.height = height; self.height = height;
self self
} }
/// Builds the configured `App` instance by creating all required components in order: /// Enables GPU frustum culling (Step 15, D8). When true, entities whose bounding sphere is
/// EventLoop → Window → Context → Renderer → PipelineCache → Scene. /// fully outside the camera frustum are skipped (their indirect draw args are zeroed on the
/// Returns Ok(App) on success or Err(WsgError) if any component fails during creation. /// GPU). Defaults to **off** (non-regression): the culling compute pass still runs but marks
/// Called after setting desired properties via the builder pattern; triggers async GPU initialization. /// 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
}
/// Enables the bloom post-process (Étape 23). Bright areas (above `config.threshold` in
/// linear HDR units) are blurred and added back to the image, creating a glow effect.
/// **Requires HDR** (`with_hdr`): without it, the bloom is silently ignored with a warning.
pub fn with_bloom(mut self, config: BloomConfig) -> Self {
if self.hdr.is_none() {
eprintln!("[wsg] Warning: with_bloom() requires with_hdr() — bloom ignored.");
}
self.bloom_config = Some(config);
self
}
/// Sets the initial exposure multiplier (Étape 22, 6.1). Default 1.0.
pub fn with_exposure(mut self, exposure: f32) -> Self {
self.exposure = exposure;
self
}
/// Enables MSAA (Multi-Sample Anti-Aliasing) with the given sample count (Étape 24).
/// The count must be 2, 4, or 8 (validated at build time; invalid values fall back to no MSAA
/// with a warning). When disabled (not set), the renderer uses single-sample (zero overhead).
/// Works independently of HDR: with HDR, the MSAA texture is `Rgba16Float` and resolves
/// into the HDR texture before bloom/TM; without HDR, it resolves directly to the swapchain.
pub fn with_msaa(mut self, sample_count: u32) -> Self {
if let Some(reason) = MsaaConfig::validate(sample_count) {
eprintln!("[wsg] Warning: with_msaa({}) — {} — MSAA disabled.", sample_count, reason);
} else {
self.msaa = Some(MsaaConfig { sample_count });
}
self
}
/// Enables distance fog (Étape 25). Fades objects into `config.color` based on their
/// distance from the camera. Use `FogConfig::exponential2(color, density)` to mask
/// the edge of the rendered world. Zero cost when not called.
pub fn with_fog(mut self, config: super::core::FogConfig) -> Self {
self.fog = Some(config);
self
}
/// Enables Depth of Field (Étape 26). Blurs pixels based on their distance from the
/// focus plane, creating a cinematic bokeh effect. **Requires HDR** (`with_hdr`):
/// without it, the DoF is silently ignored with a warning. Zero cost when not called.
pub fn with_dof(mut self, config: super::core::DoFConfig) -> Self {
if self.hdr.is_none() {
eprintln!("[wsg] Warning: with_dof() requires with_hdr() — DoF ignored.");
}
self.dof = Some(config);
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> { pub async fn build(self) -> Result<App, WsgError> {
let event_loop = EventLoop::new().unwrap(); let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
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();
Ok(App { Ok(App {
context, scene: Scene::new(),
renderer, input: InputState::new(),
cache, title: self.title,
scene, width: self.width,
height: self.height,
culling: self.culling,
shadow_config: self.shadow_config,
hdr: self.hdr,
bloom_config: self.bloom_config,
exposure: self.exposure,
msaa: self.msaa,
fog: self.fog,
dof: self.dof,
event_loop: Some(event_loop), 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>,
/// Bloom config (Étape 23); passed to `Renderer::new` in `resumed`. Only active with HDR.
bloom_config: Option<BloomConfig>,
/// Initial exposure (Étape 22, 6.1); stored in the App for per-frame use.
exposure: f32,
/// MSAA config (Étape 24); passed to `Renderer::new` in `resumed`.
msaa: Option<MsaaConfig>,
/// Fog config (Étape 25); passed to `Renderer::new` in `resumed`.
fog: Option<super::core::FogConfig>,
/// DoF config (Étape 26); passed to `Renderer::new` in `resumed`. Only active with HDR.
dof: Option<super::core::DoFConfig>,
/// 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, self.bloom_config.clone(), self.msaa.clone(), self.fog.clone(), self.dof.clone());
// 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();
let msaa_sc = self.msaa.as_ref().map(|c| c.sample_count).unwrap_or(1);
scene.init_gpu(device, context.queue.clone(), main_format, msaa_sc);
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,
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
msaa: self.msaa.clone(),
fog: self.fog.clone(),
dof: self.dof.clone(),
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();
}
_ => {}
}
}
}
+310
View File
@@ -0,0 +1,310 @@
//! # Camera Module
//!
//! Defines the `Camera` struct and related functionality for 3D viewing.
//! Supports different camera types and projection configurations.
//!
//! ## Usage
//! - Used by `Renderer` to compute view and projection matrices
//! - Configurable for perspective and orthographic projections
//! - Supports FPS-style and orbital movement patterns
//!
//! ## Related Types
//! - `Camera`: Main struct for camera configuration
//! - `view_matrix()`: Computes the view matrix
//! - `projection_matrix()`: Computes the projection matrix
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 (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
pub position: Vec3,
/// Target point the camera is looking at
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 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,
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 (world → view space)
pub fn view_matrix(&self) -> Mat4 {
glam::camera::rh::view::look_at_mat4(self.position, self.target, self.up)
}
/// Computes the perspective projection matrix for this camera using its stored fov/near/far.
///
/// # Parameters
/// - `aspect`: Aspect ratio of the viewport (width / height)
///
/// # Returns
/// 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::camera::{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);
}
}
+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. | | **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. | | **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. | | **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 ## Interaction with Other Modules
+793
View File
@@ -0,0 +1,793 @@
//! # Bloom Post-Process (Étape 23)
//!
//! Defines `BloomConfig` (public user-facing configuration) and the internal `BloomPipeline`
//! (GPU resources: half-res textures, blur/composite pipelines, bind groups). The bloom effect
//! is a 4-pass post-process that operates on the HDR texture before tone mapping:
//!
//! 1. **Threshold** (full → half res): extract pixels above a luminance threshold (soft-knee).
//! 2. **Blur H** (half res): horizontal separable Gaussian (9 taps).
//! 3. **Blur V** (half res): vertical separable Gaussian (9 taps).
//! 4. **Composite** (full res): `HDR += bloom × intensity`.
//!
//! The bloom is **opt-in** (`AppBuilder::with_bloom`) and only active when HDR is also enabled.
//! Without HDR, the values are already clamped to [0,1] and there is nothing "bright" to bloom.
use wgpu::{
BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture,
TextureUsages, TextureView,
};
/// User-facing bloom configuration (Étape 23).
///
/// Passed to `AppBuilder::with_bloom(config)` to enable the bloom post-process.
/// Can be updated at runtime via `App::set_bloom_config`.
#[derive(Debug, Clone)]
pub struct BloomConfig {
/// Luminance threshold (in linear HDR units). Pixels above this contribute to bloom.
/// Default: 1.0 (only overbright areas — emissives > 1.0, specular highlights).
pub threshold: f32,
/// Soft-knee width for the threshold ramp. Larger = smoother transition.
/// Default: 0.5.
pub knee: f32,
/// Bloom intensity (multiplier on the blurred result before adding to HDR).
/// Default: 0.8.
pub intensity: f32,
/// Blur radius in pixels (at half resolution). Larger = wider glow.
/// Default: 4.0.
pub radius: f32,
}
impl Default for BloomConfig {
fn default() -> Self {
Self {
threshold: 1.0,
knee: 0.5,
intensity: 0.8,
radius: 4.0,
}
}
}
/// Internal bloom pipeline state. Allocated when bloom + HDR are both active.
/// Recreated on resize.
pub(crate) struct BloomPipeline {
bright_texture: Texture,
bright_view: TextureView,
blur_texture: Texture,
blur_view: TextureView,
composite_texture: Texture,
composite_view: TextureView,
sampler: Sampler,
threshold_pipeline: RenderPipeline,
blur_pipeline: RenderPipeline,
composite_pipeline: RenderPipeline,
threshold_bg: BindGroup,
blur_bg_h: BindGroup,
blur_bg_v: BindGroup,
composite_bg: BindGroup,
threshold_uniform: Buffer,
blur_uniform_h: Buffer,
blur_uniform_v: Buffer,
composite_uniform: Buffer,
threshold_layout: BindGroupLayout,
blur_layout: BindGroupLayout,
composite_layout: BindGroupLayout,
half_w: u32,
half_h: u32,
width: u32,
height: u32,
}
impl BloomPipeline {
pub fn new(device: &wgpu::Device, width: u32, height: u32, hdr_view: &TextureView) -> Self {
let half_w = (width / 2).max(1);
let half_h = (height / 2).max(1);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("bloom sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let (bright_texture, bright_view) =
create_bloom_texture(device, half_w, half_h, "bloom bright");
let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur");
let (composite_texture, composite_view) =
create_bloom_texture(device, width, height, "bloom composite");
// Bind group layouts.
let threshold_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("bloom threshold bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
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,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("bloom blur bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
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,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let composite_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("bloom composite bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
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,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
// Pipeline layouts.
let threshold_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("bloom threshold pl"),
bind_group_layouts: &[Some(&threshold_layout)],
..Default::default()
});
let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("bloom blur pl"),
bind_group_layouts: &[Some(&blur_layout)],
..Default::default()
});
let composite_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("bloom composite pl"),
bind_group_layouts: &[Some(&composite_layout)],
..Default::default()
});
// Shader modules.
let threshold_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("bloom threshold"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::BLOOM_THRESHOLD_SHADER.into(),
),
});
let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("bloom blur"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::BLOOM_BLUR_SHADER.into(),
),
});
let composite_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("bloom composite"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::BLOOM_COMPOSITE_SHADER.into(),
),
});
// Shared fragment target state (all 3 passes output to Rgba16Float).
let fragment_targets = &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba16Float,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})];
// Threshold pipeline.
let threshold_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("bloom threshold pipeline"),
layout: Some(&threshold_pl),
vertex: wgpu::VertexState {
module: &threshold_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &threshold_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: fragment_targets,
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Blur pipeline.
let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("bloom blur pipeline"),
layout: Some(&blur_pl),
vertex: wgpu::VertexState {
module: &blur_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &blur_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: fragment_targets,
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Composite pipeline.
let composite_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("bloom composite pipeline"),
layout: Some(&composite_pl),
vertex: wgpu::VertexState {
module: &composite_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &composite_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: fragment_targets,
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Uniform buffers (32 bytes each — WGSL uniform alignment requires padding;
// vec2 has align 8, vec3 has align 16, so structs are larger than their field sum).
let threshold_uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("bloom threshold uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let blur_uniform_h = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("bloom blur H uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let blur_uniform_v = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("bloom blur V uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let composite_uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("bloom composite uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Bind groups.
let threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom threshold bg"),
layout: &threshold_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: threshold_uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(hdr_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom blur bg H"),
layout: &blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: blur_uniform_h.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&bright_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom blur bg V"),
layout: &blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: blur_uniform_v.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&blur_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom composite bg"),
layout: &composite_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: composite_uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(hdr_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&bright_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
Self {
bright_texture,
bright_view,
blur_texture,
blur_view,
composite_texture,
composite_view,
sampler,
threshold_pipeline,
blur_pipeline,
composite_pipeline,
threshold_bg,
blur_bg_h,
blur_bg_v,
composite_bg,
threshold_uniform,
blur_uniform_h,
blur_uniform_v,
composite_uniform,
threshold_layout,
blur_layout,
composite_layout,
half_w,
half_h,
width,
height,
}
}
pub fn resize(
&mut self,
device: &wgpu::Device,
width: u32,
height: u32,
hdr_view: &TextureView,
) {
let half_w = (width / 2).max(1);
let half_h = (height / 2).max(1);
let (bright_texture, bright_view) =
create_bloom_texture(device, half_w, half_h, "bloom bright");
let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur");
let (composite_texture, composite_view) =
create_bloom_texture(device, width, height, "bloom composite");
self.threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom threshold bg"),
layout: &self.threshold_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.threshold_uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(hdr_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom blur bg H"),
layout: &self.blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.blur_uniform_h.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&bright_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom blur bg V"),
layout: &self.blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.blur_uniform_v.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&blur_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom composite bg"),
layout: &self.composite_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.composite_uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(hdr_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&bright_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.bright_texture = bright_texture;
self.bright_view = bright_view;
self.blur_texture = blur_texture;
self.blur_view = blur_view;
self.composite_texture = composite_texture;
self.composite_view = composite_view;
self.half_w = half_w;
self.half_h = half_h;
self.width = width;
self.height = height;
}
#[allow(dead_code)]
pub fn composite_view(&self) -> &TextureView {
&self.composite_view
}
pub fn composite_texture(&self) -> &Texture {
&self.composite_texture
}
pub fn record_passes(
&self,
encoder: &mut wgpu::CommandEncoder,
queue: &wgpu::Queue,
config: &BloomConfig,
) {
let threshold_data = [config.threshold, config.knee, 0.0, 0.0];
queue.write_buffer(
&self.threshold_uniform,
0,
bytemuck::cast_slice(&threshold_data),
);
let blur_h_data = [1.0 / self.half_w as f32, 0.0, config.radius, 0.0];
queue.write_buffer(&self.blur_uniform_h, 0, bytemuck::cast_slice(&blur_h_data));
let blur_v_data = [0.0, 1.0 / self.half_h as f32, config.radius, 0.0];
queue.write_buffer(&self.blur_uniform_v, 0, bytemuck::cast_slice(&blur_v_data));
let composite_data = [config.intensity, 0.0, 0.0, 0.0];
queue.write_buffer(
&self.composite_uniform,
0,
bytemuck::cast_slice(&composite_data),
);
// Pass 1: Threshold (HDR full → bright half)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("bloom threshold"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.bright_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_viewport(
0.0,
0.0,
self.half_w as f32,
self.half_h as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.threshold_pipeline);
pass.set_bind_group(0, &self.threshold_bg, &[]);
pass.draw(0..3, 0..1);
}
// Pass 2: Blur H (bright half → blur half)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("bloom blur H"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.blur_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_viewport(
0.0,
0.0,
self.half_w as f32,
self.half_h as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.blur_pipeline);
pass.set_bind_group(0, &self.blur_bg_h, &[]);
pass.draw(0..3, 0..1);
}
// Pass 3: Blur V (blur half → bright half)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("bloom blur V"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.bright_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_viewport(
0.0,
0.0,
self.half_w as f32,
self.half_h as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.blur_pipeline);
pass.set_bind_group(0, &self.blur_bg_v, &[]);
pass.draw(0..3, 0..1);
}
// Pass 4: Composite (HDR full + bright half → composite full)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("bloom composite"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.composite_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_viewport(
0.0,
0.0,
self.width as f32,
self.height as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.composite_pipeline);
pass.set_bind_group(0, &self.composite_bg, &[]);
pass.draw(0..3, 0..1);
}
}
}
fn create_bloom_texture(
device: &wgpu::Device,
width: u32,
height: u32,
label: &str,
) -> (Texture, TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bloom_config_default() {
let cfg = BloomConfig::default();
assert_eq!(cfg.threshold, 1.0);
assert_eq!(cfg.knee, 0.5);
assert_eq!(cfg.intensity, 0.8);
assert_eq!(cfg.radius, 4.0);
}
#[test]
fn bloom_config_clone() {
let cfg = BloomConfig {
threshold: 2.0,
knee: 1.0,
intensity: 1.5,
radius: 6.0,
};
let cloned = cfg.clone();
assert_eq!(cloned.threshold, 2.0);
assert_eq!(cloned.intensity, 1.5);
}
}
+2 -2
View File
@@ -10,10 +10,10 @@
//! - **error**: returns WsgError variants from all fallible methods. //! - **error**: returns WsgError variants from all fallible methods.
//! //!
//! ## Architecture Notes (per ARCHI_APP.md) //! ## 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. //! 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. //! - **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. //! through App.renderer(), App.context(), etc., for fine-grained control over wgpu handles.
use std::sync::Arc; use std::sync::Arc;
+570
View File
@@ -0,0 +1,570 @@
//! Depth of Field (DoF) configuration (Étape 26).
//!
//! DoF simulates camera lens behavior: objects at the focus distance are sharp,
//! everything else is progressively blurred. This is a post-process effect that
//! operates on the HDR texture + depth buffer before tone mapping.
//!
//! **Opt-in**: when no `DoFConfig` is set, no DoF textures are allocated and the
//! pipeline cost is zero.
/// Depth of Field configuration.
#[derive(Clone, Copy, Debug)]
pub struct DoFConfig {
/// Focus distance in world units. The image is perfectly sharp at this distance.
pub focus_distance: f32,
/// Blur intensity: 0.0 = no blur, 1.0 = maximum. Scales the CoC calculation.
pub aperture: f32,
/// Maximum blur radius in pixels. Clamps the CoC to prevent excessive blur.
pub max_blur: f32,
}
impl DoFConfig {
/// Creates a custom DoF configuration.
///
/// - `focus_distance`: world distance where the image is sharp
/// - `aperture`: blur intensity (0.0–1.0)
/// - `max_blur`: maximum blur radius in pixels
pub fn new(focus_distance: f32, aperture: f32, max_blur: f32) -> Self {
Self {
focus_distance,
aperture: aperture.clamp(0.0, 1.0),
max_blur: max_blur.max(0.0),
}
}
/// Cinematic preset: gradual blur building up to 12px at the extremes.
/// Good for cutscenes and character close-ups.
pub fn cinematic(focus_distance: f32) -> Self {
Self::new(focus_distance, 0.3, 12.0)
}
/// Subtle preset: very gentle blur, 8px max radius.
/// Good for gameplay with a hint of depth separation.
pub fn subtle(focus_distance: f32) -> Self {
Self::new(focus_distance, 0.1, 8.0)
}
/// Packs the config into the (fog-style) two vec4 uniform layout.
/// Returns `(dof_a, dof_b)` where:
/// - `dof_a = (focus_distance, aperture, max_blur, near)`
/// - `dof_b = (far, inv_width, inv_height, 0.0)`
///
/// `near` and `far` come from the camera projection. `inv_width`/`inv_height`
/// are the reciprocal texture dimensions.
pub fn pack(
&self,
near: f32,
far: f32,
inv_width: f32,
inv_height: f32,
) -> (glam::Vec4, glam::Vec4) {
(
glam::Vec4::new(self.focus_distance, self.aperture, self.max_blur, near),
glam::Vec4::new(far, inv_width, inv_height, 0.0),
)
}
}
use wgpu::{
BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture,
TextureView,
};
/// Internal DoF pipeline state. Allocated when DoF + HDR are both active.
/// Recreated on resize.
pub(crate) struct DoFPipeline {
// Textures
coc_texture: Texture,
coc_view: TextureView,
output_texture: Texture,
output_view: TextureView,
// Samplers: non-filtering for CoC (depth), filtering for blur (color + CoC).
coc_sampler: Sampler,
blur_sampler: Sampler,
// Pipelines
coc_pipeline: RenderPipeline,
blur_pipeline: RenderPipeline,
// Uniform buffer (shared: same values for both passes, 32 bytes)
uniform_buffer: Buffer,
// Bind groups
coc_bind_group: BindGroup,
blur_bind_group: BindGroup,
// Layouts (kept for resize)
coc_layout: BindGroupLayout,
blur_layout: BindGroupLayout,
// Dimensions
width: u32,
height: u32,
}
impl DoFPipeline {
pub fn new(
device: &wgpu::Device,
width: u32,
height: u32,
depth_view: &TextureView,
color_view: &TextureView,
) -> Self {
// Non-filtering sampler for the CoC pass (depth textures require non-filtering).
let coc_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("dof coc sampler (non-filtering)"),
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
// Filtering sampler for the blur pass (color + CoC textures).
let blur_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("dof blur sampler (filtering)"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
// CoC texture: R16Float, full-res.
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof coc"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let coc_view = coc_texture.create_view(&Default::default());
// Output texture: Rgba16Float, full-res.
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof output"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let output_view = output_texture.create_view(&Default::default());
// --- CoC bind group layout (3 bindings) ---
let coc_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("dof coc bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
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,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
count: None,
},
],
});
// --- Blur bind group layout (4 bindings) ---
let blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("dof blur bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
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,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
// Pipeline layouts.
let coc_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("dof coc pl"),
bind_group_layouts: &[Some(&coc_layout)],
..Default::default()
});
let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("dof blur pl"),
bind_group_layouts: &[Some(&blur_layout)],
..Default::default()
});
// Shader modules.
let coc_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("dof coc"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::DOF_COC_SHADER.into(),
),
});
let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("dof blur"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::DOF_BLUR_SHADER.into(),
),
});
// CoC pipeline (output: R16Float).
let coc_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("dof coc pipeline"),
layout: Some(&coc_pl),
vertex: wgpu::VertexState {
module: &coc_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &coc_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::R16Float,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Blur pipeline (output: Rgba16Float).
let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("dof blur pipeline"),
layout: Some(&blur_pl),
vertex: wgpu::VertexState {
module: &blur_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &blur_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba16Float,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Uniform buffer (32 bytes: 8 f32s).
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dof uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Bind groups.
let coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof coc bg"),
layout: &coc_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&coc_sampler),
},
],
});
let blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof blur bg"),
layout: &blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(color_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&coc_view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&blur_sampler),
},
],
});
Self {
coc_texture,
coc_view,
output_texture,
output_view,
coc_sampler,
blur_sampler,
coc_pipeline,
blur_pipeline,
uniform_buffer,
coc_bind_group,
blur_bind_group,
coc_layout,
blur_layout,
width,
height,
}
}
/// Writes the DoF uniform buffer with current config values.
pub fn update_uniform(
&self,
queue: &wgpu::Queue,
config: &DoFConfig,
near: f32,
far: f32,
) {
let (a, b) = config.pack(near, far, 1.0 / self.width as f32, 1.0 / self.height as f32);
let data: [f32; 8] = [a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w];
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&data));
}
/// Recreates textures and bind groups on resize.
pub fn resize(
&mut self,
device: &wgpu::Device,
width: u32,
height: u32,
depth_view: &TextureView,
color_view: &TextureView,
) {
self.width = width;
self.height = height;
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof coc"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let coc_view = coc_texture.create_view(&Default::default());
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof output"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let output_view = output_texture.create_view(&Default::default());
self.coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof coc bg"),
layout: &self.coc_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.coc_sampler),
},
],
});
self.blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof blur bg"),
layout: &self.blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(color_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&coc_view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.blur_sampler),
},
],
});
self.coc_texture = coc_texture;
self.coc_view = coc_view;
self.output_texture = output_texture;
self.output_view = output_view;
}
/// Returns the DoF output texture (for re-pointing the TM bind group).
pub fn output_texture(&self) -> &Texture {
&self.output_texture
}
/// Returns the DoF output view.
pub fn output_view(&self) -> &TextureView {
&self.output_view
}
pub fn coc_view(&self) -> &TextureView {
&self.coc_view
}
pub fn coc_pipeline(&self) -> &RenderPipeline {
&self.coc_pipeline
}
pub fn blur_pipeline(&self) -> &RenderPipeline {
&self.blur_pipeline
}
pub fn coc_bind_group(&self) -> &BindGroup {
&self.coc_bind_group
}
pub fn blur_bind_group(&self) -> &BindGroup {
&self.blur_bind_group
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_new_clamps_aperture() {
let c = DoFConfig::new(5.0, 2.0, 8.0);
assert_eq!(c.aperture, 1.0);
assert_eq!(c.focus_distance, 5.0);
assert_eq!(c.max_blur, 8.0);
}
#[test]
fn config_new_clamps_negative_aperture() {
let c = DoFConfig::new(5.0, -1.0, 8.0);
assert_eq!(c.aperture, 0.0);
}
#[test]
fn cinematic_preset() {
let c = DoFConfig::cinematic(5.0);
assert_eq!(c.focus_distance, 5.0);
assert!((c.aperture - 0.3).abs() < f32::EPSILON);
assert!((c.max_blur - 12.0).abs() < f32::EPSILON);
}
#[test]
fn subtle_preset() {
let c = DoFConfig::subtle(3.0);
assert_eq!(c.focus_distance, 3.0);
assert!((c.aperture - 0.1).abs() < f32::EPSILON);
assert!((c.max_blur - 8.0).abs() < f32::EPSILON);
}
#[test]
fn pack_layout() {
let c = DoFConfig::new(5.0, 0.5, 8.0);
let (a, b) = c.pack(0.1, 100.0, 1.0 / 1920.0, 1.0 / 1080.0);
assert!((a.x - 5.0).abs() < f32::EPSILON);
assert!((a.y - 0.5).abs() < f32::EPSILON);
assert!((a.z - 8.0).abs() < f32::EPSILON);
assert!((a.w - 0.1).abs() < f32::EPSILON);
assert!((b.x - 100.0).abs() < f32::EPSILON);
assert!((b.y - 1.0 / 1920.0).abs() < f32::EPSILON);
assert!((b.z - 1.0 / 1080.0).abs() < f32::EPSILON);
assert_eq!(b.w, 0.0);
}
}
+141
View File
@@ -0,0 +1,141 @@
//! # Fog Module (Étape 25)
//!
//! Distance fog: fades objects into a background color based on their distance
//! from the camera. Primary use case: masking the edge of the rendered world
//! to create the illusion of an infinite scene.
//!
//! Three modes are supported:
//! - **Linear**: hard cutoff between `near` and `far` distances
//! - **Exponential**: gradual falloff `exp(-density * d)`
//! - **Exponential²**: sharper cutoff `exp(-density² * d²)` — best for masking
//!
//! Zero cost when disabled: `fog_enabled = 0` → the shader branch is never taken.
/// Fog attenuation mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FogMode {
/// Linear fade between `near` and `far` distances.
Linear,
/// Exponential falloff: `exp(-density * distance)`.
#[default]
Exponential,
/// Exponential squared: `exp(-density² * distance²)`. Sharper cutoff.
Exponential2,
}
impl FogMode {
/// Numeric value written to the GPU uniform (0 = linear, 1 = exp, 2 = exp²).
pub fn as_f32(self) -> f32 {
match self {
FogMode::Linear => 0.0,
FogMode::Exponential => 1.0,
FogMode::Exponential2 => 2.0,
}
}
}
/// Fog configuration for the scene.
///
/// When not set (no `.with_fog()` call), the renderer writes `fog_enabled = 0`
/// and the shader skips the fog block entirely — zero GPU cost.
#[derive(Clone, Copy, Debug)]
pub struct FogConfig {
/// Attenuation mode (linear / exp / exp²).
pub mode: FogMode,
/// Fog color (RGB, linear space). Should match the sky/clear color for
/// a seamless "infinite world" illusion.
pub color: [f32; 3],
/// Near distance (Linear mode only). Fog starts at this distance.
pub near: f32,
/// Far distance (Linear mode only). Fully fogged at this distance.
pub far: f32,
/// Density (Exponential / Exponential² modes). Higher = thicker fog.
/// Typical range: 0.01 (very thin) to 0.3 (very dense).
pub density: f32,
}
impl FogConfig {
/// Linear fog: fades from `near` to `far` distance.
pub fn linear(color: [f32; 3], near: f32, far: f32) -> Self {
Self {
mode: FogMode::Linear,
color,
near,
far,
density: 0.0,
}
}
/// Exponential fog: `factor = exp(-density * distance)`.
/// Natural-looking fog (forest, lake, atmosphere).
pub fn exponential(color: [f32; 3], density: f32) -> Self {
Self {
mode: FogMode::Exponential,
color,
near: 0.0,
far: 0.0,
density,
}
}
/// Exponential² fog: `factor = exp(-density² * distance²)`.
/// Gradual start, sharp cutoff — ideal for masking world edges.
pub fn exponential2(color: [f32; 3], density: f32) -> Self {
Self {
mode: FogMode::Exponential2,
color,
near: 0.0,
far: 0.0,
density,
}
}
/// Pack into two `Vec4`s for the GPU uniform buffer.
/// - `a` = (enabled, mode, near, far)
/// - `b` = (density, color_r, color_g, color_b)
pub fn pack(&self, enabled: bool) -> (glam::Vec4, glam::Vec4) {
(
glam::Vec4::new(
if enabled { 1.0 } else { 0.0 },
self.mode.as_f32(),
self.near,
self.far,
),
glam::Vec4::new(
self.density,
self.color[0],
self.color[1],
self.color[2],
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_as_f32() {
assert_eq!(FogMode::Linear.as_f32(), 0.0);
assert_eq!(FogMode::Exponential.as_f32(), 1.0);
assert_eq!(FogMode::Exponential2.as_f32(), 2.0);
}
#[test]
fn linear_pack() {
let cfg = FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0);
let (a, b) = cfg.pack(true);
assert_eq!(a, glam::Vec4::new(1.0, 0.0, 5.0, 50.0));
assert_eq!(b, glam::Vec4::new(0.0, 0.7, 0.8, 0.9));
}
#[test]
fn exp2_pack_disabled() {
let cfg = FogConfig::exponential2([1.0, 1.0, 1.0], 0.1);
let (a, b) = cfg.pack(false);
assert_eq!(a.x, 0.0); // disabled
assert_eq!(a.y, 2.0); // exp² mode
assert_eq!(b.x, 0.1); // density
}
}
+6 -2
View File
@@ -9,12 +9,16 @@
//! - **context**: provides the Surface from which Frame acquires the current texture. //! - **context**: provides the Surface from which Frame acquires the current texture.
//! - **renderer**: passes Frame's TextureView to render() as the color attachment target. //! - **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 //! - **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) //! ## 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. //! - **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 { pub struct Frame {
/// The GPU surface texture representing the current display buffer to be presented. /// The GPU surface texture representing the current display buffer to be presented.
pub surface_texture: wgpu::SurfaceTexture, 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::camera::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::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::camera::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())
}
}
+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);
}
}
+20
View File
@@ -9,11 +9,31 @@
//! - `renderer` receives Device/Queue references from Context, uses Materials from `resources`. //! - `renderer` receives Device/Queue references from Context, uses Materials from `resources`.
//! - `frame` is consumed by both Context (begin_frame → end_frame) and Renderer (render → present). //! - `frame` is consumed by both Context (begin_frame → end_frame) and Renderer (render → present).
pub mod bloom;
pub mod context; pub mod context;
pub mod dof;
pub mod fog;
pub mod frame; pub mod frame;
pub mod frustum;
pub mod geometry;
pub mod hdr;
pub mod lod;
pub mod msaa;
pub mod renderer; pub mod renderer;
pub mod shadow;
pub mod transform;
// Re-exports // Re-exports
pub use bloom::BloomConfig;
pub use context::Context; pub use context::Context;
pub use dof::DoFConfig;
pub use fog::{FogConfig, FogMode};
pub use frame::Frame; pub use frame::Frame;
pub use frustum::Frustum;
pub use geometry::{BBox, Geometry, GeometryError};
pub use hdr::ToneMapper;
pub use lod::{lod_level, projected_radius_px};
pub use msaa::MsaaConfig;
pub use renderer::Renderer; pub use renderer::Renderer;
pub use shadow::ShadowConfig;
pub use transform::Transform;
+65
View File
@@ -0,0 +1,65 @@
//! MSAA (Multi-Sample Anti-Aliasing) configuration (Étape 24, 6.4).
//!
//! When enabled, the main scene pass renders into a multi-sampled texture
//! (N samples per pixel) and wgpu resolves it (averages) into the single-sample
//! target (HDR texture or swapchain). Post-processes (bloom, TM) operate on
//! the resolved single-sample texture — they are unaffected.
//!
//! MSAA is a rasterizer feature: **no new shader** is needed. The cost is
//! in the rasterizer/fill-rate (edges are over-sampled), typically 1.3–1.5×
//! for 4× MSAA.
/// MSAA configuration.
///
/// `sample_count` must be a power of two (2, 4, or 8) and must be supported
/// by the GPU for the target texture format. The default is 4.
///
/// When disabled (not set in the builder), the renderer uses `sample_count = 1`
/// (single sample, no MSAA) and the behavior is identical to pre-MSAA.
#[derive(Clone, Copy, Debug)]
pub struct MsaaConfig {
/// Number of samples per pixel. Must be 2, 4, or 8.
pub sample_count: u32,
}
impl Default for MsaaConfig {
fn default() -> Self {
Self { sample_count: 4 }
}
}
impl MsaaConfig {
/// Validates that `sample_count` is a supported value (2, 4, or 8).
/// Returns `None` if valid, `Some(reason)` if not.
pub fn validate(sample_count: u32) -> Option<&'static str> {
match sample_count {
2 | 4 | 8 => None,
_ => Some("sample_count must be 2, 4, or 8"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_4() {
assert_eq!(MsaaConfig::default().sample_count, 4);
}
#[test]
fn validate_accepts_powers_of_two() {
assert_eq!(MsaaConfig::validate(2), None);
assert_eq!(MsaaConfig::validate(4), None);
assert_eq!(MsaaConfig::validate(8), None);
}
#[test]
fn validate_rejects_invalid() {
assert!(MsaaConfig::validate(1).is_some());
assert!(MsaaConfig::validate(3).is_some());
assert!(MsaaConfig::validate(16).is_some());
assert!(MsaaConfig::validate(0).is_some());
}
}
+1968 -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. //! the engine "brick by brick" through direct Context/PipelineCache/Renderer manipulation if needed.
use crate::app::App; use crate::app::App;
use crate::core::Frame;
/// Trait defining user-provided game logic injected into the render loop at two callback points. /// 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 /// 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 { 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, /// 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. /// 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. /// Inputs: _app — mutable reference to the App facade providing access to all subsystems.
fn update(&mut self, _app: &mut App) {} fn update(&mut self, _app: &mut App) {}
/// Called during each RedrawRequested event after frame acquisition. Used for executing draw calls /// Called during each RedrawRequested event after frame acquisition, receiving the current frame.
/// by iterating Scene entities and calling app.renderer.render(view, mesh, material) per entity. /// Used for custom draw call execution. Default implementation renders the whole scene
/// Must be implemented — called every frame that needs rendering. /// automatically (`app.render_scene(frame.view())`), so most users don't need to override it.
/// Inputs: app — mutable reference to the App facade providing access to all subsystems. /// Advanced users override this method to control drawing manually.
fn render(&mut self, app: &mut App); /// 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());
}
} }
+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::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));
}
}
+36 -6
View File
@@ -1,9 +1,9 @@
//! # WSG Library Crate Root //! # WSG Library Crate Root
//! //!
//! The top-level entry point for the wsg-lib crate. Exposes seven public modules organized by architectural responsibility: //! 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 layers), //! **app** (App facade + AppBuilder), **handler** (AppHandler trait), **core** (Manager + Executor + geometry types),
//! **resources** (data types), **pipeline** (shader compilation cache), **scene** (resource graph and entity management), //! **mesh** (geometry sources: primitives + import), **resources** (data types), **pipeline** (shader compilation cache),
//! and **utils** (configuration and error handling). //! **scene** (resource graph and entity management), **prelude** (glob re-exports), and **utils** (configuration and error handling).
//! //!
//! ## Module Interaction Map //! ## Module Interaction Map
//! - `core` consumes resources from `resources`, pipelines from `pipeline`, and errors from `utils`. //! - `core` consumes resources from `resources`, pipelines from `pipeline`, and errors from `utils`.
@@ -22,17 +22,24 @@
//! ```ignore //! ```ignore
//! use wsg_lib::core::{Context, Renderer}; //! use wsg_lib::core::{Context, Renderer};
//! use wsg_lib::resources::{Mesh, Material, Vertex}; //! 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 app;
pub mod camera;
pub mod core; pub mod core;
pub mod handler; pub mod handler;
pub mod input;
pub mod lights;
pub mod mesh;
pub mod pipeline; pub mod pipeline;
pub mod prelude;
pub mod resources; pub mod resources;
pub mod scene; pub mod scene;
pub mod utils; pub mod utils;
pub mod math;
/// Re-export of the high-level application facade for convenient top-level access. /// 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. /// Users create App instances via `AppBuilder`, then call `.run(handler)` to start the application.
@@ -41,3 +48,26 @@ pub use crate::app::App;
/// Re-export of the user-defined game logic interface for convenient top-level access. /// 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. /// Users implement this trait to define update/render callbacks injected into the render loop.
pub use crate::handler::AppHandler; 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::BloomConfig;
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 MSAA configuration for convenient top-level access.
/// Users enable MSAA via `AppBuilder::with_msaa(4)`.
pub use crate::core::MsaaConfig;
pub use crate::core::{DoFConfig, FogConfig, FogMode};
/// 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;
+197
View File
@@ -0,0 +1,197 @@
//! # Lights — Global Light List + Light Types
//!
//! Defines the scene's global light list — directional, point and spot lights — and the
//! GPU-upload types (`Light`, `LightType`, `MAX_LIGHTS`).
//!
//! ## 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 glam::{Vec3, Vec4};
/// Re-exported from `crate::resources::uniform` (where `Pod` is derived for the uniform buffer).
pub use crate::resources::uniform::{Light, LightType, MAX_LIGHTS};
/// 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.
pub fn new() -> Self {
Self {
directional: vec![Light {
position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0),
color: Vec4::ONE,
radius: Vec4::ZERO,
dir_angle: Vec4::ZERO,
}],
point: Vec::new(),
spot: Vec::new(),
}
}
/// Total number of lights.
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**.
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.
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 {
fn default() -> Self {
Self::new()
}
}
/// Builds a 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`].
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`].
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();
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);
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));
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);
}
#[test]
fn spot_cone_axis_alignment_is_positive() {
let light_pos = Vec3::new(0.0, 0.0, 3.0);
let surface_point = Vec3::ZERO;
let cone_axis = (surface_point - light_pos).normalize();
let l = (light_pos - surface_point).normalize();
let to_point = -l;
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})"
);
assert!((l.dot(cone_axis) + 1.0).abs() < 1e-6);
}
}
-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 | | 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 ## 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::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. - **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 //! ## Interaction with Other Modules
//! - `material` calls `get_or_create()` during its own construction to obtain a shared RenderPipeline. //! - `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. //! - `vertex::Vertex` defines the CPU-side layout that `build_pipeline()` uses as the vertex buffer contract.
pub mod pipeline_cache; pub mod pipeline_cache;
// Re-exports // 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,
};
+396 -56
View File
@@ -7,7 +7,7 @@
//! //!
//! ## Interaction with Other Modules //! ## Interaction with Other Modules
//! - **Material** calls `get_or_create()` during its own construction to obtain a shared RenderPipeline. //! - **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. //! - **vertex::Vertex** defines the CPU-side layout that `build_pipeline` uses as the vertex buffer contract.
//! //!
//! ## Technical Points //! ## Technical Points
@@ -15,12 +15,196 @@
//! - wgpu 30 requires `compilation_options` in VertexState/FragmentState and `depth_slice` in color attachments. //! - 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. //! - **Batching**: Multiple Materials with the same shader_id share one pipeline, enabling material-level batching in Renderer.
use crate::resources::Vertex; use crate::resources::{Texture, Vertex};
use crate::utils::BASIC_SHADER; use crate::utils::STANDARD_SHADER;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; 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_FRAGMENT,
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,
},
// Étape 27 : normal map (binding 2) + son sampler (binding 3).
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
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. /// 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. /// Ensures each unique shader+format combination is compiled at most once; subsequent requests return cached instances.
pub struct PipelineCache { pub struct PipelineCache {
@@ -29,22 +213,96 @@ pub struct PipelineCache {
pipelines: HashMap<String, Arc<wgpu::RenderPipeline>>, pipelines: HashMap<String, Arc<wgpu::RenderPipeline>>,
/// Maps shader IDs to file paths on disk for WGSL loading in `load_shader()`. /// Maps shader IDs to file paths on disk for WGSL loading in `load_shader()`.
shader_paths: HashMap<String, String>, 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>,
/// Normal map placeholder (128,128,255) = flat normal. Bound when a material has no normal map.
/// Étape 27 : ensures group-2 is always satisfied (4 bindings).
normal_placeholder: Arc<Texture>,
/// MSAA sample count for pipeline compilation (Étape 24). Must match the render pass's
/// attachment sample count. 1 = no MSAA (default).
sample_count: u32,
} }
impl PipelineCache { impl PipelineCache {
/// Creates an empty pipeline cache with no pre-loaded shaders or pipelines. /// Creates an empty pipeline cache with no pre-loaded shaders or pipelines, plus the shared
/// Inputs: device (owned Arc reference to wgpu Device, required for creating ShaderModules and RenderPipelines). /// texture bind group layout (group 2) and the white placeholder texture (Step 10).
/// Returns a new PipelineCache ready for shader registration via register_shader(). /// 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. /// 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, sample_count: u32) -> Self {
let placeholder = Texture::white_placeholder(&device, &queue).arc();
let normal_placeholder = Texture::normal_placeholder(&device, &queue).arc();
let texture_bind_group_layout = create_texture_bind_group_layout(&device);
Self { Self {
device, device,
pipelines: HashMap::new(), pipelines: HashMap::new(),
// Maps shader IDs to file paths on disk for WGSL loading in load_shader(). // 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(), shader_paths: HashMap::new(),
texture_bind_group_layout,
placeholder,
normal_placeholder,
sample_count,
} }
} }
/// 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 {
Self::texture_bind_group_full(self, texture, None)
}
/// Builds a group-2 bind group with both diffuse and normal map textures (Étape 27).
/// `texture` = diffuse (None → white placeholder), `normal_map` = normal map (None → flat placeholder).
pub fn texture_bind_group_full(
&self,
texture: Option<Arc<Texture>>,
normal_map: Option<Arc<Texture>>,
) -> wgpu::BindGroup {
let tex = texture.unwrap_or_else(|| self.placeholder.clone());
let nmap = normal_map.unwrap_or_else(|| self.normal_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),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&nmap.view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&nmap.sampler),
},
],
})
}
/// Registers an external WGSL shader file path associated with a given ID. /// 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). /// 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. /// Returns Ok(id) on success or Err(String) if the ID is already registered. Called during scene setup to register custom shaders.
@@ -80,34 +338,53 @@ impl PipelineCache {
format: wgpu::TextureFormat, format: wgpu::TextureFormat,
shader_id: &str, shader_id: &str,
) -> Arc<wgpu::RenderPipeline> { ) -> Arc<wgpu::RenderPipeline> {
// Step 1: Return cached pipeline if it already exists for this shader_id self.get_or_create_entry(format, shader_id, "fs_main")
if let Some(pipeline) = self.pipelines.get(shader_id) { }
/// Étape 27 : creates (or retrieves) a PBR pipeline using the `fs_pbr` entry point.
/// The shader_id is the same WGSL file (standard_shader.wgsl) but with a different fragment entry.
pub fn get_or_create_pbr(
&mut self,
format: wgpu::TextureFormat,
shader_id: &str,
) -> Arc<wgpu::RenderPipeline> {
self.get_or_create_entry(format, shader_id, "fs_pbr")
}
/// Shared pipeline creation: loads the shader and builds a pipeline with the given fragment entry point.
fn get_or_create_entry(
&mut self,
format: wgpu::TextureFormat,
shader_id: &str,
entry_point: &str,
) -> Arc<wgpu::RenderPipeline> {
// Cache key includes the entry point to distinguish fs_main from fs_pbr pipelines.
let cache_key = format!("{shader_id}:{entry_point}");
if let Some(pipeline) = self.pipelines.get(&cache_key) {
return pipeline.clone(); return pipeline.clone();
} }
// Step 2: Compile a new pipeline — loads shader and builds the GPU render pipeline
let path = self let path = self
.shader_paths .shader_paths
.get(shader_id) .get(shader_id)
.map(|s| s.as_str()) .map(|s| s.as_str())
.unwrap_or(shader_id); .unwrap_or(shader_id);
let shader = self.load_shader(&self.device, path); let shader = self.load_shader(&self.device, path);
let pipeline = Self::build_pipeline(&self.device, format, &shader); let pipeline = self.build_pipeline(format, &shader, entry_point);
// Step 3: Cache the new pipeline behind Arc and return it
let pipeline_arc = Arc::new(pipeline); let pipeline_arc = Arc::new(pipeline);
self.pipelines self.pipelines
.insert(shader_id.to_string(), pipeline_arc.clone()); .insert(cache_key, pipeline_arc.clone());
pipeline_arc 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). /// 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. /// 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 { fn load_shader(&self, device: &wgpu::Device, path: &str) -> wgpu::ShaderModule {
let source = std::fs::read_to_string(path).unwrap_or_else(|_| { let source = std::fs::read_to_string(path).unwrap_or_else(|_| {
println!("Shader not found: {}, falling back to default", path); println!("Shader not found: {}, falling back to default", path);
BASIC_SHADER.to_string() STANDARD_SHADER.to_string()
}); });
device.create_shader_module(wgpu::ShaderModuleDescriptor { device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -117,56 +394,42 @@ impl PipelineCache {
} }
/// Builds a RenderPipeline from a shader module, device, and surface texture format. /// Builds a RenderPipeline from a shader module, device, and surface texture format.
/// Inputs: device (GPU command source), format (output texture format), shader (compiled WGSL module). /// Inputs: format (output texture format), shader (compiled WGSL module).
/// Uses `self.device` and `self.sample_count` (Étape 24: MSAA-aware compilation).
/// Returns a fully configured RenderPipeline ready for draw calls. Called internally by `get_or_create()`. /// Returns a fully configured RenderPipeline ready for draw calls. Called internally by `get_or_create()`.
/// Internal steps: 1) define VertexBufferLayout from Vertex struct offsets →
/// 2) create PipelineLayout with bind_group_layouts + immediate_size →
/// 3) create RenderPipeline with vertex/fragment states, primitive config, multisample state.
fn build_pipeline( fn build_pipeline(
device: &wgpu::Device, &self,
format: wgpu::TextureFormat, format: wgpu::TextureFormat,
shader: &wgpu::ShaderModule, shader: &wgpu::ShaderModule,
entry_point: &str,
) -> wgpu::RenderPipeline { ) -> wgpu::RenderPipeline {
// Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs. // Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs.
// Must match Vertex struct field offsets exactly. // Must match Vertex struct field offsets exactly.
let vertex_buffer_layout = wgpu::VertexBufferLayout { let vertex_buffer_layout = vertex_buffer_layout();
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
],
};
// Pipeline layout — defines bind group bindings (empty here; no uniform buffers used). // Pipeline layout — the two uniform bind groups (frame @0 + object @1), the texture
// wgpu 30: `immediate_size` replaces `push_constant_ranges`. // 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(&self.device);
let texture_layout = create_texture_bind_group_layout(&self.device);
let shadow_layout = create_shadow_map_bind_group_layout(&self.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 = let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("render_pipeline_layout"), label: Some("render_pipeline_layout"),
bind_group_layouts: &[], bind_group_layouts: &layout_refs,
immediate_size: 0, // no var<immediate> used immediate_size: 0, // no var<immediate> used
}); });
// Create the full RenderPipeline — vertex state + fragment state + primitive configuration. // Create the full RenderPipeline — vertex state + fragment state + primitive configuration.
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Render Pipeline"), label: Some("Render Pipeline"),
layout: Some(&render_pipeline_layout), layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState { vertex: wgpu::VertexState {
@@ -178,7 +441,7 @@ impl PipelineCache {
}, },
fragment: Some(wgpu::FragmentState { fragment: Some(wgpu::FragmentState {
module: shader, module: shader,
entry_point: Some("fs_main"), entry_point: Some(entry_point),
compilation_options: Default::default(), // required field in wgpu 30 compilation_options: Default::default(), // required field in wgpu 30
// targets is now &[Option<ColorTargetState>] — each wrapped in Some. // targets is now &[Option<ColorTargetState>] — each wrapped in Some.
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
@@ -188,8 +451,22 @@ impl PipelineCache {
})], })],
}), }),
primitive: wgpu::PrimitiveState::default(), primitive: wgpu::PrimitiveState::default(),
depth_stencil: None, // Step 9 (DRAFT 9.3): depth test enabled on EVERY pipeline. The format must match
multisample: wgpu::MultisampleState::default(), // 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(),
}),
// Étape 24: MSAA-aware — the sample count must match the render pass's attachments.
multisample: wgpu::MultisampleState {
count: self.sample_count,
..Default::default()
},
// multiview → replaced by multiview_mask (NonZeroU32) and cache fields in wgpu 30. // multiview → replaced by multiview_mask (NonZeroU32) and cache fields in wgpu 30.
multiview_mask: None, multiview_mask: None,
cache: None, cache: None,
@@ -198,8 +475,71 @@ impl PipelineCache {
/// Retrieves a cached RenderPipeline by shader_id without creating one. /// Retrieves a cached RenderPipeline by shader_id without creating one.
/// Inputs: shader_id (unique key into the cache). /// 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>> { pub fn get(&self, shader_id: &str) -> Option<&Arc<wgpu::RenderPipeline>> {
self.pipelines.get(shader_id) 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,
})
}

Some files were not shown because too many files have changed in this diff Show More