Skip to main content

molgfx_render/engine/
frame.rs

1//! The frame loop: sync, build, record, one submission, present.
2//!
3//! CPU cost per frame is proportional to the active passes, the placed
4//! structures, the resident semantic tables and the representation states —
5//! never to atom or bond count, because per-primitive work lives on the GPU.
6//! A structure or representation whose revisions are unchanged costs one
7//! comparison and no upload, so a still scene walks a short fixed list rather
8//! than touching its contents. Nothing here allocates on the steady-state path
9//! beyond the first frame's pool construction.
10
11use super::{
12    Engine, FrameCompleteness, FrameDegradation, FrameMetrics, FrameReport, FrameStatus,
13    MotionBlur, QualityTier, RenderMode, TemporalOptions,
14};
15use crate::error::RenderError;
16use crate::graph::{PassContext, ResourceTable};
17use molgfx_core::Scene;
18use molgfx_gpu::Queue as _;
19use molgfx_gpu::{Device, SurfaceFrame as _};
20use molgfx_math::Camera;
21
22/// Returns the number of frame submissions currently pending completion.
23#[inline]
24fn pending_frame_submissions<D: Device>(engine: &Engine<D>) -> u32 {
25    u32::from(engine.frame_submission_pending)
26}
27
28/// Converts a duration to nanoseconds, saturating instead of panicking on
29/// overflow.
30#[inline]
31fn duration_ns(duration: std::time::Duration) -> u64 {
32    duration
33        .as_secs()
34        .saturating_mul(1_000_000_000)
35        .saturating_add(u64::from(duration.subsec_nanos()))
36}
37
38impl<D: Device> Engine<D> {
39    /// Uploads everything the scene changed since the previous frame.
40    ///
41    /// Returns whether scene data changed and invalidates temporal convergence
42    /// when scene data changed or uploads remain in flight.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error when occupancy preparation, scene synchronization, or
47    /// chunk residency synchronization fails.
48    fn sync_scene(&mut self, scene: &Scene) -> Result<bool, RenderError> {
49        self.ensure_occupancy(scene)?;
50        self.chunk_residency.poll(&self.device, &self.queue)?;
51
52        let scene_changed = self.scene_gpu.sync(crate::scene_gpu::SceneSync {
53            device: &self.device,
54            queue: &self.queue,
55            scene,
56            quality: self.tier() >= QualityTier::Standard,
57            detail: self.tier().detail(),
58            extent: [self.width, self.height],
59            ray_query_layout: self.passes.ambient_occlusion.ray_query_layout(),
60            derived_cache: &mut self.derived_cache,
61            derived_frame: self.derived_frame,
62        })?;
63
64        self.chunk_residency.sync_scene(
65            &mut self.scene_gpu,
66            &self.device,
67            &self.queue,
68            &mut self.derived_cache,
69            self.derived_frame,
70        )?;
71        self.chunk_residency.flush(&self.device, &self.queue)?;
72
73        self.derived_frame = self.derived_frame.wrapping_add(1);
74
75        if scene_changed {
76            self.temporal.invalidate_convergence();
77        }
78
79        Ok(scene_changed)
80    }
81
82    /// Updates the cached temporal scene identity.
83    ///
84    /// Returns `true` when the identity changed. An unchanged scene avoids an
85    /// unnecessary write to the cached identity on the steady-state path.
86    #[inline]
87    fn update_temporal_scene_identity(&mut self, scene: &Scene) -> bool {
88        let identity = scene.cache_identity();
89        let changed = self.temporal_scene_identity != Some(identity);
90
91        if changed {
92            self.temporal_scene_identity = Some(identity);
93        }
94
95        changed
96    }
97
98    /// Returns whether temporal accumulation must restart for the current frame.
99    #[inline]
100    fn temporal_reset_required(
101        &self,
102        scene_reset: bool,
103        pool_rebuilt: bool,
104        camera_changed: bool,
105    ) -> bool {
106        scene_reset || pool_rebuilt || (self.mode == RenderMode::Cinematic && camera_changed)
107    }
108
109    /// Renders one frame of the scene to the presentation surface.
110    ///
111    /// A lost or outdated surface is reconfigured and returns
112    /// `FrameStatus::Skipped`; the next frame recovers. Caller-reachable
113    /// conditions do not panic.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error for unrecoverable GPU errors, scene synchronization
118    /// failures, or render-graph resource reconstruction failures.
119    pub fn render(&mut self, scene: &Scene, camera: &Camera) -> Result<FrameReport, RenderError> {
120        // On native targets the adaptive controller observes this call's CPU
121        // duration — synchronization, recording and submission included. The
122        // submission itself is asynchronous there, so measuring host time is
123        // the honest per-frame cost; the fence poll at the top of the next
124        // call deliberately does not sample a second time. On browser targets
125        // submission returns immediately, so the controller instead samples
126        // submission-to-fence-completion elapsed time in
127        // `poll_pending_submission`; measuring this call there would classify
128        // queued GPU work as free.
129        #[cfg(not(target_arch = "wasm32"))]
130        let render_started_at = self.clock_origin.elapsed();
131
132        if self.poll_pending_submission()? {
133            // Fence-only skip: the previous submission is still pending.
134            // This is not surface/pool work that a retry produces; whether
135            // another frame is needed is decided by the report's upload and
136            // temporal conditions below.
137            #[cfg(not(test))]
138            return Ok(self.frame_report(FrameStatus::Skipped, true));
139        }
140
141        self.prepare_frame(scene, camera)?;
142
143        let Some(frame) = self.acquire_surface_frame()? else {
144            return Ok(self.frame_report(FrameStatus::Skipped, false));
145        };
146
147        let mut encoder = self.device.create_command_encoder();
148
149        if !self.record_frame(&mut encoder, scene, frame.view()) {
150            return Ok(self.frame_report(FrameStatus::Skipped, false));
151        }
152
153        self.submit_frame(encoder);
154        self.device.check_errors()?;
155        frame.present();
156
157        // CPU encoding/submission cost for the native adaptive loop. This is
158        // not device execution time; GPU time needs timestamp queries.
159        #[cfg(not(target_arch = "wasm32"))]
160        self.adaptive.observe(duration_ns(
161            self.clock_origin
162                .elapsed()
163                .saturating_sub(render_started_at),
164        ));
165
166        Ok(self.frame_report(FrameStatus::Presented, false))
167    }
168
169    /// Synchronizes CPU/GPU state and prepares per-frame temporal uniforms.
170    ///
171    /// Scene specializations are settled once here before command recording,
172    /// avoiding redundant specialization work during the presented-frame path.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error for GPU validation or device failures, scene
177    /// synchronization failures, pool reconstruction failures, optics
178    /// resolution failures, or frame-uniform upload failures.
179    fn prepare_frame(&mut self, scene: &Scene, camera: &Camera) -> Result<(), RenderError> {
180        self.adaptive.set_atom_count(scene.atom_count());
181        self.sync_quality_tier();
182        self.device.check_errors()?;
183
184        self.chunk_residency.begin_epoch();
185        self.scene_gpu.begin_frame();
186
187        let scene_changed = self.sync_scene(scene)?;
188        let pool_rebuilt = self.rebuild_pool_if_needed()?;
189
190        self.scene_gpu
191            .settle_specializations(&self.device, scene, &self.passes);
192
193        let camera_changed = self.temporal.camera_changed(camera);
194        let scene_reset = self.update_temporal_scene_identity(scene);
195        let cinematic = self.tier() >= QualityTier::Standard;
196
197        let optics = self.resolve_optics(scene, camera)?;
198        let shadow =
199            self.shadow_bound
200                .fit(scene, camera, self.resolved_plan.lighting(), scene_changed);
201
202        let uniforms = self.temporal.prepare(
203            camera,
204            &TemporalOptions {
205                extent: [self.width, self.height],
206                reset: self.temporal_reset_required(scene_reset, pool_rebuilt, camera_changed),
207                quality: cinematic,
208                publication: false,
209                illustration: self.resolved_plan.illustration(),
210                depth_cue: self.resolved_plan.packed_depth_cue(),
211                optics,
212                motion_blur: self
213                    .resolved_plan
214                    .motion_blur()
215                    .map_or([0.0; 4], MotionBlur::packed),
216                atmosphere: self
217                    .resolved_plan
218                    .packed_presentation(self.scene_gpu.has_translucency()),
219                lighting: self.resolved_plan.packed_lighting(),
220                shadow_view: shadow.view,
221                shadow_projection: shadow.projection,
222                shadow_view_proj: shadow.view_projection,
223            },
224        );
225
226        self.scene_gpu.write_frame_uniforms(&self.queue, &uniforms)
227    }
228
229    /// Records compute work and render-graph passes into the frame encoder.
230    ///
231    /// Returns `false` when no transient pool is available.
232    ///
233    /// `scene` remains part of the existing signature for compatibility.
234    /// Specialization settlement is performed once by `prepare_frame` before
235    /// this method is reached.
236    fn record_frame(
237        &mut self,
238        encoder: &mut D::CommandEncoder,
239        scene: &Scene,
240        swapchain: &D::TextureView,
241    ) -> bool {
242        // Preserve the existing signature without repeating specialization work.
243        let _ = scene;
244
245        let cinematic = self.tier() >= QualityTier::Standard;
246
247        self.record_scene_compute(encoder, cinematic);
248
249        let Some(pool) = &self.pool else {
250            return false;
251        };
252
253        let table = ResourceTable { pool, swapchain };
254
255        for &index in &self.order {
256            let Some(node) = self.pass_nodes.get(index) else {
257                continue;
258            };
259
260            let mut ctx = PassContext {
261                encoder: &mut *encoder,
262                resources: &table,
263                passes: &self.passes,
264                bindings: self.bindings.as_ref(),
265                scene: &self.scene_gpu,
266                timestamps: None,
267                temporal_write: self.temporal.write_index(),
268                quality: cinematic,
269                edge_smoothing: self.edge_smoothing(),
270                display_encoding: self.display_encoding(),
271            };
272
273            (node.record)(&mut ctx);
274        }
275
276        true
277    }
278
279    /// Submits the recorded frame once and updates submission bookkeeping.
280    ///
281    /// The submission timestamp is host time when the encoder reached the
282    /// queue; the completion timestamp is cleared and stays unset until a
283    /// later poll observes the backend fence. That fence fires when submitted
284    /// GPU work is done *executing* on the device, strictly later than host
285    /// queue-completion, and is never GPU execution time itself.
286    fn submit_frame(&mut self, encoder: D::CommandEncoder) {
287        self.last_submission_id = self.last_submission_id.wrapping_add(1);
288        self.last_submission_timestamp_ns = duration_ns(self.clock_origin.elapsed());
289        self.last_completion_timestamp_ns = None;
290
291        self.last_frame_submission = self.queue.submit_tracked(encoder);
292        self.frame_submission_pending = true;
293
294        // Only the browser adaptive loop samples submission-to-completion
295        // elapsed time, so only it needs the submission moment recorded.
296        #[cfg(target_arch = "wasm32")]
297        {
298            self.frame_submitted_at = Some(self.clock_origin.elapsed());
299        }
300    }
301
302    /// Polls the submission fence and updates completion timing.
303    ///
304    /// Returns whether the most recent frame submission remains pending.
305    ///
306    /// A pending-to-complete transition records the host completion
307    /// timestamp. That timestamp is host-observation latency, not exact device
308    /// completion time: the backend fence callback may have fired earlier, and
309    /// the poll that notices it runs on the host clock. GPU execution time is
310    /// not reported here at all; it requires timestamp queries and belongs to
311    /// the profiling path.
312    ///
313    /// On browser targets a completed submission also feeds one
314    /// submission-to-completion elapsed time to the adaptive controller. The
315    /// sample includes host scheduling latency between submit and the fence
316    /// callback, so it is a conservative queue-depth signal, not device time.
317    /// On native targets this poll is a no-op for the controller: `render`
318    /// already measured the frame's full CPU duration before returning, and a
319    /// second observation here would double-count the frame.
320    ///
321    /// # Errors
322    ///
323    /// Returns an error when the device cannot report the completed fence.
324    fn poll_pending_submission(&mut self) -> Result<bool, RenderError> {
325        let was_pending = self.frame_submission_pending;
326        let completed = self.queue.completed_fence(&self.device)?;
327
328        self.frame_submission_pending = completed < self.last_frame_submission;
329
330        if was_pending && !self.frame_submission_pending {
331            self.last_completion_timestamp_ns = Some(duration_ns(self.clock_origin.elapsed()));
332
333            // Only the browser path has an empty controller sample here. On
334            // native, `render` observed the complete CPU duration, so a fence
335            // observation would double-count this frame's budget.
336            #[cfg(target_arch = "wasm32")]
337            if let Some(submitted_at) = self.frame_submitted_at.take() {
338                self.adaptive.observe(duration_ns(
339                    self.clock_origin.elapsed().saturating_sub(submitted_at),
340                ));
341            }
342        }
343
344        Ok(self.frame_submission_pending)
345    }
346
347    /// Records scene-level compute passes required before render-graph passes.
348    ///
349    /// Coordinate-change signals are combined without allocating and drive
350    /// dynamic relation resolution exactly once.
351    fn record_scene_compute(&mut self, encoder: &mut D::CommandEncoder, cinematic: bool) {
352        self.passes
353            .cull
354            .record_attribute_timelines(&self.scene_gpu, encoder);
355
356        self.passes
357            .cull
358            .record_instance_timelines(&self.scene_gpu, encoder);
359
360        let point_coordinates_changed = self
361            .passes
362            .cull
363            .record_point_timelines(&self.scene_gpu, encoder);
364
365        self.scene_gpu
366            .record_particle_motion(encoder, &self.passes.particle_motion);
367
368        let structure_coordinates_changed =
369            self.scene_gpu
370                .record_trajectories(encoder, &self.passes.trajectory, None);
371
372        let paged_coordinates_changed = self
373            .passes
374            .cull
375            .record_paged_trajectories(&self.scene_gpu, encoder);
376
377        let coordinates_changed =
378            structure_coordinates_changed || paged_coordinates_changed || point_coordinates_changed;
379
380        self.scene_gpu.record_dynamic_relations(
381            encoder,
382            &self.passes.relation_resolve,
383            coordinates_changed,
384        );
385
386        self.scene_gpu
387            .record_occupancies(encoder, self.passes.occupancy.as_ref());
388
389        self.scene_gpu.record_surface_fields(
390            encoder,
391            &self.passes.surface_field,
392            &self.passes.surface_components,
393        );
394
395        self.scene_gpu.record_quality_hardware(encoder, cinematic);
396    }
397
398    /// Builds the externally visible report for the current frame state.
399    ///
400    /// The report samples existing counters and resource metrics without
401    /// introducing heap allocation in this layer.
402    ///
403    /// `fence_pending` marks the skip as a poll of a still-pending previous
404    /// submission. Such a skip is not surface or pool work that the next
405    /// frame recovers, so it does not by itself request another frame:
406    /// pending uploads and temporal convergence remain authoritative. A
407    /// surface/pool skip (`fence_pending == false`) keeps the existing
408    /// retry-next-frame contract.
409    fn frame_report(&self, status: FrameStatus, fence_pending: bool) -> FrameReport {
410        let residency = self.chunk_residency.metrics();
411        let derived = self.derived_cache.usage();
412        let physical = self.device.resource_memory();
413        let pending = residency.uploads.active_tickets;
414
415        FrameReport {
416            status,
417            completeness: if pending == 0 {
418                FrameCompleteness::Complete
419            } else {
420                FrameCompleteness::Progressive {
421                    pending_chunks: pending,
422                }
423            },
424            degradation: FrameDegradation::streaming_proxy(
425                self.mode == RenderMode::Realtime && pending != 0,
426            ),
427            metrics: FrameMetrics {
428                tracked_chunks: residency.tracked_chunks,
429                upload_in_flight_bytes: residency.uploads.in_flight_bytes,
430                pending_frame_submissions: pending_frame_submissions(self),
431                last_submission_id: self.last_submission_id,
432                submission_timestamp_ns: self.last_submission_timestamp_ns,
433                completion_timestamp_ns: self.last_completion_timestamp_ns,
434                derived_cache_gpu_bytes: derived.gpu_bytes,
435                derived_cache_peak_gpu_bytes: derived.peak_gpu_bytes,
436                physical_buffer_bytes: physical.buffer_bytes,
437                physical_texture_bytes: physical.texture_bytes,
438                physical_total_bytes: physical.total_bytes(),
439                physical_peak_bytes: physical.peak_bytes,
440            },
441            needs_another_frame: (status == FrameStatus::Skipped && !fence_pending) || pending != 0,
442            quality_tier: self.tier(),
443        }
444    }
445}