Skip to main content

molgfx_render/engine/
profile.rs

1//! Reusable presentation recipes and their allocation-free frame state.
2//!
3//! Profiles are resolved only when construction settings change. Rendering
4//! reads the compact resolved plan, so profile composition adds no per-frame
5//! allocation or dynamic dispatch.
6
7pub(super) use super::optics::{DepthOfField, MotionBlur};
8use super::profile_numeric::{finite_clamp, lerp, sanitize_bands, unit};
9use super::{BackdropStyle, DepthCue, DisplayTransform, LightingEnvironment};
10use serde::{Deserialize, Serialize};
11
12#[path = "profile_resolve.rs"]
13mod resolve;
14
15/// Screen-space cues that clarify molecular shape without changing geometry
16/// or physical colour mappings.
17#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)]
18pub struct IllustrationStyle {
19    /// Darkening at relative depth and normal discontinuities, in `[0, 1]`.
20    pub silhouette_strength: f32,
21    /// Bounded emphasis of locally concave depth, in `[0, 1]`.
22    pub cavity_strength: f32,
23    /// Distance-based fade toward the background, in `[0, 1]`.
24    pub depth_cue_strength: f32,
25    /// Cel-shading tone bands (clamped `[2, 16]`); zero keeps continuous shading.
26    #[serde(default)]
27    pub posterize_levels: f32,
28    /// Motion-trail persistence `[0, 1]`; zero keeps crisp TAA, higher values
29    /// retain a bounded exponentially decaying screen-space history.
30    #[serde(default)]
31    pub motion_persistence: f32,
32    /// Silhouette outline thickness in pixels (clamped `[0, 8]`); zero is a 1px edge.
33    #[serde(default)]
34    pub outline_width: f32,
35}
36
37/// Bright-pass highlight bleed for cinematic presentation.
38#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
39pub struct BloomStyle {
40    /// Scene-linear luminance above which light begins to bleed.
41    pub threshold: f32,
42    /// Contribution added back over the resolved frame, in `[0, 1]`.
43    pub intensity: f32,
44    /// Blur reach in quarter-resolution texels, in `[0, 8]`.
45    pub radius: f32,
46}
47
48impl BloomStyle {
49    /// A restrained lens bleed that only the brightest speculars trigger.
50    #[must_use]
51    pub const fn cinematic() -> Self {
52        Self {
53            threshold: 1.7,
54            intensity: 0.26,
55            radius: 2.0,
56        }
57    }
58
59    fn sanitize(self) -> Self {
60        Self {
61            threshold: finite_clamp(self.threshold, 0.0, 64.0, 1.05),
62            intensity: unit(self.intensity),
63            radius: finite_clamp(self.radius, 0.0, 8.0, 2.0),
64        }
65    }
66
67    fn blend(self, other: Self, weight: f32) -> Self {
68        let weight = unit(weight);
69        Self {
70            threshold: lerp(self.threshold, other.threshold, weight),
71            intensity: lerp(self.intensity, other.intensity, weight),
72            radius: lerp(self.radius, other.radius, weight),
73        }
74        .sanitize()
75    }
76
77    pub(crate) fn packed(self) -> [f32; 4] {
78        let style = self.sanitize();
79        [style.threshold, style.intensity, style.radius, 0.0]
80    }
81}
82
83impl IllustrationStyle {
84    /// A restrained publication-style treatment.
85    #[must_use]
86    pub const fn publication() -> Self {
87        Self {
88            silhouette_strength: 0.65,
89            cavity_strength: 0.35,
90            depth_cue_strength: 0.15,
91            posterize_levels: 0.0,
92            motion_persistence: 0.0,
93            outline_width: 0.0,
94        }
95    }
96
97    fn sanitize(self) -> Self {
98        Self {
99            silhouette_strength: unit(self.silhouette_strength),
100            cavity_strength: unit(self.cavity_strength),
101            depth_cue_strength: unit(self.depth_cue_strength),
102            posterize_levels: sanitize_bands(self.posterize_levels),
103            motion_persistence: unit(self.motion_persistence),
104            outline_width: finite_clamp(self.outline_width, 0.0, 8.0, 0.0),
105        }
106    }
107
108    fn blend(self, other: Self, weight: f32) -> Self {
109        let weight = unit(weight);
110        Self {
111            silhouette_strength: lerp(self.silhouette_strength, other.silhouette_strength, weight),
112            cavity_strength: lerp(self.cavity_strength, other.cavity_strength, weight),
113            depth_cue_strength: lerp(self.depth_cue_strength, other.depth_cue_strength, weight),
114            posterize_levels: lerp(self.posterize_levels, other.posterize_levels, weight),
115            motion_persistence: lerp(self.motion_persistence, other.motion_persistence, weight),
116            outline_width: lerp(self.outline_width, other.outline_width, weight),
117        }
118    }
119
120    pub(crate) fn packed(self, focus_distance: f32) -> [f32; 4] {
121        let style = self.sanitize();
122        [
123            style.silhouette_strength,
124            style.cavity_strength,
125            style.depth_cue_strength,
126            if focus_distance.is_finite() {
127                focus_distance.max(1.0e-3)
128            } else {
129                1.0
130            },
131        ]
132    }
133
134    /// Non-photorealistic lane: cel band count in `x`, motion-trail
135    /// persistence in `y`, outline width in `z`, spare in `w`.
136    pub(crate) fn npr_packed(self) -> [f32; 4] {
137        let s = self.sanitize();
138        [
139            s.posterize_levels,
140            s.motion_persistence,
141            s.outline_width,
142            0.0,
143        ]
144    }
145}
146
147/// Anti-aliasing applied to the presented image.
148///
149/// The engine renders into an HDR history buffer; this setting decides how the
150/// resolved image is smoothed on its way to the display. Edge smoothing runs at
151/// `O(pixels)` in the tonemap pass and is fused there, so selecting it never
152/// adds a pass; the temporal resolve is a separate, always-on mechanism that a
153/// moving camera relies on regardless of this choice.
154#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)]
155pub struct AntiAliasingStyle {
156    /// Whether the tonemap pass smooths edges.
157    ///
158    /// A still, converged image is already smooth, so the default leaves this
159    /// off and lets callers enable it for a realtime or non-converging view.
160    pub edge_smoothing: bool,
161}
162
163impl AntiAliasingStyle {
164    /// Edge smoothing on: the choice for an interactive view that does not
165    /// accumulate.
166    #[must_use]
167    pub const fn smoothed() -> Self {
168        Self {
169            edge_smoothing: true,
170        }
171    }
172
173    /// Edge smoothing off: the choice for publication, where the accumulated
174    /// image needs no post-filter.
175    #[must_use]
176    pub const fn none() -> Self {
177        Self {
178            edge_smoothing: false,
179        }
180    }
181
182    fn sanitize(self) -> Self {
183        self
184    }
185}
186
187/// A typed presentation module that a render profile can layer.
188///
189/// The enum is non-exhaustive so new engine effects do not force downstream
190/// callers to match every future module.
191#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
192#[non_exhaustive]
193pub enum PresentationEffect {
194    /// Molecular illustration applied after opaque lighting.
195    Illustration(IllustrationStyle),
196    /// Explicit view-space fog/depth cue applied after lighting.
197    DepthCue(DepthCue),
198    /// Camera-space thin-lens depth of field after temporal resolution.
199    DepthOfField(DepthOfField),
200    /// Camera-shutter blur from the G-buffer's true surface motion.
201    MotionBlur(MotionBlur),
202    /// Fallback colour outside represented scene content.
203    Backdrop(BackdropStyle),
204    /// Reflected, ambient, key and fill illumination, independent of backdrop.
205    Lighting(LightingEnvironment),
206    /// Exposure and display grading, independent of scene content.
207    Display(DisplayTransform),
208    /// Bright-pass highlight bleed applied before the display transform.
209    Bloom(BloomStyle),
210    /// Edge smoothing applied as the image is presented.
211    AntiAliasing(AntiAliasingStyle),
212}
213
214/// One weighted module in a reusable render profile.
215#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
216pub struct EffectLayer {
217    /// Lower priorities resolve first. Equal priorities preserve insertion
218    /// order.
219    pub priority: i16,
220    /// Interpolation weight, sanitized to `[0, 1]` while resolving.
221    pub weight: f32,
222    /// Typed effect settings.
223    pub effect: PresentationEffect,
224}
225
226impl EffectLayer {
227    /// Creates a fully weighted layer at priority zero.
228    #[must_use]
229    pub const fn new(effect: PresentationEffect) -> Self {
230        Self {
231            priority: 0,
232            weight: 1.0,
233            effect,
234        }
235    }
236
237    /// Sets the layer's interpolation weight.
238    #[must_use]
239    pub const fn with_weight(mut self, weight: f32) -> Self {
240        self.weight = weight;
241        self
242    }
243
244    /// Sets the layer's ordering priority.
245    #[must_use]
246    pub const fn with_priority(mut self, priority: i16) -> Self {
247        self.priority = priority;
248        self
249    }
250}
251
252/// A reusable, declarative recipe for presentation effects.
253#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
254pub struct RenderProfile {
255    layers: Vec<EffectLayer>,
256}
257
258impl RenderProfile {
259    /// The quantitative inspection baseline with no optional presentation
260    /// modules.
261    #[must_use]
262    pub const fn inspection() -> Self {
263        Self { layers: Vec::new() }
264    }
265
266    /// A restrained publication illustration recipe.
267    #[must_use]
268    pub fn illustrative() -> Self {
269        Self::inspection().with_effect(PresentationEffect::Illustration(
270            IllustrationStyle::publication(),
271        ))
272    }
273
274    /// Art-directed molecular-film optics and grading. It deliberately leaves
275    /// the fallback backdrop unchanged: biological context must be represented
276    /// by structures, solvent, membranes or caller density.
277    #[must_use]
278    pub fn cinematic() -> Self {
279        Self::inspection()
280            .with_effect(PresentationEffect::Illustration(IllustrationStyle {
281                silhouette_strength: 0.5,
282                cavity_strength: 0.4,
283                depth_cue_strength: 0.28,
284                posterize_levels: 0.0,
285                motion_persistence: 0.0,
286                outline_width: 0.0,
287            }))
288            .with_effect(PresentationEffect::Lighting(
289                LightingEnvironment::documentary(),
290            ))
291            .with_effect(PresentationEffect::Display(DisplayTransform::cinematic()))
292            .with_effect(PresentationEffect::Bloom(BloomStyle::cinematic()))
293            .with_effect(PresentationEffect::DepthOfField(DepthOfField::cinematic()))
294            .with_effect(PresentationEffect::MotionBlur(MotionBlur::cinematic()))
295    }
296
297    /// Appends a fully weighted module.
298    #[must_use]
299    pub fn with_effect(self, effect: PresentationEffect) -> Self {
300        self.with_layer(EffectLayer::new(effect))
301    }
302
303    /// Appends a weighted, prioritized module.
304    #[must_use]
305    pub fn with_layer(mut self, layer: EffectLayer) -> Self {
306        self.layers.push(layer);
307        self
308    }
309
310    /// Modules in insertion order. Resolution also considers priority.
311    #[must_use]
312    pub fn layers(&self) -> &[EffectLayer] {
313        &self.layers
314    }
315}
316
317/// The compact, sanitized plan the frame loop actually consumes.
318#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)]
319pub struct ResolvedRenderPlan {
320    illustration: IllustrationStyle,
321    depth_cue: DepthCue,
322    depth_of_field: Option<DepthOfField>,
323    motion_blur: Option<MotionBlur>,
324    bloom: Option<BloomStyle>,
325    backdrop: BackdropStyle,
326    lighting: LightingEnvironment,
327    display: DisplayTransform,
328    antialias: Option<AntiAliasingStyle>,
329}
330
331impl ResolvedRenderPlan {
332    /// The final molecular illustration settings after ordered blending.
333    #[must_use]
334    pub const fn illustration(&self) -> IllustrationStyle {
335        self.illustration
336    }
337    /// The explicit view-space fog/depth cue, if enabled.
338    #[must_use]
339    pub const fn depth_cue(&self) -> DepthCue {
340        self.depth_cue
341    }
342    /// The resolved thin-lens module, if the profile contributes blur.
343    #[must_use]
344    pub const fn depth_of_field(&self) -> Option<DepthOfField> {
345        self.depth_of_field
346    }
347
348    /// Resolved camera-shutter blur module, if enabled.
349    #[must_use]
350    pub const fn motion_blur(&self) -> Option<MotionBlur> {
351        self.motion_blur
352    }
353
354    /// Resolved compositing fallback.
355    #[must_use]
356    pub const fn backdrop(&self) -> BackdropStyle {
357        self.backdrop
358    }
359
360    /// Resolved reflected and direct lighting rig.
361    #[must_use]
362    pub const fn lighting(&self) -> LightingEnvironment {
363        self.lighting
364    }
365
366    /// Resolved display transform.
367    #[must_use]
368    pub const fn display(&self) -> DisplayTransform {
369        self.display
370    }
371
372    /// The resolved highlight bleed, if the profile contributes one.
373    #[must_use]
374    pub const fn bloom(&self) -> Option<BloomStyle> {
375        self.bloom
376    }
377
378    /// The resolved edge-smoothing choice, or `None` when no profile layer
379    /// stated one and the tier's default applies.
380    #[must_use]
381    pub const fn antialias(&self) -> Option<AntiAliasingStyle> {
382        self.antialias
383    }
384
385    pub(crate) fn packed_presentation(self, has_translucency: bool) -> [[f32; 4]; 6] {
386        super::backdrop::pack(
387            self.backdrop,
388            self.display,
389            has_translucency,
390            match self.bloom {
391                Some(style) => style.packed(),
392                None => [0.0; 4],
393            },
394        )
395    }
396
397    pub(crate) fn packed_lighting(self) -> [[f32; 4]; 8] {
398        self.lighting.packed()
399    }
400
401    pub(crate) fn packed_depth_cue(self) -> [f32; 4] {
402        self.depth_cue.packed()
403    }
404
405    pub(crate) fn optics(self, focus_distance: f32) -> [f32; 4] {
406        match self.depth_of_field {
407            Some(settings) => settings.packed(focus_distance),
408            None => [focus_distance.max(1.0e-3), 0.0, 0.0, 0.0],
409        }
410    }
411}
412#[cfg(test)]
413#[path = "profile_tests.rs"]
414mod tests;