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