Skip to main content

proof_engine/tiled_lighting/
mod.rs

1//! Tiled/clustered forward lighting.
2//!
3//! Based on:
4//! - Olsson & Assarsson, "Tiled Shading" (JGT 2011)
5//! - Persson, "Practical Clustered Shading" (SIGGRAPH 2015)
6//!
7//! Divides the screen into tiles, assigns lights to tiles that overlap
8//! their bounding spheres, then shades each pixel using only its tile's
9//! light list. Scales to hundreds of lights without deferred rendering.
10
11use glam::{Vec3, Vec4, Mat4};
12
13/// A light that participates in tiled shading.
14#[derive(Debug, Clone, Copy)]
15pub struct TiledLight {
16    pub position: Vec3,
17    pub radius: f32,
18    pub color: Vec3,
19    pub intensity: f32,
20    pub light_type: TiledLightType,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub enum TiledLightType {
25    Point,
26    Spot { direction: Vec3, angle: f32 },
27}
28
29/// A screen-space tile.
30#[derive(Debug, Clone)]
31pub struct Tile {
32    pub light_indices: Vec<u16>,
33}
34
35/// Configuration for the tiled lighting system.
36#[derive(Debug, Clone)]
37pub struct TiledConfig {
38    pub tile_size: u32,
39    pub max_lights_per_tile: u32,
40    pub screen_width: u32,
41    pub screen_height: u32,
42}
43
44impl Default for TiledConfig {
45    fn default() -> Self {
46        Self { tile_size: 16, max_lights_per_tile: 64, screen_width: 1920, screen_height: 1080 }
47    }
48}
49
50/// The tiled lighting system.
51pub struct TiledLighting {
52    pub config: TiledConfig,
53    pub lights: Vec<TiledLight>,
54    pub tiles: Vec<Tile>,
55    pub tiles_x: u32,
56    pub tiles_y: u32,
57}
58
59impl TiledLighting {
60    pub fn new(config: TiledConfig) -> Self {
61        let tiles_x = (config.screen_width + config.tile_size - 1) / config.tile_size;
62        let tiles_y = (config.screen_height + config.tile_size - 1) / config.tile_size;
63        let tile_count = (tiles_x * tiles_y) as usize;
64        Self {
65            tiles: vec![Tile { light_indices: Vec::new() }; tile_count],
66            tiles_x, tiles_y, lights: Vec::new(), config,
67        }
68    }
69
70    /// Set lights for this frame.
71    pub fn set_lights(&mut self, lights: Vec<TiledLight>) {
72        self.lights = lights;
73    }
74
75    /// Assign lights to tiles based on screen-space overlap.
76    pub fn cull(&mut self, view: &Mat4, proj: &Mat4) {
77        for tile in &mut self.tiles { tile.light_indices.clear(); }
78        let vp = *proj * *view;
79
80        for (li, light) in self.lights.iter().enumerate() {
81            // Project light sphere to screen-space AABB
82            let center_clip = vp * Vec4::new(light.position.x, light.position.y, light.position.z, 1.0);
83            if center_clip.w <= 0.0 { continue; } // behind camera
84            let ndc_x = center_clip.x / center_clip.w;
85            let ndc_y = center_clip.y / center_clip.w;
86
87            // Approximate screen-space radius
88            let screen_radius = light.radius / center_clip.w * self.config.screen_width as f32 * 0.5;
89            let pixel_x = (ndc_x * 0.5 + 0.5) * self.config.screen_width as f32;
90            let pixel_y = (ndc_y * 0.5 + 0.5) * self.config.screen_height as f32;
91
92            // Find overlapping tiles
93            let min_tx = ((pixel_x - screen_radius) / self.config.tile_size as f32).floor().max(0.0) as u32;
94            let max_tx = ((pixel_x + screen_radius) / self.config.tile_size as f32).ceil().min(self.tiles_x as f32) as u32;
95            let min_ty = ((pixel_y - screen_radius) / self.config.tile_size as f32).floor().max(0.0) as u32;
96            let max_ty = ((pixel_y + screen_radius) / self.config.tile_size as f32).ceil().min(self.tiles_y as f32) as u32;
97
98            for ty in min_ty..max_ty {
99                for tx in min_tx..max_tx {
100                    let idx = (ty * self.tiles_x + tx) as usize;
101                    if idx < self.tiles.len() && self.tiles[idx].light_indices.len() < self.config.max_lights_per_tile as usize {
102                        self.tiles[idx].light_indices.push(li as u16);
103                    }
104                }
105            }
106        }
107    }
108
109    /// Get lights affecting a specific tile.
110    pub fn lights_for_tile(&self, tx: u32, ty: u32) -> &[u16] {
111        let idx = (ty * self.tiles_x + tx) as usize;
112        if idx < self.tiles.len() { &self.tiles[idx].light_indices } else { &[] }
113    }
114
115    /// Get lights affecting a screen pixel.
116    pub fn lights_at_pixel(&self, px: u32, py: u32) -> &[u16] {
117        self.lights_for_tile(px / self.config.tile_size, py / self.config.tile_size)
118    }
119
120    /// Total number of light-tile assignments (for performance stats).
121    pub fn total_assignments(&self) -> usize {
122        self.tiles.iter().map(|t| t.light_indices.len()).sum()
123    }
124
125    /// Average lights per tile.
126    pub fn avg_lights_per_tile(&self) -> f32 {
127        self.total_assignments() as f32 / self.tiles.len().max(1) as f32
128    }
129
130    /// GLSL shader for tiled light evaluation.
131    pub fn glsl_source() -> &'static str {
132        r#"
133// Tiled lighting: evaluate all lights in a tile for a fragment
134vec3 evaluate_tiled_lights(vec3 world_pos, vec3 normal, vec3 albedo,
135                           float roughness, float metallic,
136                           sampler2D light_grid, sampler1D light_data,
137                           int tile_x, int tile_y) {
138    vec3 result = vec3(0.0);
139    // Read light count and indices from tile grid texture
140    int count = int(texelFetch(light_grid, ivec2(tile_x, tile_y), 0).r);
141    for (int i = 0; i < count && i < 64; i++) {
142        int light_idx = int(texelFetch(light_grid, ivec2(tile_x * 64 + i + 1, tile_y), 0).r);
143        // Fetch light params from 1D texture
144        vec4 pos_radius = texelFetch(light_data, light_idx * 2, 0);
145        vec4 color_intensity = texelFetch(light_data, light_idx * 2 + 1, 0);
146        vec3 light_pos = pos_radius.xyz;
147        float radius = pos_radius.w;
148        vec3 light_color = color_intensity.rgb;
149        float intensity = color_intensity.a;
150
151        vec3 L = light_pos - world_pos;
152        float dist = length(L);
153        if (dist > radius) continue;
154        L /= dist;
155        float attenuation = intensity * max(1.0 - dist / radius, 0.0);
156        attenuation *= attenuation; // quadratic falloff
157        float NdotL = max(dot(normal, L), 0.0);
158        result += albedo * light_color * NdotL * attenuation;
159    }
160    return result;
161}
162        "#
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_tile_assignment() {
172        let mut tl = TiledLighting::new(TiledConfig { screen_width: 320, screen_height: 240, ..Default::default() });
173        tl.set_lights(vec![TiledLight {
174            position: Vec3::new(0.0, 0.0, -5.0), radius: 10.0,
175            color: Vec3::ONE, intensity: 1.0, light_type: TiledLightType::Point,
176        }]);
177        let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, -1.0), Vec3::Y);
178        let proj = Mat4::perspective_rh_gl(60.0f32.to_radians(), 320.0/240.0, 0.1, 100.0);
179        tl.cull(&view, &proj);
180        assert!(tl.total_assignments() > 0, "light should be assigned to some tiles");
181    }
182}