É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.
This commit is contained in:
@@ -13,6 +13,7 @@ cargo run -p wsg-lib --example <name>
|
||||
| `cube` | `cargo run -p wsg-lib --example cube` | Textured cube (procedural checker) lit by a directional + point + spot light. |
|
||||
| `manual` | `cargo run -p wsg-lib --example manual` | Low-level workflow: `Context`, `Renderer`, `PipelineCache`, `Mesh` used directly (no `App` facade). |
|
||||
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Spot-light isolation: only one spot is on (near-zero ambient), cube rotates on two axes so the oriented beam is clearly visible. |
|
||||
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Shadow mapping (Étape 14): one directional light is the shadow caster (`set_shadow_caster(Some(0))`); a cube casts a PCF-softened shadow onto a thin ground slab. |
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Dedicated test for **shadow mapping** (Étape 14, Phase 4.2).
|
||||
//!
|
||||
//! 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,
|
||||
//! 2. the shadow edge is **softened** by 3×3 PCF (no hard jagged border),
|
||||
//! 3. the lit faces are bright while the shadowed ground stays near-ambient,
|
||||
//! proving the depth comparison is applied per-pixel.
|
||||
//!
|
||||
//! Run with: `cargo run -p wsg-lib --example shadow_test`
|
||||
use glam::{Vec3};
|
||||
use wsg_lib::resources::{Camera, Geometry};
|
||||
use wsg_lib::utils::WsgError;
|
||||
|
||||
/// Shadow handler: a fixed scene (ground slab + cube blocker) lit by one
|
||||
/// shadow-casting directional light.
|
||||
struct ShadowTest;
|
||||
|
||||
/// Axis-aligned box geometry (24 vertices / 36 indices, per-face normals + uvs).
|
||||
fn box_geometry(hx: f32, hy: f32, hz: f32) -> Geometry {
|
||||
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
|
||||
(
|
||||
[0.0, 0.0, 1.0],
|
||||
[[-hx, -hy, hz], [hx, -hy, hz], [hx, hy, hz], [-hx, hy, hz]],
|
||||
), // +Z
|
||||
(
|
||||
[0.0, 0.0, -1.0],
|
||||
[[hx, -hy, -hz], [-hx, -hy, -hz], [-hx, hy, -hz], [hx, hy, -hz]],
|
||||
), // -Z
|
||||
(
|
||||
[1.0, 0.0, 0.0],
|
||||
[[hx, -hy, -hz], [hx, hy, -hz], [hx, hy, hz], [hx, -hy, hz]],
|
||||
), // +X
|
||||
(
|
||||
[-1.0, 0.0, 0.0],
|
||||
[[-hx, -hy, hz], [-hx, hy, hz], [-hx, hy, -hz], [-hx, -hy, -hz]],
|
||||
), // -X
|
||||
(
|
||||
[0.0, 1.0, 0.0],
|
||||
[[-hx, hy, -hz], [hx, hy, -hz], [hx, hy, hz], [-hx, hy, hz]],
|
||||
), // +Y
|
||||
(
|
||||
[0.0, -1.0, 0.0],
|
||||
[[-hx, -hy, hz], [hx, -hy, hz], [hx, -hy, -hz], [-hx, -hy, -hz]],
|
||||
), // -Y
|
||||
];
|
||||
|
||||
let mut positions = Vec::with_capacity(24);
|
||||
let mut normals = Vec::with_capacity(24);
|
||||
let mut uvs = Vec::with_capacity(24);
|
||||
let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
for (normal, corners) in faces {
|
||||
for (i, corner) in corners.iter().enumerate() {
|
||||
positions.push(*corner);
|
||||
normals.push(normal);
|
||||
uvs.push(quad_uvs[i]);
|
||||
}
|
||||
}
|
||||
let mut indices = Vec::with_capacity(36);
|
||||
for face in 0..6u16 {
|
||||
let b = face * 4;
|
||||
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
|
||||
}
|
||||
Geometry::new(positions)
|
||||
.with_normals(normals)
|
||||
.with_uvs(uvs)
|
||||
.with_indices(indices)
|
||||
}
|
||||
|
||||
impl wsg_lib::AppHandler for ShadowTest {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
app.scene.add_material_shader("mat", "standard").unwrap();
|
||||
|
||||
// Ground slab (thin, wide) lying with its top at y = 0.
|
||||
app.scene
|
||||
.create_mesh("ground_mesh", box_geometry(5.0, 0.05, 5.0), Some("mat"))
|
||||
.unwrap();
|
||||
app.scene
|
||||
.add_entity_with_transform(
|
||||
"ground",
|
||||
"ground_mesh",
|
||||
wsg_lib::math::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::math::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", i.e. the light source sits up and to
|
||||
// the -x -z side, so the shadow is cast toward +x +z (toward the camera).
|
||||
let toward_light = Vec3::new(-0.6, 1.1, -0.6).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)
|
||||
}
|
||||
+260
-15
@@ -21,11 +21,19 @@
|
||||
use crate::core::Context;
|
||||
use crate::core::Frame;
|
||||
use crate::math::Transform;
|
||||
use crate::pipeline::{DEPTH_FORMAT, create_uniform_bind_group_layouts};
|
||||
use crate::resources::uniform::{FRAME_UNIFORMS_SIZE, OBJECT_UNIFORM_SIZE};
|
||||
use crate::resources::{Camera, FrameUniforms, Lights, Material, Mesh, ObjectUniform};
|
||||
use crate::pipeline::{
|
||||
DEPTH_FORMAT, build_shadow_pipeline, create_shadow_map_bind_group_layout,
|
||||
create_shadow_uniform_layout, create_uniform_bind_group_layouts,
|
||||
};
|
||||
use crate::resources::uniform::{FRAME_UNIFORMS_SIZE, OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE};
|
||||
use crate::resources::{
|
||||
Camera, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform, MAX_LIGHTS,
|
||||
};
|
||||
use crate::scene::Scene;
|
||||
use glam::Vec4;
|
||||
use crate::utils::conf::{
|
||||
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
|
||||
};
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -67,6 +75,22 @@ pub struct Renderer {
|
||||
/// `FrameUniforms` is set to 1 so the `standard` shader returns vertex colors as-is — flat 2D
|
||||
/// rendering is thus a special case of the 3D lit path (DRAFT Étape 5). Defaults to `false` (lit).
|
||||
unlit: bool,
|
||||
// Étape 14 (DRAFT 3.2) — shadow mapping resources, owned by the Renderer like the depth texture.
|
||||
/// Backing GPU shadow-map texture (D2), kept alive for the whole application lifetime. Sized
|
||||
/// `SHADOW_MAP_SIZE²`, `DEPTH_FORMAT`, used as the shadow pass depth attachment **and** bound
|
||||
/// for sampling in the main pass (`RENDER_ATTACHMENT | TEXTURE_BINDING`).
|
||||
_shadow_texture: wgpu::Texture,
|
||||
/// Depth view of the shadow map, bound into `shadow_bind_group` (group 3) for the PCF test.
|
||||
shadow_view: wgpu::TextureView,
|
||||
/// Group-3 bind group (comparison sampler + shadow depth texture) bound on every main draw call.
|
||||
shadow_bind_group: wgpu::BindGroup,
|
||||
/// Per-frame uniform buffer holding the shadow-casting light's `view_proj` (D3). Rewritten
|
||||
/// each frame before the shadow pass so the depth-only pipeline sees the current light pose.
|
||||
shadow_uniform_buffer: wgpu::Buffer,
|
||||
/// Group-0 bind group of the shadow pipeline (the light `view_proj`, D4).
|
||||
shadow_uniform_bind_group: wgpu::BindGroup,
|
||||
/// Depth-only pipeline rendering the scene from the shadow light's point of view (D4).
|
||||
shadow_pipeline: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
@@ -125,6 +149,55 @@ impl Renderer {
|
||||
}],
|
||||
});
|
||||
|
||||
// Étape 14 (DRAFT 3.2) : shadow mapping resources — shadow map texture/view, comparison
|
||||
// sampler, group-3 bind group, shadow-light uniform buffer + group-0 bind group, and the
|
||||
// depth-only shadow pipeline. All allocated once here at the default resolution (D2/D8).
|
||||
let (shadow_texture, shadow_view) = create_shadow_map(&device, SHADOW_MAP_SIZE);
|
||||
let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("shadow comparison sampler"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
// Comparison sampler : `textureSampleCompare` returns 1 when the sampled depth passes
|
||||
// this test against the reference, 0 otherwise (D5). GreaterEqual = lit when nothing
|
||||
// closer than the fragment has been written into the shadow map.
|
||||
compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
..Default::default()
|
||||
});
|
||||
let shadow_map_layout = create_shadow_map_bind_group_layout(&device);
|
||||
let shadow_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("shadow map bind group"),
|
||||
layout: &shadow_map_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Sampler(&shadow_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&shadow_view),
|
||||
},
|
||||
],
|
||||
});
|
||||
let shadow_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("shadow uniform buffer"),
|
||||
size: SHADOW_UNIFORM_SIZE,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let shadow_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("shadow uniform bind group"),
|
||||
layout: &create_shadow_uniform_layout(&device),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: shadow_uniform_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let shadow_pipeline = build_shadow_pipeline(&device, &object_layout);
|
||||
|
||||
let renderer = Self {
|
||||
queue,
|
||||
device,
|
||||
@@ -137,6 +210,12 @@ impl Renderer {
|
||||
shared_object_bind_group,
|
||||
object_cache: RefCell::new(HashMap::new()),
|
||||
unlit: false,
|
||||
_shadow_texture: shadow_texture,
|
||||
shadow_view,
|
||||
shadow_bind_group,
|
||||
shadow_uniform_buffer,
|
||||
shadow_uniform_bind_group,
|
||||
shadow_pipeline,
|
||||
};
|
||||
// Seed the shared frame buffer with an identity camera + current unlit flag so the low-level
|
||||
// `render` path (which has no window/camera) sees coherent values before `render_scene` runs.
|
||||
@@ -151,6 +230,9 @@ impl Renderer {
|
||||
fn write_default_frame_uniforms(&self) {
|
||||
let frame = FrameUniforms {
|
||||
options: [if self.unlit { 1 } else { 0 }, 0, 0, 0],
|
||||
// Étape 14 (D2) : no active shadow caster in the low-level path — sentinel index
|
||||
// MAX_LIGHTS disables the shadow term in the shader even if options.y were set.
|
||||
shadow_light_index: MAX_LIGHTS as u32,
|
||||
..FrameUniforms::default()
|
||||
};
|
||||
self.queue
|
||||
@@ -192,15 +274,28 @@ impl Renderer {
|
||||
/// The light array is packed via `Lights::into_frame_array` (directionals first, then point,
|
||||
/// then spot lights). Inputs: camera (the scene's active camera), lights (the scene's global
|
||||
/// light list), ambient (the scene's ambient hemisphere color, rgb), aspect (viewport width /
|
||||
/// height).
|
||||
/// height), shadow_caster (the packed-array index of the shadow-casting light, from
|
||||
/// `Scene::shadow_caster`, or `None` when shadows are disabled / the caster is a point light).
|
||||
fn write_frame_uniforms(
|
||||
&self,
|
||||
camera: &Camera,
|
||||
lights: &Lights,
|
||||
ambient: [f32; 3],
|
||||
aspect: f32,
|
||||
shadow_caster: Option<usize>,
|
||||
) {
|
||||
let (light_array, num_directional, num_point, num_spot) = lights.into_frame_array();
|
||||
// Étape 14 (DRAFT 3.2) : derive the shadow light's view_proj and shadow flags (D3).
|
||||
let (shadow_light_index, light_view_proj, shadow_params, shadow_on) =
|
||||
match self.shadow_light_view_proj(lights, shadow_caster) {
|
||||
Some((index, vp)) => (
|
||||
index as u32,
|
||||
vp,
|
||||
Vec4::new(SHADOW_MAP_SIZE as f32, SHADOW_DEPTH_BIAS, 0.0, 0.0),
|
||||
1,
|
||||
),
|
||||
None => (MAX_LIGHTS as u32, Mat4::IDENTITY, Vec4::ZERO, 0),
|
||||
};
|
||||
let frame = FrameUniforms {
|
||||
view: camera.view_matrix(),
|
||||
proj: camera.projection_matrix(aspect),
|
||||
@@ -210,13 +305,60 @@ impl Renderer {
|
||||
num_directional,
|
||||
num_point,
|
||||
num_spot,
|
||||
_pad: [0],
|
||||
options: [if self.unlit { 1 } else { 0 }, 0, 0, 0],
|
||||
shadow_light_index,
|
||||
light_view_proj,
|
||||
shadow_params,
|
||||
options: [if self.unlit { 1 } else { 0 }, shadow_on, 0, 0],
|
||||
};
|
||||
self.queue
|
||||
.write_buffer(&self.frame_buffer, 0, bytemuck::bytes_of(&frame));
|
||||
}
|
||||
|
||||
/// Computes the light-space orthographic view-projection of the shadow-casting light, plus its
|
||||
/// packed-array index. The volume covered is an orthographic box of half-size
|
||||
/// `SHADOW_SCENE_RADIUS` centered on the scene origin (SHADOW_SCENE_CENTER), oriented so its
|
||||
/// `-z` axis aligns with the light's travel direction (light → scene). Placing the eye behind
|
||||
/// the scene along the light path keeps the frustum locked to the light orientation even when
|
||||
/// the directional light's `position` is arbitrary. The projection uses `near = 0.0` /
|
||||
/// `far = SHADOW_SCENE_RADIUS` so the depth written by the shadow pass matches the `depth` the
|
||||
/// main-pass shader compares (D3). Returns `None` when no valid caster is selected (shadows
|
||||
/// disabled, index out of bounds, or the caster is a point light — D6).
|
||||
fn shadow_light_view_proj(
|
||||
&self,
|
||||
lights: &Lights,
|
||||
caster: Option<usize>,
|
||||
) -> Option<(usize, Mat4)> {
|
||||
let index = caster?;
|
||||
if index >= lights.len() {
|
||||
return None;
|
||||
}
|
||||
let light = lights.get(index)?;
|
||||
// Directional and spot lights carry a direction; point lights would need a 6-face cubemap
|
||||
// shadow, which is out of scope (D6), so we reject them.
|
||||
let dir = match light.light_type() {
|
||||
crate::resources::LightType::Directional
|
||||
| crate::resources::LightType::Spot { .. } => Vec3::new(
|
||||
light.dir_angle.x,
|
||||
light.dir_angle.y,
|
||||
light.dir_angle.z,
|
||||
),
|
||||
crate::resources::LightType::Point => return None,
|
||||
};
|
||||
let r = SHADOW_SCENE_RADIUS;
|
||||
let target = Vec3::from(SHADOW_SCENE_CENTER);
|
||||
// Eye one scene-radius behind the target along the light path, so distance(target)=r and
|
||||
// every point in the box has depth within [near=0, far=r].
|
||||
let eye = target - dir * r;
|
||||
// Avoid a degenerate basis when the light points straight down/up (parallel up vector).
|
||||
let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y };
|
||||
let view = glam::camera::rh::view::look_at_mat4(eye, target, up);
|
||||
// Orthographic box of half-size r, near 0, far r (D1/D3), in the same OpenGL NDC convention
|
||||
// as the camera projection (wgpu maps NDC z ∈ [-1,1] to depth [0,1], see standard_shader).
|
||||
let proj =
|
||||
glam::camera::rh::proj::opengl::orthographic(-r, r, -r, r, 0.0, r);
|
||||
Some((index, proj * view))
|
||||
}
|
||||
|
||||
/// Orchestrates rendering of a single object: binds Material pipeline + Mesh vertex data into a RenderPass,
|
||||
/// then submits commands to the GPU queue for execution. Called per-frame by the orchestrator (main.rs).
|
||||
/// Inputs: view (TextureView color attachment target), mesh (geometry to render), material (shader+pipeline).
|
||||
@@ -265,6 +407,7 @@ impl Renderer {
|
||||
material,
|
||||
&self.frame_bind_group,
|
||||
&self.shared_object_bind_group,
|
||||
&self.shadow_bind_group,
|
||||
);
|
||||
}
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
@@ -281,7 +424,13 @@ impl Renderer {
|
||||
/// Before drawing, the shared frame uniform buffer is rewritten from `scene.camera()` so the GPU
|
||||
/// receives the active camera's view/projection matrices and position for this frame (Étape 4.3).
|
||||
pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) {
|
||||
self.write_frame_uniforms(scene.camera(), scene.lights(), scene.ambient(), aspect);
|
||||
self.write_frame_uniforms(
|
||||
scene.camera(),
|
||||
scene.lights(),
|
||||
scene.ambient(),
|
||||
aspect,
|
||||
scene.shadow_caster(),
|
||||
);
|
||||
|
||||
let mut encoder = self
|
||||
.device
|
||||
@@ -289,6 +438,11 @@ impl Renderer {
|
||||
label: Some("scene encoder"),
|
||||
});
|
||||
|
||||
// Étape 14 (DRAFT 3.2) : run the depth-only shadow pass first when a light is configured to
|
||||
// cast shadows (D4). It populates `shadow_view` on the shared encoder; the main pass below
|
||||
// then samples it via `shadow_bind_group`. `render_shadow_map` no-ops when shadows are off.
|
||||
self.render_shadow_map(&mut encoder, scene);
|
||||
|
||||
{
|
||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("scene render pass"),
|
||||
@@ -328,12 +482,69 @@ impl Renderer {
|
||||
&material,
|
||||
&self.frame_bind_group,
|
||||
&object_bind_group,
|
||||
&self.shadow_bind_group,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
}
|
||||
|
||||
/// Renders every entity of `scene` from the shadow-casting light's point of view into the
|
||||
/// shadow depth map (Étape 14, D4), using the dedicated depth-only `shadow_pipeline`. Called at
|
||||
/// the start of `render_scene`. No-ops (produces no GPU work) when `scene.shadow_caster()` is
|
||||
/// `None`. The shadow light's `view_proj` is written to `shadow_uniform_buffer`, and the shadow
|
||||
/// pass writes depth into `shadow_view` (clear 1.0, store). The per-entity model bind groups are
|
||||
/// reused from `object_bind_group_for`, so transforms match the main pass exactly.
|
||||
/// Inputs: encoder (the shared command encoder for the frame), scene (entities to cast).
|
||||
fn render_shadow_map(&self, encoder: &mut wgpu::CommandEncoder, scene: &Scene) {
|
||||
let _caster = match scene.shadow_caster() {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
// Recompute the light's view_proj and write it into the shadow uniform buffer so the
|
||||
// depth-only vertex shader transforms vertices into light-clip space (D4).
|
||||
let (light_index, vp) = match self.shadow_light_view_proj(scene.lights(), Some(_caster)) {
|
||||
Some(pair) => pair,
|
||||
None => return,
|
||||
};
|
||||
let shadow_uniform = ShadowUniform { view_proj: vp };
|
||||
self.queue
|
||||
.write_buffer(&self.shadow_uniform_buffer, 0, bytemuck::bytes_of(&shadow_uniform));
|
||||
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("shadow map render pass"),
|
||||
color_attachments: &[],
|
||||
// Depth-only : the shadow map is the sole attachment. Clear 1.0 so fragments beyond
|
||||
// `far` read as "fully distant" and never occlude lit surfaces (D4).
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.shadow_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.shadow_pipeline);
|
||||
// Group 0 : the shadow light view_proj (D4) — the shadow pipeline's only uniform group.
|
||||
pass.set_bind_group(0, &self.shadow_uniform_bind_group, &[]);
|
||||
for (label, mesh, transform) in scene.iter_entities() {
|
||||
let object_bind_group = self.object_bind_group_for(label, transform);
|
||||
// Group 1 : per-entity model. The shadow pipeline has no texture/sampler groups.
|
||||
pass.set_bind_group(1, &object_bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||
if let Some(index_buffer) = &mesh.index_buffer {
|
||||
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
|
||||
pass.draw_indexed(0..mesh.num_indices, 0, 0..1);
|
||||
} else {
|
||||
pass.draw(0..mesh.num_vertices, 0..1);
|
||||
}
|
||||
}
|
||||
drop(pass);
|
||||
let _ = light_index; // (index retained for future per-light shadow options)
|
||||
}
|
||||
|
||||
/// Presents the rendered frame by submitting the acquired surface texture to the GPU queue.
|
||||
/// The frame must have been obtained via Context::begin_frame() or Frame::try_new(); calling present()
|
||||
/// twice on the same texture is undefined behavior. Called by the orchestrator after render().
|
||||
@@ -419,21 +630,52 @@ fn create_depth_texture(
|
||||
(depth_texture, depth_view)
|
||||
}
|
||||
|
||||
/// Binds a Material pipeline, the three shared bind groups, and Mesh buffers into an active render
|
||||
/// Allocates the shadow-map texture + view backing the depth-only shadow pass's
|
||||
/// `depth_stencil_attachment` (Étape 14, D2/D8). Square (`size` x `size`), `DEPTH_FORMAT`, single
|
||||
/// mip, no MSAA. Unlike the screen depth texture this one is flagged **both** `RENDER_ATTACHMENT`
|
||||
/// (shadow pass writes depth) **and** `TEXTURE_BINDING` (main pass samples it via the group-3
|
||||
/// comparison sampler). Allocated once at the default resolution; resizing is deferred (D8).
|
||||
/// Inputs: device (GPU resource creator), size (shadow map edge length in pixels).
|
||||
/// Returns the (texture, view) pair; the caller keeps both alive.
|
||||
fn create_shadow_map(
|
||||
device: &wgpu::Device,
|
||||
size: u32,
|
||||
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
let shadow_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("shadow map"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DEPTH_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let shadow_view = shadow_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
(shadow_texture, shadow_view)
|
||||
}
|
||||
|
||||
/// Binds a Material pipeline, the four shared bind groups, and Mesh buffers into an active render
|
||||
/// pass and issues the draw call. Shared by `Renderer::render` and `Renderer::render_scene`.
|
||||
/// The frame (@0), object (@1) and texture (@2) bind groups are **required** by every pipeline layout
|
||||
/// (Étape 3 : un seul layout pour tous — Étape 10 : groupe texture) — they must be bound even if the
|
||||
/// shader does not read them. Draws indexed geometry when an index buffer exists, otherwise falls
|
||||
/// back to a non-indexed draw.
|
||||
/// The frame (@0), object (@1), texture (@2) and shadow-map (@3) bind groups are **required** by
|
||||
/// every pipeline layout (Étape 3 : un seul layout pour tous — Étape 10 : groupe texture — Étape 14 :
|
||||
/// groupe ombre) — they must be bound even if the shader does not read them. Draws indexed geometry
|
||||
/// when an index buffer exists, otherwise falls back to a non-indexed draw.
|
||||
/// Inputs: pass (active render pass), mesh (geometry to draw), material (pipeline + texture bind
|
||||
/// group to bind), frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/identity
|
||||
/// model).
|
||||
/// group to bind), frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/
|
||||
/// identity model), shadow_bind_group (reserved group-3 shadow-map bind group, unused by the
|
||||
/// depth-only shadow pipeline but required by the standard pipeline layout).
|
||||
fn draw_entity(
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
mesh: &Mesh,
|
||||
material: &Material,
|
||||
frame_bind_group: &wgpu::BindGroup,
|
||||
object_bind_group: &wgpu::BindGroup,
|
||||
shadow_bind_group: &wgpu::BindGroup,
|
||||
) {
|
||||
if mesh.num_vertices == 0 {
|
||||
// No vertices — nothing to render.
|
||||
@@ -445,6 +687,9 @@ fn draw_entity(
|
||||
// Étape 10 (DRAFT 10.4) : groupe texture — le Material possède son bind group (placeholder
|
||||
// blanc s'il n'a pas de texture, D1/D2). Toujours liable car posé sur toutes les pipelines.
|
||||
pass.set_bind_group(2, &material.texture_bind_group, &[]);
|
||||
// Étape 14 : groupe ombre — toujours lié pour rester conforme au layout unifié, que la pipeline
|
||||
// soit éclairée ou non (le groupe @3 reste requis par toutes les pipelines standards).
|
||||
pass.set_bind_group(3, shadow_bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||
if let Some(index_buffer) = &mesh.index_buffer {
|
||||
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
pub mod pipeline_cache;
|
||||
// Re-exports
|
||||
pub use pipeline_cache::{
|
||||
DEPTH_FORMAT, PipelineCache, create_texture_bind_group_layout,
|
||||
create_uniform_bind_group_layouts,
|
||||
DEPTH_FORMAT, PipelineCache, build_shadow_pipeline, create_shadow_map_bind_group_layout,
|
||||
create_shadow_uniform_layout, create_texture_bind_group_layout,
|
||||
create_uniform_bind_group_layouts, vertex_buffer_layout,
|
||||
};
|
||||
|
||||
@@ -90,6 +90,89 @@ pub fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGrou
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates the **shadow map** bind group layout (group 3) shared by every main pipeline (Étape 14,
|
||||
/// DRAFT D1/D5). Binds a **comparison** sampler + a depth texture so the fragment can run a PCF
|
||||
/// `textureSampleCompare` against the shadow map. Added to every pipeline layout alongside groups
|
||||
/// 0–2, keeping « un seul layout pour tous » — shadows are simply a no-op when disabled.
|
||||
///
|
||||
/// - `binding 0` : `sampler_comparison` (compare fn drives the shadow test, D5).
|
||||
/// - `binding 1` : `texture_depth_2d` (the shadow map).
|
||||
pub fn create_shadow_map_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("shadow_map_bind_group_layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates the **shadow uniform** bind group layout (group 0 of the depth-only shadow pipeline,
|
||||
/// Étape 14, D4): a single uniform buffer holding the light's `view_proj` matrix. Read in the
|
||||
/// **vertex** stage only (the shadow shader transforms vertices into light-clip space).
|
||||
pub fn create_shadow_uniform_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("shadow_uniform_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// The shared GPU `Vertex`-buffer layout used by **every** pipeline that renders mesh geometry
|
||||
/// (both the main `build_pipeline` and the depth-only shadow pipeline). The array stride equals
|
||||
/// `size_of::<Vertex>()` so it matches the mesh vertex buffers exactly; the four attributes are
|
||||
/// declared position (loc 0), normal (1), uv (2), color (3).
|
||||
pub fn vertex_buffer_layout() -> wgpu::VertexBufferLayout<'static> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
}, // position
|
||||
wgpu::VertexAttribute {
|
||||
offset: 12,
|
||||
shader_location: 1,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
}, // normal
|
||||
wgpu::VertexAttribute {
|
||||
offset: 24,
|
||||
shader_location: 2,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
}, // uv
|
||||
wgpu::VertexAttribute {
|
||||
offset: 32,
|
||||
shader_location: 3,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
}, // color
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Depth texture format shared by the whole library (Étape 9, décision D1 du 2026-09-18).
|
||||
///
|
||||
/// Single z-buffer format used for **both** the depth attachment textures (`Renderer`) and the
|
||||
@@ -253,43 +336,21 @@ impl PipelineCache {
|
||||
) -> wgpu::RenderPipeline {
|
||||
// Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs.
|
||||
// Must match Vertex struct field offsets exactly.
|
||||
let vertex_buffer_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
}, // position
|
||||
wgpu::VertexAttribute {
|
||||
offset: 12,
|
||||
shader_location: 1,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
}, // normal
|
||||
wgpu::VertexAttribute {
|
||||
offset: 24,
|
||||
shader_location: 2,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
}, // uv
|
||||
wgpu::VertexAttribute {
|
||||
offset: 32,
|
||||
shader_location: 3,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
}, // color
|
||||
],
|
||||
};
|
||||
let vertex_buffer_layout = vertex_buffer_layout();
|
||||
|
||||
// Pipeline layout — the two uniform bind groups (frame @0 + object @1) AND the texture
|
||||
// bind group (@2, Étape 10 DRAFT D1) are attached to EVERY pipeline (Étape 3, décision
|
||||
// actée « un seul layout pour tous »), even if a given shader does not read them.
|
||||
// Pipeline layout — the two uniform bind groups (frame @0 + object @1), the texture
|
||||
// bind group (@2, Étape 10 DRAFT D1) AND the shadow-map bind group (@3, Étape 14 D5) are
|
||||
// attached to EVERY pipeline (Étape 3, décision actée « un seul layout pour tous »), even
|
||||
// if a given shader does not read them.
|
||||
// `immediate_size` stays 0 (no var<immediate> used).
|
||||
let uniform_layouts = create_uniform_bind_group_layouts(device);
|
||||
let texture_layout = create_texture_bind_group_layout(device);
|
||||
let shadow_layout = create_shadow_map_bind_group_layout(device);
|
||||
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> = vec![
|
||||
Some(&uniform_layouts[0]), // frame @0
|
||||
Some(&uniform_layouts[1]), // object @1
|
||||
Some(&texture_layout), // texture @2
|
||||
Some(&shadow_layout), // shadow map @3
|
||||
];
|
||||
let render_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -346,3 +407,67 @@ impl PipelineCache {
|
||||
self.pipelines.get(shader_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the **depth-only shadow pipeline** (Étape 14, D4): a vertex-only pipeline (no fragment
|
||||
/// stage) that transforms each mesh vertex into the shadow-casting light's clip space, writing only
|
||||
/// depth. Its layout is [`shadow_uniform_layout`] (group 0 : light `view_proj`) + [`object_layout`]
|
||||
/// (group 1 : per-entity model matrix — the SAME layout/bind groups the main renderer already caches
|
||||
/// per entity, so the shadow pass reuses them directly).
|
||||
///
|
||||
/// `depth_stencil` writes depth with a slope-scaled bias (D5) to suppress acne on surfaces nearly
|
||||
/// parallel to the light. The vertex buffer layout is the shared [`vertex_buffer_layout`], so the
|
||||
/// same mesh vertex/index buffers are reused.
|
||||
///
|
||||
/// Inputs: device (GPU), object_layout (the shared per-object bind group layout, group 1).
|
||||
/// Returns the compiled shadow pipeline, ready to render into a depth attachment.
|
||||
pub fn build_shadow_pipeline(
|
||||
device: &wgpu::Device,
|
||||
object_layout: &wgpu::BindGroupLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
// Vertex-only shader : this pipeline sets `fragment: None`, so only the depth is produced.
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("shadow_shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(crate::utils::SHADOW_SHADER.into()),
|
||||
});
|
||||
|
||||
let shadow_uniform_layout = create_shadow_uniform_layout(device);
|
||||
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> =
|
||||
vec![Some(&shadow_uniform_layout), Some(object_layout)];
|
||||
let shadow_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("shadow_pipeline_layout"),
|
||||
bind_group_layouts: &layout_refs,
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Shadow Pipeline"),
|
||||
layout: Some(&shadow_pipeline_layout),
|
||||
// wgpu 30 : vertex state requires `compilation_options`.
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(vertex_buffer_layout())],
|
||||
},
|
||||
// Depth-only : no fragment state (no color output, no color target).
|
||||
fragment: None,
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
// Étape 14 (D5) : slope-scaled depth bias against acne — surfaces nearly parallel to
|
||||
// the light are pushed back slightly in the shadow map so they do not self-shadow.
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 2,
|
||||
slope_scale: 2.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,22 @@ impl Lights {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Returns the light at a **packed-array index** (directionals first, then point lights, then
|
||||
/// spot lights — the same order as `into_frame_array`). Used by the Renderer's shadow pass to
|
||||
/// resolve the shadow-casting light by its packed index (`Scene::shadow_caster`, Étape 14 D7).
|
||||
pub fn get(&self, index: usize) -> Option<&Light> {
|
||||
let n_dir = self.directional.len();
|
||||
if index < n_dir {
|
||||
return self.directional.get(index);
|
||||
}
|
||||
let index = index - n_dir;
|
||||
let n_point = self.point.len();
|
||||
if index < n_point {
|
||||
return self.point.get(index);
|
||||
}
|
||||
self.spot.get(index - n_point)
|
||||
}
|
||||
|
||||
/// Packs the lights into the GPU frame array: directionals first (`0..num_directional`), then
|
||||
/// point lights, then spot lights. The tail is zero-filled. Returns
|
||||
/// `(array, num_directional, num_point, num_spot)`. Caller must ensure `len() <= MAX_LIGHTS`
|
||||
|
||||
@@ -26,7 +26,10 @@ pub use lights::Lights;
|
||||
pub use material::Material;
|
||||
pub use mesh::Mesh;
|
||||
pub use texture::{Texture, TextureError};
|
||||
pub use uniform::{FrameUniforms, Light, MAX_LIGHTS, ObjectUniform};
|
||||
pub use uniform::{
|
||||
FrameUniforms, Light, LightType, MAX_LIGHTS, ObjectUniform, ShadowUniform, FRAME_UNIFORMS_SIZE,
|
||||
OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE,
|
||||
};
|
||||
pub use vertex::Vertex;
|
||||
|
||||
// Convenience re-export of `math::Geometry` (Étape 8, D2) so examples can build meshes
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! (see the "Uniform Contract" section of that file) — 16-byte alignment (std140), no padding.
|
||||
//!
|
||||
//! Two bind groups are shared by every pipeline (single-layout decision, Étape 3) :
|
||||
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame : camera + lights) → 704 bytes
|
||||
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame : camera + lights + shadow) → 784 bytes
|
||||
//! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) → 64 bytes
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
@@ -20,6 +20,8 @@ use glam::{Mat4, Vec4};
|
||||
pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::<FrameUniforms>() as u64;
|
||||
/// Byte size of the per-object uniform buffer (`ObjectUniform`).
|
||||
pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::<ObjectUniform>() as u64;
|
||||
/// Byte size of the shadow-pass uniform buffer (`ShadowUniform`, Étape 14).
|
||||
pub const SHADOW_UNIFORM_SIZE: u64 = std::mem::size_of::<ShadowUniform>() as u64;
|
||||
|
||||
/// Maximum number of lights stored in the per-frame uniform buffer.
|
||||
/// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation).
|
||||
@@ -52,12 +54,47 @@ pub struct Light {
|
||||
pub dir_angle: Vec4,
|
||||
}
|
||||
|
||||
/// Per-frame GPU uniforms : camera matrices + ambient + global light list + options.
|
||||
/// The runtime-disambiguated type of a [`Light`] (Étape 14, D6). Not stored in the struct (the array
|
||||
/// position disambiguates on the GPU); used by CPU-side logic such as the shadow-pass light selection,
|
||||
/// which must reject point lights (cubemap shadows are out of scope).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LightType {
|
||||
/// Directional light (infinitely distant): `position_dir.xyz` = ray direction away from the
|
||||
/// light, `radius.x` = 0, `dir_angle` = 0.
|
||||
Directional,
|
||||
/// Point (omnidirectional): `position_dir.xyz` = world position, `radius.x` = attenuation
|
||||
/// radius, `dir_angle` = 0.
|
||||
Point,
|
||||
/// Spot: world position in `position_dir.xyz`, `radius.x` = attenuation radius, cone axis in
|
||||
/// `dir_angle.xyz` and `dir_angle.w` = cos of the half-angle.
|
||||
Spot,
|
||||
}
|
||||
|
||||
impl Light {
|
||||
/// Classifies the light for CPU-side logic. Query order is significant because a spot light
|
||||
/// carries both a positive attenuation radius **and** a positive `dir_angle.w` (cos of a
|
||||
/// sub-90° half-angle), so the cone flag is tested first, then the radius, and anything else is
|
||||
/// the infinite directional light. Returns [`LightType::Directional`], [`LightType::Point`] or
|
||||
/// [`LightType::Spot`].
|
||||
pub fn light_type(&self) -> LightType {
|
||||
if self.dir_angle.w > 0.0 {
|
||||
LightType::Spot
|
||||
} else if self.radius.x > 0.0 {
|
||||
LightType::Point
|
||||
} else {
|
||||
LightType::Directional
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame GPU uniforms : camera matrices + ambient + global light list + shadow data + options.
|
||||
///
|
||||
/// Mirrors the WGSL `FrameUniforms` struct in `standard_shader.wgsl` (offset table there).
|
||||
/// 160 + 64·MAX_LIGHTS bytes for the camera header + lights, then counters + padding + options —
|
||||
/// total **704 bytes**, 16-byte aligned, `Pod` for direct `bytes_of` upload. The bind-group layout
|
||||
/// uses `min_binding_size: None`, so extending this struct is transparent (no relayout).
|
||||
/// 160 + 64·MAX_LIGHTS bytes for the camera header + lights, then the counters, the single shadow
|
||||
/// light selection, the light view-projection matrix + shadow parameters, then options — total
|
||||
/// **784 bytes** (Étape 14, DRAFT 3.1), 16-byte aligned, `Pod` for direct `bytes_of` upload. The
|
||||
/// bind-group layout uses `min_binding_size: None`, so extending this struct is transparent
|
||||
/// (no relayout).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct FrameUniforms {
|
||||
@@ -78,11 +115,18 @@ pub struct FrameUniforms {
|
||||
pub num_point: u32,
|
||||
/// Number of active spot lights (indices after the point lights).
|
||||
pub num_spot: u32,
|
||||
/// Padding so `options` lands on a 16-byte boundary — matching the WGSL `vec4<u32>`
|
||||
/// (alignment 16), which Rust's `repr(C)` would otherwise place too early: three u32 counters
|
||||
/// occupy 12 bytes, so 4 bytes of padding align `options` to 16.
|
||||
pub _pad: [u32; 1],
|
||||
/// Options. `options[0]` = unlit flag (1 → flat color, no lighting).
|
||||
/// Index (in the packed frame array) of the single shadow-casting light (DRAFT Étape 14, D1).
|
||||
/// `MAX_LIGHTS` = sentinel meaning "no shadow" (shadows off). Offset 160 + 64·MAX_LIGHTS + 12.
|
||||
pub shadow_light_index: u32,
|
||||
/// View-projection matrix of the shadow-casting light (world → light clip space), used to
|
||||
/// reproject fragments into the shadow map (DRAFT Étape 14, D3). Offset 176 + 64·MAX_LIGHTS.
|
||||
pub light_view_proj: Mat4,
|
||||
/// Shadow sampling parameters (DRAFT Étape 14, D5). `x` = shadow map size in pixels (for
|
||||
/// texel-space PCF offsets), `y` = depth bias, `z`/`w` reserved. Offset 240 + 64·MAX_LIGHTS.
|
||||
pub shadow_params: Vec4,
|
||||
/// Options. `options[0]` = unlit flag (1 → flat color, no lighting);
|
||||
/// `options[1]` = shadows enabled (1 → sample the shadow map, checked alongside
|
||||
/// `shadow_light_index`). Offset 256 + 64·MAX_LIGHTS.
|
||||
pub options: [u32; 4],
|
||||
}
|
||||
|
||||
@@ -105,7 +149,10 @@ impl Default for FrameUniforms {
|
||||
num_directional: 1,
|
||||
num_point: 0,
|
||||
num_spot: 0,
|
||||
_pad: [0],
|
||||
// Shadows off by default (Étape 14, D7 — non-régression) : sentinel = MAX_LIGHTS.
|
||||
shadow_light_index: MAX_LIGHTS as u32,
|
||||
light_view_proj: Mat4::IDENTITY,
|
||||
shadow_params: Vec4::ZERO,
|
||||
options: [0, 0, 0, 0],
|
||||
}
|
||||
}
|
||||
@@ -121,6 +168,16 @@ pub struct ObjectUniform {
|
||||
pub model: Mat4,
|
||||
}
|
||||
|
||||
/// GPU uniforms of the depth-only shadow pass (Étape 14, D4): the shadow-casting light's
|
||||
/// view-projection matrix. Mirrors the WGSL `ShadowUniform` struct in `shadow_shader.wgsl`.
|
||||
/// 64 bytes, `Pod`, bound as group 0 of the shadow pipeline.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Default)]
|
||||
pub struct ShadowUniform {
|
||||
/// Light view-projection matrix (world → light clip space). Offset 0.
|
||||
pub view_proj: Mat4,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -130,9 +187,11 @@ mod tests {
|
||||
#[test]
|
||||
fn frame_uniforms_layout_matches_wgsl() {
|
||||
// The offsets below must match the offset table in standard_shader.wgsl.
|
||||
// Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters(12) + pad(4) +
|
||||
// options(16) = 32. Total = 160 + 64·MAX_LIGHTS + 32 = 704 bytes.
|
||||
assert_eq!(size_of::<FrameUniforms>(), 160 + 64 * MAX_LIGHTS + 32);
|
||||
// Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters (4×u32 = 16),
|
||||
// light_view_proj (64) + shadow_params (16) + options (16) = 112 after the counters.
|
||||
// Total = 160 + 64·8 + 16 + 112 = 784 bytes.
|
||||
assert_eq!(size_of::<FrameUniforms>(), 784);
|
||||
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112);
|
||||
assert_eq!(align_of::<FrameUniforms>(), 16);
|
||||
|
||||
let f = FrameUniforms::default();
|
||||
@@ -145,15 +204,31 @@ mod tests {
|
||||
offset_of!(FrameUniforms, num_directional),
|
||||
160 + 64 * MAX_LIGHTS
|
||||
);
|
||||
assert_eq!(offset_of!(FrameUniforms, num_point), 160 + 64 * MAX_LIGHTS + 4);
|
||||
assert_eq!(offset_of!(FrameUniforms, num_spot), 160 + 64 * MAX_LIGHTS + 8);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, options),
|
||||
offset_of!(FrameUniforms, shadow_light_index),
|
||||
160 + 64 * MAX_LIGHTS + 12
|
||||
);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, light_view_proj),
|
||||
160 + 64 * MAX_LIGHTS + 16
|
||||
);
|
||||
// Default is lit mode (unlit flag cleared), one directional light, no point/spot lights.
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, shadow_params),
|
||||
160 + 64 * MAX_LIGHTS + 80
|
||||
);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, options),
|
||||
160 + 64 * MAX_LIGHTS + 96
|
||||
);
|
||||
// Default is lit mode (unlit flag cleared), one directional light, no point/spot lights,
|
||||
// shadows off (sentinel = MAX_LIGHTS).
|
||||
assert_eq!(f.options[0], 0);
|
||||
assert_eq!(f.num_directional, 1);
|
||||
assert_eq!(f.num_point, 0);
|
||||
assert_eq!(f.num_spot, 0);
|
||||
assert_eq!(f.shadow_light_index, MAX_LIGHTS as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -64,6 +64,11 @@ pub struct Scene {
|
||||
lights: Lights,
|
||||
/// Ambient hemisphere color (rgb) used by the `standard` shader. Default = white.
|
||||
ambient: [f32; 3],
|
||||
/// Optional shadow-casting light index (DRAFT Étape 14, D1): the index (in the packed frame
|
||||
/// array: directionals, then points, then spots) of the single light that casts a shadow.
|
||||
/// `None` = shadows off (default, non-régression). Read each frame by `Renderer::render_scene`
|
||||
/// to compute the light `view_proj` and enable shadow sampling.
|
||||
shadow_caster: Option<usize>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
@@ -82,6 +87,7 @@ impl Scene {
|
||||
default_material: RefCell::new(None),
|
||||
lights: Lights::new(),
|
||||
ambient: [1.0, 1.0, 1.0],
|
||||
shadow_caster: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +349,22 @@ impl Scene {
|
||||
&self.lights
|
||||
}
|
||||
|
||||
/// Selects the single shadow-casting light by **its index in the packed frame array**
|
||||
/// (directionals first, then point lights, then spots — same order as
|
||||
/// `Lights::into_frame_array`). `None` disables shadows (default, non-régression, Étape 14 D7).
|
||||
/// The light must be **directional or spot**; a point light index disables the shadow pass
|
||||
/// (cubemap shadows are out of scope, D6). Inputs: index — the light's packed-array index, or
|
||||
/// `None` to turn shadows off.
|
||||
pub fn set_shadow_caster(&mut self, index: Option<usize>) {
|
||||
self.shadow_caster = index;
|
||||
}
|
||||
|
||||
/// Returns the index of the scene's shadow-casting light (`None` = shadows off).
|
||||
/// Read by `Renderer::render_scene` each frame to decide whether to run the shadow pass.
|
||||
pub fn shadow_caster(&self) -> Option<usize> {
|
||||
self.shadow_caster
|
||||
}
|
||||
|
||||
/// Removes all lights (directional, point and spot). The fragment shader then contributes
|
||||
/// only the ambient term. Useful for flat look without toggling `unlit`.
|
||||
pub fn clear_lights(&mut self) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! # Shadow Shader (Étape 14, Phase 4.2 — depth-only pass)
|
||||
//!
|
||||
//! Minimal vertex shader used for the **shadow map pass** (DRAFT Étape 14, D4). It transforms each
|
||||
//! vertex into the light's clip space and lets the depth write happen — no fragment stage, no color
|
||||
//! output, no lighting : the rasterizer only records the depth (D2).
|
||||
//!
|
||||
//! Only the `position` attribute (location 0) is consumed, so this pipeline needs no normal/uv/color
|
||||
//! buffers and is as cheap as possible.
|
||||
//!
|
||||
//! ## Uniform Contract (this pipeline's own layout — independent of the main pipeline)
|
||||
//! - `@group(0) @binding(0)` : `ShadowUniform` — the light's `view_proj` matrix (world → light clip).
|
||||
//! - `@group(1) @binding(0)` : `ObjectUniform` — the entity's per-entity model matrix (shared with
|
||||
//! the main pipeline, so the Renderer reuses its per-entity object bind groups).
|
||||
//!
|
||||
//! The light VP is passed as a group-0 uniform rather than reusing the camera `FrameUniforms`
|
||||
//! because the shadow pass is rendered from the light's point of view, not the camera's.
|
||||
|
||||
struct ShadowUniform {
|
||||
view_proj: mat4x4<f32>,
|
||||
};
|
||||
|
||||
struct ObjectUniform {
|
||||
model: mat4x4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> shadow: ShadowUniform;
|
||||
@group(1) @binding(0) var<uniform> object: ObjectUniform;
|
||||
|
||||
struct VertexInput {
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
@location(3) color: vec4<f32>,
|
||||
};
|
||||
|
||||
// Output carries only the clip position; any attribute interpolated without a fragment stage is
|
||||
// still fine (it is simply discarded). Keeping just the position minimizes the vertex output size.
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(input: VertexInput) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
let world = object.model * vec4<f32>(input.position, 1.0);
|
||||
out.clip_position = shadow.view_proj * world;
|
||||
return out;
|
||||
}
|
||||
@@ -7,24 +7,29 @@
|
||||
//! modulates the vertex color (`texel.rgb * in.color.rgb`).
|
||||
//!
|
||||
//! ## Uniform Contract
|
||||
//! Three bind groups, shared by every material (one single pipeline layout — voir Étape 3) :
|
||||
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights) [704 bytes]
|
||||
//! Four bind groups, shared by every material (one single pipeline layout — voir Étape 3) :
|
||||
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow) [784 bytes]
|
||||
//! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes]
|
||||
//! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10)
|
||||
//! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10)
|
||||
//! - `@group(3) @binding(0)` : `shadow_sampler` (sampler_comparison) (Étape 14)
|
||||
//! - `@group(3) @binding(1)` : `shadow_map` (texture_depth_2d) (Étape 14)
|
||||
//!
|
||||
//! `FrameUniforms` layout (std140 — each element 16-byte aligned) :
|
||||
//! | Offset | Field | Type | Meaning |
|
||||
//! |-----------------------|----------------|---------------|----------------------------------|
|
||||
//! | 0 | view | mat4x4<f32> | Camera view matrix |
|
||||
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
|
||||
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
|
||||
//! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
|
||||
//! | 160 | lights[0..MAX] | array<Light> | Global light list |
|
||||
//! | 160 + 64·MAX_LIGHTS | num_directional| u32 | # directional (indices 0..n) |
|
||||
//! | | num_point | u32 | # point (indices n..) |
|
||||
//! | | num_spot | u32 | # spot (indices after point) |
|
||||
//! | | options | vec4<u32> | x = unlit flag (1 => flat color) |
|
||||
//! | Offset | Field | Type | Meaning |
|
||||
//! |-----------------------|-------------------|---------------|----------------------------------|
|
||||
//! | 0 | view | mat4x4<f32> | Camera view matrix |
|
||||
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
|
||||
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
|
||||
//! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
|
||||
//! | 160 | lights[0..MAX] | array<Light> | Global light list |
|
||||
//! | 160 + 64·MAX_LIGHTS | num_directional | u32 | # directional (indices 0..n) |
|
||||
//! | | num_point | u32 | # point (indices n..) |
|
||||
//! | | num_spot | u32 | # spot (indices after point) |
|
||||
//! | | shadow_light_index| u32 | packed index of shadow light |
|
||||
//! | 160 + 64·MAX_LIGHTS+16| light_view_proj | mat4x4<f32> | world → light clip space (D3) |
|
||||
//! | | shadow_params | vec4<f32> | .x = map size, .y = depth bias |
|
||||
//! | | options | vec4<u32> | .x = unlit ; .y = shadows on |
|
||||
//!
|
||||
//! `MAX_LIGHTS = 8`. `struct Light` is 64 bytes (4 × vec4). Directional lights occupy
|
||||
//! `lights[0..num_directional]` (`position_dir.xyz` = direction **from the surface toward the
|
||||
@@ -79,12 +84,15 @@ struct FrameUniforms {
|
||||
view: mat4x4<f32>,
|
||||
proj: mat4x4<f32>,
|
||||
cam_pos: vec4<f32>,
|
||||
ambient: vec4<f32>, // .rgb = ambient hemisphere color
|
||||
lights: array<Light, MAX_LIGHTS>, // directional, then point, then spot
|
||||
ambient: vec4<f32>, // .rgb = ambient hemisphere color
|
||||
lights: array<Light, MAX_LIGHTS>, // directional, then point, then spot
|
||||
num_directional: u32,
|
||||
num_point: u32,
|
||||
num_spot: u32,
|
||||
options: vec4<u32>, // .x : unlit flag (1 = flat color, no lighting)
|
||||
shadow_light_index: u32, // packed index of the shadow light ; MAX_LIGHTS = off
|
||||
light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3)
|
||||
shadow_params: vec4<f32>, // .x = shadow map size, .y = depth bias
|
||||
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
|
||||
};
|
||||
|
||||
struct ObjectUniform {
|
||||
@@ -97,6 +105,10 @@ struct ObjectUniform {
|
||||
// texture lie le placeholder blanc 1×1 (D2), d'où l'échantillonnage inconditionnel.
|
||||
@group(2) @binding(0) var texture_sampler: sampler;
|
||||
@group(2) @binding(1) var diffuse_texture: texture_2d<f32>;
|
||||
// Étape 14 (DRAFT D1/D5) : groupe ombre — comparaison sampler (0) + carte de profondeur (1).
|
||||
// Toujours lié (layout unifié) ; inutilisé tant que `options.y == 0` (ombres désactivées).
|
||||
@group(3) @binding(0) var shadow_sampler: sampler_comparison;
|
||||
@group(3) @binding(1) var shadow_map: texture_depth_2d;
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@@ -189,6 +201,37 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor;
|
||||
}
|
||||
|
||||
let lit = base * (ambient + diffuse);
|
||||
let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos);
|
||||
return vec4<f32>(lit, in.color.a);
|
||||
}
|
||||
|
||||
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
|
||||
// into the shadow light's clip space, converts to depth-map UVs + normalized depth, then averages
|
||||
// a 3×3 `textureSampleCompare` neighborhood using the comparison sampler (GreaterEqual). Returns
|
||||
// 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow. The reference depth is
|
||||
// pulled toward the viewer by `frame.shadow_params.y` (bias) to suppress acne.
|
||||
fn compute_shadow(world_pos: vec3<f32>) -> f32 {
|
||||
// Shadows off (options.y == 0) or no valid caster (sentinel = MAX_LIGHTS) → fully lit.
|
||||
if (frame.options.y == 0u || frame.shadow_light_index == MAX_LIGHTS) {
|
||||
return 1.0;
|
||||
}
|
||||
let light_clip = frame.light_view_proj * vec4<f32>(world_pos, 1.0);
|
||||
// Perspective divide then map NDC [-1,1] → UV [0,1]. Orthographic depth is linear in the map.
|
||||
let shadow_ndc = light_clip.xyz / max(light_clip.w, 1e-6);
|
||||
var shadow_uv = shadow_ndc.xy * 0.5 + 0.5;
|
||||
shadow_uv = vec2<f32>(shadow_uv.x, 1.0 - shadow_uv.y); // flip V for texture coordinates
|
||||
let current_depth = shadow_ndc.z * 0.5 + 0.5;
|
||||
let bias = frame.shadow_params.y;
|
||||
let texel = 1.0 / max(frame.shadow_params.x, 1.0);
|
||||
|
||||
// 3×3 PCF : average of the comparison results around the fragment's texel.
|
||||
var lit_count = 0.0;
|
||||
for (var ox = -1i; ox <= 1; ox++) {
|
||||
for (var oy = -1i; oy <= 1; oy++) {
|
||||
let offset = vec2<f32>(f32(ox), f32(oy)) * texel;
|
||||
lit_count += textureSampleCompare(
|
||||
shadow_map, shadow_sampler, shadow_uv + offset, current_depth - bias);
|
||||
}
|
||||
}
|
||||
return lit_count / 9.0;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,35 @@ pub const STANDARD_SHADER_PATH: &str = "assets/shaders/standard_shader.wgsl";
|
||||
/// pipeline uses the unified layout (frame @0 + object @1), this is the only shader the library ships.
|
||||
pub const STANDARD_SHADER: &str = include_str!("../shaders/standard_shader.wgsl");
|
||||
|
||||
/// Path to the depth-only **shadow** WGSL shader on disk (Étape 14, D4). Used by the Renderer's
|
||||
/// shadow-map pass: a minimal vertex shader that transforms vertices into light-clip space.
|
||||
pub const SHADOW_SHADER_PATH: &str = "assets/shaders/shadow_shader.wgsl";
|
||||
|
||||
/// The depth-only shadow WGSL shader source, embedded at compile time via `include_str!`
|
||||
/// (Étape 14, D4). Serves as the fallback when `SHADOW_SHADER_PATH` cannot be read.
|
||||
pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl");
|
||||
|
||||
/// Default shadow-map resolution in pixels per side (square, D2). A 1024² depth map is a good
|
||||
/// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes.
|
||||
pub const SHADOW_MAP_SIZE: u32 = 1024;
|
||||
|
||||
/// Default shadow depth bias (Étape 14, D5) subtracted from the reference depth before the
|
||||
/// comparison, to suppress acne without killing contact shadows. Combined with the slope-scaled
|
||||
/// bias applied on the shadow pipeline itself.
|
||||
pub const SHADOW_DEPTH_BIAS: f32 = 0.006;
|
||||
|
||||
/// Default half-extent (world units) of the orthographic shadow frustum around the scene center
|
||||
/// for a directional light (D3). Chosen to comfortably frame the unit-cube scene of the examples.
|
||||
pub const SHADOW_SCENE_RADIUS: f32 = 5.0;
|
||||
|
||||
/// Default world-space scene center used to place the shadow light for the examples (D3).
|
||||
pub const SHADOW_SCENE_CENTER: [f32; 3] = [0.0, 0.0, 0.0];
|
||||
|
||||
/// Maximum number of lights in the packed frame light array (re-exported from the uniform layout
|
||||
/// so upper layers can address the shadow light safely, Étape 14 D7). Also used as the no-caster
|
||||
/// sentinel for `FrameUniforms.shadow_light_index`.
|
||||
pub use crate::resources::uniform::MAX_LIGHTS;
|
||||
|
||||
/// Default application title displayed in the OS taskbar/window decorations.
|
||||
pub const APP_DEFAULT_TITLE: &str = "WSG App";
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ pub mod conf;
|
||||
pub mod error;
|
||||
|
||||
// Re-exports
|
||||
pub use conf::STANDARD_SHADER;
|
||||
pub use conf::STANDARD_SHADER_PATH;
|
||||
pub use conf::{
|
||||
SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, SHADOW_SHADER, SHADOW_SHADER_PATH,
|
||||
STANDARD_SHADER, STANDARD_SHADER_PATH,
|
||||
};
|
||||
pub use error::WsgError;
|
||||
|
||||
@@ -28,3 +28,29 @@ fn standard_shader_is_valid_wgsl() {
|
||||
// Contrat : exactement les deux entrées vs_main / fs_main attendues.
|
||||
assert!(module.entry_points.len() >= 2, "vs_main + fs_main attendus");
|
||||
}
|
||||
|
||||
/// Parse et valide complètement le shader embarqué `shadow_shader.wgsl` (Étape 14, D4) via naga.
|
||||
/// Le pipeline « shadow » est câblé directement par `build_shadow_pipeline` (sans passer par le
|
||||
/// PipelineCache), donc cette validation hors-ligne est la garantie de sa validité. Le contrat
|
||||
/// n'attend qu'une seule entrée (`vs_main` — pipeline sans fragment stage).
|
||||
#[test]
|
||||
fn shadow_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/shadow_shader.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("shadow_shader.wgsl : erreur de parsing : {e:?}"));
|
||||
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
);
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("shadow_shader.wgsl : échec de validation : {e:?}"));
|
||||
|
||||
let entry_names: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
.map(|ep| ep.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(entry_names, vec!["vs_main"], "seule vs_main attendue");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user