feat(renderer): add CSM, point/spot shadows, and frustum light culling
- CascadedShadowMap: 2-cascade directional shadows with frustum-based splits - PointShadowMap: cube depth texture with 6-face rendering - SpotShadowMap: perspective shadow map from spot light cone - Frustum light culling: Gribb-Hartmann plane extraction + sphere tests - Mat4::inverse() for frustum corner computation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -174,6 +174,64 @@ impl Mat4 {
|
||||
)
|
||||
}
|
||||
|
||||
/// Compute the inverse of this matrix. Returns `None` if the matrix is singular.
|
||||
pub fn inverse(&self) -> Option<Self> {
|
||||
let m = &self.cols;
|
||||
// Flatten to row-major for cofactor expansion
|
||||
// m[col][row] — so element (row, col) = m[col][row]
|
||||
let e = |r: usize, c: usize| -> f32 { m[c][r] };
|
||||
|
||||
// Compute cofactors using 2x2 determinants
|
||||
let s0 = e(0,0) * e(1,1) - e(1,0) * e(0,1);
|
||||
let s1 = e(0,0) * e(1,2) - e(1,0) * e(0,2);
|
||||
let s2 = e(0,0) * e(1,3) - e(1,0) * e(0,3);
|
||||
let s3 = e(0,1) * e(1,2) - e(1,1) * e(0,2);
|
||||
let s4 = e(0,1) * e(1,3) - e(1,1) * e(0,3);
|
||||
let s5 = e(0,2) * e(1,3) - e(1,2) * e(0,3);
|
||||
|
||||
let c5 = e(2,2) * e(3,3) - e(3,2) * e(2,3);
|
||||
let c4 = e(2,1) * e(3,3) - e(3,1) * e(2,3);
|
||||
let c3 = e(2,1) * e(3,2) - e(3,1) * e(2,2);
|
||||
let c2 = e(2,0) * e(3,3) - e(3,0) * e(2,3);
|
||||
let c1 = e(2,0) * e(3,2) - e(3,0) * e(2,2);
|
||||
let c0 = e(2,0) * e(3,1) - e(3,0) * e(2,1);
|
||||
|
||||
let det = s0 * c5 - s1 * c4 + s2 * c3 + s3 * c2 - s4 * c1 + s5 * c0;
|
||||
if det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let inv_det = 1.0 / det;
|
||||
|
||||
// Adjugate matrix (transposed cofactor matrix), stored column-major
|
||||
let inv = Self::from_cols(
|
||||
[
|
||||
( e(1,1) * c5 - e(1,2) * c4 + e(1,3) * c3) * inv_det,
|
||||
(-e(0,1) * c5 + e(0,2) * c4 - e(0,3) * c3) * inv_det,
|
||||
( e(3,1) * s5 - e(3,2) * s4 + e(3,3) * s3) * inv_det,
|
||||
(-e(2,1) * s5 + e(2,2) * s4 - e(2,3) * s3) * inv_det,
|
||||
],
|
||||
[
|
||||
(-e(1,0) * c5 + e(1,2) * c2 - e(1,3) * c1) * inv_det,
|
||||
( e(0,0) * c5 - e(0,2) * c2 + e(0,3) * c1) * inv_det,
|
||||
(-e(3,0) * s5 + e(3,2) * s2 - e(3,3) * s1) * inv_det,
|
||||
( e(2,0) * s5 - e(2,2) * s2 + e(2,3) * s1) * inv_det,
|
||||
],
|
||||
[
|
||||
( e(1,0) * c4 - e(1,1) * c2 + e(1,3) * c0) * inv_det,
|
||||
(-e(0,0) * c4 + e(0,1) * c2 - e(0,3) * c0) * inv_det,
|
||||
( e(3,0) * s4 - e(3,1) * s2 + e(3,3) * s0) * inv_det,
|
||||
(-e(2,0) * s4 + e(2,1) * s2 - e(2,3) * s0) * inv_det,
|
||||
],
|
||||
[
|
||||
(-e(1,0) * c3 + e(1,1) * c1 - e(1,2) * c0) * inv_det,
|
||||
( e(0,0) * c3 - e(0,1) * c1 + e(0,2) * c0) * inv_det,
|
||||
(-e(3,0) * s3 + e(3,1) * s1 - e(3,2) * s0) * inv_det,
|
||||
( e(2,0) * s3 - e(2,1) * s1 + e(2,2) * s0) * inv_det,
|
||||
],
|
||||
);
|
||||
Some(inv)
|
||||
}
|
||||
|
||||
/// Return the transpose of this matrix.
|
||||
pub fn transpose(&self) -> Self {
|
||||
let c = &self.cols;
|
||||
|
||||
246
crates/voltex_renderer/src/csm.rs
Normal file
246
crates/voltex_renderer/src/csm.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use voltex_math::{Mat4, Vec3, Vec4};
|
||||
|
||||
pub const CSM_CASCADE_COUNT: usize = 2;
|
||||
pub const CSM_MAP_SIZE: u32 = 2048;
|
||||
pub const CSM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||||
|
||||
/// Cascaded Shadow Map with 2 cascades.
|
||||
pub struct CascadedShadowMap {
|
||||
pub textures: [wgpu::Texture; CSM_CASCADE_COUNT],
|
||||
pub views: [wgpu::TextureView; CSM_CASCADE_COUNT],
|
||||
pub sampler: wgpu::Sampler,
|
||||
}
|
||||
|
||||
impl CascadedShadowMap {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let create_cascade = |label: &str| {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: CSM_MAP_SIZE,
|
||||
height: CSM_MAP_SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: CSM_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
(texture, view)
|
||||
};
|
||||
|
||||
let (t0, v0) = create_cascade("CSM Cascade 0");
|
||||
let (t1, v1) = create_cascade("CSM Cascade 1");
|
||||
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("CSM 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,
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Self {
|
||||
textures: [t0, t1],
|
||||
views: [v0, v1],
|
||||
sampler,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CSM uniform data: 2 light-view-proj matrices, cascade split distance, shadow params.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
|
||||
pub struct CsmUniform {
|
||||
pub light_view_proj: [[[f32; 4]; 4]; CSM_CASCADE_COUNT], // 128 bytes
|
||||
pub cascade_split: f32, // view-space depth where cascade 0 ends and cascade 1 begins
|
||||
pub shadow_map_size: f32,
|
||||
pub shadow_bias: f32,
|
||||
pub _padding: f32, // 16 bytes total for last row
|
||||
}
|
||||
|
||||
/// Compute the 8 corners of a sub-frustum in world space, given the camera's
|
||||
/// inverse view-projection and sub-frustum near/far in NDC z [0..1] range.
|
||||
fn frustum_corners_world(inv_vp: &Mat4, z_near_ndc: f32, z_far_ndc: f32) -> [Vec3; 8] {
|
||||
let ndc_corners = [
|
||||
// Near plane corners
|
||||
Vec4::new(-1.0, -1.0, z_near_ndc, 1.0),
|
||||
Vec4::new( 1.0, -1.0, z_near_ndc, 1.0),
|
||||
Vec4::new( 1.0, 1.0, z_near_ndc, 1.0),
|
||||
Vec4::new(-1.0, 1.0, z_near_ndc, 1.0),
|
||||
// Far plane corners
|
||||
Vec4::new(-1.0, -1.0, z_far_ndc, 1.0),
|
||||
Vec4::new( 1.0, -1.0, z_far_ndc, 1.0),
|
||||
Vec4::new( 1.0, 1.0, z_far_ndc, 1.0),
|
||||
Vec4::new(-1.0, 1.0, z_far_ndc, 1.0),
|
||||
];
|
||||
|
||||
let mut world_corners = [Vec3::ZERO; 8];
|
||||
for (i, ndc) in ndc_corners.iter().enumerate() {
|
||||
let w = inv_vp.mul_vec4(*ndc);
|
||||
world_corners[i] = Vec3::new(w.x / w.w, w.y / w.w, w.z / w.w);
|
||||
}
|
||||
world_corners
|
||||
}
|
||||
|
||||
/// Compute a tight orthographic light-view-projection matrix for a set of frustum corners.
|
||||
fn light_matrix_for_corners(light_dir: Vec3, corners: &[Vec3; 8]) -> Mat4 {
|
||||
// Build a light-space view matrix looking in the light direction.
|
||||
let center = {
|
||||
let mut c = Vec3::ZERO;
|
||||
for corner in corners {
|
||||
c = c + *corner;
|
||||
}
|
||||
c * (1.0 / 8.0)
|
||||
};
|
||||
|
||||
// Pick a stable up vector that isn't parallel to light_dir.
|
||||
let up = if light_dir.cross(Vec3::Y).length_squared() < 1e-6 {
|
||||
Vec3::Z
|
||||
} else {
|
||||
Vec3::Y
|
||||
};
|
||||
|
||||
let light_view = Mat4::look_at(
|
||||
center - light_dir * 0.5, // eye slightly behind center along light direction
|
||||
center,
|
||||
up,
|
||||
);
|
||||
|
||||
// Transform all corners into light view space and find AABB.
|
||||
let mut min_x = f32::MAX;
|
||||
let mut max_x = f32::MIN;
|
||||
let mut min_y = f32::MAX;
|
||||
let mut max_y = f32::MIN;
|
||||
let mut min_z = f32::MAX;
|
||||
let mut max_z = f32::MIN;
|
||||
|
||||
for corner in corners {
|
||||
let v = light_view.mul_vec4(Vec4::from_vec3(*corner, 1.0));
|
||||
let p = Vec3::new(v.x, v.y, v.z);
|
||||
min_x = min_x.min(p.x);
|
||||
max_x = max_x.max(p.x);
|
||||
min_y = min_y.min(p.y);
|
||||
max_y = max_y.max(p.y);
|
||||
min_z = min_z.min(p.z);
|
||||
max_z = max_z.max(p.z);
|
||||
}
|
||||
|
||||
// Extend the z range to catch shadow casters behind the frustum.
|
||||
let z_margin = (max_z - min_z) * 2.0;
|
||||
min_z -= z_margin;
|
||||
|
||||
let light_proj = Mat4::orthographic(min_x, max_x, min_y, max_y, -max_z, -min_z);
|
||||
|
||||
light_proj.mul_mat4(&light_view)
|
||||
}
|
||||
|
||||
/// Compute cascade light-view-projection matrices for 2 cascades.
|
||||
///
|
||||
/// - `light_dir`: normalized direction **toward** the light source (opposite of light travel).
|
||||
/// Internally we negate it to get the light travel direction.
|
||||
/// - `camera_view`, `camera_proj`: the camera's view and projection matrices.
|
||||
/// - `near`, `far`: camera near/far planes.
|
||||
/// - `split`: the view-space depth where cascade 0 ends and cascade 1 begins.
|
||||
///
|
||||
/// Returns two light-view-projection matrices, one for each cascade.
|
||||
pub fn compute_cascade_matrices(
|
||||
light_dir: Vec3,
|
||||
camera_view: &Mat4,
|
||||
camera_proj: &Mat4,
|
||||
near: f32,
|
||||
far: f32,
|
||||
split: f32,
|
||||
) -> [Mat4; CSM_CASCADE_COUNT] {
|
||||
let vp = camera_proj.mul_mat4(camera_view);
|
||||
let inv_vp = vp.inverse().expect("Camera VP matrix must be invertible");
|
||||
|
||||
// Map view-space depth to NDC z. For wgpu perspective:
|
||||
// ndc_z = (far * (z_view + near)) / (z_view * (far - near))
|
||||
// But since z_view is negative in RH, and we want the NDC value, we use
|
||||
// the projection matrix directly by projecting (0, 0, -depth, 1).
|
||||
let depth_to_ndc = |depth: f32| -> f32 {
|
||||
let clip = camera_proj.mul_vec4(Vec4::new(0.0, 0.0, -depth, 1.0));
|
||||
clip.z / clip.w
|
||||
};
|
||||
|
||||
let ndc_near = depth_to_ndc(near);
|
||||
let ndc_split = depth_to_ndc(split);
|
||||
let ndc_far = depth_to_ndc(far);
|
||||
|
||||
// Light direction is the direction light travels (away from the source).
|
||||
let dir = (-light_dir).normalize();
|
||||
|
||||
let corners0 = frustum_corners_world(&inv_vp, ndc_near, ndc_split);
|
||||
let corners1 = frustum_corners_world(&inv_vp, ndc_split, ndc_far);
|
||||
|
||||
[
|
||||
light_matrix_for_corners(dir, &corners0),
|
||||
light_matrix_for_corners(dir, &corners1),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::mem;
|
||||
|
||||
#[test]
|
||||
fn test_csm_uniform_size() {
|
||||
// Must be multiple of 16 for WGSL uniform alignment
|
||||
assert_eq!(mem::size_of::<CsmUniform>() % 16, 0,
|
||||
"CsmUniform must be 16-byte aligned, got {} bytes", mem::size_of::<CsmUniform>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_cascade_matrices_produces_valid_matrices() {
|
||||
let light_dir = Vec3::new(0.0, -1.0, -1.0).normalize();
|
||||
let view = Mat4::look_at(Vec3::new(0.0, 5.0, 10.0), Vec3::ZERO, Vec3::Y);
|
||||
let proj = Mat4::perspective(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1, 100.0);
|
||||
|
||||
let matrices = compute_cascade_matrices(light_dir, &view, &proj, 0.1, 100.0, 20.0);
|
||||
|
||||
// Both matrices should not be identity (they should be actual projections)
|
||||
assert_ne!(matrices[0].cols, Mat4::IDENTITY.cols, "Cascade 0 should not be identity");
|
||||
assert_ne!(matrices[1].cols, Mat4::IDENTITY.cols, "Cascade 1 should not be identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cascade_split_distance() {
|
||||
// The split distance should partition the frustum
|
||||
let light_dir = Vec3::new(0.0, -1.0, 0.0).normalize();
|
||||
let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 1.0, 50.0);
|
||||
|
||||
let split = 15.0;
|
||||
let matrices = compute_cascade_matrices(light_dir, &view, &proj, 1.0, 50.0, split);
|
||||
|
||||
// Both matrices should be different (covering different frustum regions)
|
||||
let differ = matrices[0].cols.iter()
|
||||
.zip(matrices[1].cols.iter())
|
||||
.any(|(a, b)| {
|
||||
a.iter().zip(b.iter()).any(|(x, y)| (x - y).abs() > 1e-3)
|
||||
});
|
||||
assert!(differ, "Cascade matrices should differ for different frustum regions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frustum_corners_world_identity() {
|
||||
// With identity inverse VP, corners should be at NDC positions.
|
||||
let inv_vp = Mat4::IDENTITY;
|
||||
let corners = frustum_corners_world(&inv_vp, 0.0, 1.0);
|
||||
|
||||
// Near plane at z=0
|
||||
assert!((corners[0].z - 0.0).abs() < 1e-5);
|
||||
// Far plane at z=1
|
||||
assert!((corners[4].z - 1.0).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
262
crates/voltex_renderer/src/frustum.rs
Normal file
262
crates/voltex_renderer/src/frustum.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
use voltex_math::Vec3;
|
||||
use crate::light::{LightsUniform, LIGHT_DIRECTIONAL, LIGHT_POINT};
|
||||
|
||||
/// A plane in 3D space: normal.dot(point) + d = 0
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Plane {
|
||||
pub normal: Vec3,
|
||||
pub d: f32,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
/// Normalize the plane equation so that |normal| == 1.
|
||||
pub fn normalize(&self) -> Self {
|
||||
let len = self.normal.length();
|
||||
if len < 1e-10 {
|
||||
return *self;
|
||||
}
|
||||
Self {
|
||||
normal: Vec3::new(self.normal.x / len, self.normal.y / len, self.normal.z / len),
|
||||
d: self.d / len,
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed distance from a point to the plane (positive = inside / front).
|
||||
pub fn distance(&self, point: Vec3) -> f32 {
|
||||
self.normal.dot(point) + self.d
|
||||
}
|
||||
}
|
||||
|
||||
/// Six-plane frustum (left, right, bottom, top, near, far).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Frustum {
|
||||
pub planes: [Plane; 6],
|
||||
}
|
||||
|
||||
/// Extract 6 frustum planes from a view-projection matrix using the
|
||||
/// Gribb-Hartmann method.
|
||||
///
|
||||
/// The planes point inward so that a point is inside if distance >= 0 for all planes.
|
||||
/// Matrix is column-major `[[f32;4];4]` (same as `Mat4::cols`).
|
||||
pub fn extract_frustum(view_proj: &voltex_math::Mat4) -> Frustum {
|
||||
// We work with rows of the VP matrix.
|
||||
// For column-major storage cols[c][r]:
|
||||
// row[r] = (cols[0][r], cols[1][r], cols[2][r], cols[3][r])
|
||||
let m = &view_proj.cols;
|
||||
|
||||
let row = |r: usize| -> [f32; 4] {
|
||||
[m[0][r], m[1][r], m[2][r], m[3][r]]
|
||||
};
|
||||
|
||||
let r0 = row(0);
|
||||
let r1 = row(1);
|
||||
let r2 = row(2);
|
||||
let r3 = row(3);
|
||||
|
||||
// Left: row3 + row0
|
||||
let left = Plane {
|
||||
normal: Vec3::new(r3[0] + r0[0], r3[1] + r0[1], r3[2] + r0[2]),
|
||||
d: r3[3] + r0[3],
|
||||
}.normalize();
|
||||
|
||||
// Right: row3 - row0
|
||||
let right = Plane {
|
||||
normal: Vec3::new(r3[0] - r0[0], r3[1] - r0[1], r3[2] - r0[2]),
|
||||
d: r3[3] - r0[3],
|
||||
}.normalize();
|
||||
|
||||
// Bottom: row3 + row1
|
||||
let bottom = Plane {
|
||||
normal: Vec3::new(r3[0] + r1[0], r3[1] + r1[1], r3[2] + r1[2]),
|
||||
d: r3[3] + r1[3],
|
||||
}.normalize();
|
||||
|
||||
// Top: row3 - row1
|
||||
let top = Plane {
|
||||
normal: Vec3::new(r3[0] - r1[0], r3[1] - r1[1], r3[2] - r1[2]),
|
||||
d: r3[3] - r1[3],
|
||||
}.normalize();
|
||||
|
||||
// Near: row2 (wgpu NDC z in [0,1], so near = row2 directly)
|
||||
let near = Plane {
|
||||
normal: Vec3::new(r2[0], r2[1], r2[2]),
|
||||
d: r2[3],
|
||||
}.normalize();
|
||||
|
||||
// Far: row3 - row2
|
||||
let far = Plane {
|
||||
normal: Vec3::new(r3[0] - r2[0], r3[1] - r2[1], r3[2] - r2[2]),
|
||||
d: r3[3] - r2[3],
|
||||
}.normalize();
|
||||
|
||||
Frustum {
|
||||
planes: [left, right, bottom, top, near, far],
|
||||
}
|
||||
}
|
||||
|
||||
/// Test whether a sphere (center, radius) is at least partially inside the frustum.
|
||||
pub fn sphere_vs_frustum(center: Vec3, radius: f32, frustum: &Frustum) -> bool {
|
||||
for plane in &frustum.planes {
|
||||
if plane.distance(center) < -radius {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Return indices of lights from `lights` that are visible in the given frustum.
|
||||
///
|
||||
/// - Directional lights are always included.
|
||||
/// - Point lights use a bounding sphere (position, range).
|
||||
/// - Spot lights use a conservative bounding sphere centered at the light position
|
||||
/// with radius equal to the light range.
|
||||
pub fn cull_lights(frustum: &Frustum, lights: &LightsUniform) -> Vec<usize> {
|
||||
let count = lights.count as usize;
|
||||
let mut visible = Vec::with_capacity(count);
|
||||
|
||||
for i in 0..count {
|
||||
let light = &lights.lights[i];
|
||||
if light.light_type == LIGHT_DIRECTIONAL {
|
||||
// Directional lights affect everything
|
||||
visible.push(i);
|
||||
} else if light.light_type == LIGHT_POINT {
|
||||
let center = Vec3::new(light.position[0], light.position[1], light.position[2]);
|
||||
if sphere_vs_frustum(center, light.range, frustum) {
|
||||
visible.push(i);
|
||||
}
|
||||
} else {
|
||||
// Spot light — use bounding sphere at position with radius = range
|
||||
let center = Vec3::new(light.position[0], light.position[1], light.position[2]);
|
||||
if sphere_vs_frustum(center, light.range, frustum) {
|
||||
visible.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visible
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use voltex_math::Mat4;
|
||||
use crate::light::{LightData, LightsUniform};
|
||||
|
||||
fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
|
||||
(a - b).abs() < eps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frustum_extraction_identity() {
|
||||
// Identity VP means clip space = NDC directly.
|
||||
// For wgpu: x,y in [-1,1], z in [0,1].
|
||||
let frustum = extract_frustum(&Mat4::IDENTITY);
|
||||
|
||||
// All 6 planes should be normalized (length ~1)
|
||||
for (i, plane) in frustum.planes.iter().enumerate() {
|
||||
let len = plane.normal.length();
|
||||
assert!(approx_eq(len, 1.0, 1e-4), "Plane {} normal length = {}", i, len);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frustum_extraction_perspective() {
|
||||
let proj = Mat4::perspective(
|
||||
std::f32::consts::FRAC_PI_2, // 90 deg
|
||||
1.0,
|
||||
0.1,
|
||||
100.0,
|
||||
);
|
||||
let view = Mat4::look_at(
|
||||
Vec3::new(0.0, 0.0, 5.0),
|
||||
Vec3::ZERO,
|
||||
Vec3::Y,
|
||||
);
|
||||
let vp = proj.mul_mat4(&view);
|
||||
let frustum = extract_frustum(&vp);
|
||||
|
||||
// Origin (0,0,0) should be inside the frustum (it's 5 units in front of camera)
|
||||
assert!(sphere_vs_frustum(Vec3::ZERO, 0.0, &frustum),
|
||||
"Origin should be inside frustum");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sphere_inside_frustum() {
|
||||
let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 100.0);
|
||||
let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let vp = proj.mul_mat4(&view);
|
||||
let frustum = extract_frustum(&vp);
|
||||
|
||||
// Sphere at origin with radius 1 — well inside
|
||||
assert!(sphere_vs_frustum(Vec3::ZERO, 1.0, &frustum));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sphere_outside_frustum() {
|
||||
let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 100.0);
|
||||
let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let vp = proj.mul_mat4(&view);
|
||||
let frustum = extract_frustum(&vp);
|
||||
|
||||
// Sphere far behind the camera
|
||||
assert!(!sphere_vs_frustum(Vec3::new(0.0, 0.0, 200.0), 1.0, &frustum),
|
||||
"Sphere far behind camera should be outside");
|
||||
|
||||
// Sphere far to the side
|
||||
assert!(!sphere_vs_frustum(Vec3::new(500.0, 0.0, 0.0), 1.0, &frustum),
|
||||
"Sphere far to the side should be outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sphere_partially_inside() {
|
||||
let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 100.0);
|
||||
let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let vp = proj.mul_mat4(&view);
|
||||
let frustum = extract_frustum(&vp);
|
||||
|
||||
// Sphere at far plane boundary but with large radius should be inside
|
||||
assert!(sphere_vs_frustum(Vec3::new(0.0, 0.0, -96.0), 5.0, &frustum));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cull_lights_directional_always_included() {
|
||||
let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 50.0);
|
||||
let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let vp = proj.mul_mat4(&view);
|
||||
let frustum = extract_frustum(&vp);
|
||||
|
||||
let mut lights = LightsUniform::new();
|
||||
lights.add_light(LightData::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0], 1.0));
|
||||
lights.add_light(LightData::point([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 2.0, 5.0));
|
||||
// Point light far away — should be culled
|
||||
lights.add_light(LightData::point([500.0, 500.0, 500.0], [0.0, 1.0, 0.0], 2.0, 1.0));
|
||||
|
||||
let visible = cull_lights(&frustum, &lights);
|
||||
// Directional (0) always included, point at origin (1) inside, far point (2) culled
|
||||
assert!(visible.contains(&0), "Directional light must always be included");
|
||||
assert!(visible.contains(&1), "Point light at origin should be visible");
|
||||
assert!(!visible.contains(&2), "Far point light should be culled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cull_lights_spot() {
|
||||
let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 50.0);
|
||||
let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let vp = proj.mul_mat4(&view);
|
||||
let frustum = extract_frustum(&vp);
|
||||
|
||||
let mut lights = LightsUniform::new();
|
||||
// Spot light inside frustum
|
||||
lights.add_light(LightData::spot(
|
||||
[0.0, 2.0, 0.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0], 3.0, 10.0, 15.0, 30.0,
|
||||
));
|
||||
// Spot light far away
|
||||
lights.add_light(LightData::spot(
|
||||
[300.0, 300.0, 300.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0], 3.0, 5.0, 15.0, 30.0,
|
||||
));
|
||||
|
||||
let visible = cull_lights(&frustum, &lights);
|
||||
assert!(visible.contains(&0), "Near spot should be visible");
|
||||
assert!(!visible.contains(&1), "Far spot should be culled");
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ pub mod sphere;
|
||||
pub mod pbr_pipeline;
|
||||
pub mod shadow;
|
||||
pub mod shadow_pipeline;
|
||||
pub mod csm;
|
||||
pub mod point_shadow;
|
||||
pub mod spot_shadow;
|
||||
pub mod frustum;
|
||||
pub mod brdf_lut;
|
||||
pub mod ibl;
|
||||
pub mod gbuffer;
|
||||
@@ -38,6 +42,10 @@ pub use sphere::generate_sphere;
|
||||
pub use pbr_pipeline::create_pbr_pipeline;
|
||||
pub use shadow::{ShadowMap, ShadowUniform, ShadowPassUniform, SHADOW_MAP_SIZE, SHADOW_FORMAT};
|
||||
pub use shadow_pipeline::{create_shadow_pipeline, shadow_pass_bind_group_layout};
|
||||
pub use csm::{CascadedShadowMap, CsmUniform, compute_cascade_matrices, CSM_CASCADE_COUNT, CSM_MAP_SIZE, CSM_FORMAT};
|
||||
pub use point_shadow::{PointShadowMap, point_shadow_view_matrices, point_shadow_projection, POINT_SHADOW_SIZE, POINT_SHADOW_FORMAT};
|
||||
pub use spot_shadow::{SpotShadowMap, spot_shadow_matrix};
|
||||
pub use frustum::{Plane, Frustum, extract_frustum, sphere_vs_frustum, cull_lights};
|
||||
pub use ibl::IblResources;
|
||||
pub use gbuffer::GBuffer;
|
||||
pub use fullscreen_quad::{create_fullscreen_vertex_buffer, FullscreenVertex};
|
||||
|
||||
178
crates/voltex_renderer/src/point_shadow.rs
Normal file
178
crates/voltex_renderer/src/point_shadow.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use voltex_math::{Mat4, Vec3};
|
||||
|
||||
pub const POINT_SHADOW_SIZE: u32 = 512;
|
||||
pub const POINT_SHADOW_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||||
|
||||
/// Depth cube map for omnidirectional point light shadows.
|
||||
///
|
||||
/// Uses a single cube texture with 6 faces (512x512 each).
|
||||
pub struct PointShadowMap {
|
||||
pub texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
/// Per-face views for rendering into each cube face.
|
||||
pub face_views: [wgpu::TextureView; 6],
|
||||
pub sampler: wgpu::Sampler,
|
||||
}
|
||||
|
||||
impl PointShadowMap {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Point Shadow Cube Texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width: POINT_SHADOW_SIZE,
|
||||
height: POINT_SHADOW_SIZE,
|
||||
depth_or_array_layers: 6,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: POINT_SHADOW_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
// Full cube view for sampling in shader
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("Point Shadow Cube View"),
|
||||
dimension: Some(wgpu::TextureViewDimension::Cube),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Per-face views for rendering
|
||||
let face_labels = ["+X", "-X", "+Y", "-Y", "+Z", "-Z"];
|
||||
let face_views = std::array::from_fn(|i| {
|
||||
texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some(&format!("Point Shadow Face {}", face_labels[i])),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: i as u32,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Point Shadow 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,
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Self {
|
||||
texture,
|
||||
view,
|
||||
face_views,
|
||||
sampler,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the 6 view matrices for rendering into a point light shadow cube map.
|
||||
///
|
||||
/// Order: +X, -X, +Y, -Y, +Z, -Z (matching wgpu cube face order).
|
||||
///
|
||||
/// Each matrix is a look-at view matrix from `light_pos` toward the
|
||||
/// corresponding axis direction, with an appropriate up vector.
|
||||
pub fn point_shadow_view_matrices(light_pos: Vec3) -> [Mat4; 6] {
|
||||
[
|
||||
// +X: look right
|
||||
Mat4::look_at(light_pos, light_pos + Vec3::X, -Vec3::Y),
|
||||
// -X: look left
|
||||
Mat4::look_at(light_pos, light_pos - Vec3::X, -Vec3::Y),
|
||||
// +Y: look up
|
||||
Mat4::look_at(light_pos, light_pos + Vec3::Y, Vec3::Z),
|
||||
// -Y: look down
|
||||
Mat4::look_at(light_pos, light_pos - Vec3::Y, -Vec3::Z),
|
||||
// +Z: look forward
|
||||
Mat4::look_at(light_pos, light_pos + Vec3::Z, -Vec3::Y),
|
||||
// -Z: look backward
|
||||
Mat4::look_at(light_pos, light_pos - Vec3::Z, -Vec3::Y),
|
||||
]
|
||||
}
|
||||
|
||||
/// Compute the perspective projection for point light shadow rendering.
|
||||
///
|
||||
/// 90 degree FOV, 1:1 aspect ratio.
|
||||
/// `near` and `far` control the shadow range (typically 0.1 and light.range).
|
||||
pub fn point_shadow_projection(near: f32, far: f32) -> Mat4 {
|
||||
Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, near, far)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use voltex_math::Vec4;
|
||||
|
||||
fn approx_eq(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 1e-4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_shadow_view_matrices_count() {
|
||||
let views = point_shadow_view_matrices(Vec3::ZERO);
|
||||
assert_eq!(views.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_shadow_view_directions() {
|
||||
let pos = Vec3::new(0.0, 0.0, 0.0);
|
||||
let views = point_shadow_view_matrices(pos);
|
||||
|
||||
// For the +X face, a point at (1, 0, 0) should map to the center of the view
|
||||
// (i.e., to (0, 0, -1) in view space, roughly).
|
||||
let test_point = Vec4::new(1.0, 0.0, 0.0, 1.0);
|
||||
let in_view = views[0].mul_vec4(test_point);
|
||||
// z should be negative (in front of camera)
|
||||
assert!(in_view.z < 0.0, "+X face: point at +X should be in front, got z={}", in_view.z);
|
||||
// x and y should be near 0 (centered)
|
||||
assert!(approx_eq(in_view.x, 0.0), "+X face: expected x~0, got {}", in_view.x);
|
||||
assert!(approx_eq(in_view.y, 0.0), "+X face: expected y~0, got {}", in_view.y);
|
||||
|
||||
// For the -X face, a point at (-1, 0, 0) should be in front
|
||||
let test_neg_x = Vec4::new(-1.0, 0.0, 0.0, 1.0);
|
||||
let in_view_neg = views[1].mul_vec4(test_neg_x);
|
||||
assert!(in_view_neg.z < 0.0, "-X face: point at -X should be in front, got z={}", in_view_neg.z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_shadow_view_offset_position() {
|
||||
let pos = Vec3::new(5.0, 10.0, -3.0);
|
||||
let views = point_shadow_view_matrices(pos);
|
||||
|
||||
// Origin of the light should map to (0,0,0) in view space
|
||||
let origin = Vec4::from_vec3(pos, 1.0);
|
||||
for (i, view) in views.iter().enumerate() {
|
||||
let v = view.mul_vec4(origin);
|
||||
assert!(approx_eq(v.x, 0.0), "Face {}: origin x should be 0, got {}", i, v.x);
|
||||
assert!(approx_eq(v.y, 0.0), "Face {}: origin y should be 0, got {}", i, v.y);
|
||||
assert!(approx_eq(v.z, 0.0), "Face {}: origin z should be 0, got {}", i, v.z);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_shadow_projection_90fov() {
|
||||
let proj = point_shadow_projection(0.1, 100.0);
|
||||
|
||||
// At 90 degree FOV with aspect 1:1, a point at (-near, 0, -near) in view space
|
||||
// should project to the edge of the viewport.
|
||||
let near = 0.1f32;
|
||||
let edge = Vec4::new(near, 0.0, -near, 1.0);
|
||||
let clip = proj.mul_vec4(edge);
|
||||
let ndc_x = clip.x / clip.w;
|
||||
// Should be at x=1.0 (right edge)
|
||||
assert!(approx_eq(ndc_x, 1.0), "Expected NDC x=1.0, got {}", ndc_x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_shadow_projection_near_plane() {
|
||||
let proj = point_shadow_projection(0.1, 100.0);
|
||||
let p = Vec4::new(0.0, 0.0, -0.1, 1.0);
|
||||
let clip = proj.mul_vec4(p);
|
||||
let ndc_z = clip.z / clip.w;
|
||||
assert!(approx_eq(ndc_z, 0.0), "Near plane should map to NDC z=0, got {}", ndc_z);
|
||||
}
|
||||
}
|
||||
148
crates/voltex_renderer/src/spot_shadow.rs
Normal file
148
crates/voltex_renderer/src/spot_shadow.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use voltex_math::{Mat4, Vec3};
|
||||
use crate::shadow::{SHADOW_FORMAT, SHADOW_MAP_SIZE};
|
||||
|
||||
/// Shadow map for a single spot light. Reuses the same texture format and
|
||||
/// resolution as the directional `ShadowMap`.
|
||||
pub struct SpotShadowMap {
|
||||
pub texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
pub sampler: wgpu::Sampler,
|
||||
}
|
||||
|
||||
impl SpotShadowMap {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Spot Shadow Map Texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width: SHADOW_MAP_SIZE,
|
||||
height: SHADOW_MAP_SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: SHADOW_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Spot Shadow 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,
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Self { texture, view, sampler }
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the view-projection matrix for a spot light shadow pass.
|
||||
///
|
||||
/// - `position`: world-space position of the spot light
|
||||
/// - `direction`: normalized direction the spot light points toward
|
||||
/// - `outer_angle`: outer cone half-angle in **radians**
|
||||
/// - `range`: maximum distance the spot light reaches
|
||||
///
|
||||
/// The projection uses a perspective matrix with FOV = 2 * outer_angle,
|
||||
/// 1:1 aspect ratio, near = 0.1, far = range.
|
||||
pub fn spot_shadow_matrix(position: Vec3, direction: Vec3, outer_angle: f32, range: f32) -> Mat4 {
|
||||
let dir = direction.normalize();
|
||||
|
||||
// Pick a stable up vector that isn't parallel to the light direction.
|
||||
let up = if dir.cross(Vec3::Y).length_squared() < 1e-6 {
|
||||
Vec3::Z
|
||||
} else {
|
||||
Vec3::Y
|
||||
};
|
||||
|
||||
let target = position + dir;
|
||||
let view = Mat4::look_at(position, target, up);
|
||||
|
||||
// FOV = 2 * outer_angle; clamped to avoid degenerate projections.
|
||||
let fov = (2.0 * outer_angle).min(std::f32::consts::PI - 0.01);
|
||||
let near = 0.1_f32;
|
||||
let far = range.max(near + 0.1);
|
||||
let proj = Mat4::perspective(fov, 1.0, near, far);
|
||||
|
||||
proj.mul_mat4(&view)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use voltex_math::Vec4;
|
||||
|
||||
fn approx_eq(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 1e-4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spot_shadow_matrix_center() {
|
||||
let pos = Vec3::new(0.0, 10.0, 0.0);
|
||||
let dir = Vec3::new(0.0, -1.0, 0.0);
|
||||
let outer_angle = 30.0_f32.to_radians();
|
||||
let range = 20.0;
|
||||
|
||||
let vp = spot_shadow_matrix(pos, dir, outer_angle, range);
|
||||
|
||||
// A point directly below the light (along the direction) should map near center of NDC.
|
||||
let test_point = Vec4::new(0.0, 5.0, 0.0, 1.0); // 5 units below the light
|
||||
let clip = vp.mul_vec4(test_point);
|
||||
let ndc_x = clip.x / clip.w;
|
||||
let ndc_y = clip.y / clip.w;
|
||||
|
||||
assert!(approx_eq(ndc_x, 0.0), "Center point should have NDC x~0, got {}", ndc_x);
|
||||
// y may not be exactly 0 due to the look_at up vector choice, but should be close
|
||||
assert!(ndc_y.abs() < 0.5, "Center point should be near NDC center, got y={}", ndc_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spot_shadow_matrix_depth_range() {
|
||||
let pos = Vec3::new(0.0, 0.0, 0.0);
|
||||
let dir = Vec3::new(0.0, 0.0, -1.0);
|
||||
let outer_angle = 45.0_f32.to_radians();
|
||||
let range = 50.0;
|
||||
|
||||
let vp = spot_shadow_matrix(pos, dir, outer_angle, range);
|
||||
|
||||
// A point at near distance should have NDC z ~ 0
|
||||
let near_point = Vec4::new(0.0, 0.0, -0.1, 1.0);
|
||||
let clip_near = vp.mul_vec4(near_point);
|
||||
let ndc_z_near = clip_near.z / clip_near.w;
|
||||
assert!(ndc_z_near >= -0.1 && ndc_z_near <= 0.2,
|
||||
"Near point NDC z should be ~0, got {}", ndc_z_near);
|
||||
|
||||
// A point at far distance should have NDC z ~ 1
|
||||
let far_point = Vec4::new(0.0, 0.0, -50.0, 1.0);
|
||||
let clip_far = vp.mul_vec4(far_point);
|
||||
let ndc_z_far = clip_far.z / clip_far.w;
|
||||
assert!(ndc_z_far > 0.9 && ndc_z_far <= 1.01,
|
||||
"Far point NDC z should be ~1, got {}", ndc_z_far);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spot_shadow_matrix_not_identity() {
|
||||
let pos = Vec3::new(5.0, 5.0, 5.0);
|
||||
let dir = Vec3::new(-1.0, -1.0, -1.0).normalize();
|
||||
let vp = spot_shadow_matrix(pos, dir, 25.0_f32.to_radians(), 30.0);
|
||||
assert_ne!(vp.cols, Mat4::IDENTITY.cols);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spot_shadow_matrix_direction_down() {
|
||||
// Light pointing straight down should work (uses Z as up instead of Y)
|
||||
let pos = Vec3::new(0.0, 10.0, 0.0);
|
||||
let dir = Vec3::new(0.0, -1.0, 0.0);
|
||||
let vp = spot_shadow_matrix(pos, dir, 30.0_f32.to_radians(), 15.0);
|
||||
// Should not panic; the matrix should be valid
|
||||
assert_ne!(vp.cols, Mat4::IDENTITY.cols);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user