Skip to main content

gizmo_engine/systems/render/
shared.rs

1//! Per-frame render *setup* shared between the two render paths.
2//!
3//! The engine has two renderers: the game's DEFERRED path (`default_render_pass`
4//! → `passes.rs`, full G-buffer + SSAO/SSR/SSGI/TAA) and the studio's FORWARD
5//! editor path (`gizmo-studio::execute_render_pipeline`, plus grid/gizmo/collider
6//! overlays). The passes genuinely differ and stay separate, but the per-frame
7//! *setup* that feeds them — light collection, shadow cascades, batching and
8//! frustum culling — is the same, and it used to be copy-pasted between the two
9//! files. Every fix to that setup then had to be applied twice, and whenever it
10//! wasn't the two renderers silently diverged (the "derive cascade splits from
11//! the camera" and "cull shadow casters against the light frustum, not the camera
12//! frustum" fixes both had to be duplicated). This module single-sources it.
13
14use crate::core::World;
15use crate::math::{Vec3, Vec4};
16use crate::renderer::components::{DirectionalLight, LightRole, PointLight, SpotLight};
17use crate::renderer::gpu_types::LightData;
18use gizmo_physics_core::components::{GlobalTransform, Transform};
19
20/// Point + spot + sun lights collected from the world for one frame, ready to be
21/// dropped into `SceneUniforms`.
22pub struct SceneLights {
23    /// Up to 10 point/spot lights (the shader's fixed light array).
24    pub lights: [LightData; 10],
25    pub num_lights: u32,
26    /// Direction the sun points along (normalized). Default down-vector when the
27    /// scene has no `LightRole::Sun`.
28    pub sun_dir: Vec3,
29    /// Sun colour in rgb, intensity in w. `w == 0` means "no sun" — the deferred
30    /// lighting shader keys off this exactly like the old inline code did.
31    pub sun_col: Vec4,
32    /// Whether the scene actually contains a `LightRole::Sun`. The studio forward
33    /// shader signals "sun present" through `sun_direction.w` (1.0 vs 0.0); this
34    /// carries that bit so the studio path stays behaviourally identical.
35    pub has_sun: bool,
36    /// Index into `lights` of the point light that owns the single point-shadow cube,
37    /// or `-1` when there is no point light. There is only one point-shadow cubemap, so
38    /// exactly one point light casts; the caller renders that light's cube and the shader
39    /// only samples it for this index (avoids applying one cube to every point light).
40    pub shadow_point_index: i32,
41}
42
43/// Collect the scene's dynamic lights (point + spot, capped at 10) and the sun.
44///
45/// Each light's world transform prefers a synced `GlobalTransform` (so a parented
46/// light follows its parent, matching how meshes are placed) and falls back to the
47/// light's own `Transform` when it has none — the same robustness the camera path
48/// uses. Previously the game path queried `(&Light, &GlobalTransform)` (dropping
49/// any light without a global) while the studio path read the raw `Transform`
50/// (ignoring parenting); this unifies both onto the correct-and-robust rule.
51pub fn collect_scene_lights(world: &World) -> SceneLights {
52    let globals = world.borrow::<GlobalTransform>();
53    let locals = world.borrow::<Transform>();
54
55    // (position, rotation) in world space, GlobalTransform-preferred, Transform-fallback.
56    let world_tf = |e| {
57        globals
58            .get(e)
59            .map(|g| {
60                let (_, rot, pos) = g.matrix.to_scale_rotation_translation();
61                (pos, rot)
62            })
63            .or_else(|| locals.get(e).map(|t| (t.position, t.rotation)))
64    };
65
66    let mut lights = [LightData {
67        position: [0.0; 4],
68        color: [0.0; 4],
69        direction: [0.0, -1.0, 0.0, 0.0],
70        params: [0.0; 4],
71    }; 10];
72    let mut num_lights = 0usize;
73    // The first collected point light owns the single point-shadow cube.
74    let mut shadow_point_index: i32 = -1;
75
76    if let Some(q) = world.query::<&PointLight>() {
77        for (e, light) in q.iter() {
78            if num_lights >= 10 {
79                break;
80            }
81            let Some((pos, _)) = world_tf(e) else { continue };
82            if shadow_point_index < 0 {
83                shadow_point_index = num_lights as i32;
84            }
85            lights[num_lights] = LightData {
86                position: [pos.x, pos.y, pos.z, light.intensity],
87                color: [light.color.x, light.color.y, light.color.z, light.radius],
88                direction: [0.0, -1.0, 0.0, 0.0],
89                params: [0.0, 0.0, 0.0, 0.0], // params.y = 0 → PointLight
90            };
91            num_lights += 1;
92        }
93    }
94
95    if let Some(q) = world.query::<&SpotLight>() {
96        for (e, light) in q.iter() {
97            if num_lights >= 10 {
98                break;
99            }
100            let Some((pos, rot)) = world_tf(e) else { continue };
101            let dir = rot.mul_vec3(Vec3::new(0.0, 0.0, -1.0)).normalize();
102            // The shaders compare the cone against `dot(-L, spot_dir)` (a cosine), so the
103            // cutoffs must be COSINES of the cone angles — every lighting shader documents
104            // `w = inner_cutoff_cos`, `params.x = outer_cutoff_cos`. `SpotLight` stores the
105            // angles in radians (its ctor clamps inner ≤ outer), so convert here. Passing the
106            // raw radians made the cone a hard cut at the wrong angle with no falloff; the
107            // studio path used to `.cos()` these itself, the game path never did (its spots
108            // were broken) — single-sourcing the fix corrects both.
109            lights[num_lights] = LightData {
110                position: [pos.x, pos.y, pos.z, light.intensity],
111                color: [light.color.x, light.color.y, light.color.z, light.radius],
112                direction: [dir.x, dir.y, dir.z, light.inner_angle.cos()],
113                params: [light.outer_angle.cos(), 1.0, 0.0, 0.0], // params.y = 1 → SpotLight
114            };
115            num_lights += 1;
116        }
117    }
118
119    let mut sun_dir = Vec3::new(0.0, -1.0, 0.0);
120    let mut sun_col = Vec4::new(0.0, 0.0, 0.0, 0.0); // w = 0 → no sun
121    let mut has_sun = false;
122    if let Some(q) = world.query::<&DirectionalLight>() {
123        for (e, light) in q.iter() {
124            if light.role == LightRole::Sun {
125                if let Some((_, rot)) = world_tf(e) {
126                    // Light convention: points along its local -Z.
127                    sun_dir = rot.mul_vec3(Vec3::new(0.0, 0.0, -1.0)).normalize();
128                    sun_col = Vec4::new(light.color.x, light.color.y, light.color.z, light.intensity);
129                    has_sun = true;
130                }
131                break; // first sun wins
132            }
133        }
134    }
135
136    SceneLights {
137        lights,
138        num_lights: num_lights as u32,
139        sun_dir,
140        sun_col,
141        has_sun,
142        shadow_point_index,
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::core::World;
150    use crate::renderer::components::{PointLight, SpotLight};
151    use gizmo_physics_core::components::GlobalTransform;
152
153    // Regression: the shaders compare the spotlight cone against `dot(-L, spot_dir)`
154    // (a cosine) and every lighting shader documents the cutoffs as cosines, but
155    // `SpotLight` stores the cone half-angles in radians. The game render path fed
156    // the raw radians (broken cone), and unifying light collection briefly spread
157    // that to the studio too; collection must convert the angles to cosines.
158    #[test]
159    fn spotlight_cutoffs_are_stored_as_cosines() {
160        let mut world = World::new();
161        let e = world.spawn();
162        world.add_component(e, GlobalTransform::default());
163        // inner_angle = 0.4 rad, outer_angle = 0.6 rad (radians, ctor clamps inner ≤ outer).
164        world.add_component(e, SpotLight::new(Vec3::ONE, 10.0, 30.0, 0.4, 0.6));
165
166        let l = collect_scene_lights(&world);
167        assert_eq!(l.num_lights, 1);
168        let spot = l.lights[0];
169        assert_eq!(spot.params[1], 1.0, "params.y == 1 marks a spot light");
170        assert!(
171            (spot.direction[3] - 0.4_f32.cos()).abs() < 1e-5,
172            "inner cutoff must be cos(inner_angle), got {}",
173            spot.direction[3]
174        );
175        assert!(
176            (spot.params[0] - 0.6_f32.cos()).abs() < 1e-5,
177            "outer cutoff must be cos(outer_angle), got {}",
178            spot.params[0]
179        );
180        // Tighter inner cone → larger cosine, so the falloff (inner - outer) is positive.
181        assert!(spot.direction[3] > spot.params[0]);
182    }
183
184    // Point lights come before spot lights, and a light with only a `Transform`
185    // (no synced `GlobalTransform`) is still collected via the fallback.
186    #[test]
187    fn point_before_spot_and_transform_fallback() {
188        let mut world = World::new();
189        // A point light carrying a GlobalTransform (also registers the component).
190        let p = world.spawn();
191        world.add_component(p, GlobalTransform::default());
192        world.add_component(p, PointLight::new(Vec3::ONE, 5.0, 12.0));
193        // A spot light with ONLY a Transform → must resolve via the Transform fallback.
194        let s = world.spawn();
195        world.add_component(s, Transform::new(Vec3::new(1.0, 2.0, 3.0)));
196        world.add_component(s, SpotLight::new(Vec3::ONE, 7.0, 20.0, 0.3, 0.5));
197
198        let l = collect_scene_lights(&world);
199        assert_eq!(l.num_lights, 2);
200        assert_eq!(l.lights[0].params[1], 0.0, "point light packed first");
201        assert_eq!(l.lights[1].params[1], 1.0, "spot light packed second");
202        // Spot position came from its Transform (GlobalTransform-less) fallback.
203        assert_eq!(l.lights[1].position, [1.0, 2.0, 3.0, 7.0]);
204        // The point light (index 0) owns the single point-shadow cube.
205        assert_eq!(l.shadow_point_index, 0, "first point light is the shadow caster");
206    }
207
208    // With no point light there is no point-shadow caster: the index must be -1 so the
209    // shader (which reads caster_index + 1) sees 0 = "no point shadow this frame" and the
210    // caller skips rendering the cube.
211    #[test]
212    fn no_point_light_has_no_shadow_caster() {
213        let mut world = World::new();
214        let s = world.spawn();
215        world.add_component(s, GlobalTransform::default());
216        world.add_component(s, SpotLight::new(Vec3::ONE, 7.0, 20.0, 0.3, 0.5));
217
218        let l = collect_scene_lights(&world);
219        assert_eq!(l.num_lights, 1);
220        assert_eq!(l.shadow_point_index, -1, "no point light → no point-shadow caster");
221    }
222}