doc
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# User documentation — WSG
|
||||
|
||||
**Usage** documentation for the `wsg-lib` crate: how to build a 3D rendering application
|
||||
without touching wgpu directly. It targets a developer with basic Rust knowledge; no prior
|
||||
GPU graphics background is required.
|
||||
|
||||
> **Not to be confused**: these pages explain *how to use* the API. The **technical**
|
||||
> documentation (internal architecture, design decisions, future targets) lives in
|
||||
> [../tech/](../tech/ARCHI_APP.md), and the exhaustive API reference is generated by rustdoc
|
||||
> (`cargo doc -p wsg-lib --no-deps`).
|
||||
|
||||
## Where to start
|
||||
|
||||
1. [Quickstart](quickstart.md) — your first window and your first object, in ~30 lines.
|
||||
2. Then, at your own pace, depending on what you need:
|
||||
|
||||
| Page | Topic |
|
||||
|-------|-------|
|
||||
| [Meshes](meshes.md) | Geometries: procedural primitives, custom `Geometry`, entities and `Transform` |
|
||||
| [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures |
|
||||
| [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` |
|
||||
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall |
|
||||
| [Camera & input](camera-input.md) | Active camera, orbital controller, unified keyboard/mouse state |
|
||||
| [Examples](examples.md) | The 7 repo examples, the advanced `manual` workflow, adding your own example |
|
||||
|
||||
The pages are cross-linked: each page ends with a link to the next one.
|
||||
|
||||
## Links
|
||||
|
||||
- Technical documentation (architecture): [ARCHI_APP](../tech/ARCHI_APP.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md) · [ARCHI_CPU_GPU](../tech/ARCHI_CPU_GPU.md) · [ARCHI_ARENES](../tech/ARCHI_ARENES.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
|
||||
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md) · [DRAFT](../DRAFT.md)
|
||||
- Full API reference: `cargo doc -p wsg-lib --no-deps`
|
||||
@@ -0,0 +1,110 @@
|
||||
# Camera & input
|
||||
|
||||
Two bricks drive the viewpoint: the scene's **active `Camera`** (view/projection matrices
|
||||
built every frame) and the unified **`InputState`** (keyboard/mouse, cross-frame
|
||||
semantics). The orbital **`CameraController`** bridges the two.
|
||||
|
||||
## 1. The active camera
|
||||
|
||||
The scene holds a single camera, read by the engine every frame to write the view/projection
|
||||
matrices into the frame buffer (aspect recomputed from the window size).
|
||||
|
||||
```rust
|
||||
use wsg_lib::resources::Camera;
|
||||
use glam::Vec3;
|
||||
|
||||
app.scene.set_camera(Camera::new(
|
||||
Vec3::new(3.0, 2.0, 3.0), // eye position
|
||||
Vec3::ZERO, // target point
|
||||
Vec3::Y, // "up" vector
|
||||
));
|
||||
```
|
||||
|
||||
- **Default**: position `(0, 0, 3)`, looking at the origin, 45° vertical fov, near 0.1,
|
||||
far 100 — frames a unit cube with no tuning.
|
||||
- `Camera::with_perspective(fov, near, far)` adjusts the projection (fov in radians).
|
||||
- Read: `app.scene.camera()`; direct mutation: `app.scene.camera_mut()`.
|
||||
- The `up` field matters: the orbital camera forces it to `+Y` (level horizon).
|
||||
|
||||
> The matrices use the **WebGPU** convention (NDC depth `[0,1]`) — do not replace
|
||||
> `projection_matrix` with an OpenGL `[-1,1]` projection, the near part of the frustum would
|
||||
> be clipped.
|
||||
|
||||
## 2. The orbital controller
|
||||
|
||||
`CameraController` represents the viewpoint in spherical coordinates around a target:
|
||||
`yaw` (azimuth around +Y), `pitch` (elevation, bounded to ±~83°), `distance` (radius,
|
||||
bounded to `[0.1, 100]`), `target` (target point).
|
||||
|
||||
```rust
|
||||
use wsg_lib::resources::CameraController;
|
||||
|
||||
let mut ctrl = CameraController::default(); // target at origin, distance 3, front view
|
||||
ctrl.orbit(dx, dy); // mouse drag: yaw/pitch (bounded pitch, no poles)
|
||||
ctrl.zoom(scroll_y); // wheel: zoom (positive scroll = move closer)
|
||||
ctrl.reset(); // back to the default framing
|
||||
ctrl.apply_to(app.scene.camera_mut()); // write the framing into the active camera (do this EVERY frame)
|
||||
```
|
||||
|
||||
`CameraController::from_camera(&cam)` rebuilds a controller from an existing camera
|
||||
(useful to start the orbit from a manual framing).
|
||||
|
||||
The exact wiring snippet (orbit + zoom + reset + `1`/`2`/`3` presets, driven from
|
||||
`app.input`) is in [`demo.rs`](../../lib/examples/demo.rs), `update()` section.
|
||||
|
||||
## 3. The unified input state
|
||||
|
||||
`app.input` (public field of `App`) is fed by winit events and **rotated** automatically
|
||||
every frame (`begin_frame`/`end_frame` around your `update`). Three semantics per control:
|
||||
|
||||
| Semantics | Methods | Meaning |
|
||||
|------------|----------|---------|
|
||||
| **pressed** | `key_pressed(code)`, `mouse_button_pressed(btn)` | true **only** on the frame the key/button was just pressed |
|
||||
| **held** | `key_held(code)`, `mouse_button_held(btn)` | true while the key/button stays down |
|
||||
| **released** | `key_released(code)`, `mouse_button_released(btn)` | true **only** on the release frame |
|
||||
|
||||
Plus: `mouse_position() -> (f32, f32)`, `mouse_delta() -> (f32, f32)` (accumulated over the
|
||||
frame, reset between frames), `scroll_delta() -> (f32, f32)` (wheel).
|
||||
|
||||
`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::keyboard::KeyCode;
|
||||
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
// Orbit + zoom driven by the mouse (excerpts from demo):
|
||||
let (dx, dy) = app.input.mouse_delta();
|
||||
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`) |
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md)
|
||||
- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
|
||||
@@ -0,0 +1,47 @@
|
||||
# Examples
|
||||
|
||||
Seven examples live in [`lib/examples/`](../../lib/examples/) and all launch with
|
||||
`cargo run -p wsg-lib --example <name>`. They are **self-contained**: no assets on disk
|
||||
(procedural textures, hard-coded geometries).
|
||||
|
||||
| Example | Command | What it shows | Corresponding page |
|
||||
|---------|----------|---------------|--------------------|
|
||||
| `simple` | `cargo run -p wsg-lib --example simple` | The minimal declarative workflow: a two-tone 2D quad, **unlit**, rendered automatically. The "15 lines, no wgpu" model | [Quickstart](quickstart.md), [Materials](materials.md) (§ unlit) |
|
||||
| `cube` | `cargo run -p wsg-lib --example cube` | The 3D MVP: a textured (checkerboard) cube, lit (directional + point + spot), spinning | [Meshes](meshes.md), [Materials](materials.md), [Lights](lights.md) |
|
||||
| `demo` | `cargo run -p wsg-lib --example demo` | The full showcase: ground + 6 primitives, textures, 3 lights, **shadows**, **orbital camera** on keyboard/mouse (drag = orbit, wheel = zoom, `R` = reset, `1`/`2`/`3` = presets) | [All pages](README.md) |
|
||||
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Isolated shadow mapping: a cube casts a PCF-softened shadow on the ground (`clear_lights` technique → caster at index 0) | [Shadows](shadows.md) |
|
||||
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Isolated spot (ambient nearly zero): the directed beam, the penumbra, the attenuation | [Lights](lights.md) |
|
||||
| `manual` | `cargo run -p wsg-lib --example manual` | The **advanced** workflow: `Context`/`Renderer`/`PipelineCache` driven by hand, without the `App` facade (winit 0.30 `ApplicationHandler`) | below |
|
||||
|
||||
## The `manual` workflow (advanced)
|
||||
|
||||
When the `App` facade doesn't fit (fine-grained loop control, integration into an existing
|
||||
framework, experimentation), you bypass `App` and drive directly:
|
||||
|
||||
- `Context` (*Manager* layer): GPU lifecycle — `Instance`/`Surface`/`Adapter`/`Device`/
|
||||
`Queue`, `configure()` for the swapchain, `get_next_frame()`.
|
||||
- `Renderer` (*Executor* layer): `render(view, mesh, material)` = one object per submission;
|
||||
`present(frame)`.
|
||||
- `PipelineCache`: `register_shader(id, path)` then `Material::new(format, id, &mut cache)`.
|
||||
|
||||
The window and GPU are created in winit 0.30's `resumed()` callback (`run_app` +
|
||||
`ApplicationHandler`), as in `app.rs`. The reference file is
|
||||
[`manual.rs`](../../lib/examples/manual.rs); the two-layer architecture is detailed in
|
||||
[ARCHI_APP](../tech/ARCHI_APP.md) and [FRAME_LOOP](../tech/FRAME_LOOP.md).
|
||||
|
||||
> **Tip**: start with the declarative workflow. The manual workflow doesn't render more
|
||||
> pixels — it gives more control over command encoding.
|
||||
|
||||
## Adding your own example
|
||||
|
||||
Repo conventions (see `lib/examples/README.md`):
|
||||
|
||||
1. Create `lib/examples/my_example.rs` (Cargo discovers it automatically).
|
||||
2. Keep it **self-contained**: procedural textures, hard-coded geometries, no external assets.
|
||||
3. Use the declarative workflow (`AppBuilder` + `Scene`) when possible.
|
||||
4. Document the example in `lib/examples/README.md` (and here, `docs/user/examples.md`).
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Quickstart](quickstart.md) · [Camera & input](camera-input.md)
|
||||
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md)
|
||||
@@ -0,0 +1,84 @@
|
||||
# Lights
|
||||
|
||||
Lights are **scene-global**: a single list is packed into the frame uniforms every frame, and
|
||||
**all** entities receive their lighting (per-material lights are out of the current scope).
|
||||
|
||||
## Model
|
||||
|
||||
- Bounded capacity: **`MAX_LIGHTS = 8`** lights in total (directional + point + spot
|
||||
combined). Adding beyond that returns an error.
|
||||
- **Default**: one white directional light along **+Z** (from the surface point toward the
|
||||
light) + white ambient. This default exactly reproduces the historical single-light
|
||||
rendering — your scene "just works" with no configuration.
|
||||
- Ambient (`set_ambient`) is a global hemispherical term, independent of the lights.
|
||||
|
||||
## Adding lights
|
||||
|
||||
```rust
|
||||
use glam::Vec3;
|
||||
|
||||
// Directional: `dir` points FROM the surface point TOWARD the light.
|
||||
app.scene
|
||||
.add_directional_light(Vec3::new(1.0, 1.2, 1.0).normalize(), [1.0, 0.98, 0.92], 1.5)
|
||||
.unwrap();
|
||||
|
||||
// Point: world position, tint, intensity, attenuation radius (linear down to 0).
|
||||
app.scene
|
||||
.add_point_light(Vec3::new(0.5, 1.6, 1.8), [1.0, 0.7, 0.3], 1.2, 6.0)
|
||||
.unwrap();
|
||||
|
||||
// Spot: position, cone axis (FROM the light TOWARD the scene), tint, intensity, radius,
|
||||
// half-angle in radians (penumbra smoothed at the edge).
|
||||
app.scene.add_spot_light(
|
||||
Vec3::new(-2.5, 2.2, 1.0), // position
|
||||
Vec3::new(2.5, -2.2, -1.0).normalize(), // axis, toward the scene
|
||||
[0.3, 1.0, 0.5], // green tint
|
||||
1.4, 8.0, 0.45, // intensity, radius, half-angle (~26°)
|
||||
).unwrap();
|
||||
```
|
||||
|
||||
These three calls are the ones in the [`demo`](../../lib/examples/demo.rs) example;
|
||||
[`cube.rs`](../../lib/examples/cube.rs) shows a point + a spot on top of the default
|
||||
directional, and [`spot_test.rs`](../../lib/examples/spot_test.rs) isolates a single spot
|
||||
(ambient nearly zero).
|
||||
|
||||
Global settings:
|
||||
|
||||
| Method | Effect |
|
||||
|---------|--------|
|
||||
| `set_ambient([r, g, b])` | hemispherical ambient color (default white) |
|
||||
| `clear_lights()` | empties the list — only ambient will light the scene (useful for a flat look without switching to unlit) |
|
||||
| `set_lights(Lights)` | replaces the whole list (batch reset) |
|
||||
| `lights()` | reads the current list |
|
||||
|
||||
## ⚠️ Packed indices (important for shadows)
|
||||
|
||||
Lights are stacked in the GPU array **by type, in order**:
|
||||
|
||||
```
|
||||
index 0 .. n_dir-1 : directional
|
||||
index n_dir .. +n_point-1 : point
|
||||
index … .. +n_spot-1 : spot
|
||||
```
|
||||
|
||||
Two consequences:
|
||||
|
||||
1. **Index 0 is the default +Z directional** (the one `Lights::new()` pre-loads),
|
||||
not your first added light. This is a classic pitfall — see
|
||||
[Shadows](shadows.md).
|
||||
2. If you want **your** light to be the only one (and thus at index 0), clear the list
|
||||
first: `app.scene.clear_lights();` then `add_*_light(…)` (this is the technique in
|
||||
[`shadow_test.rs`](../../lib/examples/shadow_test.rs)).
|
||||
|
||||
## Intensities and tints
|
||||
|
||||
- `color` is an RGB in `[0..1]`; `intensity` is an unbounded multiplier.
|
||||
- Local lights (point/spot) attenuate **linearly** — intensity drops to zero at `radius`.
|
||||
Beyond the radius, the light contributes nothing.
|
||||
- The `standard` shader accumulates ambient + all lights (no mutual occlusion between
|
||||
lights; the spot cone culling happens at the fragment).
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Shadows](shadows.md) · [Materials & textures](materials.md)
|
||||
- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
|
||||
@@ -0,0 +1,100 @@
|
||||
# Materials & textures
|
||||
|
||||
A **`Material`** describes a mesh's appearance: it references a shader (by id) and
|
||||
optionally a **diffuse texture**. Several materials pointing at the same shader share the
|
||||
same compiled GPU pipeline (the `PipelineCache` held by the scene).
|
||||
|
||||
The engine ships a single shader: **`standard`** — multi-light Phong lighting (see
|
||||
[Lights](lights.md)), with an **unlit** mode for flat rendering.
|
||||
|
||||
## 1. Registering the shader
|
||||
|
||||
```rust
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
```
|
||||
|
||||
> **Note**: `STANDARD_SHADER_PATH` points to an optional file on disk; if it is missing
|
||||
> (the normal case for the embedded library), loading falls back to the shader **embedded at
|
||||
> compile time** (`include_str!`, byte-identical). The fallback message you may see is
|
||||
> therefore **expected and harmless**.
|
||||
|
||||
For a custom shader: register your `.wgsl` file path under an id of your choice (it must
|
||||
expose the same bind groups as `standard` — frame @0, object @1, texture @2, shadow @3 — see
|
||||
[ARCHI_RENDU](../tech/ARCHI_RENDU.md) and the
|
||||
[`shaders/standard_shader.wgsl`](../../lib/src/shaders/standard_shader.wgsl) file).
|
||||
|
||||
## 2. Creating materials
|
||||
|
||||
```rust
|
||||
// Textureless material: the color comes from per-vertex colors (or white by default).
|
||||
app.scene.add_material_shader("mat", "standard").unwrap();
|
||||
|
||||
// Textured material: the texture must first be registered in the scene (below).
|
||||
app.scene.add_material_texture("mat_textured", "standard", "my_texture").unwrap();
|
||||
```
|
||||
|
||||
Binding a material to a mesh happens at mesh creation (see [Meshes](meshes.md)):
|
||||
|
||||
```rust
|
||||
app.scene.create_mesh("cube_mesh", cube(1.0), Some("mat_textured")).unwrap();
|
||||
```
|
||||
|
||||
A mesh created with `material = None` is rendered with the scene's **default material**
|
||||
(`standard`, built once then cached) — that is the behavior of the
|
||||
[`simple`](../../lib/examples/simple.rs) example.
|
||||
|
||||
## 3. Diffuse textures
|
||||
|
||||
`Texture` is a GPU image in `Rgba8UnormSrgb` (linear sampler, repeat addressing).
|
||||
Four constructors:
|
||||
|
||||
| Constructor | Usage |
|
||||
|--------------|-------|
|
||||
| `Texture::from_rgba8(device, queue, w, h, rgba, label)` | raw RGBA8 bytes (procedural) |
|
||||
| `Texture::from_bytes(device, queue, label, bytes)` | encoded data (PNG/JPEG… via the `image` crate) |
|
||||
| `Texture::from_file(device, queue, label, path)` | image file on disk |
|
||||
| `Texture::white_placeholder(device, queue)` | 1×1 white — used internally when a material has no texture |
|
||||
|
||||
You get `device`/`queue` in `setup()` via `app.context()`:
|
||||
|
||||
```rust
|
||||
let (device, queue) = {
|
||||
let ctx = app.context();
|
||||
(ctx.device.clone(), ctx.queue.clone())
|
||||
};
|
||||
let texture = Texture::from_rgba8(&device, &queue, 8, 8, &my_rgba, "checker").unwrap();
|
||||
app.scene.add_texture("checker_texture", texture).unwrap();
|
||||
app.scene.add_material_texture("ground_mat", "standard", "checker_texture").unwrap();
|
||||
```
|
||||
|
||||
The exact snippet (8×8 checkerboard + stripes generation) is in
|
||||
[`demo.rs`](../../lib/examples/demo.rs) and [`cube.rs`](../../lib/examples/cube.rs).
|
||||
|
||||
Two conditions for a texture to show up:
|
||||
1. the material is created via `add_material_texture` (otherwise the 1×1 white placeholder
|
||||
is bound — no visual effect, no regression);
|
||||
2. the `Geometry` carries **UVs** (`.with_uvs(…)`). Without UVs, sampling is constant.
|
||||
The procedural primitives (`uv_sphere`, `cube`, …) already provide them.
|
||||
|
||||
## 4. Unlit mode (flat / 2D rendering)
|
||||
|
||||
"Flat" rendering (vertex colors as-is, no lighting) is a **renderer switch**, not a material:
|
||||
|
||||
```rust
|
||||
app.renderer_mut().set_unlit(true); // in setup()
|
||||
```
|
||||
|
||||
This is the mode of the `simple` example (2D quad). In this mode the scene's lights are
|
||||
ignored; per-vertex colors (or white) are rendered directly. 2D is a special case of 3D:
|
||||
the single `standard` pipeline serves both.
|
||||
|
||||
> `clear_lights()` (see [Lights](lights.md)) gives a similar result but keeps the lit
|
||||
> pipeline: only ambient stays active. Use it when you want to "turn off the lights" without
|
||||
> switching to unlit.
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Meshes](meshes.md) · [Lights](lights.md) · [Examples](examples.md)
|
||||
- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
|
||||
@@ -0,0 +1,125 @@
|
||||
# Meshes: geometries, entities and transforms
|
||||
|
||||
A displayed object in WSG goes through three levels:
|
||||
|
||||
```
|
||||
Geometry (CPU, source of truth) ──► Mesh (GPU: vertex/index buffers) ──► Entity (placement in the scene)
|
||||
```
|
||||
|
||||
- **`Geometry`**: raw CPU-side data — positions + optional normals/UVs/colors/indices.
|
||||
- **`Mesh`**: GPU container (buffers uploaded once). It **retains** its `Arc<Geometry>` on the
|
||||
CPU side, along with its material.
|
||||
- **`Entity`**: a `mesh + Transform` association. This is the unit the engine draws. The same
|
||||
`Mesh` can be shared by several entities (each with its own `Transform`).
|
||||
|
||||
## 1. Procedural primitives (the shortest path)
|
||||
|
||||
The `math::primitives` module provides ready-to-use `Geometry` generators
|
||||
(positions + normals + UVs + indices):
|
||||
|
||||
| Function | Parameters | Result |
|
||||
|----------|-----------|--------|
|
||||
| `cube(size)` | side length | origin-centered cube, per-face normals |
|
||||
| `plane(width, depth, seg_x, seg_z)` | dimensions + subdivisions | horizontal plane (Y-up), UVs |
|
||||
| `uv_sphere(radius, sectors, stacks)` | radius + resolution | UV sphere (seam visible) |
|
||||
| `icosphere(radius, subdivisions)` | radius + subdivisions | smooth sphere (normalized, seam-free) |
|
||||
| `cylinder(radius, height, sectors)` | radius, height, resolution | centered cylinder |
|
||||
| `cone(radius, height, sectors)` | radius, height, resolution | cone (base at the bottom when translated in Y) |
|
||||
| `torus(major, minor, major_segments, minor_segments)` | radii + resolution | torus |
|
||||
|
||||
```rust
|
||||
use wsg_lib::math::{cube, icosphere, torus};
|
||||
|
||||
app.scene.create_mesh("cube_mesh", cube(0.8), Some("solid_mat")).unwrap();
|
||||
app.scene.create_mesh("sphere_mesh", icosphere(0.5, 2), Some("solid_mat")).unwrap();
|
||||
```
|
||||
|
||||
## 2. Custom `Geometry` (your own mesh)
|
||||
|
||||
`Geometry` is a builder: positions are mandatory, everything else is optional
|
||||
(sensible defaults are applied at upload — e.g. normal `[0,0,1]`, white color).
|
||||
|
||||
```rust
|
||||
use wsg_lib::resources::Geometry;
|
||||
|
||||
let geometry = Geometry::new(vec![
|
||||
[-0.5, 0.5, 0.0],
|
||||
[ 0.5, 0.5, 0.0],
|
||||
[ 0.5, -0.5, 0.0],
|
||||
[-0.5, -0.5, 0.0],
|
||||
])
|
||||
.with_normals(vec![[0.0, 0.0, 1.0]; 4]) // required for lighting (Phong)
|
||||
.with_colors(vec![
|
||||
[1.0, 0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 0.0, 1.0],
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0, 1.0],
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 0, 2, 3]); // triangulation (without indices: triangle list)
|
||||
```
|
||||
|
||||
Other attributes: `.with_uvs(vec![[u, v], …])` (required for textures — see
|
||||
[Materials & textures](materials.md)). `geometry.validate()` checks the arrays for
|
||||
consistency (aligned lengths, indices in range) before upload.
|
||||
|
||||
> **Indices**: `Vec<u16>` — a custom mesh must therefore stay under 65,536 vertices. The
|
||||
> engine's primitives respect this limit.
|
||||
|
||||
## 3. Registering in the scene
|
||||
|
||||
```rust
|
||||
// The mesh is built (GPU buffers) and bound to its material in one call.
|
||||
// `material = None`: the scene will use its default material (`standard`) at render time.
|
||||
app.scene.create_mesh("cube_mesh", geometry, Some("cube_material"))?;
|
||||
|
||||
// The entity references the mesh by its id (String IDs).
|
||||
app.scene.add_entity("cube", "cube_mesh")?;
|
||||
// …or with an explicit placement:
|
||||
app.scene.add_entity_with_transform("cube", "cube_mesh", transform)?;
|
||||
```
|
||||
|
||||
All these methods return `Result<_, String>` (unifying the typed errors is on the
|
||||
horizon — see [ROADMAP](../ROADMAP.md)).
|
||||
|
||||
## 4. Moving / animating: the `Transform`
|
||||
|
||||
Placement lives on the **entity** (not on the mesh): `Transform { translation: Vec3,
|
||||
rotation: Quat, scale: Vec3 }`, converted to a world matrix by the engine every frame.
|
||||
|
||||
The snippet below is the animation from the [`cube`](../../lib/examples/cube.rs) example:
|
||||
|
||||
```rust
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
self.angle += 0.02;
|
||||
let mut tf = *app.scene.entity_transform("cube").expect("entity present");
|
||||
tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.3);
|
||||
app.scene.set_entity_transform("cube", tf);
|
||||
}
|
||||
```
|
||||
|
||||
Other entity operations: `entity_transform(label)` (read), `remove_entity(label)` (hides
|
||||
without freeing resources), `entity_count()`.
|
||||
|
||||
> **Rotation order**: `Quat` does not commute — `rot_y * rot_x` is not `rot_x * rot_y`.
|
||||
> The order above (Y then X) gives a readable "top spinning" motion.
|
||||
|
||||
## 5. Mesh sharing
|
||||
|
||||
Create **one** mesh per geometry and as many entities as occurrences:
|
||||
|
||||
```rust
|
||||
app.scene.create_mesh("rock_mesh", icosphere(0.3, 1), Some("rock_mat")).unwrap();
|
||||
for i in 0..10 {
|
||||
let label = format!("rock_{i}");
|
||||
let mut tf = Transform::identity();
|
||||
tf.translation = Vec3::new(i as f32 * 0.8, 0.15, 0.0);
|
||||
app.scene.add_entity_with_transform(&label, "rock_mesh", tf).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
The GPU buffers are uploaded only once; only the world matrices differ.
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Quickstart](quickstart.md) · [Materials & textures](materials.md) · [Lights](lights.md)
|
||||
- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
|
||||
@@ -0,0 +1,131 @@
|
||||
# Quickstart
|
||||
|
||||
Goal: a window showing an object, with the render loop handled by the library. You will only
|
||||
write three things: a struct implementing `AppHandler`, your scene declaration in `setup()`,
|
||||
and your `main()`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A recent Rust toolchain (the library is **edition 2024** — run `rustup update` if needed).
|
||||
- A windowing environment (X11/Wayland on Linux, or native macOS/Windows).
|
||||
- WSG is **not published on crates.io**: it is consumed by file path.
|
||||
|
||||
## 1. Dependencies
|
||||
|
||||
In your application's `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wsg-lib = { path = "/path/to/wsg/lib" }
|
||||
pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (AppBuilder is async)
|
||||
```
|
||||
|
||||
## 2. The minimal application
|
||||
|
||||
This snippet is the [`simple`](../../lib/examples/simple.rs) example from the repo, almost
|
||||
verbatim: a flat two-tone quad, rendered automatically every frame.
|
||||
|
||||
```rust
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::resources::Geometry;
|
||||
use wsg_lib::utils::WsgError;
|
||||
use wsg_lib::AppHandler;
|
||||
|
||||
struct MyQuad;
|
||||
|
||||
impl AppHandler for MyQuad {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
// Flat 2D: the `standard` shader in unlit mode returns the vertex color as-is.
|
||||
app.renderer_mut().set_unlit(true);
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
|
||||
let geometry = Geometry::new(vec![
|
||||
[-0.5, 0.5, 0.0],
|
||||
[ 0.5, 0.5, 0.0],
|
||||
[ 0.5, -0.5, 0.0],
|
||||
[-0.5, -0.5, 0.0],
|
||||
])
|
||||
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
|
||||
.with_colors(vec![
|
||||
[1.0, 0.0, 0.0, 1.0], // red
|
||||
[0.0, 1.0, 0.0, 1.0], // green
|
||||
[0.0, 0.0, 1.0, 1.0], // blue
|
||||
[1.0, 1.0, 0.0, 1.0], // yellow
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 0, 2, 3]);
|
||||
|
||||
// `None`: the scene injects its default material (`standard`) at render time.
|
||||
app.scene.create_mesh("quad_mesh", geometry, None).unwrap();
|
||||
app.scene.add_entity("quad", "quad_mesh").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = AppBuilder::new().title("WSG Simple").build().await?;
|
||||
app.run(MyQuad)
|
||||
}
|
||||
```
|
||||
|
||||
Note: **no `wgpu` or `winit` imports** — the `App` facade encapsulates them entirely.
|
||||
|
||||
## 3. What the library does for you
|
||||
|
||||
The full lifecycle, as driven by `App::run` (technical details in
|
||||
[FRAME_LOOP](../tech/FRAME_LOOP.md)):
|
||||
|
||||
```
|
||||
AppBuilder::build() creates the event loop
|
||||
│
|
||||
App::run(handler) starts the loop
|
||||
│
|
||||
resumed (winit) window + GPU (Instance/Surface/Adapter/Device/Queue) + Renderer
|
||||
│
|
||||
handler.setup(&mut app) ← you declare the scene here (once, GPU ready)
|
||||
│
|
||||
▼ per frame, in a loop:
|
||||
input.begin_frame() current frame's keyboard/mouse state
|
||||
handler.update(&mut app) ← your logic (motion, input, …)
|
||||
input.end_frame()
|
||||
handler.render(app, frame) ← default: app.render_scene(frame.view())
|
||||
│ (the whole scene is drawn automatically, one pass per frame)
|
||||
└─ present → next frame
|
||||
```
|
||||
|
||||
So you implement:
|
||||
|
||||
| Hook | When | Role | Default |
|
||||
|------|-------|------|---------|
|
||||
| `setup(&mut self, app)` | once, GPU ready | declare shaders, materials, textures, meshes, entities, lights, camera | empty |
|
||||
| `update(&mut self, app)` | every frame, before render | animate: transforms, input, lights… | empty |
|
||||
| `render(&mut self, app, frame)` | every frame, after update | **default**: draws the whole scene; override for custom rendering | `app.render_scene(frame.view())` |
|
||||
|
||||
Golden rule: **mutate the scene in `update()`** (and `setup()`), only read it in `render()`
|
||||
(model detailed in [ARCHI_RENDU](../tech/ARCHI_RENDU.md)).
|
||||
|
||||
## 4. Running it
|
||||
|
||||
From the WSG repo root (the examples live in `lib/examples/`):
|
||||
|
||||
| Command | What you see |
|
||||
|----------|--------------|
|
||||
| `cargo run -p wsg-lib --example simple` | the quad above (flat 2D, unlit) |
|
||||
| `cargo run -p wsg-lib --example cube` | a textured, lit, spinning cube (3D) |
|
||||
| `cargo run -p wsg-lib --example demo` | the full showcase: 6 primitives + lights + shadows + orbital camera |
|
||||
|
||||
For your own application: create a crate, add the §1 dependency, paste the §2 code into
|
||||
`src/main.rs`, and `cargo run`.
|
||||
|
||||
## 5. Where to go next
|
||||
|
||||
- Want a 3D object? → [Meshes](meshes.md)
|
||||
- Want to change the look / add a texture? → [Materials & textures](materials.md)
|
||||
- Want lights? → [Lights](lights.md)
|
||||
- Want to see everything at once? → the `demo` example ([Examples](examples.md))
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Meshes](meshes.md) · [Examples](examples.md)
|
||||
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
|
||||
@@ -0,0 +1,79 @@
|
||||
# Shadows (shadow mapping)
|
||||
|
||||
Shadows are **off by default** and are enabled by designating **a single** casting light:
|
||||
|
||||
```rust
|
||||
app.scene.set_shadow_caster(Some(index)); // packed index — see the pitfall below
|
||||
app.scene.set_shadow_caster(None); // shadows off (default)
|
||||
```
|
||||
|
||||
Only a **directional or spot** light can cast shadows. A **point** light index disables the
|
||||
shadow pass (cubemap shadows are out of scope).
|
||||
|
||||
## ⚠️ The packed-index pitfall
|
||||
|
||||
`set_shadow_caster` takes the light's index **in the packed array** (directionals first,
|
||||
then point, then spot — recalled in [Lights](lights.md)).
|
||||
|
||||
**Index 0 is the default +Z directional** pre-loaded by `Lights::new()`, not necessarily
|
||||
your light. Symptom of a wrong index: the shadow camera looks in an unexpected direction and
|
||||
misaligned objects occlude each other (blackened objects, ghost shadows).
|
||||
|
||||
Two ways to avoid it:
|
||||
|
||||
1. **Clear the list before adding yours** — your light becomes index 0:
|
||||
|
||||
```rust
|
||||
app.scene.clear_lights(); // removes the default +Z
|
||||
app.scene.add_directional_light(dir, [1.0, 0.98, 0.92], 1.6).unwrap();
|
||||
app.scene.set_shadow_caster(Some(0)); // now it really is YOUR light
|
||||
```
|
||||
|
||||
This is the technique in [`shadow_test.rs`](../../lib/examples/shadow_test.rs).
|
||||
|
||||
2. **Count the indices** — if you keep the default light and add yours, it lands at index 1:
|
||||
|
||||
```rust
|
||||
app.scene.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.5).unwrap(); // → index 1
|
||||
app.scene.set_shadow_caster(Some(1)); // this is the demo's warm light that casts
|
||||
```
|
||||
|
||||
This is the technique in [`demo.rs`](../../lib/examples/demo.rs).
|
||||
|
||||
## How it works (to understand the limits)
|
||||
|
||||
Each frame, if a caster is active, the engine runs **two passes** (technical details in
|
||||
[FRAME_LOOP](../tech/FRAME_LOOP.md)):
|
||||
|
||||
1. **Shadow pass**: the scene is rendered as seen *from the light* (depth-only
|
||||
`shadow_shader.wgsl` shader) into a 1024² `Depth32Float` shadow map (size configurable
|
||||
via `SHADOW_MAP_SIZE`), with a depth bias (slope-scaled + constant) to avoid shadow acne.
|
||||
2. **Color pass**: the `standard` fragment shader re-projects each fragment into light space
|
||||
and compares its depth against the map via a **3×3 PCF** (softened shadow edges).
|
||||
|
||||
Things to know:
|
||||
|
||||
- **Directional light**: the shadow frustum is orthographic, centered on the scene center
|
||||
(`SHADOW_SCENE_CENTER`, radius `SHADOW_SCENE_RADIUS = 5.0` by default). Objects **far from
|
||||
the origin** may fall outside the frustum and stop casting.
|
||||
- **Spot light**: the light's cone naturally bounds the shadow.
|
||||
- Only one light casts at a time (no multi-light shadows).
|
||||
- Shadows only affect meshes rendered by `standard` in lit mode — a renderer in unlit mode
|
||||
(see [Materials & textures](materials.md)) receives none.
|
||||
|
||||
## Tuning shadow rendering
|
||||
|
||||
The constants `SHADOW_MAP_SIZE`, `SHADOW_DEPTH_BIAS`, `SHADOW_SCENE_RADIUS`,
|
||||
`SHADOW_SCENE_CENTER` are exposed in `wsg_lib::utils` (defaults: 1024, 0.006, 5.0, origin).
|
||||
|
||||
Tuning tips:
|
||||
|
||||
- **Speckled shadow edges (acne)**: raise the bias.
|
||||
- **Peter-panning** (shadow detached from the object): lower the bias.
|
||||
- **Shadow clipped at the scene edge**: raise the frustum radius (directional).
|
||||
- **Shadows too blurry, want them crisper**: raise the map size.
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md)
|
||||
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
|
||||
Reference in New Issue
Block a user