Compare commits

..

68 Commits

Author SHA1 Message Date
Jérôme Bousquié 49aa9e48fd feat(particles): Étape 28 A — ParticlePool infrastructure (no driver)
Creates the GPU resource layer for particles per ARCHI §3.2/§6, without
simulation: the pool owns all buffers/pipeline/bind group but draws nothing
(indirect args zeroed → no-op) until a driver is attached (Étape B).

New:
- resources/particle.rs: Particle (80 B, #[repr(C)], no padding — D19),
  SIZE/ZERO consts + offset/layout unit tests
- shaders/particle_billboard.wgsl: camera-facing billboard, empty vertex
  layout (quad via vertex_index), instance slot via storage binding
  compact_index (D17), uv_rect atlas support (D15/D18)
- core/particles.rs: ParticlePoolConfig, BlendingMode, ParticleDriver trait
  (D3), ParticlePool (4 buffers + pipeline + bind group), default disc
  texture (D11), unit tests

Wired:
- Scene: SceneGpu keeps queue/sample_count; particle_pools registry +
  create_particle_pool() (default disc when no texture given)
- utils::conf PARTICLE_BILLBOARD_SHADER, module re-exports, prelude
- tests/wgsl_validate.rs: particle billboard naga validation (2 entry points)

Docs: DRAFT call-site/tree synced with the final code; AGENTS.md test count
(138) + wgpu 30 API drift gotcha (contents/DeviceExt/ALPHA_BLENDING,
DepthStencilState no Default, NonZero min_binding_size, const Zeroable).

cargo test -p wsg-lib: 138 pass (121 lib + 10 wgsl + 7), 0 warnings.
2026-09-26 12:34:21 +02:00
Jérôme Bousquié 8606aba510 particules: integrate decisions D15-D19 in ARCHI, rewrite DRAFT Step A
ARCHI_PARTICULES.md:
- D15 uv_rect, D16 single fixed layout, D17 compaction+indirect draw
- D18 color_range + uv_rects + alpha_scale (spawn randomization, EmitterParams 48 B)
- D19 layout audit: 80 B without padding (storage space, vec3/vec4 align 4),
  single shared Particle struct (no ParticleAlive prefix), compact_index 1 u32
  per slot via storage binding, indirect_args 16 B
- apply to sections 2/3/4/5/6/7/8/9/10/14/15/17 + new section 18

DRAFT.md (Step A) full rewrite against final decisions:
- Particle 80 B no-padding, 4 pool buffers (data, compact_index,
  indirect_args, camera_params), 5-binding layout, empty vertex layout,
  draw_indirect, no early-out, SceneGpu gains queue+sample_count
2026-09-26 10:49:13 +02:00
Jérôme Bousquié e5f3636b42 examples: apply real texture assets to multi-mesh examples
- meshes/cube: procedural checkerboard -> uv_texture.jpg (8x8 UV grid)
- meshes/pbr: floor -> ground.jpeg, bump cube -> cave.jpg + caveNormal.jpg
  (normal map pre-encoded via sRGB OETF to cancel the GPU sRGB decode)
- lights/shadow: ground -> ground.jpeg, cube -> uv_texture.jpg
- effects/demo: ground -> ground.jpeg, cube -> uv_texture.jpg
- effects/fog: ground -> ground.jpeg (tiled 80x80), cubes -> stonewall.jpg
- effects/dof: ground -> ground.jpeg, cubes -> uv_texture.jpg
- cameras/culling: shared cube mesh -> uv_texture.jpg
- add lib/examples/assets/textures/ (19 assets, 6.5 MB)
- document assets + usage in examples READMEs, docs/user/examples.md,
  docs/user/meshes/materials.md (CARGO_MANIFEST_DIR pattern, sRGB caveat)
2026-09-26 10:49:13 +02:00
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
147 changed files with 20469 additions and 1470 deletions
View File
View File
+15 -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,11 @@ 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"]`. 138 tests exist (`cargo test --workspace`, incl. particle layout + billboard WGSL validation).
- 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.
- **wgpu 30 API drift** (verified this session): `BufferInitDescriptor` has a `contents: &[u8]` field (not `data`) and `create_buffer_init` comes from the `wgpu::util::DeviceExt` trait (import it, as in `mesh.rs`). `BlendState::ALPHA_BLENDING` is the alpha-blend constant (there is no `ALPHA`); `DepthStencilState` has **no** `Default` impl — write `stencil`/`bias` fields explicitly. `min_binding_size` is `Option<NonZero<u64>>`. bytemuck 1.25: `Zeroable::zeroed()` is not `const` (const traits unstable) — use a const literal for `ZERO`-style constants. For layout-offset tests prefer `std::mem::offset_of!` (stable 1.77, no unsafe).
- **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
+133
View File
@@ -18,6 +18,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -181,6 +187,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.0" version = "1.12.0"
@@ -307,6 +319,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -387,12 +408,32 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -565,6 +606,21 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -753,6 +809,36 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "naga" name = "naga"
version = "30.0.0" version = "30.0.0"
@@ -1237,6 +1323,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -1316,6 +1415,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.39.4"
@@ -1520,6 +1625,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]] [[package]]
name = "simd_cesu8" name = "simd_cesu8"
version = "1.2.0" version = "1.2.0"
@@ -2432,6 +2543,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"glam", "glam",
"image",
"pollster", "pollster",
"thiserror 2.0.18", "thiserror 2.0.18",
"wgpu", "wgpu",
@@ -2520,3 +2632,24 @@ dependencies = [
"quote", "quote",
"syn", "syn",
] ]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+150 -138
View File
@@ -1,165 +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, and the high-level "declarative" workflow (automatic `App` scene rendering) works for flat/NDC drawing. The GPU-driven two-pass pipeline described in the architecture docs is **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 | ✅ Working — window, events, frame presentation, and **automatic scene rendering** (the per-frame view is exposed via `Frame::view()`) |
| `Scene` resource/entity registry | ✅ Working — the engine renders every registered entity automatically in one batched render pass (`App::render_scene`) |
| GPU-driven two-pass pipeline (Compute → indirect draw) | 📋 Roadmap — spec in [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) |
| 3D infrastructure (uniform bind groups, MVP + camera in the pipeline) | ✅ Working at the engine level — the `Renderer` uploads per-frame camera matrices (active `Camera`) and per-entity world matrices to shared uniform buffers every frame; the bundled `basic` shader still ignores them, so visible 3D awaits wiring `standard_shader.wgsl` to an example |
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. The Phong-lit `standard_shader.wgsl` exists and validates, and the uniform plumbing (bind groups + per-frame camera + per-entity world matrices) is in place, but it is not yet bound to a visible example. | 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) {
// render(app, frame) has a default that draws the whole scene automatically via app.scene
// app.render_scene(frame.view()). You don't need to implement it for the common case. .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.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
// Register your scene once (string IDs), then App renders it automatically each frame: .build()?;
// app.cache.register_shader("basic", wsg_lib::utils::BASIC_SHADER_PATH)?; app.run(MyScene);
// app.scene.add_mesh("quad", Arc::new(mesh))?; Ok(())
// app.scene.add_material("mat", Arc::new(Material::new(app.renderer.format(), "basic", &mut app.cache)))?;
// app.scene.add_entity("my_quad", "quad", "mat")?;
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. Rendering a whole `Scene` (`render_scene`) batches all entities into **one encoder + one submit per frame**; the low-level `render` still allocates one per object.
- **Supporting pieces** — `PipelineCache` (shader → compiled RenderPipeline, `Arc`-shared), `Material`, `Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform` (active camera wired to the frame uniforms, Étape 4.3).
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 | ✅ (auto scene rendering via `App::render_scene`) |
| AppHandler | Trait | User-defined `update()` / `render()` callbacks | ✅ (`Frame::view()` exposed; default `render` draws the scene) |
| Scene | Struct | String-ID registry: meshes, materials, entities | ✅ (registry auto-rendered by the facade) |
| Context | Struct | GPU hardware lifecycle (Instance, Surface, Adapter, Device, Queue) | ✅ |
| Renderer | Struct | Binds Material + Mesh into a RenderPass, submits | ✅ (`render_scene` batches one pass/frame) |
| PipelineCache | Struct | Shader → compiled RenderPipeline cache | ✅ |
| Material | Struct | Shader ID → RenderPipeline | ✅ |
| Mesh / Vertex | Struct | GPU geometry container / CPU-side vertex tuple | ✅ |
| Frame | Struct | Per-frame RAII wrapper (surface texture + view) | ✅ |
| Camera / Transform | Struct | Camera & transform math | ✅ Active camera + transform wired to per-frame uniforms (Étape 4.3) |
## 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 low-level workflow. The `simple` example (App facade) registers a colored quad and renders it automatically through the declarative path — it draws a scene without importing wgpu. ## 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::render_scene` iterates registered entities and draws them in one encoder/submit per frame; the frame view is exposed to `AppHandler::render` for custom draws. (Done 2026-09-16.) | 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. *(Engine-side plumbing done 2026-09-16: uniform bind groups, per-frame active camera matrices, per-entity world matrices; visible 3D awaits wiring `standard_shader.wgsl` to an example — Étape 5.)* | | `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) |
+453 -144
View File
@@ -1,159 +1,468 @@
# DRAFT — Plan d'implémentation : « 3D + éclairage Phong » # Étape 28 — Système de Particules : Étape A (Pool)
> **Usage.** Ce fichier (dans `docs/`) sert de brouillon pour le plan détaillé de l'étape en cours. > **Objectif** : Créer l'infrastructure GPU du pool de particules (buffers + pipeline render +
> **Son contenu est effacé au début de chaque nouvelle étape.** La source de vérité de l'état est > bind group), **sans driver**. C'est la brique de base sur laquelle les drivers
> le code + README.md ; les autres docs `docs/*` restent stables. > (GPU/CPU/Manual) seront construits.
> > **Référence** : `docs/tech/ARCHI_PARTICULES.md` (§2, §3.2, §6, §7.2, §8.2, §12, §13, §18)
> **Étape.** 3D + éclairage Phong (ROADMAP 1.3 + 1.5). Objectif MVP : **un mesh 3D éclairé à l'écran**,
> rendu automatiquement par la boucle `App` (Scene auto-render déjà en place).
>
> **État de départ vérifié.**
> - Rendu automatique fonctionnel mais **plat** : `basic_shader.wgsl` pose les positions telles quelles
> (`vec4(position, 1.0)`), aucune matrice, aucune uniform, aucun éclairage.
> - `Renderer::render_scene` parcourt `iter_entities()` en une passe (`&self`, `&Scene`), sans transform.
> - `Scene::entities` : `HashMap<String, (mesh_id, material_id)>` — pas de `Transform` par entité.
> - `Camera` (`resources/camera.rs`) : **fichier orphelin, non exporté** (absent de `resources/mod.rs`) ;
> `Transform`/`Geometry` exportés via `math`.
> - `PipelineCache::build_pipeline` : `bind_group_layouts: &[]`, `immediate_size: 0` — aucun binding.
> - Défaut latente : `basic_shader.wgsl` déclare `@location(1) uv`, `(2) color` alors que le
> `VertexBufferLayout` réel expose `(1) normal`, `(2) uv`, `(3) color`.
--- ---
## Point d'étape — 2026-09-16 (fin de session, reprise sur autre machine) ## Contexte
**État : infra posée, MVP 3D pas encore atteint.** Dernier commit : `f10e249` La phase 6 "Post-MVP" a couvert les effets post-process (bloom, DoF, fog, MSAA) et le PBR.
(`feat(renderer): active camera wired to frame uniforms (Étape 4.3)`), dépôt propre. 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`.
**Fait et committé (bases pour la reprise)** Les effets restants de la phase 6 (6.6 CSM, 6.7 SSAO, 6.14-6.16 Area lights / Volumetric)
- Étapes **1, 2.1, 2.2, 3, 4** du DRAFT → ✅ (détails cochés ci-dessous). seront repris **après** le système de particules (phase 7).
- `cargo check --workspace --examples` 0 warning, tests Pod + wgsl OK, doc 0 warning, fmt propre.
- `standard_shader.wgsl` validé par naga (test permanent) mais **pas encore branché** sur un pipeline d'exemple.
- `basic` ignore encore les uniforms → les exemples `simple`/`manual` tournent mais le rendu reste plat.
**Prochaine session — reprendre à (dans l'ordre)**
1. **Étape 5** : créer `lib/examples/cube.rs` (cube unitaire + `standard` éclairé + rotation, via `AppBuilder` sans wgpu), puis migrer `simple`/`manual` (2.3 + 5.2 : `basic` → variante unlit de `standard`).
2. **Validation réelle** Étape 4/5 : exécuter les exemples, confirmer rotation/éclairage sur GPU/fenêtre.
3. **Étape 6** : cas limites + update `README.md`/`PLAN.md`/`ROADMAP.md` (cases 1.3/1.5/2.3) + commits.
> Les cases 2.3, Étape 4-validation, Étape 5 et Étape 6 restent **non cochées** ci-dessous = état exact.
--- ---
## Étape 1 — Fondations data : Transform + Camera exposées ## Scope de cette étape (A)
**But** : donner à chaque entité un `Transform` et rendre `Camera` utilisable via l'API publique, **sans** | Fait | Non fait (étapes suivantes) |
toucher au rendu (pure façade de données, validable par compilation). |------|---------------------------|
| Struct `Particle` (**80 B, sans padding** [D15/D19]) | Driver GPU (compute + spawn + compaction) — Étape B |
| `ParticlePoolConfig` + `BlendingMode` | Driver CPU (simulation Rust) — Étape C |
| `ParticlePool` (4 buffers + pipeline + bind group) | Driver Manual + handle — Étape D |
| Pipeline render (billboard instancé, layout vertex vide) | Intégration Renderer (frame loop, `draw_indirect`) — Étape E |
| Vertex shader (quad via vertex_index + `compact_index[ii]` [D17/D19]) | Presets + Example — Étape F |
| Fragment shader (texture × color, UV via `uv_rect` [D15]) | Tests WGSL compute — Étape B |
| Texture par défaut (disque 16×16) | |
| Méthode `Scene::create_particle_pool` (+ champs `SceneGpu`) | |
| Pool inactif par défaut (args indirect = 0 → no-op) | |
- [X] 1.1 **Exporter `Camera`** : dans `lib/src/resources/mod.rs`, ajouter > **Cette étape produit un pool qui EXISTE mais ne draw rien** (pas de driver =
`pub mod camera;` et `pub use camera::Camera;` (aujourd'hui fichier orphelin non compilé). *(fait — 2026-09-16)* > `indirect_args` à zéro = `drawIndirect` no-op, §12 ARCHI). Le draw sera activé à l'étape E
- [X] 1.2 **Type `Entity` + transform** : nouvelle struct > (intégration Renderer). On peut néanmoins tester le pipeline en forçant des args
`Entity { mesh_id: String, material_id: String, transform: Transform }` (module `scene` ou `resources`). > artificiels dans un test.
Remplacer `Scene::entities: HashMap<String, (String, String)>` par
`HashMap<String, Entity>`. Sérialiser `iter_entities()` pour rendre le `&Transform`.
*(fait — `lib/src/scene/entity.rs`)*
- [X] 1.3 **Compat API** : garder `add_entity(label, mesh_id, material_id)` (transform identité par défaut)
+ ajouter `add_entity_with_transform(label, mesh_id, material_id, transform)`.
Ajouter `entity_transform(label) -> Option<&Transform>` et `set_entity_transform(label, transform)`.
*(fait — 2026-09-16)*
- [X] **Validation** : `cargo check --workspace` 0 warning ; `cargo doc --no-deps` 0 warning ; les exemples
`simple`/`manual` compilent inchangés (défaut : identité ⇒ même rendu). *(fait — 0 warning. Au passage,
`camera.rs` étant désormais compilée, les fonctions glam dépréciées `look_at_rh`/`perspective_rh_gl` ont été
migrées vers `glam::camera::rh::view::look_at_mat4` / `glam::camera::rh::proj::opengl::perspective`.)*
## Étape 2 — Shader Phong `standard_shader.wgsl`
**But** : produire un rendu 3D éclairé via un nouveau shader, sans encore le brancher.
- [X] 2.1 **Créer `lib/src/shaders/standard_shader.wgsl`** avec le **contrat vertex correct** :
`@location(0) position : vec3`, `(1) normal : vec3`, `(2) uv : vec2`, `(3) color : vec4`.
- `@group(0) @binding(0)` : `FrameUniforms { view: mat4, proj: mat4, cam_pos: vec4, light_dir: vec4, light_color: vec4, options: vec4<u32> }` (options.x = unlit flag)
- `@group(1) @binding(0)` : `ObjectUniform { model: mat4 }`
- `vs_main` : `clip_position = proj * view * model * vec4(position,1)` ; passe `normal`/`color` en espace monde.
- `fs_main` : éclairage hémisphérique (ambient) + diffuse directionnel (max(dot(N,L),0)), sortie `vec4(color*light, 1)`.
- **Mode unlit** : `options.x != 0` neutralise la directionnelle → couleur plate. Ainsi « 2D » = `standard`
non-éclairé, **cas particulier de la 3D** (décision actée). *(fait — 2026-09-16)*
- [X] 2.2 **Constantes** : ajouter `STANDARD_SHADER_PATH = "assets/shaders/standard_shader.wgsl"` et
`STANDARD_SHADER: &str = include_str!("../shaders/standard_shader.wgsl")` dans `lib/src/utils/conf.rs`.
*(fait — 2026-09-16)*
- [ ] 2.3 **Migrer `basic` vers le mode unlit de `standard`** (défaut latente réglée) : plus de pipeline au
**layout vide séparé**. Le rendu plat = `standard` non-éclairé (identité/ortho + ambiance) sous le **même
layout uniformisé**. Le fallback embarqué (`BASIC_SHADER`) devient la variante unlit de `standard`.
*(bloqué : dépend des bind groups du `PipelineCache`, infra de l'Étape 3)*
- [X] **Validation** : shader validé hors-ligne via un **nouveau test permanent** `lib/tests/wgsl_validate.rs`
(naga via `wgpu::naga`, aucune nouvelle dépendance) ; corrigé au passage le cast `mat4x4 -> mat3x3` non
supporté (construction de la sous-matrice 3×3 explicite). `cargo test` + `cargo check --workspace --examples`
0 warning ; `cargo doc --no-deps` OK ; `cargo fmt` propre. Le shader n'est pas encore compilé par un pipeline
(Étape 3). *(fait — 2026-09-16)*
## Étape 3 — Infrastructure uniforms dans le `PipelineCache`
**But** : permettre aux pipelines de recevoir des uniforms (bind groups) au lieu de `bind_group_layouts: &[]`.
- [X] 3.1 **Types bytemuck `Pod`** (nouveau `lib/src/resources/uniform.rs`) : `FrameUniforms` (192 B) et
`ObjectUniform` (64 B), `#[repr(C)]`, 16-byte alignés, sans padding — offset vérifiés par un test
unitaire contre la table du shader. Exports via `resources/mod.rs`. *(fait — 2026-09-16. Au passage,
`glam` feature `bytemuck` activé pour que `Mat4`/`Vec4` implémentent `Pod`/`Zeroable`.)*
- [X] 3.2 **Bind group layouts** : nouveau `create_uniform_bind_group_layouts(device)` (dans
`pipeline_cache.rs`, exporté) → frame @0 (`Uniform`, `Vertex|Fragment`) + object @1 (`Uniform`, `Vertex`).
`build_pipeline` les passe dans le `PipelineLayoutDescriptor`. `immediate_size` reste 0.
*(fait — 2026-09-16)*
- [X] 3.3 **Acté : un seul layout pour tous** (option A). `build_pipeline` attache **toujours** les 2 bind
groups (frame @0 + object @1), même si le shader ne les lit pas (validation wgpu : layout╱bind group).
*(fait — 2026-09-16)*
- [X] **Validation** : `cargo check --workspace --examples` 0 warning ; `cargo doc --no-deps` 0 warning ;
`cargo test` (types Pod + wgsl naga) OK ; `cargo fmt` propre. *(fait — 2026-09-16)*
## Étape 4 — Rendu 3D dans le `Renderer`
**But** : `render_scene` applique matrices + éclairage par entité.
- [X] 4.1 **Buffers frame partagés** : le `Renderer::new` crée le `wgpu::Buffer` `FrameUniforms` + `BindGroup(0)`
(défaut : caméra identité + lumière blanche + mode lit). *(fait — 2026-09-16)*
- [X] 4.2 **Buffers object par entité** : le `Renderer` maintient un cache
`RefCell<HashMap<String,(wgpu::Buffer, wgpu::BindGroup)>>` clefé par label d'entité ; chaque frame il
écrit `ObjectUniform.world = entity.transform.to_matrix()` (via `object_bind_group_for`). *(fait — 2026-09-16)*
- [X] 4.3 **Caméra active** : `Scene` porte une caméra active (`Camera::default()` : position (0,0,3),
fov 45°, near 0.1, far 100) via `set_camera()` / `camera()` ; `Camera` enrichie (fov/near/far +
`with_perspective` / `projection_matrix(aspect)`). Chaque frame, `Renderer::render_scene` écrit
view/proj/cam_pos réels dans le buffer frame via `write_frame_uniforms` ; l'aspect est calculé par
`App::render_scene` depuis `window.inner_size()` (le Renderer reste indépendant de la fenêtre).
*(fait — 2026-09-16)*
- [X] 4.4 **`draw_entity` étendu** : pose `set_bind_group(0, frame_bg)` + `set_bind_group(1, object_bg)` avant le
draw (groupes requis par le layout unique) ; le chemin bas-niveau `Renderer::render` pose aussi les 2 bind
groups (frame partagé + object identité partagé). *(fait — 2026-09-16)*
- [ ] **Validation** : `cargo check` 0 warning ; exécution `simple` (sans panique, boucle active). *(une partie :
`simple` reste exécutable car `basic` ignore les uniforms ; le rendu 3D réel attend l'Étape 5 où `standard` est
branché sur un exemple)*
## Étape 5 — Exemple 3D (cube éclairé)
**But** : démontrer l'objectif MVP à l'écran et **migrer** les exemples sur le pipeline unifié.
- [ ] 5.1 **Nouvel exemple `lib/examples/cube.rs`** : cube unitaire (positions + normales), matériau
`standard` éclairé, `Transform` non-identique, camera + lumière directionnelle, rotation dans `AppHandler::update`.
Toujours via `AppBuilder`/scène automatique, **sans importer wgpu** (comme `simple`).
- [ ] 5.2 **Migrer `simple.rs`** (quad plat → `standard` **unlit**, transform identité) et **`manual.rs`** (bas niveau
→ bind groups frame+object posés, unlit). `basic` disparaît comme famille séparée.
- [ ] **Validation** : compile + tourne sans panique ; rotation/éclairage visibles (à confirmer sur GPU/fenêtre).
## Étape 6 — Validation globale & docs
- [ ] 6.1 `cargo check --workspace` 0 warning ; `cargo doc --no-deps` 0 warning ; `cargo fmt --all`.
- [ ] 6.2 Cas limites (comme à l'étape précédente) : scène vide, mesh non indexé, mesh 0-vertex.
- [ ] 6.3 Mettre à jour `README.md` (statut 3D) + `docs/PLAN.md`/`docs/ROADMAP.md` (cases 1.3/1.5 actées).
- [ ] 6.4 Commits conventionnels (`feat:`, `docs:`), diffs ciblés.
--- ---
## Décisions actées (verrouillées avant l'implémentation) ## Décisions appliquées (rappel de ARCHI_PARTICULES.md §17/§18)
| Décision | Option proposée | Justification | | # | Décision | Détail |
|----------|-----------------|---------------| |---|----------|--------|
| Schéma uniforms | **Acté : 2 bind groups** — frame partagé (@0) + object par entité (@1) | Étendu, portable sur tous backends (Metal/DX12/Vulkan) ; `var<immediate>` neuf, limites de taille et hazard d'écriture par objet ; 2 binds/draw seulement, trivialement « pipeline bind-less » plus tard | | D4 | **80 bytes/particule, sans padding** [D15/D19] | pos@0, vel@12, life@24, max_life@28, size@32, size_growth@36, angle@40, angular_vel@44, color@48, uv_rect@64. Espace **storage** : vec3/vec4 align 4 → layout Rust = WGSL identique. |
| Emplacement types uniforms | **Acté : `resources/uniform.rs`** (`FrameUniforms`, `ObjectUniform`, types `Pod` bytemuck) | Couche de données GPU (avec Camera/Mesh/Material/Vertex) ; préserve `math/` pur (sans bytemuck ni couplage wgpu) | | D5 | Billboard camera-facing | Quad orienté vers la caméra (axes right/up de la view matrix) |
| Cache object buffer | `RefCell<HashMap<label, (Buffer, BindGroup)>>` dans `Renderer` | `render_scene(&self)` immuable ; MVP petit nombre d'entités | | D6 | Quad via `@builtin(vertex_index)`, **layout vertex vide** [D17/D19] | Pas de vertex buffer. Le slot arrive par **storage** (`compact_index[ii]`), pas par attribut. |
| Transform dans l'entité | `Entity { mesh_id, material_id, transform }` + `add_entity_with_transform` | `add_entity` garde sa signature (transform identité) | | D9 | Blend figé au pipeline | 1 mode par pool (Additive ou Alpha) |
| Layout pipeline | **Acté : un seul layout pour tous** (frame @0 + object @1) ; `basic` unlit = variante de `standard` | 2D = cas particulier 3D (décision utilisateur) ; supprime la fourchette à deux layouts pour toujours | | D10 | Depth test oui, depth write non | Transparence correcte |
| Exemple démo | Nouvel exemple `cube.rs` (éclairé) ; `simple.rs` et `manual.rs` **migrés** vers le pipeline unifié (unlit) | Démontre le 3D sans dédoubler ; cohérent avec « un seul layout pour tous » | | D11 | Texture par défaut : disque 16×16 | Si `texture: None` |
| Correction `basic_shader.wgsl` | **Supprimer** `basic` comme pipeline séparé ; le quad plat devient `standard` unlit | 2D ⊂ 3D : pas de famille de pipeline dédiée | | D12 | Pool inactif si pas de driver | Zéro compute ; `indirect_args` = 0 → draw no-op |
| D15 | UV par particule (`uv_rect`) | `uv = uv_rect.xy + (q + 0.5) * uv_rect.zw` |
| D17 | Compaction + indirect draw | Buffers `compact_index` (N × u32) + `indirect_args` (16 B) créés ici ; la compaction elle-même est dans le compute de l'étape B. |
| D19 | Audit layout | Un seul struct `Particle` partagé (pas de `ParticleAlive`), 1 u32 par slot dans `compact_index`, args 16 B. |
---
## Fichiers à créer / modifier
```
lib/
└── src/
├── shaders/
│ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main (pas de compute)
├── utils/
│ └── conf.rs # + pub const PARTICLE_BILLBOARD_SHADER (include_str!, pattern existant)
├── resources/
│ ├── mod.rs # + pub mod particle + re-export
│ └── particle.rs # NOUVEAU : struct Particle (80 B, Pod, sans padding)
├── core/
│ ├── mod.rs # + pub mod particles
│ └── particles.rs # NOUVEAU : ParticlePool + ParticlePoolConfig + BlendingMode
├── scene/
│ └── scene.rs # + SceneGpu { queue, sample_count }
│ # + particle_pools: HashMap<String, Arc<ParticlePool>>
│ # + create_particle_pool()
└── prelude.rs # + re-exports
lib/tests/
└── wgsl_validate.rs # + tests particle_billboard
```
> **Changement `SceneGpu`** : le struct actuel ne garde que `device`/`format`/`cache`
> (le `queue` et le `sample_count` sont reçus par `init_gpu` puis jetés). Le pool a besoin
> des deux pour créer son pipeline au moment du `create_particle_pool` → ajouter les deux
> champs à `SceneGpu` (stokés au lieu d'être jetés). Aucun autre impact : `init_gpu` garde
> sa signature. Le depth format vient de la constante `crate::pipeline::DEPTH_FORMAT`
> (pas de champ à ajouter).
---
## Détail des implémentations
### 1. `resources/particle.rs`
```rust
use bytemuck::{Pod, Zeroable};
/// 80 bytes per particle. Mirror of the WGSL `Particle` struct (ARCHI §2 / §8).
/// Layout **sans padding** : espace storage (vec3/vec4 align 4) → Rust = WGSL identique [D19].
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable, Default)]
pub struct Particle {
pub pos: [f32; 3], // offset 0
pub vel: [f32; 3], // offset 12
pub life: f32, // offset 24
pub max_life: f32, // offset 28
pub size: f32, // offset 32
pub size_growth: f32, // offset 36
pub angle: f32, // offset 40
pub angular_vel: f32, // offset 44
pub color: [f32; 4], // offset 48
pub uv_rect: [f32; 4], // offset 64 — zone UV (ox, oy, sx, sy) [D15]
}
impl Particle {
/// Taille d'un élément du buffer storage : **80 bytes** (doit rester stable — testé).
pub const SIZE: u64 = std::mem::size_of::<Self>() as u64;
/// Particule nulle (life = 0 → morte). `Default`.
pub const ZERO: Self = Self::default();
}
```
**Tests** : `size_of::<Particle>() == 80`, `align_of::<Particle>() == 4`, offsets de chaque
champ (0/12/24/28/32/36/40/44/48/64), `ZERO.life == 0.0`.
### 2. `core/particles.rs`
```rust
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum BlendingMode {
#[default]
Alpha,
Additive,
}
pub struct ParticlePoolConfig {
/// Capacité max du pool (slots). Défaut : 1024.
pub max_count: u32,
/// ID d'une texture dans `scene.textures`. `None` → disque 16×16 par défaut [D11].
pub texture: Option<String>,
/// Mode de blending figé au pipeline [D9].
pub blending: BlendingMode,
}
pub struct ParticlePool {
/// État des particules : N × 80 B. STORAGE | COPY_DST. Zéro initialisé (toutes mortes).
pub(crate) buffer: wgpu::Buffer,
/// Index compact : N × u32, 1 par slot [D17/D19]. STORAGE | COPY_DST.
pub(crate) compact_index: wgpu::Buffer,
/// Args indirect draw : 16 B (4 × u32) [D17/D19]. STORAGE | COPY_DST. Zéro initialisé.
pub(crate) indirect_args: wgpu::Buffer,
/// Camera params : 128 B (view + proj). UNIFORM | COPY_DST.
/// Possédée par le pool ; écrite par le Renderer à chaque frame (étape E).
pub(crate) camera_params: wgpu::Buffer,
pub(crate) pipeline: wgpu::RenderPipeline,
pub(crate) layout: wgpu::BindGroupLayout,
/// Bind group construit une fois à la création (tout est possédé par le pool [D17/D19]).
pub(crate) bind_group: wgpu::BindGroup,
pub(crate) sampler: wgpu::Sampler,
pub max_count: u32,
pub blending: BlendingMode,
// Driver (étapes B/C/D) :
pub(crate) driver: Option<Box<dyn ParticleDriver>>,
pub(crate) active: bool,
}
```
**Construit** par `Scene::create_particle_pool` (device + queue + format + textures).
Le pipeline est compilé immédiatement. `ParticleDriver` (trait) est déclaré ici mais
ses implémentations arrivent aux étapes B/C/D — le champ `driver` reste `None` à cette étape.
### 3. `shaders/particle_billboard.wgsl`
```wgsl
// Particle billboard shader (vertex + fragment). Étape A — pas de compute.
// Layout vertex VIDE : quad généré en shader, slot par storage [D17/D19].
struct Particle { // 80 B — espace storage, vec3/vec4 align 4, pas de padding [D19]
pos: vec3<f32>,
vel: vec3<f32>,
life: f32,
max_life: f32,
size: f32,
size_growth: f32,
angle: f32,
angular_vel: f32,
color: vec4<f32>,
uv_rect: vec4<f32>, // [D15]
}
struct CameraParams { // 128 B — préfixe de FrameUniforms (view + proj)
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<storage, read> compact_index: array<u32>; // [D17/D19]
// 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad — voir GOTCHA topologie.
const QUAD: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
vec2(-0.5, -0.5),
vec2( 0.5, -0.5),
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;
// Pas d'early-out [D17] : instance_count vient des args indirect (exact = alive).
let slot = compact_index[ii];
let p = particles[slot];
let q = QUAD[vi];
// Rotation 2D dans le plan du billboard
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;
// Axes camera-facing (colonne/ligne de la view matrix)
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 = p.uv_rect.xy + (q + vec2(0.5)) * p.uv_rect.zw; // [D15]
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)
| Group | Binding | Type | Contenu | Visibility |
|-------|---------|------|---------|------------|
| 0 | 0 | Uniform RO | `camera_params` (view + proj, 128 B) | VERTEX |
| 0 | 1 | Storage RO | `particle_data` | VERTEX |
| 0 | 2 | Storage RO | `compact_index` [D17] | VERTEX |
| 0 | 3 | Sampler | Sampler | FRAGMENT |
| 0 | 4 | Texture | Texture particule | FRAGMENT |
> Le slot de l'instance arrive par **storage** (`compact_index[ii]`), pas par attribut vertex
> (layout vide, pattern TM) [D17/D19]. Le count exact vient des args indirect — pas d'early-out.
### 5. Pipeline descriptor
```rust
wgpu::RenderPipelineDescriptor {
vertex: wgpu::VertexStage {
module: shader,
entry_point: "vs_main",
buffers: &[], // layout VIDE — quad via QUAD[vi], slot via storage binding 2 [D17/D19]
},
fragment: Some(wgpu::FragmentStage {
module: shader,
entry_point: "fs_main",
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
color_states: [wgpu::ColorState {
format,
alpha_blend: blend_alpha, // selon BlendingMode
color_blend: blend_color,
write_mask: wgpu::ColorWrites::ALL,
}],
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT, // crate::pipeline::DEPTH_FORMAT (Depth32Float)
depth_write_enabled: false, // [D10]
depth_compare: wgpu::CompareFunction::LessEqual,
..Default::default()
}),
multisample: wgpu::MultisampleState { count: sample_count, ..Default::default() },
..
}
```
Draw (étape E) : `render_pass.draw_indirect(&pool.indirect_args, 0)` — vertexCount = 6
(dans les args), instanceCount = alive [D17].
### ⚠️ 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` | 4 vertices seulement | 1 buffer index de plus à gérer |
| 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 (ci-dessus).
Pas de vertex buffer, pas d'index buffer. Cohérent avec le pattern fullscreen triangle
du TM/bloom (`draw(3, 1)`). Avec l'indirect draw [D17], les args portent
`vertex_count = 6, instance_count = alive`.
### 6. Texture par défaut (disque 16×16)
Générée en Rust au build du pool (si `config.texture == None`) [D11] :
```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) * 255.0) as u8;
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
}
```
Format `Rgba8UnormSrgb` (convention du lib). Sampler : `MagFilter::Linear`, `AddressMode::ClampToEdge`.
### 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) = 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())
}
None => {
// Créer la texture disque 16×16 par défaut [D11]
self.gpu.create_default_disc_texture()
}
};
// Construire le pool (buffers + pipeline + bind group)
let gpu = self.gpu();
let pool = ParticlePool::new(
gpu.device.as_ref(),
gpu.format,
gpu.sample_count,
&config,
texture_view,
sampler,
);
self.particle_pools.insert(id.to_string(), Arc::new(pool));
Ok(())
}
}
```
Champs ajoutés à `Scene` : `particle_pools: HashMap<String, Arc<ParticlePool>>`
(vide par défaut → zéro coût [D12]).
### 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_80` | `size_of::<Particle>() == 80` [D15/D19] |
| `particle_align_is_4` | `align_of::<Particle>() == 4` (storage, pas de padding) [D19] |
| `particle_offsets` | Offsets 0/12/24/28/32/36/40/44/48/64 de chaque champ |
| `particle_zero_is_dead` | `Particle::ZERO.life == 0.0` |
| `pool_config_default_max_count` | Valeur raisonnable (1024) |
| `pool_buffers_sizes` | particle_data = N×80, compact_index = N×4, indirect_args = 16, camera_params = 128 |
| `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) |
| `particle_billboard_layout_empty` | Le pipeline se construit avec `buffers: &[]` (layout vide) |
---
## 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` vide par défaut → zéro coût [D12]
- [ ] `SceneGpu` gagne 2 champs (`queue`, `sample_count`) — `init_gpu` inchangé
---
## Critères d'acceptation
1. ✅ `Particle` compile : **80 bytes**, align 4, Pod, offsets corrects (sans padding)
2. ✅ `particle_billboard.wgsl` compile par Naga (test WGSL)
3. ✅ `ParticlePool::new` crée les 4 buffers + pipeline + bind group sans erreur
4. ✅ `indirect_args` initialisée à zéro → un `draw_indirect` forcé est un no-op
5. ✅ La texture disque 16×16 est générée correctement
6. ✅ `Scene::create_particle_pool` fonctionne (test unitaire avec device réel)
7. ✅ Le pool est inactif (pas de driver, args = 0) tant qu'aucun driver n'est attaché
8. ✅ Zéro warning, tous les tests verts
9. ✅ Prelude expose les types
---
## Étape suivante (B)
Driver GPU : compute shader `particle_update.wgsl` (intégration + **compaction fused**
[§18 D17] : `compact_index` + `indirect_args` écrits par le compute) + `GpuEmitterConfig`
(**`color_range` + `uv_rects` + `alpha_scale`** [D18]) + spawn CPU + dispatch +
`Scene::attach_gpu_emitter`.
+22 -33
View File
@@ -11,15 +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-16).** 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. Depuis la révision du 2026-09-14, l'étape > **terminée** ; la source de vérité sur l'état actuel est **README.md** et le code. Depuis la révision
> **« Scene auto-render »** a été réalisée : le rendu de la `Scene` est **automatisé** en une seule > du 2026-09-14, le rendu de la `Scene` est automatisé en une passe groupée
> passe groupée via `App::render_scene(frame.view())` (appelée par défaut dans `AppHandler::render`), > (`App::render_scene(frame.view())`, appelée par défaut dans `AppHandler::render`) et `simple.rs`
> et `simple.rs` (API `AppBuilder`, sans `winit`/`wgpu`) déclare un quad rendu automatiquement. > (API `AppBuilder`, sans `winit`/`wgpu`) déclare un quad rendu automatiquement. Les étapes suivantes
> Le même jour (Étape 3 + 4, 2026-09-16) l'**infrastructure 3D** est en place : bind groups uniformes > ont ensuite : posé l'infrastructure 3D (bind groups uniformes frame+object partagés, caméra active,
> partagés (frame + object), caméra active dans la `Scene` (`Scene::set_camera`/`camera()`) écrite dans > matrices monde par entité — Étapes 3+4, 2026-09-16) ; atteint le **MVP 3D Phong** (Étape 5,
> le buffer frame chaque frame, matrices monde par entité. L'éclairage visible (`standard_shader.wgsl` > 2026-09-17 : l'exemple `cube` ; le 2D plat = variante **unlit** de `standard` via
> branché sur un exemple) reste une étape suivante. > `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)
@@ -49,7 +53,7 @@ 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 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). - [X] Associer le `PipelineCache` à la Scene pour que la gestion des matériaux soit entièrement portée par la scène (actuellement le cache est porté par `App`, indépendant de la Scene — le rendu de la scène est, lui, déjà automatisé depuis 2026-09-16). *(fait — 2026-09-17, DRAFT Étape 7 : `Scene::init_gpu` détient device+format+`PipelineCache` ; `App` n'a plus de champ `cache`)*
- [X] Implémenter la logique de rendu de la scène : `App::render_scene(view)` parcourt la scène, - [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** 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 (`Renderer::render_scene`), appelée automatiquement chaque frame par l'implémentation par défaut
@@ -58,8 +62,8 @@ Une fois la plomberie encapsulée, nous devons rendre l'assemblage des objets co
### 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")
@@ -76,8 +80,9 @@ 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,
É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 — - [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 `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)*. dans le buffer frame chaque frame, aspect calculé depuis la fenêtre)*.
@@ -89,22 +94,6 @@ Une fois les phases 1 à 3 validées, nous pourrons introduire :
exposée via `Frame::view()`, `render()` dessine la scène automatiquement en une passe via 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`) `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 ? — **obsolète** : depuis la migration - [X] `pollster` est-il isolé de l'utilisateur final ? — résolu : depuis winit 0.30 (2026-09-16),
winit 0.30 (2026-09-16), `pollster` est en `dependencies` de la lib (le `block_on` d'init GPU `pollster` est en `dependencies` de la lib ; le `block_on` de l'init GPU est appelé une seule fois
est désormais appelé dans le code de la lib, `app.rs`, cf. note pour mémoire ci-dessous). dans `lib/src/app.rs` (`resumed()`). Les exemples compilent sans le connaître (crates séparées).
## Note pour mémoire : couplage au runtime async (pollster)
Depuis la migration winit 0.30, la lib embarque un runtime async pour l'init GPU. Le point de
couplage actuel est **unique** : `pollster::block_on(Context::new(...))` dans `lib/src/app.rs`
(`resumed()`), plus le macro `#[pollster::main]` dans les exemples (crates séparées, hors lib).
À ce stade (un seul appel), **on ne crée volontairement PAS d'abstraction** : ce serait du
sur-engineering pour un seul point d'appel. Mais si la lib acquiert d'autres appels async
(chargements / uploads GPU, etc.), il faudra isoler le runtime derrière un **module-pivot unique**
(`lib/src/exec.rs`, une fonction `block_on`), seul fichier à modifier pour basculer de pollster
vers tokio/futures-executor — le reste du code appelant `crate::exec::block_on(...)`.
Rappel : pollster et tokio sont des runtimes indépendants qui coexistent sans conflit dans un
même binaire ; la seule contre-indication est de faire un `pollster::block_on` **à l'intérieur**
d'un contexte async tokio (blocage imbriqué / deadlock possible).
+89 -139
View File
@@ -1,168 +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-16 — la source de vérité est README.md).**
> Les fondations suivantes existent et fonctionnent déjà ; cette roadmap décrit la **trajectoire à
> venir** à partir de cet état (elle reprend les étapes 1-4 du README avant la montée GPU-driven) :
> - Workflow manuel (`Context` + `Renderer` + `PipelineCache`) : ✅ fonctionnel (exemple `manual`).
> - Façade `App` / `AppBuilder` / `AppHandler` : ✅ **Scene auto-render** (2026-09-16) — la vue de frame
> est exposée (`Frame::view()`), `render()` dessine la scène en une passe groupée
> (`App::render_scene`) et la présentation est automatique dans `App::run` (exemple `simple`).
> - `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`),
> initialement non branchés au pipeline — **désormais branchés** (caméra active + matrices monde écrites
> chaque frame, Étape 4.3, 2026-09-16 ; voir §1.1/1.5 ci-dessous).
> **Étape suivante (prochaine itération) — « 3D + éclairage Phong » (ROADMAP 1.3 + 1.5).** > **Légende** : ✅ fait · 🔶 partiel · ⬜ à faire · ❌ abandonné
> Le rendu automatique est aujourd'hui **plat** : le `basic_shader.wgsl` interprète les positions comme > **Principe** : chaque étape est **additive et opt-in** — non-régression structurelle garantie
> déjà en NDC, sans matrice monde/vue/projection ni lumière. **Une grande partie de l'infrastructure est > (tout reste désactivable, les chemins existants ne changent pas).
> déjà en place (Étapes 3+4, 2026-09-16)** : le `standard_shader.wgsl` Phong (matrice
> `projection * view * world` + lumière directionnelle) existe et valide ; les uniform buffers sont
> branchés (frame : view/proj/cam_pos + lumière ; par mesh : `world` dérivé du `Transform`) ; le `Renderer`
> écrit chaque frame la caméra active (via `Scene::set_camera`/`camera()`) et la matrice monde de chaque
> entité. **Reste à faire** pour un mesh 3D éclairé à l'écran : brancher `standard` sur un exemple et
> ajouter un mesh de test (cube). Objectif MVP : **un mesh 3D éclairé à l'écran**.
--- ---
## 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)
- [x] Module `math/` / `transform.rs`:
- [x] Struct `Transform { translation: Vec3, rotation: Quat, scale: Vec3 }`
- [x] Méthode `to_matrix() -> Mat4` pour calculer la matrice locale
- [x] Struct `Camera { position: Vec3, target: Vec3, up: Vec3 }` : resources/camera.rs — enrichi en Étape 4.3 (fov/near/far + `with_perspective`)
- [x] Fonctions `view_matrix()` et `projection_matrix(fov, aspect, near, far)` (Étape 4.3 : `projection_matrix(aspect)` utilise fov/near/far stockés)
### 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) ✅
- [x] Créer `standard_shader.wgsl` (Étape 2, 2026-09-16) :
- [x] Vertex shader : projection * view * world * position
- [x] Fragment shader : éclairage directionnel (+ hémisphérique)
- [x] Uniforms : `view`, `proj`, `cam_pos`, `light_dir`, `light_color`, `options`
- [x] Mettre à jour `Material` / pipeline pour supporter les uniforms du shader Phong (bind group layouts frame+object, Étape 3) — `standard` n'est pas encore branché sur un exemple
### 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) | ✅ |
- [x] Caméra active dans la `Scene` : `set_camera()` / `camera()` (Étape 4.3) | 3.2 | Compute matrices (compute shader : transform → world matrix) | ✅ |
- [ ] **Reporté (étape "Handles typés")** : migrer vers `slotmap` générationnel (`MeshId`/`MaterialId`) quand l'éviction/les performances le justifieront | 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é ✅
- [x] Uniform buffer pour la frame : `view`, `proj`, `cam_pos`, `light_dir` (Étapes 3+4) — écrit chaque frame depuis la caméra active
- [x] Uniform buffer par mesh : `world` (calculée sur CPU depuis `transform.to_matrix()`, Étape 4.2) | # | Item | Statut |
- [x] `Renderer::render_scene()` itère sur les entités de la Scene et dessine chacune (liaison bind groups frame+object) |---|------|:------:|
- [ ] Exemple fonctionnel : un cube éclairé tourne à l'écran — **à faire** (Étape 5 : brancher `standard` sur un exemple + mesh cube) | 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 |
- [x] Intégrer `Camera` comme ressource de la Scene (Étape 4.3 : `Scene::set_camera` / `camera()`, caméra active unique) |---|------|:------:|
- [ ] Permettre plusieurs caméras (actuelle/inactive) et une sélection par identifiant (`scene.set_active_camera(camera_id)`) | 6.8 | Handles typés par ressource (slotmap) — `docs/tech/ARCHI_ARENES.md` | ⬜ |
- [ ] Exposer une caméra orbitale contrôlable (exemple final, Phase 5) | 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 |
| **Present mode FIFO figé pour l'instant** | Le swapchain utilise `PresentMode::Fifo` avec `desired_maximum_frame_latency: 2` (double buffering vsync) — défaut sûr : pas de tearing, énergie minimale, zéro artefact. On **gèle ce choix** ; `Mailbox` (triple buffering) pourra être exposé en option et `Immediate` restera réservé à l'offscreen, **on s'occupera du present mode le moment venu** (quand le pipeline GPU-driven arrivera, Phase 3) — ce n'est pas bloquant pour les étapes 1-2 |
+28 -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
@@ -85,7 +91,7 @@ pub trait AppHandler {
- **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
@@ -104,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.
@@ -114,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).
@@ -155,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)
+79
View File
@@ -0,0 +1,79 @@
# 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, `uv_texture.jpg` UV-atlas texture | `cargo run -p wsg-lib --example cube` | [Meshes](meshes/meshes.md), [Materials](meshes/materials.md), [Lights](lights/lights.md) |
| `pbr` | meshes | PBR materials (metal/roughness) + real textures: `cave.jpg` albedo, `caveNormal.jpg` normal map, `ground.jpeg` floor | `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) |
> **Texture assets**: the multi-mesh examples use real image files from
> [`lib/examples/assets/textures/`](../../lib/examples/assets/textures/) (see the
> [Texture assets](../../lib/examples/README.md#texture-assets) section there for the full
> table): `uv_texture.jpg` (UV atlas visualization), `ground.jpeg` (tiled floor albedo),
> `stonewall.jpg`, and the `cave.jpg` + `caveNormal.jpg` albedo/normal pair. Paths are
> resolved against `CARGO_MANIFEST_DIR`, so the examples run from any working directory.
## 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)
+124
View File
@@ -0,0 +1,124 @@
# 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()` (clone them out of the
borrow before touching `app.scene` again — see the pattern in every textured example):
```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).
For **file textures** (the pattern used by `cube`, `demo`, `shadow`, `fog`, `dof`,
`culling` and `pbr`), load from `assets/textures/` and resolve the path against
`CARGO_MANIFEST_DIR` so the example works from any working directory:
```rust
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
let texture = Texture::from_file(
&device,
&queue,
"uv_atlas",
&format!("{TEXTURES}/uv_texture.jpg"),
)
.unwrap();
```
The sampler is `Linear` + `Repeat`, so textures tile automatically when UVs exceed
[0,1] (e.g. the 80×80 fog floor tiles `ground.jpeg`).
> **Normal maps**: `Texture` is always `Rgba8UnormSrgb`, so the GPU sRGB-decodes on
> sample. A normal map is *linear* data — pre-encode its channels with the sRGB OETF
> before upload so the round-trip is the identity. See `load_normal_map` in
> [`pbr.rs`](../../../lib/examples/meshes/pbr.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)
+94
View File
@@ -6,6 +6,22 @@ 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.30.13" # For window management — pinned to match examples winit = "0.30.13" # For window management — pinned to match examples
@@ -13,3 +29,81 @@ thiserror = "2"
bytemuck = { version = "1.25.0", features = ["derive"] } bytemuck = { version = "1.25.0", features = ["derive"] }
glam = { version = "0.33", features = ["bytemuck"] } # feature requis pour Pod/Zeroable sur Mat4/Vec4 (uniform.rs) glam = { version = "0.33", features = ["bytemuck"] } # feature requis pour Pod/Zeroable sur Mat4/Vec4 (uniform.rs)
pollster = { version="1.0.1", features = ["macro"] } 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"] }
# Examples live in per-category subfolders (meshes/, lights/, cameras/, effects/).
# 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"
+95
View File
@@ -0,0 +1,95 @@
# 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**: hard-coded geometries, and textures that are
either procedural or shipped in [`assets/textures/`](assets/textures/). All use the
declarative API (`AppBuilder` + `AppHandler`) except `manual`, which demonstrates
the low-level workflow instead.
## Texture assets
A few examples (the multi-mesh / multi-effect ones) use real image files from
`assets/textures/`, loaded with `Texture::from_file`. The paths are resolved
against `CARGO_MANIFEST_DIR` at compile time, so the examples work from **any
working directory**:
```rust
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
Texture::from_file(&device, &queue, "label", &format!("{TEXTURES}/ground.jpeg"))
```
Assets used by the examples:
| Asset | Size | Used by | Role |
|-------|------|---------|------|
| `uv_texture.jpg` | 437×438 | `cube`, `demo`, `shadow`, `dof`, `culling` | 8×8 UV atlas visualization (labelled cells + corner coordinates) — makes UV mapping and culling decisions explicit |
| `ground.jpeg` | 512×512 | `demo`, `pbr`, `shadow`, `fog`, `dof` | Seamless ground albedo, tiled via the `Repeat` sampler |
| `stonewall.jpg` | 300×225 | `fog` | Distinctive cube texture — the fog falloff reads clearly on it |
| `cave.jpg` + `caveNormal.jpg` | 600×450 | `pbr` | Albedo + normal-map pair for the PBR normal-mapping demo |
The remaining assets in the folder (`rock.jpg`, `seamlessRoad.jpg`, `stalag.jpg` /
`stalagNormal.jpg`, `stars1.jpg`, sprite/heightmap PNGs, …) are available for
experiments. Two notes:
- `Texture` uploads to `Rgba8UnormSrgb` — correct for **albedo** maps (the GPU
sRGB-decodes on sample). A **normal map** is linear data, so `pbr` pre-encodes
its channels with the sRGB OETF before upload (`load_normal_map`): the GPU
decode then restores the original values (EOTF∘OETF = identity).
- The texture sampler is `Linear` + `Repeat`, so any texture tiles automatically
when UVs exceed [0,1] (the 80×80 fog floor uses this to tile `ground.jpeg`).
> **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**: hard-coded geometries; textures are procedural
or come from `assets/textures/` (resolved via `CARGO_MANIFEST_DIR`, see
*Texture assets* above).
4. Document it in the folder's `README.md` (and in `docs/user/examples.md`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

+54
View File
@@ -0,0 +1,54 @@
# 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 UV-atlas 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 shared cube
mesh is textured with the `uv_texture.jpg` atlas — the colourful labelled
cells make it obvious exactly which cubes the GPU draws and which it culls.
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).
+191
View File
@@ -0,0 +1,191 @@
//! **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::resources::Texture;
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
/// 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), textured with
// the uv_texture.jpg atlas — the colourful labelled cells make it obvious exactly
// which cubes the GPU draws and which it culls.
let (device, queue) = {
let ctx = app.context();
(ctx.device.clone(), ctx.queue.clone())
};
let uv_tex = Texture::from_file(
&device,
&queue,
"uv_atlas",
&format!("{TEXTURES}/uv_texture.jpg"),
)
.unwrap();
app.scene.add_texture("uv_texture", uv_tex).unwrap();
app.scene
.add_material_texture("cube_mat", "standard", "uv_texture")
.unwrap();
app.scene
.create_mesh("cube_mesh", cube(0.5), Some("cube_mat"))
.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,
})
}
+184
View File
@@ -0,0 +1,184 @@
# 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, file + procedural textures
(`ground.jpeg` floor, `uv_texture.jpg` cube, checker/stripe grids), 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. The floor is textured with `ground.jpeg`
(tiled across 80×80 units via the `Repeat` sampler) and the cubes with
`stonewall.jpg` — the fog falloff reads clearly on the textured surfaces.
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. The floor is textured with `ground.jpeg` (tiled)
and the cubes with the `uv_texture.jpg` atlas — bokeh blur reads much better
on textured surfaces. 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(),
})
}
+355
View File
@@ -0,0 +1,355 @@
//! **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,
//! * **textures per mesh**: the ground is a **file asset** (`ground.jpeg`) and the cube a
//! **UV atlas asset** (`uv_texture.jpg`), the rest stay procedural (checker / stripe grids),
//! * 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;
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
/// 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. Textures: two **file assets** (ground + UV atlas) plus two procedural patterns.
// File paths are resolved against the crate root (`CARGO_MANIFEST_DIR`) so the
// example works from any CWD.
let ground_tex = Texture::from_file(
&device,
&queue,
"ground",
&format!("{TEXTURES}/ground.jpeg"),
)
.unwrap();
app.scene.add_texture("ground_texture", ground_tex).unwrap();
app.scene
.add_material_texture("ground_mat", "standard", "ground_texture")
.unwrap();
let uv_tex = Texture::from_file(
&device,
&queue,
"uv_atlas",
&format!("{TEXTURES}/uv_texture.jpg"),
)
.unwrap();
app.scene.add_texture("uv_texture", uv_tex).unwrap();
app.scene
.add_material_texture("uv_mat", "standard", "uv_texture")
.unwrap();
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("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("uv_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,
})
}
+203
View File
@@ -0,0 +1,203 @@
//! # 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::resources::Texture;
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
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();
// Textured ground + cubes: bokeh blur reads much better on textured surfaces.
// ground.jpeg tiles across the 80×80 floor (Repeat sampler); uv_texture.jpg on the cubes.
let (device, queue) = {
let ctx = app.context();
(ctx.device.clone(), ctx.queue.clone())
};
let ground_tex =
Texture::from_file(&device, &queue, "ground", &format!("{TEXTURES}/ground.jpeg")).unwrap();
app.scene.add_texture("ground_texture", ground_tex).unwrap();
app.scene
.add_material_texture("ground_mat", "standard", "ground_texture")
.unwrap();
let uv_tex = Texture::from_file(
&device,
&queue,
"uv_atlas",
&format!("{TEXTURES}/uv_texture.jpg"),
)
.unwrap();
app.scene.add_texture("uv_texture", uv_tex).unwrap();
app.scene
.add_material_texture("cube_mat", "standard", "uv_texture")
.unwrap();
// Ground plane.
app.scene
.create_mesh("ground_mesh", plane(80.0, 80.0, 1, 1), Some("ground_mat"))
.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), Some("cube_mat"))
.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(),
})
}
+186
View File
@@ -0,0 +1,186 @@
//! # 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::resources::Texture;
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
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();
// Textured ground + cubes: the fog falloff reads much better on textured surfaces.
// ground.jpeg tiles across the 80×80 floor (Repeat sampler); stonewall.jpg on the cubes.
let (device, queue) = {
let ctx = app.context();
(ctx.device.clone(), ctx.queue.clone())
};
let ground_tex =
Texture::from_file(&device, &queue, "ground", &format!("{TEXTURES}/ground.jpeg")).unwrap();
app.scene.add_texture("ground_texture", ground_tex).unwrap();
app.scene
.add_material_texture("ground_mat", "standard", "ground_texture")
.unwrap();
let wall_tex =
Texture::from_file(&device, &queue, "wall", &format!("{TEXTURES}/stonewall.jpg")).unwrap();
app.scene.add_texture("wall_texture", wall_tex).unwrap();
app.scene
.add_material_texture("wall_mat", "standard", "wall_texture")
.unwrap();
// Large ground plane — will fade into fog at distance.
app.scene
.create_mesh("ground_mesh", plane(80.0, 80.0, 1, 1), Some("ground_mat"))
.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), Some("wall_mat"))
.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,
})
}
+126
View File
@@ -0,0 +1,126 @@
# 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 textured 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). The floor is textured (`ground.jpeg`, tiled) and
the rotating cube uses the `uv_texture.jpg` UV atlas — the textures make the
shadow shapes and their movement clearly readable.
```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,
})
}
+233
View File
@@ -0,0 +1,233 @@
//! **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).
//!
//! The ground is textured (`ground.jpeg`, tiled via the Repeat sampler) and the
//! cube uses the `uv_texture.jpg` UV atlas — textured surfaces make the shadow
//! shapes and their movement clearly readable.
//!
//! ## 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::resources::Texture;
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;
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
/// 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();
// Textured ground + cube (assets make the shadows readable): ground.jpeg on the
// floor, uv_texture.jpg on the rotating cube. Paths resolve against CARGO_MANIFEST_DIR.
let (device, queue) = {
let ctx = app.context();
(ctx.device.clone(), ctx.queue.clone())
};
let ground_tex =
Texture::from_file(&device, &queue, "ground", &format!("{TEXTURES}/ground.jpeg")).unwrap();
app.scene.add_texture("ground_texture", ground_tex).unwrap();
app.scene
.add_material_texture("ground_mat", "standard", "ground_texture")
.unwrap();
let uv_tex = Texture::from_file(
&device,
&queue,
"uv_atlas",
&format!("{TEXTURES}/uv_texture.jpg"),
)
.unwrap();
app.scene.add_texture("uv_texture", uv_tex).unwrap();
app.scene
.add_material_texture("cube_mat", "standard", "uv_texture")
.unwrap();
// Large ground plane (receives shadows).
app.scene
.create_mesh("ground_mesh", plane(8.0, 8.0, 1, 1), Some("ground_mat"))
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// Cube (casts + receives shadow).
app.scene
.create_mesh("cube_mesh", cube(0.8), Some("cube_mat"))
.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,
})
}
-160
View File
@@ -1,160 +0,0 @@
//! Workflow bas-niveau : utilisation directe du `Context`, `Renderer`, `PipelineCache`, `Mesh` et
//! `Material`, contournant la façade `App`. Rendu d'un quad plat éclairé via la boucle winit 0.30
//! (`EventLoop::run_app` + `ApplicationHandler`). La fenêtre et le GPU sont créés dans `resumed()`,
//! comme l'exigent winit 0.30 et la migration faite dans `app.rs`.
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;
use wsg_lib::pipeline::PipelineCache;
use wsg_lib::resources::Material;
use wsg_lib::resources::Mesh;
use wsg_lib::resources::Vertex;
use wsg_lib::utils;
/// Application bas-niveau : détient les objets GPU + window, tous créés dans `resumed`.
struct App {
/// Fenêtre système, partagée via Arc (comme dans app.rs).
window: Option<Arc<Window>>,
/// Contexte GPU (Instance, Surface, Adapter, Device, Queue).
context: Option<Context>,
/// Couche d'exécution qui soumet les draw calls.
renderer: Option<Renderer>,
/// Cache de shaders/pipelines.
cache: Option<PipelineCache>,
/// Matériau (pipeline) du quad.
material: Option<Material>,
/// Mesh du quad (sommets + indices).
mesh: Option<Mesh>,
}
impl ApplicationHandler for App {
/// Crée la fenêtre puis le GPU, et construit le mesh/matériau. Exécuté une fois au démarrage.
/// Redondant `resumed` pour créer à nouveau ? double protection par `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("É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));
self.window = Some(window);
self.context = Some(context);
self.renderer = Some(renderer);
self.cache = Some(cache);
self.material = Some(material);
self.mesh = Some(mesh);
}
/// À chaque frame, demande un redessin pour un rendu continu (animation).
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(window) = &self.window {
window.request_redraw();
}
}
/// Dispatch des événements de fenêtre : RedrawRequested rend puis présente, CloseRequested quitte.
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId,
event: WindowEvent,
) {
match event {
winit::event::WindowEvent::RedrawRequested => {
if let (Some(context), Some(renderer), Some(mesh), Some(material)) =
(&self.context, &self.renderer, &self.mesh, &self.material)
{
if let Some(frame) = Frame::try_new(&context.surface) {
// 1. Rendu (plus d'arguments device/queue inutiles)
renderer.render(frame.view(), mesh, material);
// 2. Présentation
renderer.present(frame);
}
}
}
winit::event::WindowEvent::CloseRequested => {
event_loop.exit(); // C'est ici que tu demandes à la boucle de s'arrêter
}
_ => (),
}
}
}
fn main() {
println!(
"Répertoire courant : {:?}",
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();
}
+123
View File
@@ -0,0 +1,123 @@
# 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 (`uv_texture.jpg` UV atlas) cube, lit (directional + point + spot), spinning |
| `pbr` | `cargo run -p wsg-lib --example pbr` | PBR metallic/roughness + normal mapping (real `cave.jpg` albedo + `caveNormal.jpg`) |
| `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 the `uv_texture.jpg` asset — an
8×8 UV atlas visualization (labelled cells + corner coordinates) that makes
exactly where each face's UVs land visible. The texture is loaded from
`assets/textures/` via `Texture::from_file` (path resolved against
`CARGO_MANIFEST_DIR`), registered by id (`add_texture`) and bound through
`add_material_texture` (diffuse path, bind group `@group(2)`). Follows the
declarative workflow (like `simple`): `AppBuilder` + automatic scene, **no wgpu
import**; 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,
cave, textured floor). The floor is a 20×20 plane with the `ground.jpeg`
albedo; the cave cube pairs `cave.jpg` (albedo) with `caveNormal.jpg`
(normal map, pre-encoded sRGB before upload — see the *Texture assets* section
in the parent README) and shows real surface detail under the light.
---
## `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.
+107
View File
@@ -0,0 +1,107 @@
//! A lit unit cube that rotates, **textured** with the `uv_texture.jpg` asset (an 8×8 UV atlas
//! visualization — each labelled cell shows exactly where a face's UVs land) 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`);
//! here the texture is a **file asset** (`assets/textures/uv_texture.jpg`, loaded via
//! `Texture::from_file`) — the path is resolved against `CARGO_MANIFEST_DIR` so the example
//! works from any working directory.
//! 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;
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
/// Demo handler: rotates the textured cube in `update`.
struct Cube {
/// Cumulative rotation angle (radians), incremented each frame.
angle: f32,
}
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();
// Loads the `uv_texture.jpg` asset 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_file(
&device,
&queue,
"uv_atlas",
&format!("{TEXTURES}/uv_texture.jpg"),
)
.unwrap();
app.scene.add_texture("uv_texture", texture).unwrap();
app.scene
.add_material_texture("cube_material", "standard", "uv_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();
}
+224
View File
@@ -0,0 +1,224 @@
//! # 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 + albedo `ground.jpeg` (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 cave : albedo `cave.jpg` + normal map `caveNormal.jpg` (assets, normal map pré-encodée sRGB)
//!
//! ## 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();
// Textures fichiers (assets/textures) : albedo du sol + albedo/normal cave.
// La normal map est pré-encodée sRGB avant upload : `Texture` est toujours
// `Rgba8UnormSrgb` (le GPU décode en sRGB à l'échantillonnage), et les données
// d'une normal map sont linéaires — l'encodage OETF compense la décodage EOTF
// (EOTF(OETF(x)) = x), sinon la perturbation serait visiblement faussée.
let (device, queue) = {
let ctx = app.context();
(ctx.device.clone(), ctx.queue.clone())
};
let ground_albedo = Texture::from_file(
&device,
&queue,
"ground",
&format!("{TEXTURES}/ground.jpeg"),
)
.unwrap();
app.scene.add_texture("ground_albedo", ground_albedo).unwrap();
let cave_albedo =
Texture::from_file(&device, &queue, "cave", &format!("{TEXTURES}/cave.jpg")).unwrap();
app.scene.add_texture("cave_albedo", cave_albedo).unwrap();
let cave_nm = load_normal_map(
&device,
&queue,
&format!("{TEXTURES}/caveNormal.jpg"),
"cave_nm",
);
app.scene.add_texture("cave_nm", cave_nm).unwrap();
// Matériaux PBR.
app.scene
.add_material_pbr_textured("floor", "standard", 0.0, 0.8, Some("ground_albedo"), None)
.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(
"cave",
"standard",
0.0,
0.6,
Some("cave_albedo"),
Some("cave_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_cave", "cave", 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/cave/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());
}
}
}
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
/// Charge une normal map depuis un fichier et l'upload en `Texture`.
///
/// `Texture` est toujours `Rgba8UnormSrgb` : le GPU applique la EOTF sRGB à
/// l'échantillonnage. Une normal map est des données **linéaires** — on pré-encode
/// donc chaque canal avec la OETF sRGB avant l'upload, pour que le round-trip
/// GPU soit l'identité (EOTF(OETF(x)) = x). Sans ce pré-encodage, la perturbation
/// de normale serait visiblement faussée (valeurs compressées vers le noir).
fn load_normal_map(device: &wgpu::Device, queue: &wgpu::Queue, path: &str, label: &str) -> Texture {
let bytes = std::fs::read(path).expect("normal map asset present in the repo");
let rgba = image::load_from_memory(&bytes).expect("valid image").to_rgba8();
let encoded = rgba
.as_raw()
.iter()
.map(|&c| {
let v = c as f32 / 255.0;
let e = if v <= 0.0031308 {
12.92 * v
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
};
(e * 255.0).round().clamp(0.0, 255.0) as u8
})
.collect::<Vec<u8>>();
Texture::from_rgba8(device, queue, rgba.width(), rgba.height(), &encoded, label)
.expect("normal map upload 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)
}
-70
View File
@@ -1,70 +0,0 @@
//! Workflow déclaratif minimal, sans manipulation WGPU explicite dans ce fichier.
//! `AppBuilder` crée l'event loop puis `App::run` ouvre la fenêtre, construit le `Context`/`Renderer`
//! et fait tourner la boucle update → render → present. Depuis la migration winit 0.30, le GPU n'existe
//! qu'après `resumed` : c'est pourquoi l'enregistrement shader + la création mesh/matériau/entité vivent
//! dans le hook `AppHandler::setup`, appelé une fois le contexte prêt. La scène se rend automatiquement :
//! la méthode `render()` par défaut appelle `app.render_scene(frame.view())`.
use std::sync::Arc;
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::resources::{Material, Mesh, Vertex};
use wsg_lib::utils::WsgError;
struct MonQuad;
impl AppHandler for MonQuad {
fn setup(&mut self, app: &mut wsg_lib::App) {
let format = app.renderer().format();
// Enregistrement du shader, création du matériau et du mesh du quad (sans importer wgpu).
app.cache()
.register_shader("basic", wsg_lib::utils::BASIC_SHADER_PATH)
.unwrap();
let vertices = [
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 = Arc::new(Mesh::new(
app.renderer().device(),
&vertices,
Some(&indices),
));
let material = Arc::new(Material::new(format, "basic", app.cache()));
app.scene.add_mesh("quad_mesh", mesh).unwrap();
app.scene.add_material("basic_material", material).unwrap();
app.scene
.add_entity("quad", "quad_mesh", "basic_material")
.unwrap();
}
}
#[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
+300 -31
View File
@@ -8,8 +8,9 @@
//! ## 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.
//! //!
@@ -22,8 +23,8 @@
//! once right after GPU initialization so users can register shaders/meshes/materials/entities. //! 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};
@@ -38,19 +39,44 @@ use winit::window::{Window, WindowAttributes};
/// 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`, `cache`) are created lazily when the /// The GPU-facing fields (`context`, `renderer`, `window`) are created lazily when the application is
/// application is resumed (see `AppRunner`); they are only populated after `App::run` has started. /// resumed (see `AppRunner`); they are only populated after `App::run` has started. The `PipelineCache`
/// Access them through the `context()`, `renderer()`, `window()` and `cache()` accessors, which is /// 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`. /// guaranteed to work inside `AppHandler::setup`, `update` and `render`.
pub struct App { pub struct App {
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`. /// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
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`. /// Window title, read by the runner when the window is created in `resumed`.
pub(crate) title: String, pub(crate) title: String,
/// Window width, read by the runner when the window is created in `resumed`. /// Window width, read by the runner when the window is created in `resumed`.
pub(crate) width: u32, pub(crate) width: u32,
/// Window height, read by the runner when the window is created in `resumed`. /// Window height, read by the runner when the window is created in `resumed`.
pub(crate) height: u32, 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. /// 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 event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle. /// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
@@ -59,8 +85,6 @@ pub struct App {
renderer: Option<Renderer>, 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.
window: Option<Arc<Window>>, window: Option<Arc<Window>>,
/// Shader compilation cache — manages RenderPipelines keyed by shader_id.
cache: Option<PipelineCache>,
} }
impl App { impl App {
@@ -72,6 +96,16 @@ impl App {
.expect("renderer not initialized yet — call app.run(handler) first") .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. /// Returns a reference to the GPU hardware context.
/// Panics if called before `App::run` has created the context (i.e. before `resumed` fires). /// Panics if called before `App::run` has created the context (i.e. before `resumed` fires).
pub fn context(&self) -> &Context { pub fn context(&self) -> &Context {
@@ -80,14 +114,6 @@ impl App {
.expect("context not initialized yet — call app.run(handler) first") .expect("context not initialized yet — call app.run(handler) first")
} }
/// Returns a mutable reference to the shader compilation cache.
/// Panics if called before `App::run` has created the cache (i.e. before `resumed` fires).
pub fn cache(&mut self) -> &mut PipelineCache {
self.cache
.as_mut()
.expect("cache not initialized yet — call app.run(handler) first")
}
/// Returns a reference to the window backing this application. /// Returns a reference to the window backing this application.
/// Panics if called before `App::run` has created the window (i.e. before `resumed` fires). /// Panics if called before `App::run` has created the window (i.e. before `resumed` fires).
pub fn window(&self) -> &Window { pub fn window(&self) -> &Window {
@@ -107,12 +133,20 @@ impl App {
/// 5) on RedrawRequested: acquire frame → call handler.render() → present frame → /// 5) on RedrawRequested: acquire frame → call handler.render() → present frame →
/// 6) on CloseRequested: exit the event loop. /// 6) on CloseRequested: exit the event loop.
pub fn run<H: AppHandler + 'static>(mut self, handler: H) -> Result<(), WsgError> { pub fn run<H: AppHandler + 'static>(mut self, handler: H) -> Result<(), WsgError> {
// On extrait l'event_loop de manière sûre grâce au Option // Extract the event_loop safely via Option
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // Erreur si déjà pris let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // error if already taken
let mut runner = AppRunner { let mut runner = AppRunner {
title: self.title.clone(), title: self.title.clone(),
width: self.width, width: self.width,
height: self.height, 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, handler,
app: None, app: None,
}; };
@@ -126,13 +160,72 @@ impl App {
/// who override `render` to control drawing themselves. /// who override `render` to control drawing themselves.
/// Inputs: view — the frame's texture view acting as the color attachment target. /// 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, Étape 4.3) /// 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 /// is derived here from the window's current inner size, so the `Renderer` stays independent of
/// the windowing backend. /// the windowing backend.
pub fn render_scene(&self, view: &wgpu::TextureView) { pub fn render_scene(&self, view: &wgpu::TextureView) {
let size = self.window().inner_size(); let size = self.window().inner_size();
let aspect = size.width as f32 / size.height.max(1) as f32; let aspect = size.width as f32 / size.height.max(1) as f32;
self.renderer().render_scene(view, &self.scene, aspect); 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(())
} }
} }
@@ -145,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 {
@@ -155,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.
@@ -170,6 +289,73 @@ impl AppBuilder {
self.height = height; self.height = height;
self self
} }
/// Enables GPU frustum culling (Step 15, D8). When true, entities whose bounding sphere is
/// fully outside the camera frustum are skipped (their indirect draw args are zeroed on the
/// GPU). Defaults to **off** (non-regression): the culling compute pass still runs but marks
/// every active entity visible, so the rendered image is identical to culling-off.
pub fn with_culling(mut self, enabled: bool) -> Self {
self.culling = enabled;
self
}
/// Sets the shadow mapping configuration (map size, depth/slope bias, ortho frustum).
/// Defaults to `ShadowConfig::default()` (1024² map, bias 0.002, slope 0.004, radius 5.0).
pub fn with_shadow_config(mut self, config: ShadowConfig) -> Self {
self.shadow_config = config;
self
}
/// Enables HDR rendering with the given tone mapping curve (Étape 20). The main pass
/// renders into an offscreen `Rgba16Float` texture, then a fullscreen tone mapping pass
/// compresses the result to [0,1] and writes it to the sRGB surface. Without this call,
/// the renderer draws directly to the surface (LDR, zero overhead).
pub fn with_hdr(mut self, tonemapper: ToneMapper) -> Self {
self.hdr = Some(tonemapper);
self
}
/// 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 /// 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 /// 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. /// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
@@ -179,14 +365,22 @@ impl AppBuilder {
let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?; let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
Ok(App { Ok(App {
scene: Scene::new(), scene: Scene::new(),
input: InputState::new(),
title: self.title, title: self.title,
width: self.width, width: self.width,
height: self.height, 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),
context: None, context: None,
renderer: None, renderer: None,
window: None, window: None,
cache: None,
}) })
} }
} }
@@ -201,6 +395,22 @@ struct AppRunner<H: AppHandler> {
width: u32, width: u32,
/// Window height in pixels, applied when the window is created in `resumed`. /// Window height in pixels, applied when the window is created in `resumed`.
height: u32, 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. /// The user-provided game logic.
handler: H, handler: H,
/// The fully-built App facade, populated on the first `resumed` event. /// The fully-built App facade, populated on the first `resumed` event.
@@ -227,27 +437,50 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
.expect("failed to create window"), .expect("failed to create window"),
); );
// Initialization GPU (bloquant, simplifié au max) // GPU initialization (blocking, kept as simple as possible)
let context = pollster::block_on(Context::new(window.clone())).expect("Échec init GPU"); let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
let format = context let format = context
.configure(&context.adapter, self.width, self.height) .configure(&context.adapter, self.width, self.height)
.expect("Échec configuration surface"); .expect("surface configuration failed");
let device = Arc::new(context.device.clone()); let device = Arc::new(context.device.clone());
let cache = PipelineCache::new(device); let renderer =
let renderer = Renderer::new(&context, format); 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 { let mut app = App {
scene: Scene::new(), scene,
input: InputState::new(),
title: self.title.clone(), title: self.title.clone(),
width: self.width, width: self.width,
height: self.height, 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, event_loop: None,
context: Some(context), context: Some(context),
renderer: Some(renderer), renderer: Some(renderer),
window: Some(window), window: Some(window),
cache: Some(cache),
}; };
// On laisse l'utilisateur enregistrer shaders/meshes/matériaux/entités une fois le GPU prêt. // Let the user register shaders/meshes/materials/entities once the GPU is ready.
self.handler.setup(&mut app); self.handler.setup(&mut app);
self.app = Some(app); self.app = Some(app);
} }
@@ -258,7 +491,23 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
let Some(app) = self.app.as_mut() else { let Some(app) = self.app.as_mut() else {
return; 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); self.handler.update(app);
app.input.end_frame();
app.window().request_redraw(); app.window().request_redraw();
} }
@@ -273,14 +522,34 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
let Some(app) = self.app.as_mut() else { let Some(app) = self.app.as_mut() else {
return; return;
}; };
// Step 15 (input): feed the unified state from winit events (keyboard/mouse/wheel).
app.input.handle_window_event(&event);
match 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 => { 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 // Rendering logic
let frame = app.context().get_next_frame(); let frame = app.context().get_next_frame();
// On appelle le render() de l'utilisateur (reçoit la frame courante) // Call the user's render() (receives the current frame)
self.handler.render(app, &frame); self.handler.render(app, &frame);
// On présente automatiquement // Present automatically
app.renderer().present(frame); app.renderer().present(frame);
} }
WindowEvent::CloseRequested => { WindowEvent::CloseRequested => {
+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
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
//! 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`. /// A per-frame RAII wrapper around the surface texture and its `TextureView`.
+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);
}
}
+22
View File
@@ -9,11 +9,33 @@
//! - `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 particles;
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 particles::{BlendingMode, ParticleDriver, ParticlePool, ParticlePoolConfig};
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());
}
}
+361
View File
@@ -0,0 +1,361 @@
//! # Particles — Pool Infrastructure (Étape 28 A)
//!
//! Implements the **pool** side of the Pool ≠ Driver architecture
//! (ARCHI_PARTICULES §1–§3, §6–§8): the pool owns the GPU resources — the
//! per-particle state buffer (80 B/slot, D15/D19), the compaction index buffer
//! and the indirect draw args (D17), the per-pool camera-params uniform, the
//! billboard render pipeline and its bind group — while *drivers* (GPU/CPU/Manual,
//! Étapes B/C/D) provide the simulation logic through the [`ParticleDriver`] trait.
//!
//! A pool without an attached driver costs nothing at render time: its indirect
//! args stay zero, so the draw is a no-op (D12). Pools are created through
//! [`Scene::create_particle_pool`](crate::scene::Scene::create_particle_pool).
use crate::pipeline::DEPTH_FORMAT;
use crate::resources::Particle;
use crate::utils::PARTICLE_BILLBOARD_SHADER;
use wgpu::util::DeviceExt;
/// Default pool capacity (particle slots).
pub const DEFAULT_POOL_CAPACITY: u32 = 1024;
/// Size in bytes of the per-pool camera-params uniform (view + proj — a prefix
/// of `FrameUniforms`, so it can later be fed from the same buffer).
pub const CAMERA_PARAMS_SIZE: u64 = 128;
/// Blend mode frozen at pipeline creation (D9): one mode per pool.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum BlendingMode {
/// Standard alpha blending (`SrcAlpha`/`OneMinusSrcAlpha` on color,
/// `One`/`OneMinusSrcAlpha` on alpha).
#[default]
Alpha,
/// Additive blending (`One`/`One` on both) — flames, sparks, glows.
Additive,
}
impl BlendingMode {
/// The wgpu blend state corresponding to this mode.
fn state(&self) -> wgpu::BlendState {
match self {
BlendingMode::Alpha => wgpu::BlendState::ALPHA_BLENDING,
BlendingMode::Additive => wgpu::BlendState::ADDITIVE,
}
}
}
/// Configuration for [`ParticlePool::new`].
#[derive(Clone, Debug)]
pub struct ParticlePoolConfig {
/// Pool capacity (particle slots). Default: [`DEFAULT_POOL_CAPACITY`].
pub max_count: u32,
/// Id of a texture already registered in the Scene. `None` → the built-in
/// 16×16 disc (D11).
pub texture: Option<String>,
/// Blend mode frozen at pipeline creation (D9).
pub blending: BlendingMode,
}
impl Default for ParticlePoolConfig {
fn default() -> Self {
Self {
max_count: DEFAULT_POOL_CAPACITY,
texture: None,
blending: BlendingMode::default(),
}
}
}
/// Common trait implemented by every driver type (ARCHI §4.1). The pool calls
/// these methods in order each frame (wired into the render loop at Étape E).
pub trait ParticleDriver: Send {
/// Called BEFORE the compute (if the driver is GPU) or before the draw.
/// The driver may write into the pool's buffers (spawns, CPU updates).
fn pre_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool, dt: f32);
/// Called AFTER the compute (GPU driver only). Lets the driver update
/// post-simulation uniforms.
fn post_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool);
/// Does the driver want a compute dispatch this frame?
fn needs_compute(&self) -> bool;
/// Does the driver want the pool drawn?
fn needs_draw(&self) -> bool;
}
/// The particle pool: all GPU resources needed to simulate and draw particles,
/// without the simulation logic (which is the driver's job, D1).
///
/// `allow(dead_code)`: in Étape 28 A the resources are created but not yet consumed —
/// Étapes B/E wire the driver, the compaction dispatch and the indirect draw into the
/// render loop. The allow keeps the "zero warnings" acceptance criterion in the meantime.
#[allow(dead_code)]
pub struct ParticlePool {
/// Per-particle state: N × 80 B (D15/D19). STORAGE | COPY_DST, zeroed (all dead).
pub(crate) particle_data: wgpu::Buffer,
/// Compaction index: N × u32, one per slot (D17/D19). STORAGE | COPY_DST, zeroed.
pub(crate) compact_index: wgpu::Buffer,
/// Indirect draw args: 16 B (4 × u32) (D17). Zeroed → the draw is a no-op (D12).
pub(crate) indirect_args: wgpu::Buffer,
/// Per-pool camera params: 128 B (view + proj). UNIFORM | COPY_DST, owned by
/// the pool; the renderer writes it each frame (Étape E).
pub(crate) camera_params: wgpu::Buffer,
/// Billboard render pipeline (empty vertex layout, D6).
pub(crate) pipeline: wgpu::RenderPipeline,
/// The pool's bind group layout (5 bindings, ARCHI §6).
pub(crate) layout: wgpu::BindGroupLayout,
/// The single render bind group, built once at creation (D17/D19: the pool
/// owns all its buffers — self-contained).
pub(crate) bind_group: wgpu::BindGroup,
/// Texture view bound in the group (a scene texture, or the owned default disc, D11).
pub(crate) texture_view: wgpu::TextureView,
/// Pool capacity (particle slots).
pub max_count: u32,
/// Blend mode frozen at pipeline creation (D9).
pub blending: BlendingMode,
/// Attached driver (Étapes B/C/D). `None` → the pool is inactive (D12).
pub(crate) driver: Option<Box<dyn ParticleDriver>>,
}
impl ParticlePool {
/// Creates the pool's GPU resources: the 4 buffers, the shader module, the
/// bind group layout, the render pipeline (eagerly compiled) and the bind group.
pub fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
sample_count: u32,
config: &ParticlePoolConfig,
texture_view: wgpu::TextureView,
sampler: wgpu::Sampler,
) -> Self {
let n = config.max_count;
// --- Buffers (ARCHI §3.2/§6) — zeroed: all particles dead, empty draw args.
let particle_data = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("particle pool: particle_data"),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
contents: &vec![0u8; n as usize * Particle::SIZE as usize],
});
let compact_index = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("particle pool: compact_index"),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
contents: &vec![0u8; n as usize * 4],
});
let indirect_args = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("particle pool: indirect_args"),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
contents: &[0u8; 16],
});
let camera_params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("particle pool: camera_params"),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
contents: &vec![0u8; CAMERA_PARAMS_SIZE as usize],
});
// --- Shader + bind group layout (5 bindings, ARCHI §6).
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("particle_billboard"),
source: wgpu::ShaderSource::Wgsl(PARTICLE_BILLBOARD_SHADER.into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("particle pool: bind group layout"),
entries: &[
// 0: camera params (uniform, 128 B)
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,
},
// 1: particle state (storage read-only)
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 2: compaction index (storage read-only, D17/D19)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 3: sampler
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
// 4: particle texture
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
// --- Render pipeline: EMPTY vertex layout (D6/D17/D19) — the quad is
// generated in the shader (QUAD[vi]), the instance slot comes from
// storage binding 2.
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("particle pool: pipeline layout"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let blend = config.blending.state();
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("particle pool: billboard pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[], // empty layout (D17/D19)
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(blend),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(false), // D10
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: sample_count,
..Default::default()
},
multiview_mask: None,
cache: None,
});
// --- Bind group (built once: the pool owns all its buffers, D17/D19).
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("particle pool: bind group"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: camera_params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: particle_data.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: compact_index.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&sampler),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(&texture_view),
},
],
});
Self {
particle_data,
compact_index,
indirect_args,
camera_params,
pipeline,
layout,
bind_group,
texture_view,
max_count: n,
blending: config.blending,
driver: None,
}
}
}
/// RGBA bytes of the built-in 16×16 soft disc (D11), used when a pool has no texture.
/// A radial gradient (opaque center → transparent edge), white.
pub(crate) fn default_disc_rgba() -> 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) * 255.0) as u8;
let i = (y * size + x) * 4;
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = alpha;
}
}
data
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pool_config_default() {
let c = ParticlePoolConfig::default();
assert_eq!(c.max_count, DEFAULT_POOL_CAPACITY);
assert!(c.texture.is_none());
assert_eq!(c.blending, BlendingMode::Alpha);
}
#[test]
fn default_disc_size() {
assert_eq!(default_disc_rgba().len(), 16 * 16 * 4);
}
#[test]
fn default_disc_center_opaque() {
let d = default_disc_rgba();
// 16×16 is even: the exact center falls between the four middle pixels, so the
// highest alpha is 1 - sqrt(2)/15 ≈ 0.905 → 230, not 255. The soft disc must still
// be (nearly) opaque at its core.
let i = (7 * 16 + 7) * 4;
assert!(d[i + 3] >= 200, "center alpha was {}", d[i + 3]);
}
#[test]
fn default_disc_corner_transparent() {
let d = default_disc_rgba();
assert_eq!(d[3], 0); // pixel (0, 0)
assert_eq!(d[(15 * 16 + 15) * 4 + 3], 0); // pixel (15, 15)
}
}
+1840 -93
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}");
}
}
+1 -2
View File
@@ -26,8 +26,7 @@ use crate::core::Frame;
pub trait AppHandler { pub trait AppHandler {
/// Called once by `App::run`, right after the window/GPU context are created (winit `resumed`). /// 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 /// Use it to register shaders, build Meshes/Materials, and populate `app.scene` before the loop
/// starts. This replaces the pre-`run` setup that was possible before the winit 0.30 migration. /// starts. Default implementation does nothing.
/// Default implementation does nothing.
/// Inputs: app — mutable reference to the fully-initialized App facade. /// Inputs: app — mutable reference to the fully-initialized App facade.
fn setup(&mut self, _app: &mut App) {} 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,
+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));
}
}
+33 -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,21 @@
//! ```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 if a public API item has no rustdoc comment, keeping API coverage at 100%.
#![warn(missing_docs)] #![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 math; 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;
@@ -44,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;

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