Skip to main content

loam_app/
environment.rs

1use anyhow::{anyhow, Result};
2use loam_console::Console;
3use loam_render::sky_ground::{
4    DEFAULT_FOG_PER_UNIT, DEFAULT_SKY, GROUND_DARK_GREY, GROUND_LIGHT_GREY,
5};
6use loam_render::{Ground, Sky};
7
8// Above this the checker blends into the sky inside one body length.
9const MAX_FOG_PER_UNIT: f32 = 1.0;
10
11#[derive(Copy, Clone, Debug, PartialEq)]
12pub struct Environment {
13    pub sky: Sky,
14    pub dark: [f32; 3],
15    pub light: [f32; 3],
16    pub fog_per_unit: f32,
17    pub fog_start: f32,
18    pub floor_visible: bool,
19}
20
21impl Default for Environment {
22    fn default() -> Self {
23        Self {
24            sky: DEFAULT_SKY,
25            dark: GROUND_DARK_GREY,
26            light: GROUND_LIGHT_GREY,
27            fog_per_unit: DEFAULT_FOG_PER_UNIT,
28            fog_start: 0.0,
29            floor_visible: true,
30        }
31    }
32}
33
34impl Environment {
35    pub fn ground(&self, y: f32, visible: bool) -> Ground {
36        Ground {
37            y,
38            dark: self.dark,
39            light: self.light,
40            fog_per_unit: self.fog_per_unit,
41            fog_start: self.fog_start,
42            visible,
43        }
44    }
45
46    pub fn report(&self, field: Option<&str>) -> String {
47        match field {
48            Some("dark") => format!("ground dark: {}", rgb_text(self.dark)),
49            Some("light") => format!("ground light: {}", rgb_text(self.light)),
50            Some("fog") => format!(
51                "ground fog: {:.4} per unit (half sky at {:.1} units)",
52                self.fog_per_unit,
53                half_blend_distance(self.fog_per_unit)
54            ),
55            _ => format!(
56                "ground dark {} light {} fog {:.4} (half sky at {:.1} units)",
57                rgb_text(self.dark),
58                rgb_text(self.light),
59                self.fog_per_unit,
60                half_blend_distance(self.fog_per_unit)
61            ),
62        }
63    }
64
65    pub fn apply(&mut self, args: &[&str]) -> Result<String> {
66        let Some(field) = args.first().copied() else {
67            return Ok(self.report(None));
68        };
69        match field {
70            "reset" => {
71                *self = Self {
72                    sky: self.sky,
73                    ..Self::default()
74                };
75                Ok(format!("ground: reset ({})", self.report(None)))
76            }
77            "dark" | "light" => {
78                if args.len() == 1 {
79                    return Ok(self.report(Some(field)));
80                }
81                let rgb = parse_rgb(field, &args[1..])?;
82                if field == "dark" {
83                    self.dark = rgb;
84                } else {
85                    self.light = rgb;
86                }
87                Ok(format!("ground {field}: set to {}", rgb_text(rgb)))
88            }
89            "fog" => {
90                if args.len() == 1 {
91                    return Ok(self.report(Some(field)));
92                }
93                self.fog_per_unit = parse_fog(args[1])?;
94                Ok(format!("ground fog: set to {}", self.report(Some("fog"))))
95            }
96            other => Err(anyhow!(
97                "ground: unknown field `{other}` (try dark|light|fog|reset)"
98            )),
99        }
100    }
101}
102
103pub fn half_blend_distance(fog_per_unit: f32) -> f32 {
104    if fog_per_unit <= 0.0 {
105        return f32::INFINITY;
106    }
107    std::f32::consts::LN_2 / fog_per_unit
108}
109
110fn rgb_text(rgb: [f32; 3]) -> String {
111    format!("{:.3} {:.3} {:.3}", rgb[0], rgb[1], rgb[2])
112}
113
114fn parse_rgb(field: &str, args: &[&str]) -> Result<[f32; 3]> {
115    let [r, g, b] = args else {
116        return Err(anyhow!(
117            "usage: ground {field} <r> <g> <b>, each a float in [0, 1]"
118        ));
119    };
120    let mut rgb = [0.0_f32; 3];
121    for (slot, token) in rgb.iter_mut().zip([r, g, b]) {
122        let value: f32 = token
123            .parse()
124            .map_err(|e| anyhow!("ground {field}: invalid channel `{token}`: {e}"))?;
125        if !(value.is_finite() && (0.0..=1.0).contains(&value)) {
126            return Err(anyhow!(
127                "ground {field}: channel {value} out of range; expected [0, 1]"
128            ));
129        }
130        *slot = value;
131    }
132    Ok(rgb)
133}
134
135fn parse_fog(token: &str) -> Result<f32> {
136    let value: f32 = token
137        .parse()
138        .map_err(|e| anyhow!("ground fog: invalid density `{token}`: {e}"))?;
139    if !(value.is_finite() && (0.0..=MAX_FOG_PER_UNIT).contains(&value)) {
140        return Err(anyhow!(
141            "ground fog: density {value} out of range; expected [0, {MAX_FOG_PER_UNIT}]"
142        ));
143    }
144    Ok(value)
145}
146
147pub fn register_ground_command<Ctx: 'static>(
148    console: &mut Console<Ctx>,
149    reach: fn(&mut Ctx) -> &mut Environment,
150) {
151    console.register(
152        loam_console::cmd::<Ctx, _>(
153            "ground",
154            "background checker colors and fog density (bare reads all three)",
155            move |args, ctx, out| {
156                let line = reach(ctx).apply(args)?;
157                out.line(line);
158                Ok(())
159            },
160        )
161        .with_args(&[&["dark", "light", "fog", "reset"]])
162        .with_long_help(
163            "ground                     read all three\n\
164             ground dark                read the dark checker color\n\
165             ground dark <r> <g> <b>    set it, each channel in [0, 1]\n\
166             ground light <r> <g> <b>   the light checker color\n\
167             ground fog                 read the density\n\
168             ground fog <density>       sky blended per world unit, in [0, 1];\n\
169             \x20                          half the sky is mixed in at ln2/density\n\
170             ground reset               back to the shipped defaults",
171        ),
172    );
173}
174
175pub fn register_floor_command<Ctx: 'static>(
176    console: &mut Console<Ctx>,
177    reach: fn(&mut Ctx) -> &mut Environment,
178) {
179    console.register(
180        loam_console::cmd::<Ctx, _>(
181            "floor",
182            "toggle the ground plane (on | off; bare flips)",
183            move |args, ctx, out| {
184                let env = reach(ctx);
185                let next = match args.first().copied() {
186                    None => !env.floor_visible,
187                    Some("on") => true,
188                    Some("off") => false,
189                    Some(other) => {
190                        return Err(anyhow!("floor: unknown arg `{other}` (try on|off)"));
191                    }
192                };
193                env.floor_visible = next;
194                out.line(format!("floor: {}", if next { "on" } else { "off" }));
195                Ok(())
196            },
197        )
198        .with_args(&[&["on", "off"]]),
199    );
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn the_parse_rejects_out_of_range_and_malformed_arguments() {
208        let mut env = Environment::default();
209        for line in [
210            vec!["fog", "-0.1"],
211            vec!["fog", "2.0"],
212            vec!["fog", "nan"],
213            vec!["fog", "wide"],
214            vec!["dark", "0.5"],
215            vec!["dark", "0.5", "0.5"],
216            vec!["dark", "0.5", "0.5", "0.5", "0.5"],
217            vec!["dark", "0.5", "0.5", "1.5"],
218            vec!["dark", "0.5", "0.5", "red"],
219            vec!["sky"],
220        ] {
221            assert!(
222                env.apply(&line).is_err(),
223                "`ground {}` was accepted",
224                line.join(" ")
225            );
226        }
227        assert_eq!(env, Environment::default(), "a rejected line still wrote");
228    }
229
230    #[test]
231    fn ground_reset_keeps_a_page_set_sky() {
232        let sky = Sky {
233            below: [0.9, 0.8, 0.7],
234            above: [0.6, 0.5, 0.4],
235        };
236        let mut env = Environment {
237            sky,
238            ..Environment::default()
239        };
240        env.apply(&["fog", "0.5"]).expect("set");
241        env.apply(&["reset"]).expect("reset");
242        assert_eq!(env.fog_per_unit, DEFAULT_FOG_PER_UNIT);
243        assert_eq!(env.sky, sky, "the ground verb wiped the sky");
244    }
245
246    #[test]
247    fn the_environment_reaches_the_uniform_the_shader_reads() {
248        let mut env = Environment::default();
249        env.apply(&["fog", "0.07"]).expect("set");
250        env.apply(&["dark", "0.1", "0.2", "0.3"]).expect("set");
251        env.sky = Sky {
252            below: [0.4, 0.5, 0.6],
253            above: [0.7, 0.8, 0.9],
254        };
255        let ground = env.ground(-1.5, false);
256        assert_eq!(ground.fog_per_unit, 0.07);
257        assert_eq!(ground.dark, [0.1, 0.2, 0.3]);
258        assert_eq!(ground.y, -1.5);
259        assert!(!ground.visible);
260
261        let uniforms = loam_render::SkyGroundUniforms::new(
262            glam::Mat4::IDENTITY,
263            loam_render::Viewport::full([4, 4]),
264            env.sky,
265            ground,
266        );
267        assert_eq!(uniforms.fog_per_unit, 0.07);
268        assert_eq!(uniforms.ground_dark, [0.1, 0.2, 0.3]);
269        assert_eq!(uniforms.show_ground, 0.0);
270        assert_eq!(uniforms.sky_below, [0.4, 0.5, 0.6]);
271        assert_eq!(uniforms.sky_above, [0.7, 0.8, 0.9]);
272    }
273
274    #[test]
275    fn the_verb_registers_on_a_console_and_dispatches_to_the_scene_state() {
276        let mut console = Console::<Environment>::new();
277        register_ground_command(&mut console, |env| env);
278        assert!(console.has_command("ground"));
279
280        let mut env = Environment::default();
281        console.dispatch("ground", &["fog", "0.03"], &mut env);
282        assert_eq!(env.fog_per_unit, 0.03, "the verb never reached the state");
283        console.dispatch("ground", &["fog"], &mut env);
284        assert_eq!(env.fog_per_unit, 0.03, "the bare read wrote");
285    }
286}