Skip to main content

molgfx_render/engine/
image.rs

1//! Off-screen rendering and mapped publication images.
2use super::{Engine, MotionBlur, QualityTier, TemporalOptions};
3use crate::error::RenderError;
4use crate::graph::{PassContext, ResourceTable};
5use molgfx_core::Scene;
6use molgfx_gpu::{
7    BufferDesc, BufferUsage, CommandEncoder as _, Device, FenceValue, Queue as _, TextureDesc,
8    TextureFormat, TextureUsage, TextureViewDesc,
9};
10use molgfx_math::Camera;
11#[path = "image/layout.rs"]
12mod layout;
13pub(super) use layout::ImageLayout;
14/// Publication samples that no tier refines any further.
15pub(crate) const PUBLICATION_IMAGE_SAMPLES: u32 = 64;
16#[derive(Clone, Copy, PartialEq, Eq)]
17pub(crate) enum ImagePurpose {
18    Publication,
19    #[cfg(not(target_arch = "wasm32"))]
20    SequenceFrame,
21}
22#[derive(Clone, Copy)]
23pub(super) struct ImagePreparation {
24    pub(super) scene_changed: bool,
25    pub(super) rebuild: bool,
26}
27/// Requested off-screen image dimensions.
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub struct ImageConfig {
30    /// Output width in pixels.
31    pub width: u32,
32    /// Output height in pixels.
33    pub height: u32,
34}
35impl ImageConfig {
36    /// Standard 3840×2160 publication output.
37    #[must_use]
38    pub const fn publication_4k() -> Self {
39        Self {
40            width: 3840,
41            height: 2160,
42        }
43    }
44
45    pub(super) fn validate(self, max_dimension: u32) -> Result<(), RenderError> {
46        if self.width == 0
47            || self.height == 0
48            || self.width > max_dimension
49            || self.height > max_dimension
50        {
51            return Err(RenderError::InvalidImageSize);
52        }
53        Ok(())
54    }
55}
56
57/// Mapped, tightly packed RGBA8 image owned by the caller.
58#[derive(Clone, PartialEq, Eq, Debug)]
59pub struct Image {
60    /// Width in pixels.
61    pub width: u32,
62    /// Height in pixels.
63    pub height: u32,
64    /// Row-major RGBA8 pixels with no row padding.
65    pub pixels: Vec<u8>,
66}
67
68impl Image {
69    /// Encodes the already-tonemapped image as a deterministic RGBA PNG.
70    ///
71    /// The engine performs scene-linear HDR lighting, exposure, display
72    /// grading and sRGB conversion before the readback. This method therefore
73    /// serializes publication pixels without applying a second colour curve.
74    /// No filesystem or application state is touched.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`RenderError::ImageEncoding`] if the pixel buffer is malformed
79    /// or the PNG encoder rejects the stream.
80    pub fn png_bytes(&self) -> Result<Vec<u8>, RenderError> {
81        let mut bytes = Vec::new();
82        self.write_png(&mut bytes)?;
83        Ok(bytes)
84    }
85
86    /// Streams deterministic RGBA PNG bytes into a caller-owned writer.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`RenderError::ImageEncoding`] for malformed pixels or an
91    /// encoder/writer failure.
92    pub fn write_png(&self, output: impl std::io::Write) -> Result<(), RenderError> {
93        self.validate_pixels()?;
94        let mut encoder = png::Encoder::new(output, self.width, self.height);
95        encoder.set_color(png::ColorType::Rgba);
96        encoder.set_depth(png::BitDepth::Eight);
97        let mut writer = encoder
98            .write_header()
99            .map_err(|error| RenderError::ImageEncoding {
100                summary: error.to_string(),
101            })?;
102        writer
103            .write_image_data(&self.pixels)
104            .map_err(|error| RenderError::ImageEncoding {
105                summary: error.to_string(),
106            })?;
107        writer.finish().map_err(|error| RenderError::ImageEncoding {
108            summary: error.to_string(),
109        })
110    }
111
112    fn validate_pixels(&self) -> Result<(), RenderError> {
113        let expected = usize::try_from(self.width)
114            .ok()
115            .and_then(|width| width.checked_mul(usize::try_from(self.height).ok()?))
116            .and_then(|pixels| pixels.checked_mul(4))
117            .ok_or(RenderError::InvalidImageSize)?;
118        if self.pixels.len() != expected {
119            return Err(RenderError::ImageEncoding {
120                summary: "RGBA8 pixel buffer length does not match image dimensions".to_owned(),
121            });
122        }
123        Ok(())
124    }
125}
126
127impl<D: Device> Engine<D> {
128    /// Asynchronously renders a deterministic off-screen image. Browser
129    /// callers use this path so mapped-buffer completion yields to the event
130    /// loop.
131    ///
132    /// # Errors
133    ///
134    /// Returns a typed device error, graph allocation failure, or
135    /// [`RenderError::InvalidImageSize`] for zero or overflowing dimensions.
136    pub async fn render_image_async(
137        &mut self,
138        scene: &Scene,
139        camera: &Camera,
140        config: ImageConfig,
141    ) -> Result<Image, RenderError> {
142        let pending =
143            self.render_image_to_buffer(scene, camera, config, ImagePurpose::Publication)?;
144        let mapped = self
145            .queue
146            .read_buffer_async(&self.device, &pending.buffer, 0, pending.layout.buffer_size)
147            .await?;
148        pending.resolve(mapped, self.target_format)
149    }
150
151    /// Renders one deterministic off-screen frame without opening a window.
152    ///
153    /// # Errors
154    ///
155    /// Returns a typed device error, graph allocation failure, or
156    /// [`RenderError::InvalidImageSize`] for zero or overflowing dimensions.
157    #[cfg(not(target_arch = "wasm32"))]
158    pub fn render_image(
159        &mut self,
160        scene: &Scene,
161        camera: &Camera,
162        config: ImageConfig,
163    ) -> Result<Image, RenderError> {
164        let pending =
165            self.render_image_to_buffer(scene, camera, config, ImagePurpose::Publication)?;
166        let mapped = self.queue.read_buffer_blocking(
167            &self.device,
168            &pending.buffer,
169            0,
170            pending.layout.buffer_size,
171        )?;
172        pending.resolve(mapped, self.target_format)
173    }
174
175    pub(super) fn render_image_to_buffer(
176        &mut self,
177        scene: &Scene,
178        camera: &Camera,
179        config: ImageConfig,
180        purpose: ImagePurpose,
181    ) -> Result<PendingImage<D>, RenderError> {
182        config.validate(self.device.capabilities().max_texture_dim)?;
183        let layout = ImageLayout::new(config, 4)?;
184        self.width = config.width;
185        self.height = config.height;
186        let preparation = self.prepare_image(scene, purpose)?;
187        let identity = scene.cache_identity();
188        let scene_reset = self.temporal_scene_identity.replace(identity) != Some(identity);
189        if purpose == ImagePurpose::Publication {
190            self.temporal.reset();
191        }
192        let optics = self.resolve_optics(scene, camera)?;
193        let texture = self.device.create_texture(&TextureDesc {
194            label: "off-screen image",
195            width: config.width,
196            height: config.height,
197            depth: 1,
198            dimension: molgfx_gpu::TextureDimension::D2,
199            format: self.target_format,
200            usage: TextureUsage::RENDER_ATTACHMENT.union(TextureUsage::COPY_SRC),
201        })?;
202        let view = self
203            .device
204            .create_texture_view(&texture, &TextureViewDesc::default());
205        let readback = self.device.create_buffer(&BufferDesc {
206            label: "off-screen readback",
207            size: layout.buffer_size,
208            usage: BufferUsage::COPY_DST.union(BufferUsage::MAP_READ),
209        })?;
210        let samples = match purpose {
211            // A sequence frame is one deterministic exposure per output frame.
212            #[cfg(not(target_arch = "wasm32"))]
213            ImagePurpose::SequenceFrame => 1,
214            ImagePurpose::Publication => self.tier().image_samples(),
215        };
216        let shadow = self.shadow_bound.fit(
217            scene,
218            camera,
219            self.resolved_plan.lighting(),
220            preparation.scene_changed || preparation.rebuild,
221        );
222        let mut completion = FenceValue::default();
223        for sample in 0..samples {
224            self.scene_gpu.begin_frame();
225            let cinematic = self.tier() >= QualityTier::Standard;
226            let uniforms = self.temporal.prepare(
227                camera,
228                &TemporalOptions {
229                    extent: [self.width, self.height],
230                    reset: (purpose == ImagePurpose::Publication
231                        || scene_reset
232                        || preparation.rebuild)
233                        && sample == 0,
234                    quality: cinematic,
235                    publication: purpose == ImagePurpose::Publication,
236                    illustration: self.resolved_plan.illustration(),
237                    depth_cue: self.resolved_plan.packed_depth_cue(),
238                    optics,
239                    motion_blur: self
240                        .resolved_plan
241                        .motion_blur()
242                        .map_or([0.0; 4], MotionBlur::packed),
243                    atmosphere: self
244                        .resolved_plan
245                        .packed_presentation(self.scene_gpu.has_translucency()),
246                    lighting: self.resolved_plan.packed_lighting(),
247                    shadow_view: shadow.view,
248                    shadow_projection: shadow.projection,
249                    shadow_view_proj: shadow.view_projection,
250                },
251            );
252            self.scene_gpu
253                .write_frame_uniforms(&self.queue, &uniforms)?;
254            let mut encoder = self.device.create_command_encoder();
255            self.record_image_scene_updates(&mut encoder, cinematic);
256            self.record_image(&mut encoder, &view, None, cinematic, false);
257            if sample + 1 == samples {
258                encoder.copy_texture_to_buffer(
259                    &texture,
260                    (0, 0),
261                    (config.width, config.height),
262                    layout.padded_row,
263                    0,
264                    &readback,
265                );
266            }
267            completion = self.submit_image_sample(encoder, sample + 1 == samples);
268        }
269        if purpose == ImagePurpose::Publication {
270            self.temporal_scene_identity = None;
271        }
272        Ok(PendingImage {
273            config,
274            layout,
275            buffer: readback,
276            _texture: texture,
277            completion,
278        })
279    }
280
281    fn submit_image_sample(&self, encoder: D::CommandEncoder, final_sample: bool) -> FenceValue {
282        if final_sample {
283            self.queue.submit_tracked(encoder)
284        } else {
285            self.queue.submit(encoder);
286            FenceValue::default()
287        }
288    }
289
290    fn record_image_scene_updates(&mut self, encoder: &mut D::CommandEncoder, quality: bool) {
291        self.passes
292            .cull
293            .record_attribute_timelines(&self.scene_gpu, encoder);
294        self.passes
295            .cull
296            .record_instance_timelines(&self.scene_gpu, encoder);
297        let point_coordinates_changed = self
298            .passes
299            .cull
300            .record_point_timelines(&self.scene_gpu, encoder);
301        self.scene_gpu
302            .record_particle_motion(encoder, &self.passes.particle_motion);
303        let structure_coordinates_changed =
304            self.scene_gpu
305                .record_trajectories(encoder, &self.passes.trajectory, None);
306        let paged_coordinates_changed = self
307            .passes
308            .cull
309            .record_paged_trajectories(&self.scene_gpu, encoder);
310        self.scene_gpu.record_dynamic_relations(
311            encoder,
312            &self.passes.relation_resolve,
313            structure_coordinates_changed || paged_coordinates_changed || point_coordinates_changed,
314        );
315        self.scene_gpu
316            .record_occupancies(encoder, self.passes.occupancy.as_ref());
317        self.scene_gpu.record_surface_fields(
318            encoder,
319            &self.passes.surface_field,
320            &self.passes.surface_components,
321        );
322        self.scene_gpu.record_quality_hardware(encoder, quality);
323    }
324
325    /// Prepares the off-screen frame: scene sync, tier publication and pool
326    /// rebuild.
327    ///
328    /// The adaptive loop is pinned only for publication. A sequence frame is a
329    /// deterministic exposure of a caller-driven timeline, but the engine that
330    /// renders it is still an interactive one, so its tiers keep adapting.
331    pub(super) fn prepare_image(
332        &mut self,
333        scene: &Scene,
334        purpose: ImagePurpose,
335    ) -> Result<ImagePreparation, RenderError> {
336        self.ensure_occupancy(scene)?;
337        self.device.check_errors()?;
338        if purpose == ImagePurpose::Publication {
339            self.adaptive.set_publication(true);
340        }
341        self.adaptive.set_atom_count(scene.atom_count());
342        self.sync_quality_tier();
343        self.chunk_residency.begin_epoch();
344        let scene_changed = self.scene_gpu.sync(crate::scene_gpu::SceneSync {
345            device: &self.device,
346            queue: &self.queue,
347            scene,
348            quality: self.tier() >= QualityTier::Standard,
349            detail: self.tier().detail(),
350            extent: [self.width, self.height],
351            ray_query_layout: self.passes.ambient_occlusion.ray_query_layout(),
352            derived_cache: &mut self.derived_cache,
353            derived_frame: self.derived_frame,
354        })?;
355        self.chunk_residency.sync_scene(
356            &mut self.scene_gpu,
357            &self.device,
358            &self.queue,
359            &mut self.derived_cache,
360            self.derived_frame,
361        )?;
362        self.derived_frame = self.derived_frame.wrapping_add(1);
363        let rebuild = self.rebuild_pool_if_needed()?;
364        self.scene_gpu
365            .settle_specializations(&self.device, scene, &self.passes);
366        self.device.check_errors()?;
367        Ok(ImagePreparation {
368            scene_changed,
369            rebuild,
370        })
371    }
372
373    pub(super) fn record_image(
374        &self,
375        encoder: &mut D::CommandEncoder,
376        target: &D::TextureView,
377        queries: Option<&D::QuerySet>,
378        quality: bool,
379        timestamps_started: bool,
380    ) {
381        self.record_image_until(encoder, target, queries, quality, timestamps_started, None);
382    }
383
384    pub(super) fn record_image_until(
385        &self,
386        encoder: &mut D::CommandEncoder,
387        target: &D::TextureView,
388        queries: Option<&D::QuerySet>,
389        quality: bool,
390        timestamps_started: bool,
391        stop_after: Option<crate::graph::ResourceId>,
392    ) {
393        let Some(pool) = &self.pool else {
394            return;
395        };
396        let table = ResourceTable {
397            pool,
398            swapchain: target,
399        };
400        for (position, &index) in self.order.iter().enumerate() {
401            let Some(node) = self.pass_nodes.get(index) else {
402                continue;
403            };
404            let boundary = position == 0 || position + 1 == self.order.len();
405            (node.record)(&mut PassContext {
406                encoder,
407                resources: &table,
408                passes: &self.passes,
409                bindings: self.bindings.as_ref(),
410                scene: &self.scene_gpu,
411                timestamps: queries
412                    .filter(|_| {
413                        boundary && (!timestamps_started || position + 1 == self.order.len())
414                    })
415                    .map(|queries| molgfx_gpu::TimestampWrites {
416                        queries,
417                        beginning: (position == 0 && !timestamps_started).then_some(0),
418                        end: (position + 1 == self.order.len()).then_some(1),
419                    }),
420                temporal_write: self.temporal.write_index(),
421                quality,
422                edge_smoothing: self.edge_smoothing(),
423                display_encoding: self.display_encoding(),
424            });
425            if stop_after.is_some_and(|resource| node.writes.contains(&resource)) {
426                break;
427            }
428        }
429    }
430}
431
432pub(super) struct PendingImage<D: Device> {
433    config: ImageConfig,
434    layout: ImageLayout,
435    buffer: D::Buffer,
436    _texture: D::Texture,
437    completion: FenceValue,
438}
439
440impl<D: Device> PendingImage<D> {
441    #[cfg(not(target_arch = "wasm32"))]
442    pub(super) const fn completion(&self) -> FenceValue {
443        self.completion
444    }
445
446    #[cfg(not(target_arch = "wasm32"))]
447    pub(super) fn readback(&self) -> (&D::Buffer, u64) {
448        (&self.buffer, self.layout.buffer_size)
449    }
450
451    pub(super) fn resolve(
452        self,
453        mapped: Vec<u8>,
454        format: TextureFormat,
455    ) -> Result<Image, RenderError> {
456        let _completion = self.completion;
457        let mut pixels = self.layout.unpack(mapped)?;
458        if matches!(
459            format,
460            TextureFormat::Bgra8Unorm | TextureFormat::Bgra8UnormSrgb
461        ) {
462            for pixel in pixels.as_chunks_mut::<4>().0 {
463                pixel.swap(0, 2);
464            }
465        }
466        Ok(Image {
467            width: self.config.width,
468            height: self.config.height,
469            pixels,
470        })
471    }
472}
473
474#[cfg(all(test, not(target_arch = "wasm32")))]
475#[path = "image_tests.rs"]
476mod tests;