Skip to main content

molgfx_render/engine/
backdrop.rs

1//! Compositing fallback and display transform.
2//!
3//! Neither type represents biology. Biological surroundings enter the scene as
4//! structures, declared solvent, membranes or caller-provided density fields.
5
6use molgfx_math::Rgba8;
7use serde::{Deserialize, Serialize};
8
9/// Colour shown only where no represented biology contributes a fragment.
10#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
11pub struct BackdropStyle {
12    /// Upper fallback colour in display sRGB.
13    pub top: Rgba8,
14    /// Lower fallback colour in display sRGB.
15    pub bottom: Rgba8,
16    /// Optional central compositing glow in display sRGB.
17    pub glow_color: Rgba8,
18    /// Glow contribution in `[0, 1]`.
19    pub glow_strength: f32,
20}
21
22impl BackdropStyle {
23    /// Neutral publication/compositing fallback.
24    #[must_use]
25    pub const fn compositing() -> Self {
26        Self {
27            top: Rgba8::opaque(244, 247, 250),
28            bottom: Rgba8::opaque(218, 224, 232),
29            glow_color: Rgba8::opaque(255, 255, 255),
30            glow_strength: 0.08,
31        }
32    }
33
34    /// Transparent compositing fallback retaining neutral RGB fringe colours.
35    ///
36    /// Opaque molecular fragments remain opaque and translucent scene matter
37    /// contributes its accumulated coverage to the exported alpha channel.
38    #[must_use]
39    pub const fn transparent() -> Self {
40        Self {
41            top: Rgba8::new(244, 247, 250, 0),
42            bottom: Rgba8::new(218, 224, 232, 0),
43            glow_color: Rgba8::new(255, 255, 255, 0),
44            glow_strength: 0.0,
45        }
46    }
47
48    /// A bright, near-neutral studio sweep with a soft central pool of light.
49    ///
50    /// A specimen photographed in a lightbox reads as a real object: the eye
51    /// has a lit ground to measure the subject against, and shadow and
52    /// occlusion — which carry the shape — stay legible because they are darker
53    /// than their surroundings rather than lost in an already-black frame. The
54    /// faint cool cast is a lighting choice, not depicted matter.
55    #[must_use]
56    pub const fn studio() -> Self {
57        Self {
58            top: Rgba8::opaque(238, 241, 245),
59            bottom: Rgba8::opaque(206, 214, 223),
60            glow_color: Rgba8::opaque(255, 255, 255),
61            glow_strength: 0.30,
62        }
63    }
64
65    pub(crate) fn sanitize(self) -> Self {
66        Self {
67            glow_strength: unit(self.glow_strength),
68            ..self
69        }
70    }
71
72    pub(crate) fn blend(self, other: Self, weight: f32) -> Self {
73        let weight = unit(weight);
74        if weight <= 0.0 {
75            return self.sanitize();
76        }
77        if weight >= 1.0 {
78            return other.sanitize();
79        }
80        Self {
81            top: choose_color(self.top, other.top, weight),
82            bottom: choose_color(self.bottom, other.bottom, weight),
83            glow_color: choose_color(self.glow_color, other.glow_color, weight),
84            glow_strength: lerp(self.glow_strength, other.glow_strength, weight),
85        }
86        .sanitize()
87    }
88}
89
90impl Default for BackdropStyle {
91    fn default() -> Self {
92        Self::studio()
93    }
94}
95
96/// Scene-linear exposure and display grading, independent of the backdrop.
97#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
98pub enum ToneMapping {
99    /// ACES fitted SDR curve used by the publication and cinematic defaults.
100    AcesFitted,
101    /// Simple scene-linear Reinhard compression for diagnostic output.
102    Reinhard,
103    /// Unmapped scene-linear output for callers that own the display transform.
104    None,
105}
106
107impl ToneMapping {
108    pub(crate) const fn tag(self) -> f32 {
109        match self {
110            Self::AcesFitted => 0.0,
111            Self::Reinhard => 1.0,
112            Self::None => 2.0,
113        }
114    }
115}
116
117/// Display primaries used after the scene-linear presentation transform.
118#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
119pub enum DisplayGamut {
120    /// Rec.709 primaries used by sRGB displays.
121    #[default]
122    Srgb,
123    /// DCI-P3 primaries with a D65 white point.
124    DisplayP3,
125    /// Wide-gamut Rec.2020 primaries.
126    Rec2020,
127}
128
129impl DisplayGamut {
130    /// Every gamut, in pipeline-variant order.
131    pub(crate) const ALL: [Self; 3] = [Self::Srgb, Self::DisplayP3, Self::Rec2020];
132
133    /// Index of this gamut's pre-built pipeline variant.
134    pub(crate) const fn index(self) -> usize {
135        match self {
136            Self::Srgb => 0,
137            Self::DisplayP3 => 1,
138            Self::Rec2020 => 2,
139        }
140    }
141
142    pub(crate) const fn tag(self) -> f32 {
143        match self {
144            Self::Srgb => 0.0,
145            Self::DisplayP3 => 1.0,
146            Self::Rec2020 => 2.0,
147        }
148    }
149}
150
151/// Transfer curve used to encode the selected display gamut.
152#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
153pub enum TransferFunction {
154    /// Standard dynamic-range sRGB electro-optical transfer curve.
155    #[default]
156    Srgb,
157    /// Linear values for callers that own the final encoding.
158    Linear,
159    /// SMPTE ST 2084 perceptual quantizer for HDR output.
160    Pq,
161    /// Hybrid Log-Gamma scene-referred HDR transfer curve.
162    Hlg,
163}
164
165impl TransferFunction {
166    /// Every transfer curve, in pipeline-variant order.
167    pub(crate) const ALL: [Self; 4] = [Self::Srgb, Self::Linear, Self::Pq, Self::Hlg];
168
169    /// Index of this curve's pre-built pipeline variant.
170    pub(crate) const fn index(self) -> usize {
171        match self {
172            Self::Srgb => 0,
173            Self::Linear => 1,
174            Self::Pq => 2,
175            Self::Hlg => 3,
176        }
177    }
178
179    pub(crate) const fn tag(self) -> f32 {
180        match self {
181            Self::Srgb => 0.0,
182            Self::Linear => 1.0,
183            Self::Pq => 2.0,
184            Self::Hlg => 3.0,
185        }
186    }
187}
188
189/// Scene-linear exposure and display grading, independent of the backdrop.
190#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
191pub struct DisplayTransform {
192    /// Exposure compensation in stops.
193    pub exposure_ev: f32,
194    /// Display contrast multiplier in `[0.5, 1.5]`.
195    pub contrast: f32,
196    /// Display saturation multiplier in `[0, 1.5]`.
197    pub saturation: f32,
198    /// Optical edge falloff in `[0, 1]`.
199    pub vignette_strength: f32,
200    /// Explicit output tone operator.
201    pub tone_mapping: ToneMapping,
202    /// Output display primaries.
203    #[serde(default = "default_display_gamut")]
204    pub gamut: DisplayGamut,
205    /// Output electro-optical transfer curve.
206    #[serde(default = "default_transfer_function")]
207    pub transfer: TransferFunction,
208    /// Luminance represented by a unit scene-linear output for PQ, in nits.
209    #[serde(default = "default_peak_luminance")]
210    pub peak_luminance_nits: f32,
211}
212
213impl DisplayTransform {
214    /// Restrained art-directed grade without selecting a backdrop.
215    #[must_use]
216    pub const fn cinematic() -> Self {
217        Self {
218            exposure_ev: 0.22,
219            contrast: 1.18,
220            saturation: 1.3,
221            vignette_strength: 0.16,
222            tone_mapping: ToneMapping::AcesFitted,
223            gamut: DisplayGamut::Srgb,
224            transfer: TransferFunction::Srgb,
225            peak_luminance_nits: 100.0,
226        }
227    }
228
229    pub(crate) fn sanitize(self) -> Self {
230        Self {
231            exposure_ev: finite_clamp(self.exposure_ev, -8.0, 8.0, 0.0),
232            contrast: finite_clamp(self.contrast, 0.5, 1.5, 1.0),
233            saturation: finite_clamp(self.saturation, 0.0, 1.5, 1.0),
234            vignette_strength: unit(self.vignette_strength),
235            tone_mapping: self.tone_mapping,
236            gamut: self.gamut,
237            transfer: self.transfer,
238            peak_luminance_nits: finite_clamp(self.peak_luminance_nits, 80.0, 10_000.0, 100.0),
239        }
240    }
241
242    pub(crate) fn blend(self, other: Self, weight: f32) -> Self {
243        let weight = unit(weight);
244        Self {
245            exposure_ev: lerp(self.exposure_ev, other.exposure_ev, weight),
246            contrast: lerp(self.contrast, other.contrast, weight),
247            saturation: lerp(self.saturation, other.saturation, weight),
248            vignette_strength: lerp(self.vignette_strength, other.vignette_strength, weight),
249            tone_mapping: if weight >= 0.5 {
250                other.tone_mapping
251            } else {
252                self.tone_mapping
253            },
254            gamut: if weight >= 0.5 {
255                other.gamut
256            } else {
257                self.gamut
258            },
259            transfer: if weight >= 0.5 {
260                other.transfer
261            } else {
262                self.transfer
263            },
264            peak_luminance_nits: lerp(self.peak_luminance_nits, other.peak_luminance_nits, weight),
265        }
266        .sanitize()
267    }
268}
269
270impl Default for DisplayTransform {
271    fn default() -> Self {
272        Self {
273            exposure_ev: 0.0,
274            contrast: 1.0,
275            saturation: 1.0,
276            vignette_strength: 0.0,
277            tone_mapping: ToneMapping::AcesFitted,
278            gamut: DisplayGamut::Srgb,
279            transfer: TransferFunction::Srgb,
280            peak_luminance_nits: 100.0,
281        }
282    }
283}
284
285pub(crate) fn pack(
286    backdrop: BackdropStyle,
287    display: DisplayTransform,
288    has_translucency: bool,
289    bloom: [f32; 4],
290) -> [[f32; 4]; 6] {
291    let backdrop = backdrop.sanitize();
292    let display = display.sanitize();
293    let top = linear_rgb(backdrop.top);
294    let bottom = linear_rgb(backdrop.bottom);
295    let glow = linear_rgb(backdrop.glow_color);
296    let display_encoding = display.gamut.tag() + display.transfer.tag() * 4.0;
297    [
298        [top[0], top[1], top[2], backdrop.glow_strength],
299        [
300            bottom[0],
301            bottom[1],
302            bottom[2],
303            2.0_f32.powf(display.exposure_ev),
304        ],
305        [glow[0], glow[1], glow[2], display.contrast],
306        [
307            display.saturation,
308            display.vignette_strength,
309            display.tone_mapping.tag(),
310            0.42,
311        ],
312        [
313            f32::from(backdrop.top.a) / 255.0,
314            f32::from(backdrop.bottom.a) / 255.0,
315            if has_translucency { 1.0 } else { 0.0 },
316            display_encoding,
317        ],
318        [bloom[0], bloom[1], bloom[2], display.peak_luminance_nits],
319    ]
320}
321
322const fn default_display_gamut() -> DisplayGamut {
323    DisplayGamut::Srgb
324}
325
326const fn default_transfer_function() -> TransferFunction {
327    TransferFunction::Srgb
328}
329
330const fn default_peak_luminance() -> f32 {
331    100.0
332}
333
334fn choose_color(current: Rgba8, next: Rgba8, weight: f32) -> Rgba8 {
335    if weight >= 0.5 { next } else { current }
336}
337
338pub(crate) fn linear_rgb(color: Rgba8) -> [f32; 3] {
339    let normalized = color.to_f32();
340    [
341        srgb_to_linear(normalized[0]),
342        srgb_to_linear(normalized[1]),
343        srgb_to_linear(normalized[2]),
344    ]
345}
346
347/// One display-referred channel decoded to scene-linear.
348///
349/// This is the cubic fit, not the exact piecewise transfer function, because it
350/// is not the only place the engine crosses this boundary: `srgb_to_linear` in
351/// `include/material/shade.wgsl` decodes every surface albedo the same way, and
352/// two different curves would mean a backdrop and a surface given the same
353/// colour resolve to different values. The fit is within about half of one
354/// 8-bit step of the real curve — finer than the quantization of the bytes it
355/// decodes — and the exact form measured 20% of frame time on the shader side,
356/// where it runs per fragment. One transfer function, defined the same way in
357/// both languages.
358fn srgb_to_linear(channel: f32) -> f32 {
359    let clamped = channel.clamp(0.0, 1.0);
360    clamped * (clamped * (clamped * 0.305_306_01 + 0.682_171_1) + 0.012_522_878)
361}
362
363fn unit(value: f32) -> f32 {
364    if value.is_finite() {
365        value.clamp(0.0, 1.0)
366    } else {
367        0.0
368    }
369}
370
371fn lerp(from: f32, to: f32, weight: f32) -> f32 {
372    from + (to - from) * weight
373}
374
375fn finite_clamp(value: f32, minimum: f32, maximum: f32, fallback: f32) -> f32 {
376    if value.is_finite() {
377        value.clamp(minimum, maximum)
378    } else {
379        fallback
380    }
381}