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)
This commit is contained in:
Jérôme Bousquié
2026-09-26 10:49:13 +02:00
parent fecfdcd2d2
commit e5f3636b42
32 changed files with 341 additions and 104 deletions
+15 -10
View File
@@ -6,8 +6,8 @@ PBR shading, file import, and the low-level (non-`App`) workflow.
| Example | Run command | What it shows |
|---------|-------------|---------------|
| `simple` | `cargo run -p wsg-lib --example simple` | The minimal declarative workflow: a flat two-tone quad, **unlit**, rendered automatically |
| `cube` | `cargo run -p wsg-lib --example cube` | The 3D MVP: a textured (checkerboard) cube, lit (directional + point + spot), spinning |
| `pbr` | `cargo run -p wsg-lib --example pbr` | PBR metallic/roughness + normal mapping |
| `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 |
@@ -37,12 +37,15 @@ No keys — static render.
## `cube` — The 3D MVP
A lit unit cube that rotates, **textured** with a procedural 8×8 checkerboard
via the diffuse path (bind group `@group(2)`). Follows the declarative workflow
(like `simple`): `AppBuilder` + automatic scene, **no wgpu import**. The texture
is generated procedurally (RGBA bytes → `Texture::from_rgba8`) to stay
self-contained; the default camera at (0, 0, 3) frames the cube, and
`update()` rotates the entity via `set_entity_transform` each frame.
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
@@ -68,8 +71,10 @@ cargo run -p wsg-lib --example pbr
| `R` | Reset camera |
Scene: 6 PBR materials (mirror metal, smooth plastic, rusty metal, ceramic,
bump map, matte floor). The bump-map cube shows procedural sin-wave surface
detail.
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.
---
+18 -22
View File
@@ -1,4 +1,5 @@
//! A lit unit cube that rotates, **textured** with a procedural checkerboard via the diffuse path
//! 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.
@@ -7,7 +8,9 @@
//! `add_material_shader`/`add_material_texture` + `create_mesh` + `add_entity`. The mesh
//! is declared from a **`Geometry`** (positions, normals, indices). A texture is
//! registered by id (`add_texture`) and a textured material bound to it (`add_material_texture`);
//! the texture is generated *procedurally* (RGBA 8×8 checkerboard) to stay self-contained, no on-disk asset.
//! 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};
@@ -17,27 +20,15 @@ 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,
}
/// Generates a *procedural* RGBA 8×8 checkerboard (white/brick), no on-disk asset, to texture the
/// cube. Returned as a raw RGBA8 `Vec<u8>`, loadable via `Texture::from_rgba8`.
fn checkerboard_rgba() -> Vec<u8> {
const SIZE: u32 = 8;
let mut rgba = Vec::with_capacity((SIZE * SIZE * 4) as usize);
for y in 0..SIZE {
for x in 0..SIZE {
let even = (x + y) % 2 == 0;
let (r, g, b) = if even { (255, 255, 255) } else { (190, 40, 40) };
rgba.extend_from_slice(&[r, g, b, 255]);
}
}
rgba
}
impl AppHandler for Cube {
fn setup(&mut self, app: &mut wsg_lib::App) {
// Phong shader `standard` (carries the frame + object + texture bind groups).
@@ -45,17 +36,22 @@ impl AppHandler for Cube {
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Builds the checkerboard texture with the Context's device/queue (via `app.context()`), then
// 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_rgba8(&device, &queue, 8, 8, &checkerboard_rgba(), "checker").unwrap();
app.scene.add_texture("checker_texture", texture).unwrap();
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", "checker_texture")
.add_material_texture("cube_material", "standard", "uv_texture")
.unwrap();
app.scene
+69 -39
View File
@@ -3,12 +3,12 @@
//! Démonstration du workflow PBR Cook-Torrance (GGX + Smith + Schlick) avec IBL hémisphérique.
//!
//! ## Scène
//! - Sol : plan 20×20, PBR matte (metallic=0, roughness=0.8)
//! - 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 normal map : bump procédural (sin wave)
//! - Cube cave : albedo `cave.jpg` + normal map `caveNormal.jpg` (assets, normal map pré-encodée sRGB)
//!
//! ## Contrôles
//! | Touche | Action |
@@ -51,18 +51,51 @@ impl AppHandler for PbrDemo {
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Normal map procédurale 256×256 : bump sin(x)*sin(y).
let bump_map = make_bump_normal_map(&app.context().device, &app.context().queue);
app.scene.add_texture("bump_nm", bump_map).unwrap();
// 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("floor", "standard", 0.0, 0.8).unwrap();
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("bump", "standard", 0.0, 0.5, None, Some("bump_nm"))
.add_material_pbr_textured(
"cave",
"standard",
0.0,
0.6,
Some("cave_albedo"),
Some("cave_nm"),
)
.unwrap();
// Sol (plan 20×20).
@@ -82,7 +115,7 @@ impl AppHandler for PbrDemo {
("c_metal", "metal", Vec3::new(-3.0, 0.5, 0.0)),
("c_plastic", "plastic", Vec3::new(-1.0, 0.5, 0.0)),
("c_rust", "rust", Vec3::new(1.0, 0.5, 0.0)),
("c_bump", "bump", Vec3::new(3.0, 0.5, 0.0)),
("c_cave", "cave", Vec3::new(3.0, 0.5, 0.0)),
];
for (id, mat, pos) in &cubes {
app.scene
@@ -126,7 +159,7 @@ impl AppHandler for PbrDemo {
self.camera.target = Vec3::new(0.0, 0.5, 0.0);
self.camera.apply_to(app.scene.camera_mut());
eprintln!("[PBR] Scene: 6 PBR materials (metal/plastic/rust/ceramic/bump/floor)");
eprintln!("[PBR] Scene: 6 PBR materials (metal/plastic/rust/ceramic/cave/floor)");
eprintln!("[PBR] Drag=orbit, Wheel=zoom, R=reset");
}
@@ -149,37 +182,34 @@ impl AppHandler for PbrDemo {
}
}
/// Génère une normal map procédurale 256×256 : pattern sin(x*freq)*sin(y*freq) → bump.
/// Chaque pixel : normale perturbée encodée en RGB (nx*0.5+0.5, ny*0.5+0.5, nz*0.5+0.5) * 255.
fn make_bump_normal_map(device: &wgpu::Device, queue: &wgpu::Queue) -> Texture {
let size = 256u32;
let freq = 8.0;
let mut pixels: Vec<u8> = vec![0u8; (size * size * 4) as usize];
/// 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");
for y in 0..size {
for x in 0..size {
let u = x as f32 / size as f32;
let v = y as f32 / size as f32;
let h = (u * freq * std::f32::consts::PI).sin()
* (v * freq * std::f32::consts::PI).sin();
let eps = 1.0 / size as f32;
let hx = ((u + eps) * freq * std::f32::consts::PI).sin()
* (v * freq * std::f32::consts::PI).sin();
let hy = (u * freq * std::f32::consts::PI).sin()
* ((v + eps) * freq * std::f32::consts::PI).sin();
let dhdx = (hx - h) / eps;
let dhdy = (hy - h) / eps;
let n = Vec3::new(-dhdx, -dhdy, 1.0).normalize();
let idx = ((y * size + x) * 4) as usize;
pixels[idx] = ((n.x * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
pixels[idx + 1] = ((n.y * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
pixels[idx + 2] = ((n.z * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
pixels[idx + 3] = 255;
}
}
Texture::from_rgba8(device, queue, size, size, &pixels, "bump_normal_map")
.expect("bump normal map creation failed")
/// 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]