Skip to main content

concinnity_core/components/
environment_map.rs

1// Baked image-based lighting environment schema.
2
3use crate::ecs::PayloadLocator;
4use crate::ecs::asset_id::AssetId;
5use alloc::string::String;
6
7/// A baked lighting environment built from an equirectangular source (or a
8/// built-in generator). It provides the scene's ambient image-based lighting
9/// (soft diffuse fill plus glossy reflections that follow surface roughness)
10/// and the on-screen sky.
11///
12/// **Source formats:** a Radiance `.hdr`, or a panorama-sphere `.glb` /
13/// `.gltf` -- the packaging where an environment image is painted on the
14/// emissive channel of a sphere you stand inside. `cn add` recognises the
15/// latter and produces an EnvironmentMap instead of scene geometry.
16///
17/// **Dynamic range:** a `.hdr` carries real radiance, so its sun can be
18/// thousands of times brighter than the sky and bakes into a bright key light
19/// with a hot specular highlight. A panorama inside a `.glb` is a display
20/// image whose brightest value is white; it is read literally, with the sRGB
21/// curve inverted and white landing at 1.0 radiance. That makes it an exact
22/// backdrop and a soft, low-contrast fill light, never a key light. Raise
23/// [PostProcessConfig](#postprocessconfig)'s `ambient_intensity` to lift the
24/// level rather than expecting the bake to invent range the file lacks.
25///
26/// **`prefilter_face_size` note:** this controls both the reflection detail and
27/// the on-screen sky sharpness. 512 is the default balance: 256 visibly
28/// pixelates a 4K-source sky, 1024 sharpens it further at 4× the size.
29///
30/// **Built-in generators:** `sky` produces a procedural blue sky with a soft
31/// sun, and `stars` a near-black night sky of scattered points, the brightest
32/// of them above 1.0 radiance so bloom catches them, darker below the
33/// horizon. Both are useful when no source file is available. The
34/// sky is drawn at the resolution `prefilter_face_size` sets, so a starfield
35/// wants the largest of them, 1024; below that each point is magnified into a
36/// blob.
37///
38/// The sky mesh that displays the map (a skybox
39/// [ProceduralMesh](#proceduralmesh) plus its [Material](#material) and
40/// [Prop](#prop)) is injected at world start when the world declares no skybox
41/// mesh of its own. Declare an [EngineDefaults](#enginedefaults) with
42/// `"sky": false` to use the map for image-based lighting only, with the
43/// background left to `clear_color` or your own geometry.
44///
45/// ```rust
46/// # use concinnity_core::components::EnvironmentMap;
47/// EnvironmentMap {
48///     source: "assets/hdri/studio.hdr".into(),
49///     ..Default::default()
50/// };
51/// ```
52#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
53#[serde(default)]
54pub struct EnvironmentMap {
55    /// Asset identity; injected via `inject_name`. Not part of `args`.
56    #[serde(skip)]
57    pub asset_id: AssetId,
58    /// Path to the source equirectangular panorama -- a Radiance `.hdr`, or a
59    /// panorama-sphere `.glb` / `.gltf` -- relative to the project root.
60    /// Mutually exclusive with `generator`.
61    pub source: String,
62    /// Built-in source name, `sky` or `stars`. Mutually exclusive with
63    /// `source`.
64    pub generator: String,
65    /// Face size of the reflection/sky cubemap, in pixels. Higher is sharper
66    /// but larger.
67    pub prefilter_face_size: u32,
68    /// Face size of the diffuse ambient cubemap, in pixels.
69    pub irradiance_face_size: u32,
70    /// Number of samples used to filter each reflection texel. Higher reduces
71    /// noise at the cost of build time.
72    pub prefilter_samples: u32,
73    /// Upper bound on how bright a single source texel may count while building
74    /// the glossy reflection mips. A clear-sky HDR holds a few sun or sky
75    /// texels thousands of times brighter than their surroundings; left
76    /// unbounded they survive into the small (coarse) reflection mips as lone
77    /// hot texels and smear across glossy floors as hard bright squares. This
78    /// caps each sampled texel so that energy spreads smoothly across the
79    /// reflection instead. It affects reflections only, never the on-screen
80    /// sky. Set to `0` to disable (no cap); lower values clamp harder.
81    pub prefilter_clamp: f32,
82    /// Injected at load time from the compiled blob payload.
83    #[serde(skip)]
84    pub locator: Option<PayloadLocator>,
85}
86
87// The face-size / sample-count defaults below are the single source of truth:
88// the build pipeline deserialises args through this struct, so a field absent
89// from a JSONL entry inherits these values rather than a constant duplicated in
90// the build crate. They are chosen for ~32 MB payloads and a few seconds of
91// build cost on the dev box. `prefilter_face_size` does double duty: mips 1..N
92// feed the GGX specular IBL lookup (fine at low resolution) while mip 0 is
93// sampled directly by the skybox sentinel branch in the fragment shaders, so it
94// has to be large enough that the displayed sky doesn't look blocky. 512 is the
95// balance point; 256 visibly pixelates a 4K HDR sky, 1024 quadruples the payload
96// for sharpness only the skybox (not the IBL math) actually uses.
97//
98// `prefilter_clamp` defaults to a moderate cap rather than off: an unbounded
99// clear-sky HDR aliases its sun and bright sky into hard squares on glossy
100// floors (the coarse reflection mips hold only a handful of texels, so one hot
101// texel paints a whole region). The cap spreads that energy without touching
102// the on-screen sky, and a uniform sky below the cap is unchanged.
103impl Default for EnvironmentMap {
104    fn default() -> Self {
105        Self {
106            asset_id: AssetId::default(),
107            source: String::new(),
108            generator: String::new(),
109            prefilter_face_size: 512,
110            irradiance_face_size: 8,
111            prefilter_samples: 1024,
112            prefilter_clamp: 12.0,
113            locator: None,
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn defaults_size_the_prefilter_for_a_sharp_skybox() {
124        // Mip 0 is sampled directly by the skybox branch, so the prefilter face
125        // has to be far larger than the irradiance one, which only ever feeds
126        // the diffuse convolution.
127        let e = EnvironmentMap::default();
128        assert_eq!(e.prefilter_face_size, 512);
129        assert_eq!(e.irradiance_face_size, 8);
130        assert!(e.prefilter_face_size > e.irradiance_face_size);
131        assert_eq!(e.prefilter_samples, 1024);
132        // The clamp defaults on: an unbounded HDR sun aliases into hard squares
133        // in the coarse reflection mips.
134        assert_eq!(e.prefilter_clamp, 12.0);
135        assert!(e.source.is_empty());
136        assert!(e.generator.is_empty());
137        assert!(e.locator.is_none());
138    }
139
140    #[test]
141    fn an_authored_bake_parses_and_round_trips_through_postcard() {
142        let e: EnvironmentMap = serde_json::from_str(
143            r#"{"source":"sky.hdr","prefilter_face_size":1024,"irradiance_face_size":16,
144                "prefilter_samples":512,"prefilter_clamp":0}"#,
145        )
146        .unwrap();
147        assert_eq!(e.source, "sky.hdr");
148        assert_eq!(e.prefilter_face_size, 1024);
149        // A zero clamp turns the cap off rather than blacking out the sky.
150        assert_eq!(e.prefilter_clamp, 0.0);
151
152        let bytes = postcard::to_allocvec(&e).unwrap();
153        let back: EnvironmentMap = postcard::from_bytes(&bytes).unwrap();
154        assert_eq!(back.irradiance_face_size, 16);
155        assert_eq!(back.prefilter_samples, 512);
156        assert_eq!(back.asset_id, AssetId::default());
157        assert!(back.locator.is_none());
158    }
159
160    #[test]
161    fn a_generated_environment_names_its_generator_instead_of_a_source() {
162        let e: EnvironmentMap = serde_json::from_str(r#"{"generator":"gradient_sky"}"#).unwrap();
163        assert_eq!(e.generator, "gradient_sky");
164        assert!(e.source.is_empty());
165    }
166}