diff --git a/crates/voltex_renderer/src/lib.rs b/crates/voltex_renderer/src/lib.rs index 09c7e48..442ee71 100644 --- a/crates/voltex_renderer/src/lib.rs +++ b/crates/voltex_renderer/src/lib.rs @@ -11,7 +11,7 @@ pub mod sphere; pub mod pbr_pipeline; pub use gpu::{GpuContext, DEPTH_FORMAT}; -pub use light::{CameraUniform, LightUniform}; +pub use light::{CameraUniform, LightUniform, LightData, LightsUniform, MAX_LIGHTS, LIGHT_DIRECTIONAL, LIGHT_POINT, LIGHT_SPOT}; pub use mesh::Mesh; pub use camera::{Camera, FpsController}; pub use texture::GpuTexture; diff --git a/crates/voltex_renderer/src/light.rs b/crates/voltex_renderer/src/light.rs index fe78eb1..35fb4dc 100644 --- a/crates/voltex_renderer/src/light.rs +++ b/crates/voltex_renderer/src/light.rs @@ -39,3 +39,157 @@ impl LightUniform { } } } + +// Multi-light support +pub const MAX_LIGHTS: usize = 16; +pub const LIGHT_DIRECTIONAL: u32 = 0; +pub const LIGHT_POINT: u32 = 1; +pub const LIGHT_SPOT: u32 = 2; + +/// Per-light data. Must be exactly 64 bytes (4 × vec4) for WGSL array alignment. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct LightData { + pub position: [f32; 3], + pub light_type: u32, // 16 bytes + pub direction: [f32; 3], + pub range: f32, // 32 bytes + pub color: [f32; 3], + pub intensity: f32, // 48 bytes + pub inner_cone: f32, + pub outer_cone: f32, + pub _padding: [f32; 2], // 64 bytes +} + +impl LightData { + pub fn directional(direction: [f32; 3], color: [f32; 3], intensity: f32) -> Self { + Self { + position: [0.0; 3], + light_type: LIGHT_DIRECTIONAL, + direction, + range: 0.0, + color, + intensity, + inner_cone: 0.0, + outer_cone: 0.0, + _padding: [0.0; 2], + } + } + + pub fn point(position: [f32; 3], color: [f32; 3], intensity: f32, range: f32) -> Self { + Self { + position, + light_type: LIGHT_POINT, + direction: [0.0; 3], + range, + color, + intensity, + inner_cone: 0.0, + outer_cone: 0.0, + _padding: [0.0; 2], + } + } + + pub fn spot( + position: [f32; 3], + direction: [f32; 3], + color: [f32; 3], + intensity: f32, + range: f32, + inner_angle_deg: f32, + outer_angle_deg: f32, + ) -> Self { + Self { + position, + light_type: LIGHT_SPOT, + direction, + range, + color, + intensity, + inner_cone: inner_angle_deg.to_radians().cos(), + outer_cone: outer_angle_deg.to_radians().cos(), + _padding: [0.0; 2], + } + } +} + +/// Uniform buffer holding up to MAX_LIGHTS lights plus ambient color. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct LightsUniform { + pub lights: [LightData; MAX_LIGHTS], + pub count: u32, + pub ambient_color: [f32; 3], +} + +impl LightsUniform { + pub fn new() -> Self { + Self { + lights: [LightData::zeroed(); MAX_LIGHTS], + count: 0, + ambient_color: [0.03, 0.03, 0.03], + } + } + + pub fn add_light(&mut self, light: LightData) { + if (self.count as usize) < MAX_LIGHTS { + self.lights[self.count as usize] = light; + self.count += 1; + } + } + + pub fn clear(&mut self) { + self.count = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::mem; + + #[test] + fn test_light_data_size() { + assert_eq!(mem::size_of::() % 16, 0, + "LightData must be a multiple of 16 bytes for WGSL array alignment"); + assert_eq!(mem::size_of::(), 64, + "LightData must be exactly 64 bytes"); + } + + #[test] + fn test_lights_uniform_add() { + let mut u = LightsUniform::new(); + u.add_light(LightData::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0], 1.0)); + u.add_light(LightData::point([0.0, 5.0, 0.0], [1.0, 0.0, 0.0], 2.0, 10.0)); + assert_eq!(u.count, 2); + } + + #[test] + fn test_lights_uniform_max() { + let mut u = LightsUniform::new(); + for _ in 0..20 { + u.add_light(LightData::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0], 1.0)); + } + assert_eq!(u.count, MAX_LIGHTS as u32, + "count must be capped at MAX_LIGHTS (16)"); + } + + #[test] + fn test_spot_light_cone() { + let light = LightData::spot( + [0.0, 10.0, 0.0], + [0.0, -1.0, 0.0], + [1.0, 1.0, 1.0], + 3.0, + 20.0, + 15.0, + 30.0, + ); + let expected_inner = 15.0_f32.to_radians().cos(); + let expected_outer = 30.0_f32.to_radians().cos(); + assert!((light.inner_cone - expected_inner).abs() < 1e-6, + "inner_cone should be cos(15°)"); + assert!((light.outer_cone - expected_outer).abs() < 1e-6, + "outer_cone should be cos(30°)"); + } +} diff --git a/crates/voltex_renderer/src/pbr_shader.wgsl b/crates/voltex_renderer/src/pbr_shader.wgsl index 43c3a6c..70c81f9 100644 --- a/crates/voltex_renderer/src/pbr_shader.wgsl +++ b/crates/voltex_renderer/src/pbr_shader.wgsl @@ -4,10 +4,22 @@ struct CameraUniform { camera_pos: vec3, }; -struct LightUniform { +struct LightData { + position: vec3, + light_type: u32, direction: vec3, + range: f32, color: vec3, - ambient_strength: f32, + intensity: f32, + inner_cone: f32, + outer_cone: f32, + _padding: vec2, +}; + +struct LightsUniform { + lights: array, + count: u32, + ambient_color: vec3, }; struct MaterialUniform { @@ -18,7 +30,7 @@ struct MaterialUniform { }; @group(0) @binding(0) var camera: CameraUniform; -@group(0) @binding(1) var light: LightUniform; +@group(0) @binding(1) var lights_uniform: LightsUniform; @group(1) @binding(0) var t_diffuse: texture_2d; @group(1) @binding(1) var s_diffuse: sampler; @@ -81,6 +93,78 @@ fn fresnel_schlick(cosTheta: f32, F0: vec3) -> vec3 { return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); } +// Point light distance attenuation: inverse-square with smooth falloff at range boundary +fn attenuation_point(distance: f32, range: f32) -> f32 { + let d_over_r = distance / range; + let d_over_r4 = d_over_r * d_over_r * d_over_r * d_over_r; + let falloff = clamp(1.0 - d_over_r4, 0.0, 1.0); + return (falloff * falloff) / (distance * distance + 0.0001); +} + +// Spot light angular attenuation +fn attenuation_spot(light: LightData, L: vec3) -> f32 { + let spot_dir = normalize(light.direction); + let theta = dot(spot_dir, -L); + return clamp( + (theta - light.outer_cone) / (light.inner_cone - light.outer_cone + 0.0001), + 0.0, + 1.0, + ); +} + +// Cook-Torrance BRDF contribution for one light +fn compute_light_contribution( + light: LightData, + N: vec3, + V: vec3, + world_pos: vec3, + F0: vec3, + albedo: vec3, + metallic: f32, + roughness: f32, +) -> vec3 { + var L: vec3; + var radiance: vec3; + + if light.light_type == 0u { + // Directional + L = normalize(-light.direction); + radiance = light.color * light.intensity; + } else if light.light_type == 1u { + // Point + let to_light = light.position - world_pos; + let dist = length(to_light); + L = normalize(to_light); + let att = attenuation_point(dist, light.range); + radiance = light.color * light.intensity * att; + } else { + // Spot + let to_light = light.position - world_pos; + let dist = length(to_light); + L = normalize(to_light); + let att_dist = attenuation_point(dist, light.range); + let att_ang = attenuation_spot(light, L); + radiance = light.color * light.intensity * att_dist * att_ang; + } + + let H = normalize(V + L); + + let NDF = distribution_ggx(N, H, roughness); + let G = geometry_smith(N, V, L, roughness); + let F = fresnel_schlick(max(dot(H, V), 0.0), F0); + + let ks = F; + let kd = (vec3(1.0) - ks) * (1.0 - metallic); + + let numerator = NDF * G * F; + let NdotL = max(dot(N, L), 0.0); + let NdotV = max(dot(N, V), 0.0); + let denominator = 4.0 * NdotV * NdotL + 0.0001; + let specular = numerator / denominator; + + return (kd * albedo / 3.14159265358979 + specular) * radiance * NdotL; +} + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let tex_color = textureSample(t_diffuse, s_diffuse, in.uv); @@ -95,29 +179,18 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // F0: base reflectivity; 0.04 for dielectrics, albedo for metals let F0 = mix(vec3(0.04, 0.04, 0.04), albedo, metallic); - // Single directional light - let L = normalize(-light.direction); - let H = normalize(V + L); - let radiance = light.color; - - // Cook-Torrance BRDF components - let NDF = distribution_ggx(N, H, roughness); - let G = geometry_smith(N, V, L, roughness); - let F = fresnel_schlick(max(dot(H, V), 0.0), F0); - - let ks = F; - let kd = (vec3(1.0) - ks) * (1.0 - metallic); - - let numerator = NDF * G * F; - let NdotL = max(dot(N, L), 0.0); - let NdotV = max(dot(N, V), 0.0); - let denominator = 4.0 * NdotV * NdotL + 0.0001; - let specular = numerator / denominator; - - let Lo = (kd * albedo / 3.14159265358979 + specular) * radiance * NdotL; + // Accumulate contribution from all active lights + var Lo = vec3(0.0); + let light_count = min(lights_uniform.count, 16u); + for (var i = 0u; i < light_count; i++) { + Lo += compute_light_contribution( + lights_uniform.lights[i], + N, V, in.world_pos, F0, albedo, metallic, roughness, + ); + } // Ambient term - let ambient = light.ambient_strength * light.color * albedo * ao; + let ambient = lights_uniform.ambient_color * albedo * ao; var color = ambient + Lo;