Skip to main content

alpha2_render/
renderer.rs

1use crate::{BlendSpace, Color, RenderError, RenderSize, RenderedTexture};
2use alpha2_gpu::GpuContext;
3use alpha2_gpu::IntermediateFormat;
4use alpha2_gpu::SubmitTimings;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8/// Renders graphs of sources, effects, transitions and composites on one GPU
9/// device, and owns the pipelines, pools and caches they use.
10#[derive(Debug)]
11pub struct Renderer {
12    pub(crate) gpu: GpuContext,
13    pub(crate) blend_space: BlendSpace,
14    pub(crate) effects: crate::registry::EffectRegistry,
15    pub(crate) effect_resources: crate::effect_resource::EffectResourceRegistry,
16    pub(crate) textures: crate::Shared<crate::pool::TexturePool>,
17    pub(crate) upload_textures: crate::Shared<crate::pool::UploadTexturePool>,
18    pub(crate) readback_buffers: crate::Shared<crate::pool::ReadbackBufferPool>,
19    pub(crate) uniform_buffers: crate::Shared<crate::uniform::UniformBufferPool>,
20    /// Destroys what the pools let go of, once no encode uses it.
21    pub(crate) retirement: crate::Shared<crate::retire::Retirement>,
22    pub(crate) pipelines: crate::pipeline::PipelineCache,
23    pub(crate) diagnostics: crate::diagnostics::SharedDiagnostics,
24    pub(crate) quality: Mutex<QualityState>,
25    pub(crate) result_cache: Mutex<crate::result_cache::ResultCache>,
26    /// Vector coverage kept between frames, and the rasterizer's buffers.
27    pub(crate) vector_state: Mutex<crate::vector::VectorState>,
28    pub(crate) timestamp_probe: std::sync::OnceLock<Option<crate::runtime_effect::TimestampProbe>>,
29    pub(crate) pass_timing: std::sync::atomic::AtomicBool,
30    /// Test-only: composite every layer as a full-frame pass, culling
31    /// nothing, as the reference the bounded passes must match.
32    #[cfg(test)]
33    pub(crate) full_frame_layers: std::sync::atomic::AtomicBool,
34    /// Test-only: the most layers a composite pass takes when nonzero, so
35    /// one layer per pass is the reference batched passes must match. A
36    /// nonzero limit composites as a GPU does on any device, small layers
37    /// with the loop pipeline.
38    #[cfg(test)]
39    pub(crate) composite_batch_limit: std::sync::atomic::AtomicUsize,
40    /// Test-only: the tile rows of a vector atlas page when nonzero, so a
41    /// small scene fills several pages.
42    #[cfg(test)]
43    pub(crate) vector_page_rows: std::sync::atomic::AtomicU32,
44    /// Test-only: the pipeline each composite pass drew with, and how many
45    /// composite pipelines were created.
46    #[cfg(test)]
47    pub(crate) composite_pipelines: Mutex<crate::composite::CompositePipelineLog>,
48    pub(crate) retired_leases: Arc<crate::yuv::RetiredLeases>,
49    pub(crate) gpu_wait_timeout: Duration,
50    /// The device's limits, read once: they never change for a device, and
51    /// on the browser backend every read crosses into JavaScript per field.
52    pub(crate) limits: wgpu::Limits,
53    /// The device's enabled features, read once for the same reason.
54    pub(crate) features: wgpu::Features,
55    /// The texture bound to a generator's unused image inputs.
56    pub(crate) empty_input: wgpu::TextureView,
57    /// What each YUV readback's staging buffer must end in, and whether one
58    /// did not.
59    pub(crate) canaries: crate::canary::Canaries,
60    /// The canaries of submitted NV12 packs and repacks not checked yet.
61    pub(crate) submission_canaries: crate::canary::SubmissionCanaries,
62    /// GPU errors the embedding saw outside the renderer's own scopes.
63    pub(crate) gpu_errors: crate::GpuErrorReports,
64    /// Held by each renderer encoder until it is submitted.
65    pub(crate) frame_lock: crate::Shared<FrameLock>,
66}
67
68/// A command encoder of this renderer's, open until its commands are
69/// submitted. Pooled resources it uses that are let go of meanwhile are
70/// destroyed only after that (see [`crate::retire`]), so every encoder the
71/// renderer records into is one of these.
72///
73/// It also holds the validation and out-of-memory scopes that its
74/// submission closes (see [`crate::gpu_errors`]), so it stays on the thread
75/// that created it.
76///
77/// And it holds the renderer's frame lock (see [`FrameLock`]) from creation
78/// until that submission.
79#[derive(Debug)]
80pub(crate) struct RenderEncoder {
81    encoder: wgpu::CommandEncoder,
82    errors: crate::gpu_errors::FrameErrorScope,
83    scope: crate::retire::EncodeScope,
84    frame: FrameGuard,
85}
86
87/// Lets one thread at a time record and submit renderer work.
88///
89/// A graph hands each intermediate back to the texture pool as soon as its
90/// last reader is encoded, so a later pass of the same frame can reuse it.
91/// The passes that wrote it are still in the unsubmitted encoder. A renderer
92/// is `Sync` natively, and another thread that took that texture, drew into
93/// it and submitted first would have its kept picture overwritten when the
94/// first frame's submission ran. Holding this lock from an encoder's
95/// creation until its submission keeps every other thread's submission
96/// after it, so the reuse stays within the frame that released the texture.
97///
98/// It is re-entrant: the thread that holds it may open another renderer
99/// encoder. Natively renders on one renderer from several threads queue
100/// here; they share one queue, so their GPU work was ordered anyway.
101#[derive(Debug, Default)]
102pub(crate) struct FrameLock {
103    owner: Mutex<Option<(std::thread::ThreadId, usize)>>,
104    released: std::sync::Condvar,
105}
106
107/// One hold of a [`FrameLock`], released when dropped.
108#[derive(Debug)]
109pub(crate) struct FrameGuard {
110    lock: crate::Shared<FrameLock>,
111}
112
113impl FrameLock {
114    /// Waits until no other thread holds the lock, then holds it.
115    pub(crate) fn hold(self: &crate::Shared<Self>) -> FrameGuard {
116        let me = std::thread::current().id();
117        let mut owner = self
118            .owner
119            .lock()
120            .unwrap_or_else(std::sync::PoisonError::into_inner);
121        loop {
122            match owner.as_mut() {
123                None => {
124                    *owner = Some((me, 1));
125                    break;
126                }
127                Some((thread, depth)) if *thread == me => {
128                    *depth += 1;
129                    break;
130                }
131                Some(_) => {
132                    owner = self
133                        .released
134                        .wait(owner)
135                        .unwrap_or_else(std::sync::PoisonError::into_inner);
136                }
137            }
138        }
139        drop(owner);
140        FrameGuard {
141            lock: crate::Shared::clone(self),
142        }
143    }
144}
145
146impl Drop for FrameGuard {
147    fn drop(&mut self) {
148        let mut owner = self
149            .lock
150            .owner
151            .lock()
152            .unwrap_or_else(std::sync::PoisonError::into_inner);
153        if let Some((_, depth)) = owner.as_mut() {
154            *depth -= 1;
155            if *depth == 0 {
156                *owner = None;
157                drop(owner);
158                self.lock.released.notify_all();
159            }
160        }
161    }
162}
163
164impl std::ops::Deref for RenderEncoder {
165    type Target = wgpu::CommandEncoder;
166
167    fn deref(&self) -> &Self::Target {
168        &self.encoder
169    }
170}
171
172impl std::ops::DerefMut for RenderEncoder {
173    fn deref_mut(&mut self) -> &mut Self::Target {
174        &mut self.encoder
175    }
176}
177
178impl RenderEncoder {
179    /// Finishes recording; the encode stays open until [`Renderer::submit`].
180    pub(crate) fn finish(self) -> RenderCommands {
181        RenderCommands {
182            buffer: self.encoder.finish(),
183            errors: self.errors,
184            scope: self.scope,
185            frame: self.frame,
186        }
187    }
188}
189
190/// A finished [`RenderEncoder`] waiting to be submitted.
191#[derive(Debug)]
192pub(crate) struct RenderCommands {
193    buffer: wgpu::CommandBuffer,
194    errors: crate::gpu_errors::FrameErrorScope,
195    scope: crate::retire::EncodeScope,
196    frame: FrameGuard,
197}
198
199/// One queue submission and the spans a caller asked to be measured.
200pub(crate) struct MeasuredSubmit {
201    pub(crate) index: wgpu::SubmissionIndex,
202    pub(crate) timings: SubmitTimings,
203    pub(crate) lease_retire: Duration,
204}
205
206/// Quality controls consumed by graph compilation and pass execution.
207#[derive(Copy, Clone, Debug, Eq, PartialEq)]
208#[non_exhaustive]
209pub struct RenderQualityControls {
210    /// Maximum samples available to filter effects and layer minification.
211    /// Analytic edge coverage remains enabled even with a one-sample budget.
212    pub max_filter_samples: u32,
213    /// Deterministic temporal samples used by motion-aware runners.
214    pub motion_blur_subframes: u32,
215}
216
217impl RenderQualityControls {
218    /// The most filter samples any pass takes: the runtime passes, layer
219    /// minification and the blur shaders all stop there, so a larger budget
220    /// renders the same pixels. [`Renderer::set_quality_controls`] applies at
221    /// most this, or less where the device's tier takes less
222    /// ([`Renderer::filter_sample_limit`]).
223    pub const MAX_FILTER_SAMPLES: u32 = 64;
224}
225
226impl Default for RenderQualityControls {
227    fn default() -> Self {
228        Self {
229            max_filter_samples: Self::MAX_FILTER_SAMPLES,
230            motion_blur_subframes: 1,
231        }
232    }
233}
234
235/// The quality controls a renderer applies, and the filter sample budget it
236/// was asked for, which the limit may have reduced.
237#[derive(Copy, Clone, Debug)]
238pub(crate) struct QualityState {
239    applied: RenderQualityControls,
240    requested_filter_samples: u32,
241}
242
243impl QualityState {
244    /// The default controls, under `limit`.
245    fn new(limit: u32) -> Self {
246        let requested = RenderQualityControls::default();
247        Self {
248            applied: RenderQualityControls {
249                max_filter_samples: requested.max_filter_samples.min(limit),
250                ..requested
251            },
252            requested_filter_samples: requested.max_filter_samples,
253        }
254    }
255}
256
257impl Renderer {
258    pub(crate) fn validate_size(&self, size: RenderSize) -> Result<(), RenderError> {
259        RenderSize::new(size.width, size.height)?;
260        let maximum = self.limits.max_texture_dimension_2d;
261        if size.width > maximum || size.height > maximum {
262            return Err(RenderError::DimensionTooLarge);
263        }
264        Ok(())
265    }
266
267    /// Creates a renderer that owns the supplied GPU context.
268    #[must_use]
269    pub fn new(gpu: GpuContext) -> Self {
270        // The one read of the device's limits; everything else uses the copy.
271        #[allow(clippy::disallowed_methods)]
272        let limits = gpu.device().limits();
273        let uniform_alignment = u64::from(limits.min_uniform_buffer_offset_alignment.max(1));
274        let features = gpu.device().features();
275        let filter_sample_limit = crate::filter_sample_limit(gpu.capabilities().tier);
276        let retirement = crate::Shared::new(crate::retire::Retirement::default());
277        let gpu_errors = crate::GpuErrorReports::default();
278        let canaries = crate::canary::Canaries::new(gpu_errors.clone(), &retirement);
279        let empty_input =
280            crate::pool::default_view(&gpu.device().create_texture(&wgpu::TextureDescriptor {
281                label: Some("alpha2 generator empty input"),
282                size: wgpu::Extent3d {
283                    width: 1,
284                    height: 1,
285                    depth_or_array_layers: 1,
286                },
287                mip_level_count: 1,
288                sample_count: 1,
289                dimension: wgpu::TextureDimension::D2,
290                format: wgpu::TextureFormat::Rgba8Unorm,
291                usage: wgpu::TextureUsages::TEXTURE_BINDING,
292                view_formats: &[],
293            }));
294        Self {
295            gpu,
296            blend_space: BlendSpace::default(),
297            effects: crate::registry::EffectRegistry::default(),
298            effect_resources: crate::effect_resource::EffectResourceRegistry::default(),
299            textures: crate::Shared::new(crate::pool::TexturePool::new(crate::Shared::clone(
300                &retirement,
301            ))),
302            upload_textures: crate::Shared::new(crate::pool::UploadTexturePool::new(
303                crate::Shared::clone(&retirement),
304            )),
305            readback_buffers: crate::Shared::new(crate::pool::ReadbackBufferPool::new(
306                crate::Shared::clone(&retirement),
307            )),
308            uniform_buffers: crate::Shared::new(crate::uniform::UniformBufferPool::new(
309                uniform_alignment,
310                crate::Shared::clone(&retirement),
311            )),
312            retirement,
313            pipelines: crate::pipeline::PipelineCache::default(),
314            diagnostics: Arc::new(Mutex::new(crate::diagnostics::DiagnosticsState::default())),
315            quality: Mutex::new(QualityState::new(filter_sample_limit)),
316            result_cache: Mutex::new(crate::result_cache::ResultCache::default()),
317            vector_state: Mutex::default(),
318            timestamp_probe: std::sync::OnceLock::new(),
319            pass_timing: std::sync::atomic::AtomicBool::new(false),
320            #[cfg(test)]
321            full_frame_layers: std::sync::atomic::AtomicBool::new(false),
322            #[cfg(test)]
323            composite_batch_limit: std::sync::atomic::AtomicUsize::new(0),
324            #[cfg(test)]
325            vector_page_rows: std::sync::atomic::AtomicU32::new(0),
326            #[cfg(test)]
327            composite_pipelines: Mutex::new(crate::composite::CompositePipelineLog::default()),
328            retired_leases: Arc::new(crate::yuv::RetiredLeases::default()),
329            gpu_wait_timeout: crate::error::DEFAULT_GPU_WAIT_TIMEOUT,
330            limits,
331            features,
332            empty_input,
333            canaries,
334            submission_canaries: crate::canary::SubmissionCanaries::default(),
335            gpu_errors,
336            frame_lock: crate::Shared::default(),
337        }
338    }
339
340    /// Where an embedding reports the GPU errors its device's
341    /// uncaptured-error handler receives, on a backend whose error scopes
342    /// resolve only after a frame has returned: the browser. The renderer
343    /// then drops the results it kept and the resources it pooled before
344    /// its next frame, since the error may have come from any of them, and
345    /// reads a readback canary missing after such a report as that error
346    /// rather than as a device that drops work.
347    ///
348    /// A native embedding does not need it: there every frame's own scopes
349    /// resolve before the frame returns, and fail it
350    /// ([`RenderError::Gpu`]).
351    #[must_use]
352    pub fn gpu_error_reports(&self) -> crate::GpuErrorReports {
353        self.gpu_errors.clone()
354    }
355
356    /// This renderer, hearing of GPU errors through `reports` (see
357    /// [`Renderer::gpu_error_reports`]): for an embedding that installs its
358    /// device's error handler before the renderer exists.
359    #[must_use]
360    pub fn with_gpu_error_reports(mut self, reports: crate::GpuErrorReports) -> Self {
361        self.canaries.clear();
362        self.canaries = crate::canary::Canaries::new(reports.clone(), &self.retirement);
363        self.gpu_errors = reports;
364        self
365    }
366
367    /// Stops trusting what the pools hold, after the GPU reported an error
368    /// for work that may have used it, and destroys what they keep idle.
369    fn forget_untrusted_resources(&self) {
370        self.retirement.doubt();
371        self.textures.clear();
372        self.upload_textures.clear();
373        self.readback_buffers.clear();
374        self.uniform_buffers.clear();
375        self.canaries.clear();
376    }
377
378    /// What the GPU this renderer drives offers: tier, intermediate format,
379    /// adapter, and limits.
380    #[must_use]
381    pub fn gpu_capabilities(&self) -> &alpha2_gpu::Capabilities {
382        self.gpu.capabilities()
383    }
384
385    /// The device's limits, as read when the renderer was created.
386    #[must_use]
387    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
388    pub const fn limits(&self) -> &wgpu::Limits {
389        &self.limits
390    }
391
392    /// Sets how long a native wait on GPU work may take before it fails with
393    /// [`RenderError::GpuTimeout`]. Readbacks begun afterwards use it.
394    pub fn set_gpu_wait_timeout(&mut self, timeout: Duration) {
395        self.gpu_wait_timeout = timeout;
396    }
397
398    /// Why this renderer's device can no longer run work, once it found
399    /// out: it completed work without running it, which a readback's missing
400    /// canary showed ([`RenderError::GpuWorkDropped`]), or it could not make
401    /// the resources a frame needs any more ([`RenderError::GpuDeviceLost`]).
402    /// From then on this renderer's readbacks and NV12 packs fail with that
403    /// error; a host should answer what it was doing and start a new process.
404    #[must_use]
405    pub fn gpu_lost(&self) -> Option<RenderError> {
406        self.canaries.lost()
407    }
408
409    /// Fails once the device has been found unable to run work.
410    pub(crate) fn ensure_gpu_runs_work(&self) -> Result<(), RenderError> {
411        self.canaries.lost().map_or(Ok(()), Err)
412    }
413
414    /// Checks the canaries of earlier NV12 packs and repacks that have
415    /// mapped, without waiting for the rest, and fails once the device has
416    /// been found dropping work.
417    pub(crate) fn check_submitted_work(&self) -> Result<(), RenderError> {
418        // Mapping callbacks run only when the device is polled; a frame loop fed by a
419        // hardware encoder may not poll it otherwise.
420        let _ = self.gpu.device().poll(wgpu::PollType::Poll);
421        self.submission_canaries.check_mapped()?;
422        self.ensure_gpu_runs_work()
423    }
424
425    /// Waits for every NV12 pack and repack submitted so far and checks that
426    /// the GPU ran each of them.
427    ///
428    /// Those submissions read nothing back, so a device that completes work
429    /// without running it (see [`RenderError::GpuWorkDropped`]) hands the
430    /// encoder planes it never wrote. Each frame checks the canaries of the
431    /// ones before it that have finished; an export calls this after its
432    /// last frame, so that the last few are checked before it reports
433    /// success.
434    ///
435    /// It may run while another thread polls the same device, as the macOS
436    /// `VideoToolbox` encode worker does: a canary's mapping then reports on
437    /// that thread, and this waits for the report.
438    ///
439    /// # Errors
440    /// Returns [`RenderError::GpuWorkDropped`] when a canary is missing, or
441    /// [`RenderError::GpuTimeout`] when the work does not complete, or a
442    /// canary's mapping does not report, within the wait timeout.
443    #[cfg(not(target_arch = "wasm32"))]
444    pub fn confirm_gpu_work(&self) -> Result<(), RenderError> {
445        let deadline = web_time::Instant::now() + self.gpu_wait_timeout;
446        self.gpu
447            .device()
448            .poll(wgpu::PollType::Wait {
449                submission_index: None,
450                timeout: Some(self.gpu_wait_timeout),
451            })
452            .map_err(crate::error::poll_error)?;
453        let settled = self.settle(&self.submission_canaries.reports, deadline, || {
454            self.check_submitted_work()?;
455            Ok(self.submission_canaries.outstanding() == 0)
456        })?;
457        if settled {
458            Ok(())
459        } else {
460            Err(RenderError::GpuTimeout)
461        }
462    }
463
464    /// How long a native wait on GPU work may take.
465    #[must_use]
466    pub const fn gpu_wait_timeout(&self) -> Duration {
467        self.gpu_wait_timeout
468    }
469
470    /// Releases every native lease whose GPU work has finished.
471    ///
472    /// Waiting on the queue is what makes progress possible: a decoder surface
473    /// is only returned once the submission sampling it completes, so a caller
474    /// that has run out of surface budget can call this and retry rather than
475    /// failing the export. Surfaces held by work that has not been submitted
476    /// yet are unaffected, so this never waits for something that cannot
477    /// happen.
478    ///
479    /// A completed submission's leases are parked by its queue callback,
480    /// which runs on whichever thread's poll collected it; this waits, up to
481    /// the wait timeout, for every submitted one to be parked, then drops
482    /// them.
483    ///
484    /// Returns how many leases it released.
485    ///
486    /// # Errors
487    /// Returns a readback error if the device cannot be polled.
488    ///
489    /// Native only: a browser cannot block on the queue, and its external
490    /// frames are released by the event loop.
491    #[cfg(not(target_arch = "wasm32"))]
492    pub fn reclaim_external_leases(&self) -> Result<usize, RenderError> {
493        let deadline = web_time::Instant::now() + self.gpu_wait_timeout;
494        self.gpu
495            .device()
496            .poll(wgpu::PollType::Wait {
497                submission_index: None,
498                timeout: Some(self.gpu_wait_timeout),
499            })
500            .map_err(crate::error::poll_error)?;
501        // Whatever is still awaited at the deadline stays parked for a later reclaim.
502        self.settle(&self.retired_leases.parks, deadline, || {
503            Ok(self.retired_leases.awaited() == 0)
504        })?;
505        Ok(self.retired_leases.drain())
506    }
507
508    /// Submits command buffers and then releases every completed native lease.
509    ///
510    /// A decoder surface must not be handed back to `FFmpeg` from inside
511    /// `wgpu`'s submission, because a shared-queue backend holds the decoder's
512    /// queue lock for the whole call while freeing a frame takes `FFmpeg`'s
513    /// frame lock. `FFmpeg`'s own decode threads take those two in the
514    /// opposite order, so doing it inside the submission deadlocks the export.
515    /// Completed leases are therefore parked by the queue callback and dropped
516    /// here, once the queue lock is released.
517    ///
518    /// # Errors
519    /// Returns [`RenderError::Gpu`] when the GPU reported an error for the
520    /// work recorded under the commands' scopes, which rejects the whole
521    /// submission: nothing in it ran.
522    pub(crate) fn submit<I>(&self, commands: I) -> Result<wgpu::SubmissionIndex, RenderError>
523    where
524        I: IntoIterator<Item = RenderCommands>,
525    {
526        self.submit_measured(commands, false)
527            .map(|submitted| submitted.index)
528    }
529
530    /// Opens a command encoder whose encode lasts until it is submitted, and
531    /// the error scopes that submission closes.
532    ///
533    /// Open it before acquiring what the frame draws into, so an allocation
534    /// that fails is the frame's error.
535    pub(crate) fn command_encoder(&self, label: &'static str) -> RenderEncoder {
536        if self.gpu_errors.take_unhandled() {
537            // An error the browser reported for earlier work: whatever it came from may be kept.
538            self.clear_result_cache_for(crate::CacheInvalidation::GpuError);
539            self.forget_untrusted_resources();
540        }
541        // Before anything else: waiting for another thread's frame holds no scope of this one.
542        let frame = self.frame_lock.hold();
543        // The scope opens first, so nothing the encoder uses can be destroyed under it.
544        let scope = self.retirement.open();
545        let errors = crate::gpu_errors::FrameErrorScope::open(
546            self.gpu.device(),
547            crate::Shared::clone(&self.retirement),
548        );
549        #[allow(clippy::disallowed_methods)] // The one place a renderer encoder is created.
550        let encoder = self
551            .gpu
552            .device()
553            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
554        RenderEncoder {
555            encoder,
556            errors,
557            scope,
558            frame,
559        }
560    }
561
562    /// Keeps whatever the pools let go of from being destroyed until the
563    /// returned scope is dropped: for renderer work recorded into an encoder
564    /// the caller submits.
565    pub(crate) fn open_encode(&self) -> crate::retire::EncodeScope {
566        self.retirement.open()
567    }
568
569    /// Submits and reports the queue wait, the submit, and the lease drain.
570    ///
571    /// With `measure` false no clock is read and every reported span is zero,
572    /// which is what [`Renderer::submit`] relies on.
573    ///
574    /// # Errors
575    /// Returns [`RenderError::Gpu`] when a scope of the commands caught an
576    /// error: the GPU rejected the submission, and nothing the renderer
577    /// pooled before it is trusted any more. The caller returns it before
578    /// keeping a result or tracking a canary of the rejected work.
579    pub(crate) fn submit_measured<I>(
580        &self,
581        commands: I,
582        measure: bool,
583    ) -> Result<MeasuredSubmit, RenderError>
584    where
585        I: IntoIterator<Item = RenderCommands>,
586    {
587        let mut command_buffers = Vec::new();
588        let mut errors = Vec::new();
589        let mut scopes = Vec::new();
590        let mut frames = Vec::new();
591        for commands in commands {
592            command_buffers.push(commands.buffer);
593            errors.push(commands.errors);
594            scopes.push(commands.scope);
595            frames.push(commands.frame);
596        }
597        let (index, timings) = if measure {
598            self.gpu.submit_timed(command_buffers)
599        } else {
600            (self.gpu.submit(command_buffers), SubmitTimings::default())
601        };
602        // The submission reports its own errors, so its scopes close after it, in reverse order.
603        let mut caught = None;
604        for scope in errors.into_iter().rev() {
605            caught = match (caught, scope.close()) {
606                (Some(alpha2_gpu::GpuErrorKind::OutOfMemory), _) | (_, None) => caught,
607                (_, kind) => kind,
608            };
609        }
610        // Submitted: what these encodes kept alive may be destroyed, and another frame may go.
611        drop(scopes);
612        drop(frames);
613        let drain_started = measure.then(web_time::Instant::now);
614        self.retired_leases.drain();
615        if let Some(kind) = caught {
616            self.forget_untrusted_resources();
617            return Err(RenderError::Gpu { kind });
618        }
619        Ok(MeasuredSubmit {
620            index,
621            timings,
622            lease_retire: drain_started.map_or(Duration::ZERO, |started| started.elapsed()),
623        })
624    }
625
626    /// Returns whether per-pass timing is being collected.
627    #[must_use]
628    pub fn pass_timing_enabled(&self) -> bool {
629        self.pass_timing.load(std::sync::atomic::Ordering::Relaxed)
630    }
631
632    /// Enables or disables per-pass timing collection. Disabled by default.
633    ///
634    /// Timing is a profiling aid, not part of rendering. While it is off no
635    /// clock is read, no pass record is taken, and no GPU timestamp resources
636    /// are created, so the render path carries none of its cost. Turning it off
637    /// also drops the retained history that [`Renderer::diagnostics`] reports,
638    /// and the GPU timestamp of a pass encoded before then, whose read lands
639    /// later, is dropped with it.
640    ///
641    /// Every other diagnostic (adapter identity, texture pool and pipeline
642    /// counters, and frame outcomes) stays available at all times because it
643    /// costs nothing per pass.
644    pub fn set_pass_timing_enabled(&self, enabled: bool) {
645        self.pass_timing
646            .store(enabled, std::sync::atomic::Ordering::Relaxed);
647        if !enabled {
648            crate::diagnostics::end_timing_run(&self.diagnostics);
649        }
650    }
651
652    /// The timing run a pass encoded now records into, or `None` while pass
653    /// timing is off. Read before the flag: a run that ends after this read
654    /// drops the pass's record even if the flag was still on.
655    pub(crate) fn pass_timing_epoch(&self) -> Option<u64> {
656        let epoch = crate::diagnostics::timing_epoch(&self.diagnostics);
657        self.pass_timing_enabled().then_some(epoch)
658    }
659
660    /// Capability-negotiated storage precision. Render graph images contain
661    /// premultiplied sRGB values; layer blend arithmetic may use linear light.
662    #[must_use]
663    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
664    pub fn intermediate_format(&self) -> wgpu::TextureFormat {
665        match self.gpu.capabilities().intermediate_format {
666            IntermediateFormat::Rgba16Float => wgpu::TextureFormat::Rgba16Float,
667            _ => wgpu::TextureFormat::Rgba8Unorm,
668        }
669    }
670
671    /// Returns allocation and reuse counters for intermediate render targets.
672    #[must_use]
673    pub fn texture_pool_stats(&self) -> crate::TexturePoolStats {
674        self.textures.stats()
675    }
676
677    /// Acquires a source-upload texture with RGBA8 storage.
678    ///
679    /// The texture and its default view return to a bounded renderer-owned pool
680    /// when the handle is dropped. Queue submissions that use it must be issued
681    /// before dropping the handle so later uploads remain queue-ordered.
682    ///
683    /// # Errors
684    /// Returns an error when the requested dimensions exceed device limits.
685    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
686    pub fn acquire_rgba8_upload_texture(
687        &self,
688        size: RenderSize,
689    ) -> Result<crate::UploadTexture, RenderError> {
690        self.validate_size(size)?;
691        Ok(self.upload_textures.acquire(
692            self.gpu.device(),
693            size,
694            wgpu::TextureFormat::Rgba8Unorm,
695            "alpha2 RGBA upload texture",
696        ))
697    }
698
699    /// Destroys all currently idle intermediate textures.
700    pub fn clear_texture_pool(&self) {
701        self.textures.clear();
702    }
703
704    /// Gives back the GPU memory the renderer holds only for reuse: idle
705    /// intermediate and upload textures, idle readback, uniform and canary
706    /// buffers, and kept graph results. Each is destroyed, not only dropped, so the
707    /// browser's WebGPU frees it now rather than at garbage collection.
708    /// Nothing a render, a readback, or a caller still holds is released (a
709    /// kept result the caller also holds stays with the caller), and work
710    /// already submitted completes first, so this is safe at any point
711    /// between calls. Later work allocates again what it needs.
712    ///
713    /// Returns the bytes this destroyed. If another thread is recording
714    /// renderer work at that moment, the destruction waits for that work to
715    /// be submitted, and is counted here all the same.
716    pub fn release_idle_memory(&self) -> u64 {
717        let before = self.retirement.retired_bytes();
718        self.clear_result_cache();
719        self.textures.clear();
720        self.upload_textures.clear();
721        self.readback_buffers.clear();
722        self.uniform_buffers.clear();
723        self.canaries.clear();
724        // Kept vector coverage is host memory, given back with the rest.
725        self.vector_state
726            .lock()
727            .unwrap_or_else(std::sync::PoisonError::into_inner)
728            .cache
729            .clear();
730        self.retirement.retired_bytes().saturating_sub(before)
731    }
732
733    /// Sets the soft byte budget for idle reusable intermediate textures.
734    ///
735    /// Existing idle textures are evicted immediately until the pool is within
736    /// the new budget. In-flight textures are considered when they return.
737    pub fn set_texture_budget_bytes(&self, budget_bytes: u64) {
738        self.textures.set_budget(budget_bytes);
739    }
740
741    /// Applies filter and temporal sampling controls.
742    ///
743    /// The filter sample budget is applied up to
744    /// [`Self::filter_sample_limit`], since no pass takes more; a reduced
745    /// budget is reported in [`Self::diagnostics`] as
746    /// [`crate::CapabilityDiagnostic::FilterSamplesReduced`], and the plan,
747    /// the diagnostics and the result cache all see the applied one. Kept
748    /// results are dropped only when what is applied changes.
749    pub fn set_quality_controls(&self, max_filter_samples: u32, motion_blur_subframes: u32) {
750        let requested = max_filter_samples.max(1);
751        let next = RenderQualityControls {
752            max_filter_samples: requested.min(self.filter_sample_limit()),
753            motion_blur_subframes: motion_blur_subframes.max(1),
754        };
755        let mut quality = self
756            .quality
757            .lock()
758            .unwrap_or_else(std::sync::PoisonError::into_inner);
759        quality.requested_filter_samples = requested;
760        if quality.applied != next {
761            quality.applied = next;
762            self.clear_result_cache_for(crate::CacheInvalidation::Quality);
763        }
764    }
765
766    /// The most filter samples a pass of this renderer takes:
767    /// [`RenderQualityControls::MAX_FILTER_SAMPLES`], or 16 on a Tier-B
768    /// device (see [`crate::filter_sample_limit`]).
769    #[must_use]
770    pub fn filter_sample_limit(&self) -> u32 {
771        crate::filter_sample_limit(self.gpu.capabilities().tier)
772    }
773
774    /// Lets kept graph results occupy up to `budget_bytes` (see
775    /// [`crate::RenderGraph::set_cache_intent`]); zero, the default, turns
776    /// the cache off and drops what it holds. Kept results come out of the
777    /// same GPU memory as the texture pool, which does not count them.
778    pub fn set_result_cache_budget(&self, budget_bytes: u64) {
779        self.lock_result_cache().set_budget(budget_bytes);
780    }
781
782    /// Drops every kept graph result.
783    pub fn clear_result_cache(&self) {
784        self.clear_result_cache_for(crate::CacheInvalidation::Explicit);
785    }
786
787    /// The result cache's counters.
788    #[must_use]
789    pub fn result_cache_stats(&self) -> crate::ResultCacheStats {
790        self.lock_result_cache().stats()
791    }
792
793    /// A counter that changes whenever this renderer may draw a graph with
794    /// an unchanged [`Self::graph_cache_key`] differently: on every event
795    /// that clears the result cache (effects, effect resources, quality
796    /// controls, an explicit clear) whether or not it held anything, and on
797    /// every GPU error reported for earlier work. Equal keys under an equal
798    /// epoch name equal pixels, so a player can keep showing a frame instead
799    /// of drawing it again.
800    #[must_use]
801    pub fn content_epoch(&self) -> u64 {
802        self.lock_result_cache()
803            .clears()
804            .wrapping_add(self.gpu_errors.count())
805    }
806
807    pub(crate) fn clear_result_cache_for(&self, reason: crate::CacheInvalidation) {
808        self.lock_result_cache().clear(reason);
809    }
810
811    pub(crate) fn lock_result_cache(
812        &self,
813    ) -> std::sync::MutexGuard<'_, crate::result_cache::ResultCache> {
814        self.result_cache
815            .lock()
816            .unwrap_or_else(std::sync::PoisonError::into_inner)
817    }
818
819    /// Returns the active render quality controls.
820    #[must_use]
821    pub fn quality_controls(&self) -> RenderQualityControls {
822        self.quality_state().applied
823    }
824
825    fn quality_state(&self) -> QualityState {
826        *self
827            .quality
828            .lock()
829            .unwrap_or_else(std::sync::PoisonError::into_inner)
830    }
831
832    /// The fallbacks this renderer applies to what its caller asked for.
833    fn degradations(&self) -> Vec<crate::CapabilityDiagnostic> {
834        let quality = self.quality_state();
835        let (requested, applied) = (
836            quality.requested_filter_samples,
837            quality.applied.max_filter_samples,
838        );
839        if requested > applied {
840            vec![crate::CapabilityDiagnostic::FilterSamplesReduced { requested, applied }]
841        } else {
842            Vec::new()
843        }
844    }
845
846    /// Returns a stable snapshot for telemetry or benchmark output.
847    #[must_use]
848    pub fn diagnostics(&self) -> crate::RenderDiagnostics {
849        let quality = self.quality_controls();
850        crate::diagnostics::snapshot(
851            &self.diagnostics,
852            self.gpu.capabilities(),
853            &crate::diagnostics::ResourceDiagnostics {
854                textures: self.texture_pool_stats(),
855                upload_textures: self.upload_textures.stats(),
856                readback_buffers: self.readback_buffers.stats(),
857                uniform_buffers: self.uniform_buffers.stats(),
858                pipelines: self.pipeline_cache_stats(),
859                result_cache: self.result_cache_stats(),
860            },
861            quality.max_filter_samples,
862            quality.motion_blur_subframes,
863            self.degradations(),
864        )
865    }
866
867    /// Records a host presentation outcome without imposing a product clock.
868    pub fn record_frame_outcome(&self, presented: bool, repeated: bool) {
869        crate::diagnostics::record_frame(&self.diagnostics, presented, repeated);
870    }
871
872    /// Adds time spent waiting for a host decoder or refill operation.
873    pub fn record_decoder_wait(&self, duration: Duration) {
874        crate::diagnostics::record_decoder_wait(&self.diagnostics, duration);
875    }
876
877    /// Returns the number of cached runtime and built-in pipelines.
878    #[must_use]
879    pub fn pipeline_cache_stats(&self) -> crate::PipelineCacheStats {
880        crate::PipelineCacheStats {
881            runtime_effects: self.effects.len(),
882            builtins: self.pipelines.builtin_count(),
883        }
884    }
885
886    /// Selects the single color space used by all layer blending.
887    #[must_use]
888    pub const fn with_blend_space(mut self, blend_space: BlendSpace) -> Self {
889        self.blend_space = blend_space;
890        self
891    }
892
893    /// Returns the configured layer-compositing color space.
894    #[must_use]
895    pub const fn blend_space(&self) -> BlendSpace {
896        self.blend_space
897    }
898
899    /// Clears an offscreen RGBA texture and submits it to the GPU queue.
900    ///
901    /// # Errors
902    ///
903    /// Returns [`RenderError`] for an invalid size or color.
904    pub fn render_solid(
905        &self,
906        size: RenderSize,
907        color: Color,
908    ) -> Result<RenderedTexture, RenderError> {
909        self.validate_size(size)?;
910        if !color.is_valid() {
911            return Err(RenderError::InvalidColor);
912        }
913        let format = self.intermediate_format();
914        let mut encoder = self.command_encoder("alpha2 solid encoder");
915        let texture = self.textures.acquire(
916            self.gpu.device(),
917            size,
918            format,
919            "alpha2 solid render target",
920        );
921        {
922            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
923                label: Some("alpha2 solid clear pass"),
924                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
925                    view: texture.view(),
926                    depth_slice: None,
927                    resolve_target: None,
928                    ops: wgpu::Operations {
929                        load: wgpu::LoadOp::Clear(wgpu::Color {
930                            r: color.red,
931                            g: color.green,
932                            b: color.blue,
933                            a: color.alpha,
934                        }),
935                        store: wgpu::StoreOp::Store,
936                    },
937                })],
938                depth_stencil_attachment: None,
939                timestamp_writes: None,
940                occlusion_query_set: None,
941                multiview_mask: None,
942            });
943        }
944        self.submit([encoder.finish()])?;
945        Ok(RenderedTexture::pooled(size, format, texture))
946    }
947}