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