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
7use super::profile_numeric::{finite_clamp, lerp, sanitize_bands, unit};
8use super::{BackdropStyle, DisplayTransform, LightingEnvironment};
9use molgfx_core::SelectionHandle;
10use molgfx_math::Vec3;
11use serde::{Deserialize, Serialize};
12
13#[path = "profile_resolve.rs"]
14mod resolve;
15
16/// A physical or scene-tracked focal plane for thin-lens presentation.
17#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)]
18pub enum FocusTarget {
19    /// Follow the camera's look-at target.
20    #[default]
21    CameraTarget,
22    /// Use an explicit positive view-space distance in Ångström.
23    Distance(f32),
24    /// Track one world-space point as the camera moves.
25    WorldPoint(Vec3),
26    /// Track the centroid of a caller-authored molecular selection.
27    Selection(SelectionHandle),
28}
29
30impl FocusTarget {
31    fn sanitize(self) -> Self {
32        match self {
33            Self::Distance(distance) if distance.is_finite() && distance > 0.0 => self,
34            Self::WorldPoint(point) if point.is_finite() => self,
35            Self::Selection(_) | Self::CameraTarget => self,
36            Self::Distance(_) | Self::WorldPoint(_) => Self::CameraTarget,
37        }
38    }
39}
40
41/// Screen-space cues that clarify molecular shape without changing geometry
42/// or scientific colour mappings.
43#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)]
44pub struct IllustrationStyle {
45    /// Darkening at relative depth and normal discontinuities, in `[0, 1]`.
46    pub silhouette_strength: f32,
47    /// Bounded emphasis of locally concave depth, in `[0, 1]`.
48    pub cavity_strength: f32,
49    /// Distance-based fade toward the background, in `[0, 1]`.
50    pub depth_cue_strength: f32,
51    /// Cel-shading tone bands (clamped `[2, 16]`); zero keeps continuous shading.
52    #[serde(default)]
53    pub posterize_levels: f32,
54    /// Motion-trail persistence `[0, 1]`; zero keeps crisp TAA, higher values
55    /// retain a bounded exponentially decaying screen-space history.
56    #[serde(default)]
57    pub motion_persistence: f32,
58    /// Silhouette outline thickness in pixels (clamped `[0, 8]`); zero is a 1px edge.
59    #[serde(default)]
60    pub outline_width: f32,
61}
62
63/// Thin-lens depth-of-field settings for cinematic presentation.
64#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
65pub struct DepthOfField {
66    /// Lens focal length, millimetres.
67    pub focal_length_mm: f32,
68    /// Aperture f-number.
69    pub f_number: f32,
70    /// Horizontal sensor extent, millimetres.
71    pub sensor_width_mm: f32,
72    /// Maximum circle-of-confusion radius, physical pixels.
73    pub max_blur_pixels: f32,
74    /// Aperture blade count, clamped to `[3, 12]`.
75    pub blade_count: u8,
76    /// Scene target from which the focal plane is resolved.
77    pub focus: FocusTarget,
78}
79
80impl DepthOfField {
81    /// A restrained full-frame macro-lens recipe for molecular cinematics.
82    #[must_use]
83    pub const fn cinematic() -> Self {
84        Self {
85            focal_length_mm: 50.0,
86            // Stopped well down on purpose. A molecule is a deep subject: at a
87            // wide aperture its own front and back fall outside the focal
88            // range and the whole specimen goes soft, which reads as a
89            // low-resolution image rather than a photographic one. This keeps
90            // the subject crisp and spends the blur on what is genuinely far
91            // from the focal plane.
92            f_number: 8.0,
93            sensor_width_mm: 36.0,
94            max_blur_pixels: 14.0,
95            blade_count: 7,
96            focus: FocusTarget::CameraTarget,
97        }
98    }
99
100    fn sanitize(self) -> Self {
101        Self {
102            focal_length_mm: finite_clamp(self.focal_length_mm, 1.0, 300.0, 50.0),
103            f_number: finite_clamp(self.f_number, 0.7, 64.0, 4.0),
104            sensor_width_mm: finite_clamp(self.sensor_width_mm, 1.0, 100.0, 36.0),
105            max_blur_pixels: finite_clamp(self.max_blur_pixels, 0.0, 64.0, 0.0),
106            blade_count: self.blade_count.clamp(3, 12),
107            focus: self.focus.sanitize(),
108        }
109    }
110
111    fn blend(self, other: Self, weight: f32) -> Self {
112        let weight = unit(weight);
113        Self {
114            focal_length_mm: lerp(self.focal_length_mm, other.focal_length_mm, weight),
115            f_number: lerp(self.f_number, other.f_number, weight),
116            sensor_width_mm: lerp(self.sensor_width_mm, other.sensor_width_mm, weight),
117            max_blur_pixels: lerp(self.max_blur_pixels, other.max_blur_pixels, weight),
118            blade_count: if weight >= 0.5 {
119                other.blade_count
120            } else {
121                self.blade_count
122            },
123            focus: if weight >= 0.5 {
124                other.focus
125            } else {
126                self.focus
127            },
128        }
129        .sanitize()
130    }
131
132    pub(crate) fn packed(self, focus_distance: f32) -> [f32; 4] {
133        let settings = self.sanitize();
134        [
135            focus_distance.max(1.0e-3),
136            settings.focal_length_mm / (settings.f_number * settings.sensor_width_mm),
137            settings.max_blur_pixels,
138            f32::from(settings.blade_count),
139        ]
140    }
141}
142
143/// Camera-shutter motion blur driven by the renderer's true object motion.
144#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
145pub struct MotionBlur {
146    /// Fraction of the frame interval exposed by the virtual shutter.
147    pub shutter: f32,
148    /// Maximum blur length in physical pixels.
149    pub max_blur_pixels: f32,
150}
151
152impl MotionBlur {
153    /// A restrained cinematic shutter that preserves molecular legibility.
154    #[must_use]
155    pub const fn cinematic() -> Self {
156        Self {
157            shutter: 0.55,
158            max_blur_pixels: 18.0,
159        }
160    }
161
162    fn sanitize(self) -> Self {
163        Self {
164            shutter: unit(self.shutter),
165            max_blur_pixels: finite_clamp(self.max_blur_pixels, 0.0, 96.0, 18.0),
166        }
167    }
168
169    fn blend(self, other: Self, weight: f32) -> Self {
170        let weight = unit(weight);
171        Self {
172            shutter: lerp(self.shutter, other.shutter, weight),
173            max_blur_pixels: lerp(self.max_blur_pixels, other.max_blur_pixels, weight),
174        }
175        .sanitize()
176    }
177
178    pub(crate) fn packed(self) -> [f32; 4] {
179        let value = self.sanitize();
180        [value.shutter, value.max_blur_pixels, 0.0, 0.0]
181    }
182}
183
184/// Bright-pass highlight bleed for cinematic presentation.
185#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
186pub struct BloomStyle {
187    /// Scene-linear luminance above which light begins to bleed.
188    pub threshold: f32,
189    /// Contribution added back over the resolved frame, in `[0, 1]`.
190    pub intensity: f32,
191    /// Blur reach in quarter-resolution texels, in `[0, 8]`.
192    pub radius: f32,
193}
194
195impl BloomStyle {
196    /// A restrained lens bleed that only the brightest speculars trigger.
197    #[must_use]
198    pub const fn cinematic() -> Self {
199        Self {
200            threshold: 1.7,
201            intensity: 0.26,
202            radius: 2.0,
203        }
204    }
205
206    fn sanitize(self) -> Self {
207        Self {
208            threshold: finite_clamp(self.threshold, 0.0, 64.0, 1.05),
209            intensity: unit(self.intensity),
210            radius: finite_clamp(self.radius, 0.0, 8.0, 2.0),
211        }
212    }
213
214    fn blend(self, other: Self, weight: f32) -> Self {
215        let weight = unit(weight);
216        Self {
217            threshold: lerp(self.threshold, other.threshold, weight),
218            intensity: lerp(self.intensity, other.intensity, weight),
219            radius: lerp(self.radius, other.radius, weight),
220        }
221        .sanitize()
222    }
223
224    pub(crate) fn packed(self) -> [f32; 4] {
225        let style = self.sanitize();
226        [style.threshold, style.intensity, style.radius, 0.0]
227    }
228}
229
230impl IllustrationStyle {
231    /// A restrained publication-style treatment.
232    #[must_use]
233    pub const fn publication() -> Self {
234        Self {
235            silhouette_strength: 0.65,
236            cavity_strength: 0.35,
237            depth_cue_strength: 0.15,
238            posterize_levels: 0.0,
239            motion_persistence: 0.0,
240            outline_width: 0.0,
241        }
242    }
243
244    fn sanitize(self) -> Self {
245        Self {
246            silhouette_strength: unit(self.silhouette_strength),
247            cavity_strength: unit(self.cavity_strength),
248            depth_cue_strength: unit(self.depth_cue_strength),
249            posterize_levels: sanitize_bands(self.posterize_levels),
250            motion_persistence: unit(self.motion_persistence),
251            outline_width: finite_clamp(self.outline_width, 0.0, 8.0, 0.0),
252        }
253    }
254
255    fn blend(self, other: Self, weight: f32) -> Self {
256        let weight = unit(weight);
257        Self {
258            silhouette_strength: lerp(self.silhouette_strength, other.silhouette_strength, weight),
259            cavity_strength: lerp(self.cavity_strength, other.cavity_strength, weight),
260            depth_cue_strength: lerp(self.depth_cue_strength, other.depth_cue_strength, weight),
261            posterize_levels: lerp(self.posterize_levels, other.posterize_levels, weight),
262            motion_persistence: lerp(self.motion_persistence, other.motion_persistence, weight),
263            outline_width: lerp(self.outline_width, other.outline_width, weight),
264        }
265    }
266
267    pub(crate) fn packed(self, focus_distance: f32) -> [f32; 4] {
268        let style = self.sanitize();
269        [
270            style.silhouette_strength,
271            style.cavity_strength,
272            style.depth_cue_strength,
273            if focus_distance.is_finite() {
274                focus_distance.max(1.0e-3)
275            } else {
276                1.0
277            },
278        ]
279    }
280
281    /// Non-photorealistic lane: cel band count in `x`, motion-trail
282    /// persistence in `y`, outline width in `z`, spare in `w`.
283    pub(crate) fn npr_packed(self) -> [f32; 4] {
284        let s = self.sanitize();
285        [
286            s.posterize_levels,
287            s.motion_persistence,
288            s.outline_width,
289            0.0,
290        ]
291    }
292}
293
294/// A typed presentation module that a render profile can layer.
295///
296/// The enum is non-exhaustive so new engine effects do not force downstream
297/// callers to match every future module.
298#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
299#[non_exhaustive]
300pub enum PresentationEffect {
301    /// Molecular illustration applied after opaque lighting.
302    Illustration(IllustrationStyle),
303    /// Camera-space thin-lens depth of field after temporal resolution.
304    DepthOfField(DepthOfField),
305    /// Camera-shutter blur from the G-buffer's true surface motion.
306    MotionBlur(MotionBlur),
307    /// Fallback colour outside represented scene content.
308    Backdrop(BackdropStyle),
309    /// Reflected, ambient, key and fill illumination, independent of backdrop.
310    Lighting(LightingEnvironment),
311    /// Exposure and display grading, independent of scene content.
312    Display(DisplayTransform),
313    /// Bright-pass highlight bleed applied before the display transform.
314    Bloom(BloomStyle),
315}
316
317/// One weighted module in a reusable render profile.
318#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
319pub struct EffectLayer {
320    /// Lower priorities resolve first. Equal priorities preserve insertion
321    /// order.
322    pub priority: i16,
323    /// Interpolation weight, sanitized to `[0, 1]` while resolving.
324    pub weight: f32,
325    /// Typed effect settings.
326    pub effect: PresentationEffect,
327}
328
329impl EffectLayer {
330    /// Creates a fully weighted layer at priority zero.
331    #[must_use]
332    pub const fn new(effect: PresentationEffect) -> Self {
333        Self {
334            priority: 0,
335            weight: 1.0,
336            effect,
337        }
338    }
339
340    /// Sets the layer's interpolation weight.
341    #[must_use]
342    pub const fn with_weight(mut self, weight: f32) -> Self {
343        self.weight = weight;
344        self
345    }
346
347    /// Sets the layer's ordering priority.
348    #[must_use]
349    pub const fn with_priority(mut self, priority: i16) -> Self {
350        self.priority = priority;
351        self
352    }
353}
354
355/// A reusable, declarative recipe for presentation effects.
356#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
357pub struct RenderProfile {
358    layers: Vec<EffectLayer>,
359}
360
361impl RenderProfile {
362    /// The quantitative inspection baseline with no optional presentation
363    /// modules.
364    #[must_use]
365    pub const fn inspection() -> Self {
366        Self { layers: Vec::new() }
367    }
368
369    /// A restrained publication illustration recipe.
370    #[must_use]
371    pub fn illustrative() -> Self {
372        Self::inspection().with_effect(PresentationEffect::Illustration(
373            IllustrationStyle::publication(),
374        ))
375    }
376
377    /// Art-directed molecular-film optics and grading. It deliberately leaves
378    /// the fallback backdrop unchanged: biological context must be represented
379    /// by structures, solvent, membranes or caller density.
380    #[must_use]
381    pub fn cinematic() -> Self {
382        Self::inspection()
383            .with_effect(PresentationEffect::Illustration(IllustrationStyle {
384                silhouette_strength: 0.5,
385                cavity_strength: 0.4,
386                depth_cue_strength: 0.28,
387                posterize_levels: 0.0,
388                motion_persistence: 0.0,
389                outline_width: 0.0,
390            }))
391            .with_effect(PresentationEffect::Lighting(
392                LightingEnvironment::documentary(),
393            ))
394            .with_effect(PresentationEffect::Display(DisplayTransform::cinematic()))
395            .with_effect(PresentationEffect::Bloom(BloomStyle::cinematic()))
396            .with_effect(PresentationEffect::DepthOfField(DepthOfField::cinematic()))
397            .with_effect(PresentationEffect::MotionBlur(MotionBlur::cinematic()))
398    }
399
400    /// Appends a fully weighted module.
401    #[must_use]
402    pub fn with_effect(self, effect: PresentationEffect) -> Self {
403        self.with_layer(EffectLayer::new(effect))
404    }
405
406    /// Appends a weighted, prioritized module.
407    #[must_use]
408    pub fn with_layer(mut self, layer: EffectLayer) -> Self {
409        self.layers.push(layer);
410        self
411    }
412
413    /// Modules in insertion order. Resolution also considers priority.
414    #[must_use]
415    pub fn layers(&self) -> &[EffectLayer] {
416        &self.layers
417    }
418}
419
420/// The compact, sanitized plan the frame loop actually consumes.
421#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)]
422pub struct ResolvedRenderPlan {
423    illustration: IllustrationStyle,
424    depth_of_field: Option<DepthOfField>,
425    motion_blur: Option<MotionBlur>,
426    bloom: Option<BloomStyle>,
427    backdrop: BackdropStyle,
428    lighting: LightingEnvironment,
429    display: DisplayTransform,
430}
431
432impl ResolvedRenderPlan {
433    /// The final molecular illustration settings after ordered blending.
434    #[must_use]
435    pub const fn illustration(&self) -> IllustrationStyle {
436        self.illustration
437    }
438
439    /// The resolved thin-lens module, if the profile contributes blur.
440    #[must_use]
441    pub const fn depth_of_field(&self) -> Option<DepthOfField> {
442        self.depth_of_field
443    }
444
445    /// Resolved camera-shutter blur module, if enabled.
446    #[must_use]
447    pub const fn motion_blur(&self) -> Option<MotionBlur> {
448        self.motion_blur
449    }
450
451    /// Resolved compositing fallback.
452    #[must_use]
453    pub const fn backdrop(&self) -> BackdropStyle {
454        self.backdrop
455    }
456
457    /// Resolved reflected and direct lighting rig.
458    #[must_use]
459    pub const fn lighting(&self) -> LightingEnvironment {
460        self.lighting
461    }
462
463    /// Resolved display transform.
464    #[must_use]
465    pub const fn display(&self) -> DisplayTransform {
466        self.display
467    }
468
469    /// The resolved highlight bleed, if the profile contributes one.
470    #[must_use]
471    pub const fn bloom(&self) -> Option<BloomStyle> {
472        self.bloom
473    }
474
475    pub(crate) fn packed_presentation(self, has_translucency: bool) -> [[f32; 4]; 6] {
476        super::backdrop::pack(
477            self.backdrop,
478            self.display,
479            has_translucency,
480            match self.bloom {
481                Some(style) => style.packed(),
482                None => [0.0; 4],
483            },
484        )
485    }
486
487    pub(crate) fn packed_lighting(self) -> [[f32; 4]; 8] {
488        self.lighting.packed()
489    }
490
491    pub(crate) fn optics(self, focus_distance: f32) -> [f32; 4] {
492        match self.depth_of_field {
493            Some(settings) => settings.packed(focus_distance),
494            None => [focus_distance.max(1.0e-3), 0.0, 0.0, 0.0],
495        }
496    }
497}
498#[cfg(test)]
499#[path = "profile_tests.rs"]
500mod tests;