Skip to main content

molgfx_render/engine/
hdr_image.rs

1//! Scene-linear HDR readback without a second full-resolution render target.
2
3use super::image::ImageLayout;
4use super::image::{ImagePurpose, PUBLICATION_IMAGE_SAMPLES};
5use super::shadow::ShadowMatrices;
6use super::{Engine, ImageConfig, MotionBlur, QualityTier, TemporalOptions};
7use crate::error::RenderError;
8use crate::graph::ResourceId;
9use crate::passes::{DOF_RESOURCE, HISTORY_A_RESOURCE, HISTORY_B_RESOURCE, MOTION_BLUR_RESOURCE};
10use molgfx_core::Scene;
11use molgfx_gpu::{BufferDesc, BufferUsage, CommandEncoder as _, Device, Queue as _};
12use molgfx_math::Camera;
13
14/// Tightly packed scene-linear RGBA16F pixels owned by the caller.
15///
16/// `rgba16f` contains row-major RGBA components as little-endian IEEE 754
17/// binary16 words. Keeping the native eight-byte texel avoids expanding a 4K
18/// readback to twice its size on the CPU. Exposure, bloom, tone mapping,
19/// display-gamut conversion, transfer encoding and screen overlays are not
20/// baked into these scene-linear values.
21#[derive(PartialEq, Eq, Debug)]
22pub struct HdrImage {
23    /// Width in pixels.
24    pub(super) width: u32,
25    /// Height in pixels.
26    pub(super) height: u32,
27    /// Row-major RGBA16F bytes with no row padding.
28    pub(super) rgba16f: Vec<u8>,
29}
30
31impl HdrImage {
32    /// Width in pixels.
33    #[must_use]
34    pub const fn width(&self) -> u32 {
35        self.width
36    }
37
38    /// Height in pixels.
39    #[must_use]
40    pub const fn height(&self) -> u32 {
41        self.height
42    }
43
44    /// Borrowed, tightly packed scene-linear RGBA16F pixels.
45    #[must_use]
46    pub fn rgba16f(&self) -> &[u8] {
47        &self.rgba16f
48    }
49
50    /// Streams deterministic, uncompressed `OpenEXR` to a caller-owned sink.
51    ///
52    /// Only one planar scanline is allocated and reused, so a 4K export does
53    /// not duplicate the complete RGBA16F frame in host memory.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`RenderError::ImageEncoding`] for malformed pixels, layout
58    /// overflow, or a sink write failure.
59    pub fn write_exr(&self, writer: impl std::io::Write) -> Result<(), RenderError> {
60        super::exr::write(self, writer)
61    }
62}
63
64impl<D: Device> Engine<D> {
65    /// Asynchronously renders the resolved scene-linear HDR input that would
66    /// otherwise enter presentation tone mapping.
67    ///
68    /// Browser callers use this path so mapped-buffer completion yields to the
69    /// event loop. The capture copies directly from the graph's RGBA16F
70    /// resource and does not allocate another full-resolution GPU texture.
71    ///
72    /// # Errors
73    ///
74    /// Returns a typed device error, graph allocation failure, or
75    /// [`RenderError::InvalidImageSize`] for invalid dimensions.
76    pub async fn render_hdr_image_async(
77        &mut self,
78        scene: &Scene,
79        camera: &Camera,
80        config: ImageConfig,
81    ) -> Result<HdrImage, RenderError> {
82        let pending = self.render_hdr_image_to_buffer(scene, camera, config)?;
83        let mapped = self
84            .queue
85            .read_buffer_async(&self.device, &pending.buffer, 0, pending.layout.buffer_size)
86            .await?;
87        pending.resolve(mapped)
88    }
89
90    /// Renders the resolved scene-linear HDR input without opening a window.
91    ///
92    /// The capture copies directly from the graph's RGBA16F resource and does
93    /// not allocate another full-resolution GPU texture.
94    ///
95    /// # Errors
96    ///
97    /// Returns a typed device error, graph allocation failure, or
98    /// [`RenderError::InvalidImageSize`] for invalid dimensions.
99    #[cfg(not(target_arch = "wasm32"))]
100    pub fn render_hdr_image(
101        &mut self,
102        scene: &Scene,
103        camera: &Camera,
104        config: ImageConfig,
105    ) -> Result<HdrImage, RenderError> {
106        let pending = self.render_hdr_image_to_buffer(scene, camera, config)?;
107        let mapped = self.queue.read_buffer_blocking(
108            &self.device,
109            &pending.buffer,
110            0,
111            pending.layout.buffer_size,
112        )?;
113        pending.resolve(mapped)
114    }
115
116    fn render_hdr_image_to_buffer(
117        &mut self,
118        scene: &Scene,
119        camera: &Camera,
120        config: ImageConfig,
121    ) -> Result<PendingHdrImage<D>, RenderError> {
122        config.validate(self.device.capabilities().max_texture_dim)?;
123        let layout = ImageLayout::new(config, 8)?;
124        self.width = config.width;
125        self.height = config.height;
126        let preparation = self.prepare_image(scene, ImagePurpose::Publication)?;
127        self.temporal.reset();
128        self.temporal_scene_identity = None;
129        let optics = self.resolve_optics(scene, camera)?;
130        let readback = self.device.create_buffer(&BufferDesc {
131            label: "scene-linear HDR readback",
132            size: layout.buffer_size,
133            usage: BufferUsage::COPY_DST.union(BufferUsage::MAP_READ),
134        })?;
135        // A scene-linear export is a deterministic artifact, so it keeps the
136        // full publication budget: a tier must never silently reduce the
137        // fidelity of a caller's readback.
138        let samples = PUBLICATION_IMAGE_SAMPLES;
139        let shadow = self.shadow_bound.fit(
140            scene,
141            camera,
142            self.resolved_plan.lighting(),
143            preparation.scene_changed || preparation.rebuild,
144        );
145        for sample in 0..samples {
146            self.scene_gpu.begin_frame();
147            let quality = self.tier() >= QualityTier::Standard;
148            self.prepare_hdr_sample(camera, sample, quality, optics, shadow)?;
149            let source = self.scene_linear_resource();
150            let Some(pool) = &self.pool else {
151                return Err(molgfx_gpu::GpuError::DeviceLost.into());
152            };
153            let Some(target) = pool.view(source) else {
154                return Err(molgfx_gpu::GpuError::DeviceLost.into());
155            };
156            let mut encoder = self.device.create_command_encoder();
157            self.passes
158                .cull
159                .record_attribute_timelines(&self.scene_gpu, &mut encoder);
160            self.passes
161                .cull
162                .record_instance_timelines(&self.scene_gpu, &mut encoder);
163            let point_coordinates_changed = self
164                .passes
165                .cull
166                .record_point_timelines(&self.scene_gpu, &mut encoder);
167            self.scene_gpu
168                .record_particle_motion(&mut encoder, &self.passes.particle_motion);
169            let structure_coordinates_changed =
170                self.scene_gpu
171                    .record_trajectories(&mut encoder, &self.passes.trajectory, None);
172            let paged_coordinates_changed = self
173                .passes
174                .cull
175                .record_paged_trajectories(&self.scene_gpu, &mut encoder);
176            self.scene_gpu.record_dynamic_relations(
177                &mut encoder,
178                &self.passes.relation_resolve,
179                structure_coordinates_changed
180                    || paged_coordinates_changed
181                    || point_coordinates_changed,
182            );
183            self.scene_gpu
184                .record_occupancies(&mut encoder, self.passes.occupancy.as_ref());
185            self.scene_gpu.record_surface_fields(
186                &mut encoder,
187                &self.passes.surface_field,
188                &self.passes.surface_components,
189            );
190            self.scene_gpu
191                .record_quality_hardware(&mut encoder, quality);
192            self.record_image_until(&mut encoder, target, None, quality, false, Some(source));
193            if sample + 1 == samples {
194                let Some(texture) = pool.texture(source) else {
195                    return Err(molgfx_gpu::GpuError::DeviceLost.into());
196                };
197                encoder.copy_texture_to_buffer(
198                    texture,
199                    (0, 0),
200                    (config.width, config.height),
201                    layout.padded_row,
202                    0,
203                    &readback,
204                );
205            }
206            self.queue.submit(encoder);
207        }
208        Ok(PendingHdrImage {
209            config,
210            layout,
211            buffer: readback,
212        })
213    }
214
215    fn prepare_hdr_sample(
216        &mut self,
217        camera: &Camera,
218        sample: u32,
219        quality: bool,
220        optics: [f32; 4],
221        shadow: ShadowMatrices,
222    ) -> Result<(), RenderError> {
223        let uniforms = self.temporal.prepare(
224            camera,
225            &TemporalOptions {
226                extent: [self.width, self.height],
227                reset: sample == 0,
228                quality,
229                publication: true,
230                illustration: self.resolved_plan.illustration(),
231                depth_cue: self.resolved_plan.packed_depth_cue(),
232                optics,
233                motion_blur: self
234                    .resolved_plan
235                    .motion_blur()
236                    .map_or([0.0; 4], MotionBlur::packed),
237                atmosphere: self
238                    .resolved_plan
239                    .packed_presentation(self.scene_gpu.has_translucency()),
240                lighting: self.resolved_plan.packed_lighting(),
241                shadow_view: shadow.view,
242                shadow_projection: shadow.projection,
243                shadow_view_proj: shadow.view_projection,
244            },
245        );
246        self.scene_gpu.write_frame_uniforms(&self.queue, &uniforms)
247    }
248
249    fn scene_linear_resource(&self) -> ResourceId {
250        if self.resolved_plan.motion_blur().is_some() {
251            MOTION_BLUR_RESOURCE
252        } else if self.resolved_plan.depth_of_field().is_some() {
253            DOF_RESOURCE
254        } else if self.temporal.write_index() == 0 {
255            HISTORY_A_RESOURCE
256        } else {
257            HISTORY_B_RESOURCE
258        }
259    }
260}
261
262struct PendingHdrImage<D: Device> {
263    config: ImageConfig,
264    layout: ImageLayout,
265    buffer: D::Buffer,
266}
267
268impl<D: Device> PendingHdrImage<D> {
269    fn resolve(self, mapped: Vec<u8>) -> Result<HdrImage, RenderError> {
270        Ok(HdrImage {
271            width: self.config.width,
272            height: self.config.height,
273            rgba16f: self.layout.unpack(mapped)?,
274        })
275    }
276}
277
278#[cfg(all(test, not(target_arch = "wasm32")))]
279#[path = "hdr_image_tests.rs"]
280mod tests;