ferravex_render/
shaders.rs

1//! Original built-in faceted lighting and distance fog.
2//!
3//! Immediate geometry evaluates lighting on the CPU. Retained meshes evaluate
4//! directional lighting/fog plus scene lights/shadows in the GPU fragment shader. Both preserve flat
5//! face normals and require no external material resources.
6use crate::prelude::{Color, Vec3, vec3};
7
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct LitSettings {
10    /// World-space direction from a surface toward the directional light.
11    pub light_direction: Vec3,
12    pub camera_position: Vec3,
13    pub fog_color: Color,
14    pub fog_start: f32,
15    pub fog_end: f32,
16    pub ambient: f32,
17}
18
19impl Default for LitSettings {
20    fn default() -> Self {
21        Self {
22            light_direction: vec3(-0.4, 0.85, 0.3),
23            camera_position: vec3(12.0, 10.0, 16.0),
24            fog_color: Color::from_rgba(14, 22, 35, 255),
25            fog_start: 20.0,
26            fog_end: 55.0,
27            ambient: 0.38,
28        }
29    }
30}
31
32impl LitSettings {
33    /// Normalize the light and bound user-supplied settings once per material begin.
34    /// Nonfinite settings use their defaults; a zero light direction is ambient-only.
35    pub fn sanitized(self) -> Self {
36        let defaults = Self::default();
37        let finite = |value: f32, fallback: f32| {
38            if value.is_finite() { value } else { fallback }
39        };
40        Self {
41            light_direction: if self.light_direction.is_finite() {
42                self.light_direction.normalize_or_zero()
43            } else {
44                defaults.light_direction.normalize_or_zero()
45            },
46            camera_position: if self.camera_position.is_finite() {
47                self.camera_position
48            } else {
49                defaults.camera_position
50            },
51            fog_color: Color::new(
52                finite(self.fog_color.r, defaults.fog_color.r).clamp(0.0, 1.0),
53                finite(self.fog_color.g, defaults.fog_color.g).clamp(0.0, 1.0),
54                finite(self.fog_color.b, defaults.fog_color.b).clamp(0.0, 1.0),
55                finite(self.fog_color.a, defaults.fog_color.a).clamp(0.0, 1.0),
56            ),
57            fog_start: finite(self.fog_start, defaults.fog_start).max(0.0),
58            fog_end: finite(self.fog_end, defaults.fog_end).max(0.0),
59            ambient: finite(self.ambient, defaults.ambient).clamp(0.0, 1.0),
60        }
61    }
62
63    /// Shade an already world-transformed position and normal, retaining alpha.
64    ///
65    /// Pass settings through [`Self::sanitized`] before a batch so the light is
66    /// normalized once. Normals may be unnormalized or zero. Reversed/coincident
67    /// fog bounds become a 0.001-unit transition starting at `fog_start`.
68    pub fn shade_vertex(self, position: Vec3, normal: Vec3, color: Color) -> Color {
69        let diffuse = normal
70            .normalize_or_zero()
71            .dot(self.light_direction)
72            .clamp(0.0, 1.0);
73        let shade = self.ambient + (1.0 - self.ambient) * diffuse;
74        let fog = ((position.distance(self.camera_position) - self.fog_start)
75            / (self.fog_end - self.fog_start).max(0.001))
76        .clamp(0.0, 1.0);
77        Color::new(
78            color.r * shade * (1.0 - fog) + self.fog_color.r * fog,
79            color.g * shade * (1.0 - fog) + self.fog_color.g * fog,
80            color.b * shade * (1.0 - fog) + self.fog_color.b * fog,
81            color.a,
82        )
83    }
84}
85
86/// Built-in faceted lighting material; no external GPU resources are required.
87///
88/// `begin` applies to subsequently recorded geometry until `end` or another
89/// material begins. Recorded draw colors retain their material state.
90#[derive(Clone, Copy, Debug, Default)]
91pub struct LitMaterial;
92
93impl LitMaterial {
94    /// Kept fallible for source compatibility with games that load materials.
95    pub fn new() -> Result<Self, String> {
96        Ok(Self)
97    }
98
99    pub fn begin(&self, settings: LitSettings) {
100        crate::set_lighting(Some(settings.sanitized()));
101    }
102
103    pub fn end(&self) {
104        crate::set_lighting(None);
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn settings() -> LitSettings {
113        LitSettings {
114            light_direction: Vec3::Y,
115            camera_position: Vec3::ZERO,
116            fog_color: Color::new(0.2, 0.4, 0.6, 1.0),
117            fog_start: 10.0,
118            fog_end: 20.0,
119            ambient: 0.25,
120        }
121    }
122
123    fn close(actual: Color, expected: Color) {
124        assert!(actual.to_vec().abs_diff_eq(expected.to_vec(), 0.00001));
125    }
126
127    #[test]
128    fn diffuse_faces_receive_light_back_faces_receive_ambient() {
129        let material = settings().sanitized();
130        let color = Color::new(0.8, 0.6, 0.4, 0.3);
131        close(
132            material.shade_vertex(Vec3::ZERO, Vec3::Y * 7.0, color),
133            color,
134        );
135        for normal in [-Vec3::Y, Vec3::X, Vec3::ZERO] {
136            close(
137                material.shade_vertex(Vec3::ZERO, normal, color),
138                Color::new(0.2, 0.15, 0.1, 0.3),
139            );
140        }
141    }
142
143    #[test]
144    fn fog_interpolates_by_world_distance_and_preserves_alpha() {
145        let material = settings().sanitized();
146        let color = Color::new(0.8, 0.6, 0.4, 0.3);
147        close(material.shade_vertex(Vec3::X * 10.0, Vec3::Y, color), color);
148        close(
149            material.shade_vertex(Vec3::X * 15.0, Vec3::Y, color),
150            Color::new(0.5, 0.5, 0.5, 0.3),
151        );
152        close(
153            material.shade_vertex(Vec3::X * 25.0, Vec3::Y, color),
154            material.fog_color.with_alpha(0.3),
155        );
156        let moved_eye = LitSettings {
157            camera_position: Vec3::X * 15.0,
158            ..material
159        };
160        close(
161            moved_eye.shade_vertex(Vec3::X * 15.0, Vec3::Y, color),
162            color,
163        );
164    }
165
166    #[test]
167    fn light_normalization_zero_light_and_collapsed_fog_are_defined() {
168        let color = Color::new(1.0, 1.0, 1.0, 0.7);
169        let material = LitSettings {
170            light_direction: Vec3::Y * 8.0,
171            fog_end: 5.0,
172            ..settings()
173        }
174        .sanitized();
175        close(material.shade_vertex(Vec3::ZERO, Vec3::Y, color), color);
176        close(
177            material.shade_vertex(Vec3::X * 11.0, Vec3::Y, color),
178            material.fog_color.with_alpha(0.7),
179        );
180        let ambient_only = LitSettings {
181            light_direction: Vec3::ZERO,
182            ..material
183        };
184        close(
185            ambient_only.shade_vertex(Vec3::ZERO, Vec3::Y, color),
186            Color::new(0.25, 0.25, 0.25, 0.7),
187        );
188    }
189
190    #[test]
191    fn invalid_settings_do_not_poison_recorded_vertex_colors() {
192        let material = LitSettings {
193            light_direction: Vec3::NAN,
194            camera_position: Vec3::NAN,
195            fog_color: Color::new(f32::NAN, f32::INFINITY, -1.0, 8.0),
196            fog_start: f32::NAN,
197            fog_end: f32::INFINITY,
198            ambient: f32::NAN,
199        }
200        .sanitized();
201        let color = material.shade_vertex(Vec3::ZERO, Vec3::Y, Color::new(1.0, 1.0, 1.0, 1.0));
202        assert!(color.to_vec().is_finite());
203        assert!(
204            color
205                .to_array()
206                .into_iter()
207                .all(|channel| (0.0..=1.0).contains(&channel))
208        );
209        assert_eq!(material.fog_color.b, 0.0);
210        assert_eq!(material.fog_color.a, 1.0);
211    }
212}