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                    optics,
238                    motion_blur: self
239                        .resolved_plan
240                        .motion_blur()
241                        .map_or([0.0; 4], MotionBlur::packed),
242                    atmosphere: self
243                        .resolved_plan
244                        .packed_presentation(self.scene_gpu.has_translucency()),
245                    lighting: self.resolved_plan.packed_lighting(),
246                    shadow_view: shadow.view,
247                    shadow_projection: shadow.projection,
248                    shadow_view_proj: shadow.view_projection,
249                },
250            );
251            self.scene_gpu
252                .write_frame_uniforms(&self.queue, &uniforms)?;
253            let mut encoder = self.device.create_command_encoder();
254            self.record_image_scene_updates(&mut encoder, cinematic);
255            self.record_image(&mut encoder, &view, None, cinematic, false);
256            if sample + 1 == samples {
257                encoder.copy_texture_to_buffer(
258                    &texture,
259                    (0, 0),
260                    (config.width, config.height),
261                    layout.padded_row,
262                    0,
263                    &readback,
264                );
265            }
266            completion = self.submit_image_sample(encoder, sample + 1 == samples);
267        }
268        if purpose == ImagePurpose::Publication {
269            self.temporal_scene_identity = None;
270        }
271        Ok(PendingImage {
272            config,
273            layout,
274            buffer: readback,
275            _texture: texture,
276            completion,
277        })
278    }
279
280    fn submit_image_sample(&self, encoder: D::CommandEncoder, final_sample: bool) -> FenceValue {
281        if final_sample {
282            self.queue.submit_tracked(encoder)
283        } else {
284            self.queue.submit(encoder);
285            FenceValue::default()
286        }
287    }
288
289    fn record_image_scene_updates(&mut self, encoder: &mut D::CommandEncoder, quality: bool) {
290        self.passes
291            .cull
292            .record_attribute_timelines(&self.scene_gpu, encoder);
293        self.passes
294            .cull
295            .record_instance_timelines(&self.scene_gpu, encoder);
296        let point_coordinates_changed = self
297            .passes
298            .cull
299            .record_point_timelines(&self.scene_gpu, encoder);
300        self.scene_gpu
301            .record_particle_motion(encoder, &self.passes.particle_motion);
302        let structure_coordinates_changed =
303            self.scene_gpu
304                .record_trajectories(encoder, &self.passes.trajectory, None);
305        let paged_coordinates_changed = self
306            .passes
307            .cull
308            .record_paged_trajectories(&self.scene_gpu, encoder);
309        self.scene_gpu.record_dynamic_relations(
310            encoder,
311            &self.passes.relation_resolve,
312            structure_coordinates_changed || paged_coordinates_changed || point_coordinates_changed,
313        );
314        self.scene_gpu
315            .record_occupancies(encoder, self.passes.occupancy.as_ref());
316        self.scene_gpu.record_surface_fields(
317            encoder,
318            &self.passes.surface_field,
319            &self.passes.surface_components,
320        );
321        self.scene_gpu.record_quality_hardware(encoder, quality);
322    }
323
324    /// Prepares the off-screen frame: scene sync, tier publication and pool
325    /// rebuild.
326    ///
327    /// The adaptive loop is pinned only for publication. A sequence frame is a
328    /// deterministic exposure of a caller-driven timeline, but the engine that
329    /// renders it is still an interactive one, so its tiers keep adapting.
330    pub(super) fn prepare_image(
331        &mut self,
332        scene: &Scene,
333        purpose: ImagePurpose,
334    ) -> Result<ImagePreparation, RenderError> {
335        self.ensure_occupancy(scene)?;
336        self.device.check_errors()?;
337        if purpose == ImagePurpose::Publication {
338            self.adaptive.set_publication(true);
339        }
340        self.sync_quality_tier();
341        self.chunk_residency.begin_epoch();
342        let scene_changed = self.scene_gpu.sync(crate::scene_gpu::SceneSync {
343            device: &self.device,
344            queue: &self.queue,
345            scene,
346            quality: self.tier() >= QualityTier::Standard,
347            extent: [self.width, self.height],
348            ray_query_layout: self.passes.ambient_occlusion.ray_query_layout(),
349            derived_cache: &mut self.derived_cache,
350            derived_frame: self.derived_frame,
351        })?;
352        self.chunk_residency.sync_scene(
353            &mut self.scene_gpu,
354            &self.device,
355            &self.queue,
356            &mut self.derived_cache,
357            self.derived_frame,
358        )?;
359        self.derived_frame = self.derived_frame.wrapping_add(1);
360        let rebuild = self.rebuild_pool_if_needed()?;
361        self.scene_gpu
362            .settle_specializations(&self.device, scene, &self.passes);
363        self.device.check_errors()?;
364        Ok(ImagePreparation {
365            scene_changed,
366            rebuild,
367        })
368    }
369
370    pub(super) fn record_image(
371        &self,
372        encoder: &mut D::CommandEncoder,
373        target: &D::TextureView,
374        queries: Option<&D::QuerySet>,
375        quality: bool,
376        timestamps_started: bool,
377    ) {
378        self.record_image_until(encoder, target, queries, quality, timestamps_started, None);
379    }
380
381    pub(super) fn record_image_until(
382        &self,
383        encoder: &mut D::CommandEncoder,
384        target: &D::TextureView,
385        queries: Option<&D::QuerySet>,
386        quality: bool,
387        timestamps_started: bool,
388        stop_after: Option<crate::graph::ResourceId>,
389    ) {
390        let Some(pool) = &self.pool else {
391            return;
392        };
393        let table = ResourceTable {
394            pool,
395            swapchain: target,
396        };
397        for (position, &index) in self.order.iter().enumerate() {
398            let Some(node) = self.pass_nodes.get(index) else {
399                continue;
400            };
401            let boundary = position == 0 || position + 1 == self.order.len();
402            (node.record)(&mut PassContext {
403                encoder,
404                resources: &table,
405                passes: &self.passes,
406                bindings: self.bindings.as_ref(),
407                scene: &self.scene_gpu,
408                timestamps: queries
409                    .filter(|_| {
410                        boundary && (!timestamps_started || position + 1 == self.order.len())
411                    })
412                    .map(|queries| molgfx_gpu::TimestampWrites {
413                        queries,
414                        beginning: (position == 0 && !timestamps_started).then_some(0),
415                        end: (position + 1 == self.order.len()).then_some(1),
416                    }),
417                temporal_write: self.temporal.write_index(),
418                quality,
419                display_encoding: self.display_encoding(),
420            });
421            if stop_after.is_some_and(|resource| node.writes.contains(&resource)) {
422                break;
423            }
424        }
425    }
426}
427
428pub(super) struct PendingImage<D: Device> {
429    config: ImageConfig,
430    layout: ImageLayout,
431    buffer: D::Buffer,
432    _texture: D::Texture,
433    completion: FenceValue,
434}
435
436impl<D: Device> PendingImage<D> {
437    #[cfg(not(target_arch = "wasm32"))]
438    pub(super) const fn completion(&self) -> FenceValue {
439        self.completion
440    }
441
442    #[cfg(not(target_arch = "wasm32"))]
443    pub(super) fn readback(&self) -> (&D::Buffer, u64) {
444        (&self.buffer, self.layout.buffer_size)
445    }
446
447    pub(super) fn resolve(
448        self,
449        mapped: Vec<u8>,
450        format: TextureFormat,
451    ) -> Result<Image, RenderError> {
452        let _completion = self.completion;
453        let mut pixels = self.layout.unpack(mapped)?;
454        if matches!(
455            format,
456            TextureFormat::Bgra8Unorm | TextureFormat::Bgra8UnormSrgb
457        ) {
458            for pixel in pixels.as_chunks_mut::<4>().0 {
459                pixel.swap(0, 2);
460            }
461        }
462        Ok(Image {
463            width: self.config.width,
464            height: self.config.height,
465            pixels,
466        })
467    }
468}
469
470#[cfg(all(test, not(target_arch = "wasm32")))]
471#[path = "image_tests.rs"]
472mod tests;