Skip to main content

molgfx_render/engine/
lighting_environment.rs

1//! Declarative image-based approximation and bounded key/fill light rig.
2//!
3//! This is presentation state, not biological context. Reflections and ambient
4//! response can match the caller's compositing environment without coupling
5//! them to the fallback backdrop or to a named graphics backend.
6
7use molgfx_math::{Rgba8, Vec3};
8use serde::{Deserialize, Serialize};
9
10/// Scene-linear lighting sampled by molecular materials.
11#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
12pub struct LightingEnvironment {
13    /// Upper-hemisphere environment colour in display sRGB.
14    pub zenith: Rgba8,
15    /// Grazing environment colour in display sRGB.
16    pub horizon: Rgba8,
17    /// Lower-hemisphere environment colour in display sRGB.
18    pub ground: Rgba8,
19    /// Grazing-angle separation colour in display sRGB.
20    pub rim_color: Rgba8,
21    /// Broad key-light colour in display sRGB.
22    pub key_color: Rgba8,
23    /// Broad fill-light colour in display sRGB.
24    pub fill_color: Rgba8,
25    /// View-space direction toward the key light.
26    pub key_direction: Vec3,
27    /// View-space direction toward the fill light.
28    pub fill_direction: Vec3,
29    /// Diffuse environment multiplier in `[0, 4]`.
30    pub diffuse_strength: f32,
31    /// Reflected environment multiplier in `[0, 4]`.
32    pub specular_strength: f32,
33    /// Grazing rim multiplier in `[0, 2]`.
34    pub rim_strength: f32,
35    /// Key-light multiplier in `[0, 8]`.
36    pub key_strength: f32,
37    /// Fill-light multiplier in `[0, 8]`.
38    pub fill_strength: f32,
39    /// Apparent key-light angular radius in `[0, 0.5]`; controls penumbra.
40    pub key_angular_radius: f32,
41    /// Direct-light shadow attenuation in `[0, 1]`.
42    pub shadow_strength: f32,
43}
44
45impl LightingEnvironment {
46    /// Balanced neutral illumination for quantitative inspection.
47    #[must_use]
48    pub const fn neutral() -> Self {
49        Self {
50            zenith: Rgba8::opaque(170, 184, 204),
51            horizon: Rgba8::opaque(118, 126, 138),
52            ground: Rgba8::opaque(72, 66, 60),
53            rim_color: Rgba8::opaque(132, 156, 202),
54            key_color: Rgba8::opaque(255, 244, 230),
55            fill_color: Rgba8::opaque(151, 181, 224),
56            key_direction: Vec3::new(-0.42, 0.58, 0.70),
57            fill_direction: Vec3::new(0.78, -0.12, 0.50),
58            diffuse_strength: 0.72,
59            specular_strength: 0.58,
60            rim_strength: 0.22,
61            key_strength: 1.55,
62            fill_strength: 0.28,
63            key_angular_radius: 0.16,
64            shadow_strength: 0.48,
65        }
66    }
67
68    /// Soft documentary rig with stronger shape separation.
69    #[must_use]
70    pub const fn documentary() -> Self {
71        Self {
72            // A specimen lit against a dark sweep sits in a dark environment:
73            // dimming the ambient lets the key shape the form and keeps albedo
74            // saturated instead of washing it toward the ambient's own colour.
75            // Matched to the bright sweep. A specimen in a lightbox sits in a
76            // bright environment, so the ambient is high and the key only
77            // shapes it; a dark ambient under a bright backdrop would read as
78            // a cut-out pasted onto the frame.
79            zenith: Rgba8::opaque(226, 232, 240),
80            horizon: Rgba8::opaque(188, 194, 202),
81            ground: Rgba8::opaque(150, 146, 141),
82            fill_color: Rgba8::opaque(206, 214, 226),
83            rim_color: Rgba8::opaque(120, 132, 152),
84            diffuse_strength: 0.62,
85            specular_strength: 0.72,
86            rim_strength: 0.16,
87            key_strength: 1.35,
88            fill_strength: 0.30,
89            key_angular_radius: 0.20,
90            shadow_strength: 0.66,
91            ..Self::neutral()
92        }
93    }
94
95    pub(crate) fn sanitize(self) -> Self {
96        let neutral = Self::neutral();
97        Self {
98            key_direction: direction(self.key_direction, neutral.key_direction),
99            fill_direction: direction(self.fill_direction, neutral.fill_direction),
100            diffuse_strength: bounded(self.diffuse_strength, 4.0, neutral.diffuse_strength),
101            specular_strength: bounded(self.specular_strength, 4.0, neutral.specular_strength),
102            rim_strength: bounded(self.rim_strength, 2.0, neutral.rim_strength),
103            key_strength: bounded(self.key_strength, 8.0, neutral.key_strength),
104            fill_strength: bounded(self.fill_strength, 8.0, neutral.fill_strength),
105            key_angular_radius: bounded(self.key_angular_radius, 0.5, neutral.key_angular_radius),
106            shadow_strength: bounded(self.shadow_strength, 1.0, neutral.shadow_strength),
107            ..self
108        }
109    }
110
111    pub(crate) fn blend(self, other: Self, weight: f32) -> Self {
112        let weight = unit(weight);
113        let choose = |current, next| if weight >= 0.5 { next } else { current };
114        Self {
115            zenith: choose(self.zenith, other.zenith),
116            horizon: choose(self.horizon, other.horizon),
117            ground: choose(self.ground, other.ground),
118            rim_color: choose(self.rim_color, other.rim_color),
119            key_color: choose(self.key_color, other.key_color),
120            fill_color: choose(self.fill_color, other.fill_color),
121            key_direction: self.key_direction.lerp(other.key_direction, weight),
122            fill_direction: self.fill_direction.lerp(other.fill_direction, weight),
123            diffuse_strength: lerp(self.diffuse_strength, other.diffuse_strength, weight),
124            specular_strength: lerp(self.specular_strength, other.specular_strength, weight),
125            rim_strength: lerp(self.rim_strength, other.rim_strength, weight),
126            key_strength: lerp(self.key_strength, other.key_strength, weight),
127            fill_strength: lerp(self.fill_strength, other.fill_strength, weight),
128            key_angular_radius: lerp(self.key_angular_radius, other.key_angular_radius, weight),
129            shadow_strength: lerp(self.shadow_strength, other.shadow_strength, weight),
130        }
131        .sanitize()
132    }
133
134    pub(crate) fn packed(self) -> [[f32; 4]; 8] {
135        let lighting = self.sanitize();
136        let lane = |color: Rgba8, strength| {
137            let rgb = super::backdrop::linear_rgb(color);
138            [rgb[0], rgb[1], rgb[2], strength]
139        };
140        [
141            lane(lighting.zenith, lighting.diffuse_strength),
142            lane(lighting.horizon, lighting.specular_strength),
143            lane(lighting.ground, lighting.rim_strength),
144            lane(lighting.rim_color, lighting.shadow_strength),
145            lighting
146                .key_direction
147                .extend(lighting.key_strength)
148                .to_array(),
149            lane(lighting.key_color, lighting.key_angular_radius),
150            lighting
151                .fill_direction
152                .extend(lighting.fill_strength)
153                .to_array(),
154            lane(lighting.fill_color, 0.0),
155        ]
156    }
157}
158
159impl Default for LightingEnvironment {
160    fn default() -> Self {
161        Self::documentary()
162    }
163}
164
165fn direction(value: Vec3, fallback: Vec3) -> Vec3 {
166    if value.is_finite() && value.length_squared() > 1.0e-8 {
167        value.normalize()
168    } else {
169        fallback.normalize()
170    }
171}
172
173fn bounded(value: f32, maximum: f32, fallback: f32) -> f32 {
174    if value.is_finite() {
175        value.clamp(0.0, maximum)
176    } else {
177        fallback
178    }
179}
180
181fn unit(value: f32) -> f32 {
182    bounded(value, 1.0, 0.0)
183}
184
185fn lerp(from: f32, to: f32, weight: f32) -> f32 {
186    from + (to - from) * weight
187}