ff_render/context.rs
1use std::collections::HashMap;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::{Arc, Mutex};
4
5use crate::error::RenderError;
6use crate::pool::TexturePool;
7
8/// Owns the wgpu device and queue used by the render pipeline.
9///
10/// Share via `Arc<RenderContext>` when multiple components (graph, sink, etc.)
11/// need access to the same GPU device.
12pub struct RenderContext {
13 pub device: wgpu::Device,
14 pub queue: wgpu::Queue,
15 /// Shared reuse pool for GPU textures, so the graph (and, later, the
16 /// compositor) avoid per-frame texture allocation.
17 pub(crate) pool: Mutex<TexturePool>,
18 /// Count of GPU-to-CPU readbacks performed (staging-buffer maps). The
19 /// zero-copy display path never increments this; tests assert it stays flat.
20 pub(crate) readback_count: AtomicU64,
21 /// Compiled transition pipelines, keyed by shader label.
22 ///
23 /// A transition node carries the incoming clip's pixels, so it is rebuilt for every
24 /// frame of a transition -- the graph feeds `input[1..]` the *source* frame, not a
25 /// caller texture, so there is nowhere else for those pixels to live. Compiling the
26 /// shader per instance would therefore mean compiling it per frame. The pipeline
27 /// depends only on the shader and the layout, never on the node's data, so it is
28 /// cached here on the device that owns it instead (#1726).
29 ///
30 /// **The uniform buffer lives inside each cached entry, so nodes of one kind now
31 /// share it.** That is safe as things stand: a `SceneRunner` owns its own compositor
32 /// and therefore its own context, and the export drains on one thread. Two graphs
33 /// driving the same kind on *one* context concurrently would interleave
34 /// `write_buffer` and `submit` and draw each other's uniforms — if that ever becomes
35 /// possible, the buffer has to come back out of the cache.
36 ///
37 /// The logic lives in `nodes::transition::cached_pipeline`; this is only the store.
38 pub(crate) transition_pipelines: Mutex<
39 HashMap<
40 crate::nodes::transition::TransitionPipelineKey,
41 Arc<crate::nodes::transition::TransitionPipeline>,
42 >,
43 >,
44}
45
46impl RenderContext {
47 /// Wrap an existing wgpu device (e.g. shared with the window renderer).
48 #[must_use]
49 pub fn new(device: wgpu::Device, queue: wgpu::Queue) -> Self {
50 Self {
51 device,
52 queue,
53 pool: Mutex::new(TexturePool::new()),
54 readback_count: AtomicU64::new(0),
55 transition_pipelines: Mutex::new(HashMap::new()),
56 }
57 }
58
59 /// Record one GPU-to-CPU readback (called from the staging-buffer path).
60 pub(crate) fn note_readback(&self) {
61 self.readback_count.fetch_add(1, Ordering::Relaxed);
62 }
63
64 /// Number of GPU-to-CPU readbacks performed so far.
65 #[cfg(test)]
66 pub(crate) fn readback_count(&self) -> u64 {
67 self.readback_count.load(Ordering::Relaxed)
68 }
69
70 /// Number of distinct transition pipelines compiled on this device so far.
71 ///
72 /// The cache is what keeps a per-frame node from recompiling its shader, and a
73 /// cache is only load-bearing if something checks it: this lets a test assert the
74 /// count stays at one across a multi-frame transition rather than trust the
75 /// `entry().or_insert_with` by construction (#1726).
76 #[cfg(test)]
77 pub(crate) fn transition_pipeline_count(&self) -> usize {
78 match self.transition_pipelines.lock() {
79 Ok(guard) => guard.len(),
80 Err(poisoned) => poisoned.into_inner().len(),
81 }
82 }
83
84 /// Initialise wgpu using the default (best available) backend.
85 ///
86 /// Backend priority: Metal → Vulkan → DX12 → WebGPU → OpenGL.
87 ///
88 /// # Errors
89 ///
90 /// Returns [`RenderError::DeviceCreation`] if no suitable adapter is found or
91 /// the device request fails.
92 pub async fn init() -> Result<Self, RenderError> {
93 Self::init_with_backend(wgpu::Backends::all()).await
94 }
95
96 /// Blocking wrapper over [`init`](Self::init) for synchronous callers.
97 ///
98 /// The preview runner and the export path are synchronous, so the block-on
99 /// executor is kept here in the GPU crate; callers stay executor-agnostic.
100 ///
101 /// # Errors
102 ///
103 /// Returns [`RenderError::DeviceCreation`] if no suitable adapter is found or
104 /// the device request fails.
105 pub fn init_blocking() -> Result<Self, RenderError> {
106 futures::executor::block_on(Self::init())
107 }
108
109 /// Initialise wgpu with an explicit backend set.
110 ///
111 /// Useful in CI where only `wgpu::Backends::GL` may be available.
112 ///
113 /// # Errors
114 ///
115 /// Returns [`RenderError::DeviceCreation`] if no suitable adapter is found or
116 /// the device request fails.
117 pub async fn init_with_backend(backends: wgpu::Backends) -> Result<Self, RenderError> {
118 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
119 backends,
120 flags: wgpu::InstanceFlags::default(),
121 memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
122 backend_options: wgpu::BackendOptions::default(),
123 display: None,
124 });
125
126 let adapter = instance
127 .request_adapter(&wgpu::RequestAdapterOptions {
128 power_preference: wgpu::PowerPreference::HighPerformance,
129 force_fallback_adapter: false,
130 compatible_surface: None,
131 apply_limit_buckets: false,
132 })
133 .await
134 .map_err(|e| RenderError::DeviceCreation {
135 message: e.to_string(),
136 })?;
137
138 log::info!(
139 "render adapter selected backend={:?} name={}",
140 adapter.get_info().backend,
141 adapter.get_info().name
142 );
143
144 let (device, queue) = adapter
145 .request_device(&wgpu::DeviceDescriptor {
146 label: Some("ff-render"),
147 ..Default::default()
148 })
149 .await
150 .map_err(|e| RenderError::DeviceCreation {
151 message: e.to_string(),
152 })?;
153
154 Ok(Self {
155 device,
156 queue,
157 pool: Mutex::new(TexturePool::new()),
158 readback_count: AtomicU64::new(0),
159 transition_pipelines: Mutex::new(HashMap::new()),
160 })
161 }
162}