1mod post;
6pub use post::{ColourLut, ColourMatrix, ReinhardCurve, ToneMap};
7mod status;
8pub use status::{PostProcessingStatus, PostStage, PostStageStatus, PostStageStatusKind};
9
10use serde::Serialize;
11use serde_json::Value;
12
13use crate::{JsonError, Resource, World};
14
15#[derive(Clone, Debug, Default, PartialEq, Serialize)]
18pub struct Atmosphere {
19 pub tone_map: Option<ToneMap>,
21 pub fog: Option<Fog>,
22 pub exposure: Option<Exposure>,
23 pub volumetric: Option<VolumetricFog>,
25 pub auto_exposure: Option<AutoExposure>,
28}
29
30#[derive(Clone, Debug, Default, PartialEq, Serialize)]
37pub struct VolumetricFog {
38 pub enabled: Option<bool>,
39 pub game_scale: Option<f32>,
40 pub near_depth: Option<f32>,
41 pub far_depth: Option<f32>,
42 pub near_offset: Option<f32>,
43 pub fixed_density: Option<f32>,
44 pub scattering_g: Option<f32>,
45 pub local_light_scale: Option<f32>,
46 pub local_light_dist_scale: Option<f32>,
47 pub height_fog_distribution: Option<f32>,
48 pub height_fog_offset: Option<f32>,
49 pub height_fog_density_scale: Option<f32>,
50 pub sky_colour: Option<[f32; 4]>,
52 pub sky_start_dist: Option<f32>,
53 pub directional_light_bank: Option<u32>,
54 pub directional_direct_colour: Option<[f32; 4]>,
55 pub directional_opposite_colour: Option<[f32; 4]>,
56 pub directional_start_dist: Option<f32>,
57}
58
59impl VolumetricFog {
60 pub fn fields(&self) -> Vec<(&'static str, String)> {
62 let f = |v: Option<f32>| v.map(|x| x.to_string());
63 let c = |v: Option<[f32; 4]>| v.map(|x| format!("{x:?}"));
64 [
65 ("Enable", self.enabled.map(|x| x.to_string())),
66 ("GameScale", f(self.game_scale)),
67 ("NearDepth", f(self.near_depth)),
68 ("FarDepth", f(self.far_depth)),
69 ("NearOffset", f(self.near_offset)),
70 ("Global FixedDensity", f(self.fixed_density)),
71 ("ScatteringG", f(self.scattering_g)),
72 ("Global LocalLightScale", f(self.local_light_scale)),
73 ("Global LocalLightDistScale", f(self.local_light_dist_scale)),
74 ("Global HeightFogDistribution", f(self.height_fog_distribution)),
75 ("Global HeightFogOffset", f(self.height_fog_offset)),
76 ("Global HeightFogDensity Scale", f(self.height_fog_density_scale)),
77 ("SkyLight SkyColor", c(self.sky_colour)),
78 ("SkyLight StartDist", f(self.sky_start_dist)),
79 (
80 "DirLight LightBankId",
81 self.directional_light_bank.map(|x| x.to_string()),
82 ),
83 ("DirLight DirectColor", c(self.directional_direct_colour)),
84 ("DirLight OppositeColor", c(self.directional_opposite_colour)),
85 ("DirLight StartDist", f(self.directional_start_dist)),
86 ]
87 .into_iter()
88 .filter_map(|(name, value)| Some((name, value?)))
89 .collect()
90 }
91
92 fn check(&self) -> Result<(), String> {
93 let scalars = [
94 ("GameScale", self.game_scale),
95 ("NearDepth", self.near_depth),
96 ("FarDepth", self.far_depth),
97 ("NearOffset", self.near_offset),
98 ("Global FixedDensity", self.fixed_density),
99 ("ScatteringG", self.scattering_g),
100 ("Global LocalLightScale", self.local_light_scale),
101 ("Global LocalLightDistScale", self.local_light_dist_scale),
102 ("Global HeightFogDistribution", self.height_fog_distribution),
103 ("Global HeightFogOffset", self.height_fog_offset),
104 ("Global HeightFogDensity Scale", self.height_fog_density_scale),
105 ("SkyLight StartDist", self.sky_start_dist),
106 ("DirLight StartDist", self.directional_start_dist),
107 ];
108 if let Some((name, v)) = scalars
109 .into_iter()
110 .find_map(|(n, v)| v.filter(|x| !x.is_finite()).map(|x| (n, x)))
111 {
112 return Err(format!("volumetric fog {name} {v} is not finite"));
113 }
114 let colours = [
115 ("SkyLight SkyColor", self.sky_colour),
116 ("DirLight DirectColor", self.directional_direct_colour),
117 ("DirLight OppositeColor", self.directional_opposite_colour),
118 ];
119 if let Some((name, v)) = colours
120 .into_iter()
121 .find_map(|(n, v)| v.filter(|c| !c.iter().all(|x| x.is_finite())).map(|c| (n, c)))
122 {
123 return Err(format!("volumetric fog {name} {v:?} is not finite"));
124 }
125 Ok(())
126 }
127}
128
129#[derive(Clone, Debug, PartialEq, Serialize)]
132pub struct Fog {
133 pub colour: [f32; 3],
136 pub falloff: FogFalloff,
137}
138
139#[derive(Clone, Debug, PartialEq, Serialize)]
141pub enum FogFalloff {
142 Exponential { density: f32 },
144 Linear { start: f32, end: f32 },
146 ExponentialHeightSlab { density: f32, bottom: f32, top: f32 },
151}
152
153#[derive(Clone, Debug, PartialEq, Serialize)]
157pub struct Exposure {
158 pub stops: f32,
159}
160
161#[derive(Clone, Debug, PartialEq, Serialize)]
174pub struct AutoExposure {
175 pub stops: f32,
177 pub middle_grey: f32,
179 pub max: f32,
181 pub min: f32,
183 pub meter: ExposureMeter,
185 pub brighten_speed: f32,
188 pub darken_speed: f32,
190 pub frozen: bool,
192}
193
194#[derive(Clone, Debug, PartialEq, Serialize)]
196pub enum ExposureMeter {
197 CentreWeightedLog2 {
204 centre_weight: f32,
206 },
207}
208
209impl ExposureMeter {
210 pub const SAMPLE_OFFSETS: [[f32; 2]; 16] = {
212 let mut out = [[0.0; 2]; 16];
213 let mut i = 0;
214 while i < 16 {
215 out[i] = [((i % 4) as f32 - 1.5) / 256.0, ((i / 4) as f32 - 1.5) / 256.0];
216 i += 1;
217 }
218 out
219 };
220 pub const LUMINANCE_FLOOR: f32 = 1e-5;
222 pub const LUMINANCE: [f32; 3] = [0.299, 0.587, 0.114];
224
225 pub fn weight(&self, u: f32, v: f32, aspect: f32) -> f32 {
228 match *self {
229 ExposureMeter::CentreWeightedLog2 { centre_weight } => {
230 let r = ((2.0 * u - 1.0) * centre_weight).hypot((2.0 * v - 1.0) * centre_weight / aspect);
231 1.0 - (1.0 - (-r).exp2()).powi(3)
232 }
233 }
234 }
235}
236
237impl AutoExposure {
238 pub fn correction(&self, x: f32) -> f32 {
242 let bound = if x > 0.0 { self.max } else { self.min };
243 if self.frozen || x == 0.0 || bound == 0.0 {
244 return 0.0;
245 }
246 bound * (1.0 - (-x / bound).exp())
247 }
248
249 pub fn scale(&self, adapted: f32) -> f32 {
251 (self.stops - self.correction(adapted - self.middle_grey)).exp2()
252 }
253
254 pub fn adapt(&self, adapted: f32, target: f32, dt: f32) -> f32 {
257 let speed = if target > adapted {
258 self.brighten_speed
259 } else {
260 self.darken_speed
261 };
262 adapted + (target - adapted) * (1.0 - 10f32.powf(-speed * dt))
263 }
264
265 fn check(&self) -> Result<(), String> {
266 let ExposureMeter::CentreWeightedLog2 { centre_weight } = self.meter;
267 for (name, v, ok) in [
268 ("stops", self.stops, true),
269 ("middle grey", self.middle_grey, true),
270 ("max", self.max, self.max >= 0.0),
271 ("min", self.min, self.min <= 0.0),
272 ("centre weight", centre_weight, centre_weight >= 0.0),
273 ("brighten speed", self.brighten_speed, self.brighten_speed >= 0.0),
274 ("darken speed", self.darken_speed, self.darken_speed >= 0.0),
275 ] {
276 if !(v.is_finite() && ok) {
277 return Err(format!("auto exposure {name} {v} is out of range"));
278 }
279 }
280 Ok(())
281 }
282}
283
284impl Atmosphere {
285 fn check(&self) -> Result<(), String> {
289 if let Some(tone_map) = &self.tone_map {
290 tone_map.check()?;
291 }
292 if let Some(fog) = &self.fog {
293 if let Some(c) = (!fog.colour.iter().all(|x| x.is_finite() && *x >= 0.)).then_some(fog.colour) {
294 return Err(format!("fog colour {c:?} is not finite and at least 0"));
295 }
296 match fog.falloff {
297 FogFalloff::Exponential { density } if !(density.is_finite() && density >= 0.) => {
298 return Err(format!("fog density {density} is not finite and at least 0"));
299 }
300 FogFalloff::Linear { start, end }
301 if !(start.is_finite() && end.is_finite() && start >= 0. && start < end) =>
302 {
303 return Err(format!(
304 "linear fog from {start} to {end} is not finite, from at least 0 to a later end"
305 ));
306 }
307 FogFalloff::ExponentialHeightSlab { density, bottom, top }
308 if !(density.is_finite()
309 && density >= 0.
310 && bottom.is_finite()
311 && top.is_finite()
312 && bottom < top) =>
313 {
314 return Err(format!(
315 "fog slab density {density}, bottom {bottom}, top {top} requires finite nonnegative density and bottom < top"
316 ));
317 }
318 _ => {}
319 }
320 }
321 if let Some(e) = self.exposure.as_ref().filter(|e| !e.stops.is_finite()) {
322 return Err(format!("exposure {} stops is not finite", e.stops));
323 }
324 if let Some(auto) = &self.auto_exposure {
325 if self.exposure.is_some() {
326 return Err("both an exposure and an auto exposure".to_owned());
327 }
328 auto.check()?;
329 }
330 self.volumetric.as_ref().map_or(Ok(()), VolumetricFog::check)
331 }
332}
333
334impl Resource for Atmosphere {
335 const NAME: &'static str = "core.atmosphere";
336 const VERSION: u32 = 6;
337 fn to_json(&self) -> Result<Option<Value>, JsonError> {
338 crate::json::to_value(self).map(Some)
339 }
340 fn verify(&self, _w: &World) -> Result<(), String> {
341 self.check()
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use std::collections::BTreeSet;
349
350 fn fog(falloff: FogFalloff) -> Atmosphere {
351 Atmosphere {
352 fog: Some(Fog {
353 colour: [0.5, 0.6, 0.7],
354 falloff,
355 }),
356 exposure: Some(Exposure { stops: -1.5 }),
357 volumetric: None,
358 auto_exposure: None,
359 tone_map: None,
360 }
361 }
362
363 fn noon() -> AutoExposure {
366 AutoExposure {
367 stops: 3.4042,
368 middle_grey: -6.4,
369 max: 0.4,
370 min: -0.8933,
371 meter: ExposureMeter::CentreWeightedLog2 { centre_weight: 7.0 },
372 brighten_speed: 0.2,
373 darken_speed: 0.2,
374 frozen: false,
375 }
376 }
377
378 #[test]
379 fn a_valid_atmosphere_is_kept_by_every_part_and_shown_as_json() {
380 let mut w = World::new();
381 let e = w.spawn();
382 w.insert_resource(fog(FogFalloff::Linear {
383 start: 10.,
384 end: 400.,
385 }));
386 assert!(w.verify_resources().is_empty());
387 let part = w.subset(&BTreeSet::from([e]));
388 assert_eq!(part.resource::<Atmosphere>(), w.resource::<Atmosphere>());
389 assert_eq!(
390 w.resource::<Atmosphere>().unwrap().to_json().unwrap(),
391 Some(serde_json::json!({
392 "fog": {"colour": [0.5, 0.6000000238418579, 0.699999988079071],
393 "falloff": {"Linear": {"start": 10.0, "end": 400.0}}},
394 "exposure": {"stops": -1.5},
395 "volumetric": null,
396 "auto_exposure": null, "tone_map": null
397 }))
398 );
399 assert_eq!(
400 Atmosphere::default().to_json().unwrap(),
401 Some(
402 serde_json::json!({"fog": null, "exposure": null, "volumetric": null, "auto_exposure": null, "tone_map": null})
403 )
404 );
405 }
406
407 #[test]
408 fn a_volumetric_fog_keeps_the_sources_fields_unconverted_and_lists_what_it_states() {
409 let volumetric = VolumetricFog {
410 game_scale: Some(12.),
411 fixed_density: Some(0.004),
412 sky_colour: Some([0.062745, 0.070588, 0.078431, 0.]),
413 directional_light_bank: Some(111010000),
414 ..Default::default()
415 };
416 let a = Atmosphere {
417 volumetric: Some(volumetric.clone()),
418 ..Default::default()
419 };
420 let mut w = World::new();
421 let e = w.spawn();
422 w.insert_resource(a);
423 assert!(w.verify_resources().is_empty());
424 assert_eq!(
425 w.subset(&BTreeSet::from([e])).resource::<Atmosphere>(),
426 w.resource::<Atmosphere>()
427 );
428 assert_eq!(
429 volumetric.fields(),
430 [
431 ("GameScale", "12".to_owned()),
432 ("Global FixedDensity", "0.004".to_owned()),
433 (
434 "SkyLight SkyColor",
435 "[0.062745, 0.070588, 0.078431, 0.0]".to_owned()
436 ),
437 ("DirLight LightBankId", "111010000".to_owned()),
438 ]
439 );
440 let json = w.resource::<Atmosphere>().unwrap().to_json().unwrap().unwrap();
441 assert_eq!(json["volumetric"]["game_scale"], serde_json::json!(12.0));
442 assert!(json["volumetric"]["far_depth"].is_null());
443 for bad in [
444 VolumetricFog {
445 fixed_density: Some(f32::NAN),
446 ..Default::default()
447 },
448 VolumetricFog {
449 sky_colour: Some([0., f32::INFINITY, 0., 0.]),
450 ..Default::default()
451 },
452 ] {
453 w.insert_resource(Atmosphere {
454 volumetric: Some(bad),
455 ..Default::default()
456 });
457 assert_eq!(w.verify_resources().len(), 1);
458 }
459 }
460
461 #[test]
462 fn values_out_of_range_are_refused_by_the_verifier() {
463 let mut w = World::new();
464 for (a, bad) in [
465 (
466 fog(FogFalloff::Exponential { density: -0.1 }),
467 "fog density -0.1 is not finite and at least 0",
468 ),
469 (
470 fog(FogFalloff::Exponential { density: f32::NAN }),
471 "fog density NaN is not finite and at least 0",
472 ),
473 (
474 fog(FogFalloff::Linear { start: 5., end: 5. }),
475 "linear fog from 5 to 5 is not finite, from at least 0 to a later end",
476 ),
477 (
478 fog(FogFalloff::Linear { start: -1., end: 5. }),
479 "linear fog from -1 to 5 is not finite, from at least 0 to a later end",
480 ),
481 (
482 Atmosphere {
483 fog: Some(Fog {
484 colour: [0.1, -0.1, 0.1],
485 falloff: FogFalloff::Exponential { density: 0.01 },
486 }),
487 exposure: None,
488 volumetric: None,
489 auto_exposure: None,
490 tone_map: None,
491 },
492 "fog colour [0.1, -0.1, 0.1] is not finite and at least 0",
493 ),
494 (
495 Atmosphere {
496 fog: None,
497 exposure: Some(Exposure { stops: f32::INFINITY }),
498 volumetric: None,
499 auto_exposure: None,
500 tone_map: None,
501 },
502 "exposure inf stops is not finite",
503 ),
504 ] {
505 w.insert_resource(a);
506 assert_eq!(w.verify_resources(), [("core.atmosphere", bad.to_owned())]);
507 }
508 w.insert_resource(Atmosphere::default());
509 assert!(w.verify_resources().is_empty());
510 }
511
512 #[test]
516 fn the_exposure_of_a_frame_of_the_game_is_its_stops_less_the_soft_clamped_metering() {
517 let auto = noon();
518 let shown = auto.scale(auto.middle_grey - 0.3336).log2();
519 assert!((shown - 3.6826).abs() < 2e-4, "{shown}");
520 assert!(auto.correction(-0.3336) < 0.0);
522 let near = auto.correction(-3.0);
523 assert!(near > auto.min && near < auto.min + 0.05, "{near}");
524 let far = auto.correction(-50.0);
525 assert!(far >= auto.min && far - auto.min < 1e-6, "{far}");
526 let near = auto.correction(3.0);
528 assert!(near < auto.max && near > auto.max - 0.05, "{near}");
529 let far = auto.correction(50.0);
530 assert!(far <= auto.max && auto.max - far < 1e-6, "{far}");
531 for x in [1e-3f32, -1e-3] {
533 assert!((auto.correction(x) / x - 1.0).abs() < 3e-3, "{x}");
534 }
535 assert_eq!(auto.correction(0.0), 0.0);
536 let frozen = AutoExposure {
538 frozen: true,
539 ..noon()
540 };
541 assert_eq!(frozen.scale(-20.0), 3.4042f32.exp2());
542 let flat = AutoExposure { max: 0.0, ..noon() };
544 assert_eq!(flat.correction(2.0), 0.0);
545 }
546
547 #[test]
548 fn the_adapted_value_moves_by_its_speed_and_rests_at_the_frames_metering() {
549 let auto = AutoExposure {
550 brighten_speed: 0.2,
551 darken_speed: 0.5,
552 ..noon()
553 };
554 assert_eq!(auto.adapt(-6.0, -6.0, 1.0 / 30.0), -6.0);
556 let up = auto.adapt(-8.0, -6.0, 1.0);
558 assert!((up - (-8.0 + 2.0 * (1.0 - 0.630_957_3))).abs() < 1e-5, "{up}");
559 let down = auto.adapt(-6.0, -8.0, 1.0);
560 assert!((down - (-6.0 - 2.0 * (1.0 - 0.316_227_8))).abs() < 1e-5, "{down}");
561 let mut a = -10.0f32;
563 for _ in 0..2000 {
564 let next = auto.adapt(a, -6.0, 1.0 / 30.0);
565 assert!(next >= a && next <= -6.0);
566 a = next;
567 }
568 assert!((a + 6.0).abs() < 1e-4, "{a}");
569 }
570
571 #[test]
572 fn the_meter_weighs_the_centre_most_and_samples_a_four_by_four_grid_per_cell() {
573 let meter = ExposureMeter::CentreWeightedLog2 { centre_weight: 7.0 };
574 assert_eq!(meter.weight(0.5, 0.5, 16.0 / 9.0), 1.0);
575 let quarter = meter.weight(0.25, 0.5, 1280.0 / 720.0);
578 assert!(
579 (quarter - (1.0 - (1.0 - 2f32.powf(-3.5)).powi(3))).abs() < 1e-6,
580 "{quarter}"
581 );
582 let vertical = meter.weight(0.5, 0.25, 1280.0 / 720.0);
583 assert!(
584 (vertical - (1.0 - (1.0 - 2f32.powf(-3.9375 / 2.0)).powi(3))).abs() < 1e-6,
585 "{vertical}"
586 );
587 assert!(meter.weight(0.0, 0.0, 1.0) < 0.01);
588 let flat = ExposureMeter::CentreWeightedLog2 { centre_weight: 0.0 };
589 assert_eq!(flat.weight(0.0, 1.0, 1.0), 1.0);
590 let o = ExposureMeter::SAMPLE_OFFSETS;
592 assert_eq!(o[0], [-1.5 / 256.0, -1.5 / 256.0]);
593 assert_eq!(o[1], [-0.5 / 256.0, -1.5 / 256.0]);
594 assert_eq!(o[15], [1.5 / 256.0, 1.5 / 256.0]);
595 assert!((o[0][0] + 0.005859).abs() < 1e-6 && (o[1][0] + 0.001953).abs() < 1e-6);
596 }
597
598 #[test]
599 fn an_auto_exposure_out_of_range_or_beside_an_exposure_is_refused() {
600 let mut w = World::new();
601 for (auto, exposure, bad) in [
602 (
603 AutoExposure { max: -0.1, ..noon() },
604 None,
605 "auto exposure max -0.1 is out of range",
606 ),
607 (
608 AutoExposure { min: 0.5, ..noon() },
609 None,
610 "auto exposure min 0.5 is out of range",
611 ),
612 (
613 AutoExposure {
614 stops: f32::NAN,
615 ..noon()
616 },
617 None,
618 "auto exposure stops NaN is out of range",
619 ),
620 (
621 AutoExposure {
622 darken_speed: -1.0,
623 ..noon()
624 },
625 None,
626 "auto exposure darken speed -1 is out of range",
627 ),
628 (
629 AutoExposure {
630 meter: ExposureMeter::CentreWeightedLog2 { centre_weight: -1.0 },
631 ..noon()
632 },
633 None,
634 "auto exposure centre weight -1 is out of range",
635 ),
636 (
637 noon(),
638 Some(Exposure { stops: 1.0 }),
639 "both an exposure and an auto exposure",
640 ),
641 ] {
642 w.insert_resource(Atmosphere {
643 exposure,
644 auto_exposure: Some(auto),
645 ..Default::default()
646 });
647 assert_eq!(w.verify_resources(), [("core.atmosphere", bad.to_owned())]);
648 }
649 w.insert_resource(Atmosphere {
650 auto_exposure: Some(noon()),
651 ..Default::default()
652 });
653 assert!(w.verify_resources().is_empty());
654 let json = w.resource::<Atmosphere>().unwrap().to_json().unwrap().unwrap();
655 assert_eq!(
656 json["auto_exposure"]["meter"]["CentreWeightedLog2"]["centre_weight"],
657 serde_json::json!(7.0)
658 );
659 }
660
661 #[test]
662 fn height_slab_uses_absolute_heights_including_below_the_origin() {
663 assert!(
664 fog(FogFalloff::ExponentialHeightSlab {
665 density: 0.01,
666 bottom: -40.,
667 top: 200.
668 })
669 .check()
670 .is_ok()
671 );
672 for (density, bottom, top) in [(0.01, 2., 2.), (-0.01, 0., 2.), (0.01, f32::NAN, 2.)] {
673 assert!(
674 fog(FogFalloff::ExponentialHeightSlab { density, bottom, top })
675 .check()
676 .is_err()
677 );
678 }
679 }
680}