Skip to main content

frust_gpu/
pipeline.rs

1//! Render-pipeline variants and the cache that builds them predictably.
2//!
3//! The philosophy is Impeller's: a frame must never be the first place a
4//! pipeline state object gets compiled. Three pieces implement that here.
5//!
6//! - [`RenderPipelineDesc`] is a **value** describing one pipeline variant —
7//!   a [`ShaderId`] plus entry points, vertex layouts, topology, and the four
8//!   render-state axes (`blend`, `format`, `sample_count`, `depth`). It holds
9//!   no GPU handle, so it can be listed up front, sent to a worker thread, and
10//!   hashed.
11//! - [`PipelineCache::warm_up`] takes a list of those descs and builds them on
12//!   a worker thread when the surface is installed, so the variants an app is
13//!   known to need exist before the first frame asks for one. At most one such
14//!   worker is ever alive per cache, and [`PipelineCache::shutdown`] — which
15//!   `Drop` calls — joins it, so no thread holding a clone of the device
16//!   outlives the cache.
17//! - [`PipelineCache::get_or_create`] compiles a variant at most once. If the
18//!   render thread asks for a variant that is still *queued* for the warm-up
19//!   worker, it does not wait its turn: it **steals** the job, builds it
20//!   inline, and marks the queue entry done (Impeller's
21//!   `PipelineCompileQueue::PerformJobEagerly`). If the worker is already
22//!   mid-build on that exact variant, the request waits for it rather than
23//!   compiling a second copy.
24//!
25//! ## The variant key
26//!
27//! Every desc collapses to a `u64` packed from five axes, the same trick as
28//! Impeller's `ContentContextOptions::ToKey`: fixed-width bit fields, one per
29//! axis, so lookup is an integer hash and two descs that differ in any axis
30//! can never collide.
31//!
32//! | Bits | Axis |
33//! |------|------|
34//! | `[0..16)`  | program — shader module + entry points + vertex layouts + topology |
35//! | `[16..28)` | color target format |
36//! | `[28..40)` | blend state (`None` is its own value) |
37//! | `[40..52)` | depth/stencil state (`None` is its own value) |
38//! | `[52..56)` | `log2(sample_count)` |
39//!
40//! wgpu's render-state types are far too wide to pack literally (a
41//! `BlendState` alone is six enums), so the three wide axes are **interned**:
42//! each distinct value seen gets the next index in a small side table, and the
43//! index is what the bit field holds. That is exact — unlike a truncated
44//! content hash, no two distinct values can ever share a field — at the cost
45//! of a key being meaningful only within one cache instance, which is all it
46//! is ever used for.
47//!
48//! The first axis is the whole *program*, not just the [`ShaderId`], because a
49//! variant that reused one module under a different entry point or vertex
50//! layout would otherwise share a key with its sibling and silently get the
51//! wrong pipeline. Interning the program tuple makes those distinct variants,
52//! which is what Impeller's per-pipeline-type maps achieve structurally.
53//!
54//! A field whose table overflows its width (or a `sample_count` that is not a
55//! power of two, which wgpu would reject anyway) yields no key: that request
56//! falls back to an uncached compile rather than risking a wrong hit. It takes
57//! thousands of distinct blend states to get there, so it is a safety net, not
58//! a path.
59//!
60//! ## The warm-up worker
61//!
62//! One worker at a time, spawned on demand and **exiting when the queue
63//! drains** rather than parking: a surface reinstall calls `warm_up` again,
64//! and the choice keeps a long-lived cache from holding an idle thread (and a
65//! device clone) for the process lifetime. The liveness flag is set and
66//! cleared inside the same critical section that enqueues jobs and claims
67//! them, so a worker on its way out can never be mistaken for one that will
68//! still pick up the job just queued.
69//!
70//! A compile that unwinds marks its variant `JobState::Failed` instead of
71//! leaving it claimable-but-unreachable: the queue entry is already spent, so
72//! a `Pending` job nobody re-queues is a job the worker can never build again
73//! and `queued_variants` would count forever. `Failed` is terminal for the
74//! *queue* only — the next [`PipelineCache::get_or_create`] for that variant
75//! rebuilds it, and a second failure unwinds into that caller rather than
76//! parking it on a build that will never publish. The worker catches the
77//! unwind so one bad variant costs its own pipeline and not the rest of the
78//! queue; an inline build does not, because there the requester is the right
79//! place for the failure to land.
80//!
81//! ## Persisted driver cache
82//!
83//! A [`wgpu::PipelineCache`] — Vulkan-only, seeded from a shell-persisted blob
84//! validated by [`crate::pipeline_cache`] — can be handed to
85//! [`PipelineCache::new`]. Every pipeline built here is then created against
86//! it, so a warm start reuses the driver's previously compiled machine code.
87//! On Metal/DX12 the feature does not exist and `None` is passed; nothing else
88//! changes.
89
90use std::borrow::Cow;
91use std::collections::{HashMap, VecDeque};
92use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
93
94use crate::shader::{ShaderId, ShaderLibrary};
95
96/// Width of the packed key's program field.
97const PROGRAM_BITS: u32 = 16;
98/// Width of the packed key's color-format field.
99const FORMAT_BITS: u32 = 12;
100/// Width of the packed key's blend-state field.
101const BLEND_BITS: u32 = 12;
102/// Width of the packed key's depth/stencil field.
103const DEPTH_BITS: u32 = 12;
104/// Width of the packed key's `log2(sample_count)` field.
105const SAMPLE_BITS: u32 = 4;
106
107const PROGRAM_SHIFT: u32 = 0;
108const FORMAT_SHIFT: u32 = PROGRAM_SHIFT + PROGRAM_BITS;
109const BLEND_SHIFT: u32 = FORMAT_SHIFT + FORMAT_BITS;
110const DEPTH_SHIFT: u32 = BLEND_SHIFT + BLEND_BITS;
111const SAMPLE_SHIFT: u32 = DEPTH_SHIFT + DEPTH_BITS;
112
113/// One vertex buffer's layout, owned so a [`RenderPipelineDesc`] can be stored,
114/// cloned, hashed and sent to the warm-up worker.
115///
116/// The borrowed `wgpu::VertexBufferLayout` this converts to
117/// ([`VertexLayout::as_wgpu`]) is built only for the duration of the
118/// `create_render_pipeline` call.
119#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
120pub struct VertexLayout {
121    /// Stride in bytes between consecutive elements.
122    pub array_stride: wgpu::BufferAddress,
123    /// Whether the buffer steps per vertex or per instance.
124    pub step_mode: wgpu::VertexStepMode,
125    /// The attributes making up one element.
126    pub attributes: Vec<wgpu::VertexAttribute>,
127}
128
129impl VertexLayout {
130    /// A layout stepped per vertex (the common case) over `attributes`.
131    #[must_use]
132    pub fn per_vertex(
133        array_stride: wgpu::BufferAddress,
134        attributes: Vec<wgpu::VertexAttribute>,
135    ) -> Self {
136        Self {
137            array_stride,
138            step_mode: wgpu::VertexStepMode::Vertex,
139            attributes,
140        }
141    }
142
143    /// The borrowed wgpu view of this layout.
144    #[must_use]
145    pub fn as_wgpu(&self) -> wgpu::VertexBufferLayout<'_> {
146        wgpu::VertexBufferLayout {
147            array_stride: self.array_stride,
148            step_mode: self.step_mode,
149            attributes: &self.attributes,
150        }
151    }
152}
153
154/// One render-pipeline variant, as a plain hashable value.
155///
156/// Build the descs an app needs at start-up, hand them to
157/// [`PipelineCache::warm_up`], and keep them around: a per-frame
158/// [`PipelineCache::get_or_create`] takes one by reference and never allocates
159/// on a hit.
160///
161/// The pipeline's bind-group layout is always wgpu's **default layout**,
162/// deduced from the shader modules — there is no explicit-layout axis in v1,
163/// so a variant set that needs one is out of this cache's scope.
164#[derive(Clone, Debug, PartialEq, Eq, Hash)]
165pub struct RenderPipelineDesc {
166    /// The module holding both entry points, from the cache's own library.
167    pub shader: ShaderId,
168    /// Vertex entry point name.
169    pub vs: Cow<'static, str>,
170    /// Fragment entry point name.
171    pub fs: Cow<'static, str>,
172    /// Vertex buffer layouts, in bind order. Empty for a shader that
173    /// generates its own vertices (a fullscreen triangle).
174    pub vertex_layouts: Vec<VertexLayout>,
175    /// Color blending, or `None` for an opaque write.
176    pub blend: Option<wgpu::BlendState>,
177    /// Color target format.
178    pub format: wgpu::TextureFormat,
179    /// MSAA sample count; must be a power of two, `1` for no MSAA.
180    pub sample_count: u32,
181    /// Depth/stencil state, or `None` for a color-only pass.
182    pub depth: Option<wgpu::DepthStencilState>,
183    /// Primitive topology. Strip topologies here are non-indexed only: the
184    /// desc carries no `strip_index_format`.
185    pub topology: wgpu::PrimitiveTopology,
186}
187
188impl RenderPipelineDesc {
189    /// A minimal opaque variant: no vertex buffers, no blending, no depth,
190    /// `sample_count: 1`, triangle list. Set the axes that differ with struct
191    /// update syntax.
192    #[must_use]
193    pub fn new(
194        shader: ShaderId,
195        vs: impl Into<Cow<'static, str>>,
196        fs: impl Into<Cow<'static, str>>,
197        format: wgpu::TextureFormat,
198    ) -> Self {
199        Self {
200            shader,
201            vs: vs.into(),
202            fs: fs.into(),
203            vertex_layouts: Vec::new(),
204            blend: None,
205            format,
206            sample_count: 1,
207            depth: None,
208            topology: wgpu::PrimitiveTopology::TriangleList,
209        }
210    }
211}
212
213/// The part of a desc that identifies the *program*: everything the packed
214/// key's first field stands for.
215#[derive(Clone, Debug, PartialEq, Eq, Hash)]
216struct ProgramKey {
217    shader: ShaderId,
218    vs: Cow<'static, str>,
219    fs: Cow<'static, str>,
220    vertex_layouts: Vec<VertexLayout>,
221    topology: wgpu::PrimitiveTopology,
222}
223
224impl ProgramKey {
225    fn of(desc: &RenderPipelineDesc) -> Self {
226        Self {
227            shader: desc.shader,
228            vs: desc.vs.clone(),
229            fs: desc.fs.clone(),
230            vertex_layouts: desc.vertex_layouts.clone(),
231            topology: desc.topology,
232        }
233    }
234
235    /// Whether `desc` names this same program, without building a `ProgramKey`
236    /// (and therefore without allocating) — the per-request hot path.
237    fn matches(&self, desc: &RenderPipelineDesc) -> bool {
238        self.shader == desc.shader
239            && self.vs == desc.vs
240            && self.fs == desc.fs
241            && self.topology == desc.topology
242            && self.vertex_layouts == desc.vertex_layouts
243    }
244}
245
246/// Assigns each distinct value of one key axis a dense index.
247///
248/// `None` is returned once the assigned indices would no longer fit the axis's
249/// bit field; the caller then treats the whole key as unavailable.
250#[derive(Debug)]
251struct AxisTable<K> {
252    index: HashMap<K, u64>,
253    bits: u32,
254}
255
256impl<K: std::hash::Hash + Eq + Clone> AxisTable<K> {
257    fn new(bits: u32) -> Self {
258        Self {
259            index: HashMap::new(),
260            bits,
261        }
262    }
263
264    fn intern(&mut self, value: &K) -> Option<u64> {
265        if let Some(existing) = self.index.get(value) {
266            return Some(*existing);
267        }
268        let next = self.index.len() as u64;
269        if next >= 1 << self.bits {
270            return None;
271        }
272        self.index.insert(value.clone(), next);
273        Some(next)
274    }
275}
276
277/// The program axis's own table: a hash index onto stored [`ProgramKey`]s, so
278/// a repeat lookup compares rather than clones.
279#[derive(Debug, Default)]
280struct ProgramTable {
281    /// Program-tuple hash → the indices assigned to programs with that hash.
282    /// A chain longer than one entry means a genuine hash collision, which the
283    /// stored key comparison then resolves.
284    by_hash: HashMap<u64, Vec<u64>>,
285    entries: Vec<ProgramKey>,
286}
287
288impl ProgramTable {
289    fn intern(&mut self, desc: &RenderPipelineDesc) -> Option<u64> {
290        let hash = program_hash(desc);
291        let chain = self.by_hash.entry(hash).or_default();
292        for &candidate in chain.iter() {
293            if self.entries[candidate as usize].matches(desc) {
294                return Some(candidate);
295            }
296        }
297        let next = self.entries.len() as u64;
298        if next >= 1 << PROGRAM_BITS {
299            return None;
300        }
301        chain.push(next);
302        self.entries.push(ProgramKey::of(desc));
303        Some(next)
304    }
305}
306
307/// Hashes a desc's program fields without building a [`ProgramKey`].
308fn program_hash(desc: &RenderPipelineDesc) -> u64 {
309    use std::hash::{Hash, Hasher};
310    let mut hasher = std::collections::hash_map::DefaultHasher::new();
311    desc.shader.hash(&mut hasher);
312    desc.vs.hash(&mut hasher);
313    desc.fs.hash(&mut hasher);
314    desc.vertex_layouts.hash(&mut hasher);
315    desc.topology.hash(&mut hasher);
316    hasher.finish()
317}
318
319/// Packs a [`RenderPipelineDesc`] into the `u64` variant key.
320#[derive(Debug)]
321struct KeyPacker {
322    programs: ProgramTable,
323    formats: AxisTable<wgpu::TextureFormat>,
324    blends: AxisTable<Option<wgpu::BlendState>>,
325    depths: AxisTable<Option<wgpu::DepthStencilState>>,
326}
327
328impl Default for KeyPacker {
329    fn default() -> Self {
330        Self {
331            programs: ProgramTable::default(),
332            formats: AxisTable::new(FORMAT_BITS),
333            blends: AxisTable::new(BLEND_BITS),
334            depths: AxisTable::new(DEPTH_BITS),
335        }
336    }
337}
338
339impl KeyPacker {
340    /// The variant key for `desc`, or `None` if an axis cannot be represented
341    /// (an unusable `sample_count`, or an exhausted interning table).
342    fn key_for(&mut self, desc: &RenderPipelineDesc) -> Option<u64> {
343        // Resolved first so a rejected sample count never leaves a fresh entry
344        // behind in a table.
345        let sample = sample_field(desc.sample_count)?;
346        let program = self.programs.intern(desc)?;
347        let format = self.formats.intern(&desc.format)?;
348        let blend = self.blends.intern(&desc.blend)?;
349        let depth = self.depths.intern(&desc.depth)?;
350        Some(
351            (program << PROGRAM_SHIFT)
352                | (format << FORMAT_SHIFT)
353                | (blend << BLEND_SHIFT)
354                | (depth << DEPTH_SHIFT)
355                | (sample << SAMPLE_SHIFT),
356        )
357    }
358}
359
360/// `log2(sample_count)`, or `None` for a count wgpu could not use anyway.
361fn sample_field(sample_count: u32) -> Option<u64> {
362    if sample_count == 0 || !sample_count.is_power_of_two() {
363        return None;
364    }
365    let field = u64::from(sample_count.trailing_zeros());
366    (field < 1 << SAMPLE_BITS).then_some(field)
367}
368
369/// Where a queued warm-up job has got to.
370#[derive(Clone, Copy, Debug, PartialEq, Eq)]
371enum JobState {
372    /// Listed by `warm_up`, nobody has started it.
373    Pending,
374    /// Some thread is compiling it right now.
375    Building,
376    /// Built; the pipeline is in `Inner::built`.
377    Done,
378    /// A compile of this variant unwound. Terminal as far as the warm-up
379    /// queue is concerned — its place in the queue is spent, so no worker
380    /// will claim it again — but a later request rebuilds it inline.
381    Failed,
382}
383
384/// One warm-up job: the variant to build and how far along it is.
385#[derive(Debug)]
386struct Job {
387    desc: RenderPipelineDesc,
388    state: JobState,
389}
390
391/// Everything both the render thread and the warm-up worker touch.
392#[derive(Debug)]
393struct Inner<P> {
394    built: HashMap<u64, Arc<P>>,
395    jobs: HashMap<u64, Job>,
396    /// Keys in the order `warm_up` listed them. Entries whose job was stolen
397    /// or finished are skipped when popped.
398    queue: VecDeque<u64>,
399    /// How many compiles have actually run — the "compiled once" evidence.
400    compiles: u64,
401    /// Whether a warm-up worker is draining `queue` right now. Only ever
402    /// written while the lock is held, which is what makes "one worker" a
403    /// fact rather than a race: see the module docs.
404    worker_live: bool,
405    /// Set once the cache is shutting down; a worker claims nothing more, so
406    /// teardown waits for at most the compile already in flight.
407    shutdown: bool,
408    /// Bumped every time liveness is claimed, so a departing worker can tell
409    /// whether the flag it is about to clear is still its own.
410    worker_epoch: u64,
411}
412
413impl<P> Default for Inner<P> {
414    fn default() -> Self {
415        Self {
416            built: HashMap::new(),
417            jobs: HashMap::new(),
418            queue: VecDeque::new(),
419            compiles: 0,
420            worker_live: false,
421            shutdown: false,
422            worker_epoch: 0,
423        }
424    }
425}
426
427impl<P> Inner<P> {
428    /// Claims the next still-pending job, marking it `Building`.
429    ///
430    /// Returning `None` also clears `worker_live`, in this same critical
431    /// section: a caller that enqueues after this point sees no live worker
432    /// and spawns one, so the job it just listed cannot fall between the
433    /// worker's last look at the queue and its exit.
434    fn claim_next_pending(&mut self) -> Option<(u64, RenderPipelineDesc)> {
435        while !self.shutdown
436            && let Some(key) = self.queue.pop_front()
437        {
438            if let Some(job) = self.jobs.get_mut(&key)
439                && job.state == JobState::Pending
440            {
441                job.state = JobState::Building;
442                return Some((key, job.desc.clone()));
443            }
444        }
445        self.worker_live = false;
446        None
447    }
448}
449
450/// The shared half of a cache: state plus the condition variable a waiter
451/// blocks on while another thread builds the variant it wants.
452#[derive(Debug)]
453struct Shared<P> {
454    inner: Mutex<Inner<P>>,
455    built: Condvar,
456}
457
458impl<P> Default for Shared<P> {
459    fn default() -> Self {
460        Self {
461            inner: Mutex::new(Inner::default()),
462            built: Condvar::new(),
463        }
464    }
465}
466
467/// Locks a mutex, adopting the guard even if a previous holder panicked — this
468/// cache never leaves inconsistent state behind, and a poisoned lock must not
469/// turn a warm-up hiccup into a crash.
470fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
471    mutex.lock().unwrap_or_else(PoisonError::into_inner)
472}
473
474/// Holds a job `Building` for the duration of one compile, and marks it
475/// [`JobState::Failed`] if that compile unwinds — without this a panicking
476/// builder would leave every waiter blocked on a build that will never finish.
477///
478/// `Failed` rather than `Pending` because the job's place in the warm-up queue
479/// is already spent: a `Pending` entry no worker can reach is a phantom that
480/// `queued_variants` would report for the process lifetime. The failure stays
481/// visible to the next requester, which rebuilds the variant inline.
482struct BuildGuard<'a, P> {
483    shared: &'a Shared<P>,
484    key: u64,
485    armed: bool,
486}
487
488impl<'a, P> BuildGuard<'a, P> {
489    fn new(shared: &'a Shared<P>, key: u64) -> Self {
490        Self {
491            shared,
492            key,
493            armed: true,
494        }
495    }
496
497    /// Publishes the finished pipeline, marks the job done, and wakes waiters.
498    fn publish(mut self, pipeline: Arc<P>) {
499        self.armed = false;
500        let mut inner = lock(&self.shared.inner);
501        inner.built.insert(self.key, pipeline);
502        if let Some(job) = inner.jobs.get_mut(&self.key) {
503            job.state = JobState::Done;
504        }
505        inner.compiles += 1;
506        drop(inner);
507        self.shared.built.notify_all();
508    }
509}
510
511/// Clears the worker-liveness flag if a drain leaves by any route other than
512/// the ordinary "queue empty" one, which clears it under the lock itself.
513///
514/// The epoch check is what stops a departing worker from clearing liveness
515/// that a *newer* worker has since claimed — without it, an abnormal exit
516/// racing a fresh `warm_up` could leave two workers on one queue.
517struct LiveWorkerGuard<'a, P> {
518    shared: &'a Shared<P>,
519    epoch: u64,
520}
521
522impl<P> Drop for LiveWorkerGuard<'_, P> {
523    fn drop(&mut self) {
524        let mut inner = lock(&self.shared.inner);
525        if inner.worker_epoch == self.epoch {
526            inner.worker_live = false;
527        }
528    }
529}
530
531impl<P> Drop for BuildGuard<'_, P> {
532    fn drop(&mut self) {
533        if !self.armed {
534            return;
535        }
536        let mut inner = lock(&self.shared.inner);
537        if let Some(job) = inner.jobs.get_mut(&self.key) {
538            job.state = JobState::Failed;
539        }
540        drop(inner);
541        self.shared.built.notify_all();
542    }
543}
544
545/// The device-free half of [`PipelineCache`]: key packing, the warm-up queue,
546/// eager stealing, and the render-thread mirror.
547///
548/// Generic over the compiled artifact so all of that policy is exercised by
549/// host tests with a fake compile function, with no GPU in the loop — the same
550/// split [`crate::caps::TierCaps`] uses to keep tier decisions testable.
551#[derive(Debug)]
552struct VariantCache<P> {
553    shared: Arc<Shared<P>>,
554    /// Render-thread mirror of `Shared::built`, so a steady-state hit takes no
555    /// lock and can hand out a plain reference.
556    local: HashMap<u64, Arc<P>>,
557    /// The most recent uncached compile (see the key-exhaustion note on the
558    /// module docs). Held only so a reference can be returned.
559    overflow: Option<Arc<P>>,
560    keys: KeyPacker,
561    warned_key_space: bool,
562    /// The one warm-up worker, kept so [`VariantCache::shutdown`] can join it.
563    worker: Option<std::thread::JoinHandle<()>>,
564}
565
566impl<P> Default for VariantCache<P> {
567    fn default() -> Self {
568        Self {
569            shared: Arc::new(Shared::default()),
570            local: HashMap::new(),
571            overflow: None,
572            keys: KeyPacker::default(),
573            warned_key_space: false,
574            worker: None,
575        }
576    }
577}
578
579impl<P> Drop for VariantCache<P> {
580    fn drop(&mut self) {
581        self.shutdown();
582    }
583}
584
585impl<P> VariantCache<P> {
586    /// A handle on the shared state, for a warm-up worker.
587    fn shared(&self) -> Arc<Shared<P>> {
588        Arc::clone(&self.shared)
589    }
590
591    /// Returns the variant for `desc`, compiling it at most once.
592    fn get_or_create<F>(&mut self, desc: &RenderPipelineDesc, compile: F) -> &P
593    where
594        F: Fn(&RenderPipelineDesc) -> P,
595    {
596        let Some(key) = self.keys.key_for(desc) else {
597            if !self.warned_key_space {
598                self.warned_key_space = true;
599                log::error!(
600                    "frust-gpu: pipeline variant key space exhausted (or an unusable \
601                     sample_count {}); this variant is compiled per request instead of cached",
602                    desc.sample_count
603                );
604            }
605            self.overflow = Some(Arc::new(compile(desc)));
606            return self
607                .overflow
608                .as_deref()
609                .expect("the uncached variant was just stored");
610        };
611
612        if !self.local.contains_key(&key) {
613            let built = self.resolve(key, desc, &compile);
614            self.local.insert(key, built);
615        }
616        &self.local[&key]
617    }
618
619    /// The shared-state half of `get_or_create`: hit, steal, wait, or build.
620    fn resolve<F>(&self, key: u64, desc: &RenderPipelineDesc, compile: &F) -> Arc<P>
621    where
622        F: Fn(&RenderPipelineDesc) -> P,
623    {
624        let mut inner = lock(&self.shared.inner);
625        loop {
626            if let Some(built) = inner.built.get(&key) {
627                return Arc::clone(built);
628            }
629            match inner.jobs.get(&key).map(|job| job.state) {
630                // The warm-up worker holds this job: wait for its result
631                // rather than compiling a second copy of the same variant.
632                Some(JobState::Building) => {
633                    inner = self
634                        .shared
635                        .built
636                        .wait(inner)
637                        .unwrap_or_else(PoisonError::into_inner);
638                }
639                // Queued but unstarted — steal it and build it inline. Same
640                // for a variant whose last compile unwound: the queue will
641                // never come back to it, so the requester rebuilds it here
642                // (and eats the failure itself if it unwinds again).
643                Some(JobState::Pending | JobState::Failed) => {
644                    if let Some(job) = inner.jobs.get_mut(&key) {
645                        job.state = JobState::Building;
646                    }
647                    drop(inner);
648                    return self.build(key, desc, compile);
649                }
650                // Never queued (or queued, finished, and since dropped):
651                // record it as in-flight so a concurrent request waits.
652                Some(JobState::Done) | None => {
653                    inner.jobs.insert(
654                        key,
655                        Job {
656                            desc: desc.clone(),
657                            state: JobState::Building,
658                        },
659                    );
660                    drop(inner);
661                    return self.build(key, desc, compile);
662                }
663            }
664        }
665    }
666
667    /// Runs one compile for an already-claimed (`Building`) job and publishes it.
668    fn build<F>(&self, key: u64, desc: &RenderPipelineDesc, compile: &F) -> Arc<P>
669    where
670        F: Fn(&RenderPipelineDesc) -> P,
671    {
672        let guard = BuildGuard::new(&self.shared, key);
673        let pipeline = Arc::new(compile(desc));
674        guard.publish(Arc::clone(&pipeline));
675        pipeline
676    }
677
678    /// Records `descs` as warm-up jobs, skipping any variant already built or
679    /// already queued. Compiles nothing itself.
680    ///
681    /// Returns whether the caller must start a worker: `true` only when there
682    /// is pending work and no live worker to take it, in which case the
683    /// liveness flag is claimed here, under the same lock that queued the
684    /// jobs. A cache that is shutting down never asks for one.
685    fn enqueue(&mut self, descs: &[RenderPipelineDesc]) -> bool {
686        let mut inner = lock(&self.shared.inner);
687        for desc in descs {
688            let Some(key) = self.keys.key_for(desc) else {
689                log::error!(
690                    "frust-gpu: warm-up variant has no representable key (sample_count {}); \
691                     it will be compiled on first use instead",
692                    desc.sample_count
693                );
694                continue;
695            };
696            if inner.built.contains_key(&key) || inner.jobs.contains_key(&key) {
697                continue;
698            }
699            inner.jobs.insert(
700                key,
701                Job {
702                    desc: desc.clone(),
703                    state: JobState::Pending,
704                },
705            );
706            inner.queue.push_back(key);
707        }
708        if inner.shutdown || inner.worker_live || inner.queue.is_empty() {
709            return false;
710        }
711        inner.worker_live = true;
712        inner.worker_epoch += 1;
713        true
714    }
715
716    /// Builds every still-pending queued job. This is the warm-up worker's
717    /// whole body; a job the render thread stole in the meantime is skipped.
718    ///
719    /// A compile that unwinds is caught here rather than tearing the worker
720    /// down with the rest of the queue unbuilt: [`BuildGuard`] has already
721    /// marked that one variant failed, and the drain moves on.
722    fn drain_queue<F>(shared: &Arc<Shared<P>>, compile: F)
723    where
724        F: Fn(&RenderPipelineDesc) -> P,
725    {
726        let _live = LiveWorkerGuard {
727            shared,
728            epoch: lock(&shared.inner).worker_epoch,
729        };
730        loop {
731            let claimed = lock(&shared.inner).claim_next_pending();
732            let Some((key, desc)) = claimed else {
733                return;
734            };
735            let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
736                let guard = BuildGuard::new(shared, key);
737                guard.publish(Arc::new(compile(&desc)));
738            }));
739            if built.is_err() {
740                log::error!(
741                    "frust-gpu: a warm-up compile panicked; that variant is left to be \
742                     rebuilt on request and the rest of the queue continues"
743                );
744            }
745        }
746    }
747
748    /// Queues `descs` and makes sure exactly one worker is draining them.
749    ///
750    /// `make_compile` is called once per thread that needs one — the worker,
751    /// or the calling thread when the spawn fails and the queue has to be
752    /// drained inline rather than silently not warmed at all.
753    fn warm_up<F>(&mut self, descs: &[RenderPipelineDesc], make_compile: impl Fn() -> F)
754    where
755        P: Send + Sync + 'static,
756        F: Fn(&RenderPipelineDesc) -> P + Send + 'static,
757    {
758        if !self.enqueue(descs) {
759            return;
760        }
761        // The previous worker has already cleared the liveness flag and is on
762        // its way out; joining it here is what keeps "no worker outlives the
763        // cache" true of the handle this cache actually holds.
764        self.join_worker();
765
766        let worker_shared = self.shared();
767        let worker_compile = make_compile();
768        let spawned = std::thread::Builder::new()
769            .name("frust-gpu pipeline warm-up".to_string())
770            .spawn(move || VariantCache::drain_queue(&worker_shared, worker_compile));
771        match spawned {
772            Ok(handle) => self.worker = Some(handle),
773            Err(err) => {
774                log::warn!(
775                    "frust-gpu: could not spawn the pipeline warm-up thread ({err}); \
776                     building the listed variants inline"
777                );
778                VariantCache::drain_queue(&self.shared(), make_compile());
779            }
780        }
781    }
782
783    /// Stops the warm-up worker and waits for it, so nothing this cache
784    /// spawned can still be holding the device once it returns.
785    ///
786    /// Idempotent, and terminal: the shutdown flag stays set, so a later
787    /// `warm_up` queues its variants without starting a worker and they are
788    /// built on request instead.
789    fn shutdown(&mut self) {
790        lock(&self.shared.inner).shutdown = true;
791        self.join_worker();
792    }
793
794    /// Joins the worker if one is recorded, reporting a panic that escaped it.
795    fn join_worker(&mut self) {
796        if let Some(worker) = self.worker.take()
797            && worker.join().is_err()
798        {
799            log::error!("frust-gpu: the pipeline warm-up worker panicked");
800        }
801    }
802
803    /// How many compiles have run since this cache was created.
804    fn compiled_variants(&self) -> u64 {
805        lock(&self.shared.inner).compiles
806    }
807
808    /// How many warm-up jobs are still waiting to be built.
809    fn queued_variants(&self) -> usize {
810        let inner = lock(&self.shared.inner);
811        inner
812            .jobs
813            .values()
814            .filter(|job| job.state == JobState::Pending)
815            .count()
816    }
817}
818
819/// The engine's render-pipeline cache.
820///
821/// Owns nothing but policy plus the compiled pipelines: the `wgpu::Device` is
822/// passed per call, and the shader modules come from the shared
823/// [`ShaderLibrary`] handed to [`PipelineCache::new`].
824///
825/// See the module docs for the variant key, the warm-up queue and the
826/// eager-steal rule.
827#[derive(Debug)]
828pub struct PipelineCache {
829    core: VariantCache<wgpu::RenderPipeline>,
830    shaders: Arc<ShaderLibrary>,
831    driver_cache: Option<wgpu::PipelineCache>,
832}
833
834impl PipelineCache {
835    /// A cache over `shaders`, optionally seeding every pipeline it builds
836    /// from a persisted `driver_cache`.
837    ///
838    /// `driver_cache` is `None` on every backend but Vulkan; see
839    /// [`crate::pipeline_cache`] for how a persisted blob is validated before
840    /// it becomes one.
841    #[must_use]
842    pub fn new(shaders: Arc<ShaderLibrary>, driver_cache: Option<wgpu::PipelineCache>) -> Self {
843        Self {
844            core: VariantCache::default(),
845            shaders,
846            driver_cache,
847        }
848    }
849
850    /// The library this cache resolves [`ShaderId`]s against.
851    #[must_use]
852    pub fn shaders(&self) -> &ShaderLibrary {
853        &self.shaders
854    }
855
856    /// The pipeline for `desc`, compiling it if this is the first request.
857    ///
858    /// A variant queued for the warm-up worker but not yet started is stolen
859    /// and built inline; one the worker is already building is waited for.
860    /// Either way the variant is compiled exactly once.
861    ///
862    /// # Panics
863    ///
864    /// If `desc.shader` was not minted by this cache's [`ShaderLibrary`]. An
865    /// id is opaque and library-scoped, so that is a wiring bug, not a runtime
866    /// condition.
867    pub fn get_or_create(
868        &mut self,
869        device: &wgpu::Device,
870        desc: &RenderPipelineDesc,
871    ) -> &wgpu::RenderPipeline {
872        let shaders = &self.shaders;
873        let driver_cache = self.driver_cache.as_ref();
874        self.core.get_or_create(desc, |desc| {
875            build_render_pipeline(device, shaders, driver_cache, desc)
876        })
877    }
878
879    /// Queues `descs` and builds them on a worker thread.
880    ///
881    /// Call it once the surface (and therefore the device) exists, with every
882    /// variant the app is known to draw. Returns immediately, and the render
883    /// thread never blocks on the worker — a variant it asks for early is
884    /// stolen out of the queue instead.
885    ///
886    /// Safe to call repeatedly: a surface reinstall enqueues its variants
887    /// against the live worker rather than starting a second one, and a call
888    /// whose variants are all built already starts nothing at all.
889    ///
890    /// If the thread cannot be spawned the queue is drained inline instead, so
891    /// warm-up degrades to a synchronous build rather than silently not
892    /// happening.
893    pub fn warm_up(&mut self, device: &wgpu::Device, descs: &[RenderPipelineDesc]) {
894        let shaders = Arc::clone(&self.shaders);
895        let driver_cache = self.driver_cache.clone();
896        self.core
897            .warm_up(descs, || compiler(device, &shaders, driver_cache.as_ref()));
898    }
899
900    /// Stops warm-up and joins the worker, so no thread holding a clone of the
901    /// device outlives this cache.
902    ///
903    /// [`Drop`] calls this; call it explicitly to order the wait before other
904    /// teardown of your own. It is idempotent, and terminal — afterwards
905    /// [`Self::warm_up`] records its variants without starting a worker, and
906    /// they are compiled on first request.
907    pub fn shutdown(&mut self) {
908        self.core.shutdown();
909    }
910
911    /// How many pipelines this cache has actually compiled.
912    #[must_use]
913    pub fn compiled_variants(&self) -> u64 {
914        self.core.compiled_variants()
915    }
916
917    /// How many warm-up variants are still waiting to be built.
918    #[must_use]
919    pub fn queued_variants(&self) -> usize {
920        self.core.queued_variants()
921    }
922}
923
924/// A self-contained compile function for a worker thread: it owns clones of
925/// the device, library and driver cache, all of which are `Arc`-backed
926/// handles.
927fn compiler(
928    device: &wgpu::Device,
929    shaders: &Arc<ShaderLibrary>,
930    driver_cache: Option<&wgpu::PipelineCache>,
931) -> impl Fn(&RenderPipelineDesc) -> wgpu::RenderPipeline + Send + 'static + use<> {
932    let device = device.clone();
933    let shaders = Arc::clone(shaders);
934    let driver_cache = driver_cache.cloned();
935    move |desc| build_render_pipeline(&device, &shaders, driver_cache.as_ref(), desc)
936}
937
938/// Creates one `wgpu::RenderPipeline` from a desc — the only place in this
939/// module that touches the device.
940fn build_render_pipeline(
941    device: &wgpu::Device,
942    shaders: &ShaderLibrary,
943    driver_cache: Option<&wgpu::PipelineCache>,
944    desc: &RenderPipelineDesc,
945) -> wgpu::RenderPipeline {
946    let name = shaders.name_of(desc.shader).unwrap_or("<unknown shader>");
947    let module = shaders
948        .get(desc.shader)
949        .expect("a RenderPipelineDesc's ShaderId must come from this cache's ShaderLibrary");
950    // `wgpu::VertexState::buffers` is a slice of `Option`s: a `None` slot
951    // declares a vertex-buffer index the pipeline leaves unbound. Every layout
952    // this cache carries is used, so each one is wrapped in `Some` and no gap
953    // is ever produced.
954    let layouts: Vec<Option<wgpu::VertexBufferLayout<'_>>> = desc
955        .vertex_layouts
956        .iter()
957        .map(|layout| Some(VertexLayout::as_wgpu(layout)))
958        .collect();
959    let label = format!(
960        "frust-gpu pipeline: {name} [{:?}, msaa x{}]",
961        desc.format, desc.sample_count
962    );
963
964    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
965        label: Some(&label),
966        // No explicit-layout axis in v1 — see `RenderPipelineDesc`'s docs.
967        layout: None,
968        vertex: wgpu::VertexState {
969            module,
970            entry_point: Some(desc.vs.as_ref()),
971            compilation_options: wgpu::PipelineCompilationOptions::default(),
972            buffers: &layouts,
973        },
974        primitive: wgpu::PrimitiveState {
975            topology: desc.topology,
976            ..Default::default()
977        },
978        depth_stencil: desc.depth.clone(),
979        multisample: wgpu::MultisampleState {
980            count: desc.sample_count,
981            mask: !0,
982            alpha_to_coverage_enabled: false,
983        },
984        fragment: Some(wgpu::FragmentState {
985            module,
986            entry_point: Some(desc.fs.as_ref()),
987            compilation_options: wgpu::PipelineCompilationOptions::default(),
988            targets: &[Some(wgpu::ColorTargetState {
989                format: desc.format,
990                blend: desc.blend,
991                write_mask: wgpu::ColorWrites::ALL,
992            })],
993        }),
994        multiview_mask: None,
995        cache: driver_cache,
996    })
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
1003
1004    /// Stands in for a `wgpu::RenderPipeline` so the queue/steal policy runs
1005    /// with no device: it records which variant it was built from.
1006    #[derive(Debug, PartialEq, Eq)]
1007    struct FakePipeline {
1008        sample_count: u32,
1009        serial: u64,
1010    }
1011
1012    /// A compile function that counts its calls, so "compiled once" is an
1013    /// assertion rather than an inference.
1014    #[derive(Default)]
1015    struct Counter(AtomicU64);
1016
1017    impl Counter {
1018        fn compile(&self, desc: &RenderPipelineDesc) -> FakePipeline {
1019            let serial = self.0.fetch_add(1, Ordering::SeqCst);
1020            FakePipeline {
1021                sample_count: desc.sample_count,
1022                serial,
1023            }
1024        }
1025
1026        fn count(&self) -> u64 {
1027            self.0.load(Ordering::SeqCst)
1028        }
1029    }
1030
1031    fn desc() -> RenderPipelineDesc {
1032        RenderPipelineDesc::new(
1033            ShaderId::from_raw(0),
1034            "vs_main",
1035            "fs_main",
1036            wgpu::TextureFormat::Rgba8Unorm,
1037        )
1038    }
1039
1040    #[test]
1041    fn a_repeated_request_compiles_once() {
1042        let mut cache = VariantCache::<FakePipeline>::default();
1043        let counter = Counter::default();
1044        let desc = desc();
1045
1046        let first = cache.get_or_create(&desc, |d| counter.compile(d)).serial;
1047        for _ in 0..8 {
1048            let again = cache.get_or_create(&desc, |d| counter.compile(d)).serial;
1049            assert_eq!(again, first, "every repeat must return the same pipeline");
1050        }
1051        assert_eq!(counter.count(), 1);
1052        assert_eq!(cache.compiled_variants(), 1);
1053    }
1054
1055    #[test]
1056    fn each_key_axis_is_its_own_variant() {
1057        let base = desc();
1058        let variants = [
1059            RenderPipelineDesc {
1060                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
1061                ..base.clone()
1062            },
1063            RenderPipelineDesc {
1064                format: wgpu::TextureFormat::Bgra8Unorm,
1065                ..base.clone()
1066            },
1067            RenderPipelineDesc {
1068                sample_count: 4,
1069                ..base.clone()
1070            },
1071            RenderPipelineDesc {
1072                depth: Some(wgpu::DepthStencilState {
1073                    format: wgpu::TextureFormat::Depth32Float,
1074                    depth_write_enabled: Some(true),
1075                    depth_compare: Some(wgpu::CompareFunction::Less),
1076                    stencil: wgpu::StencilState::default(),
1077                    bias: wgpu::DepthBiasState::default(),
1078                }),
1079                ..base.clone()
1080            },
1081            RenderPipelineDesc {
1082                shader: ShaderId::from_raw(1),
1083                ..base.clone()
1084            },
1085            // Same shader id, different entry point: the program axis, not a
1086            // silent collision with `base`.
1087            RenderPipelineDesc {
1088                fs: "fs_other".into(),
1089                ..base.clone()
1090            },
1091            // Same shader id and entry points, different vertex layout.
1092            RenderPipelineDesc {
1093                vertex_layouts: vec![VertexLayout::per_vertex(
1094                    16,
1095                    vec![wgpu::VertexAttribute {
1096                        format: wgpu::VertexFormat::Float32x4,
1097                        offset: 0,
1098                        shader_location: 0,
1099                    }],
1100                )],
1101                ..base.clone()
1102            },
1103            RenderPipelineDesc {
1104                topology: wgpu::PrimitiveTopology::LineList,
1105                ..base.clone()
1106            },
1107        ];
1108
1109        let mut packer = KeyPacker::default();
1110        let base_key = packer.key_for(&base).expect("base key");
1111        let mut seen = vec![base_key];
1112        for variant in &variants {
1113            let key = packer.key_for(variant).expect("variant key");
1114            assert!(
1115                !seen.contains(&key),
1116                "{variant:?} must not reuse an existing key"
1117            );
1118            seen.push(key);
1119        }
1120
1121        // And each of them really is a separate compile.
1122        let mut cache = VariantCache::<FakePipeline>::default();
1123        let counter = Counter::default();
1124        cache.get_or_create(&base, |d| counter.compile(d));
1125        for variant in &variants {
1126            cache.get_or_create(variant, |d| counter.compile(d));
1127        }
1128        assert_eq!(counter.count(), 1 + variants.len() as u64);
1129    }
1130
1131    #[test]
1132    fn a_key_is_stable_across_repeated_packing() {
1133        let mut packer = KeyPacker::default();
1134        let desc = desc();
1135        let first = packer.key_for(&desc).expect("key");
1136        for _ in 0..4 {
1137            assert_eq!(packer.key_for(&desc), Some(first));
1138        }
1139    }
1140
1141    #[test]
1142    fn an_unusable_sample_count_has_no_key_and_is_not_cached() {
1143        let mut packer = KeyPacker::default();
1144        assert_eq!(
1145            packer.key_for(&RenderPipelineDesc {
1146                sample_count: 3,
1147                ..desc()
1148            }),
1149            None
1150        );
1151        assert_eq!(
1152            packer.key_for(&RenderPipelineDesc {
1153                sample_count: 0,
1154                ..desc()
1155            }),
1156            None
1157        );
1158
1159        // The uncached fallback still returns a usable pipeline, it just
1160        // recompiles per request.
1161        let mut cache = VariantCache::<FakePipeline>::default();
1162        let counter = Counter::default();
1163        let odd = RenderPipelineDesc {
1164            sample_count: 3,
1165            ..desc()
1166        };
1167        assert_eq!(
1168            cache
1169                .get_or_create(&odd, |d| counter.compile(d))
1170                .sample_count,
1171            3
1172        );
1173        cache.get_or_create(&odd, |d| counter.compile(d));
1174        assert_eq!(counter.count(), 2, "an unkeyable variant is never cached");
1175    }
1176
1177    #[test]
1178    fn draining_the_queue_builds_every_listed_variant_once() {
1179        let mut cache = VariantCache::<FakePipeline>::default();
1180        let counter = Counter::default();
1181        let listed: Vec<_> = [1u32, 2, 4]
1182            .into_iter()
1183            .map(|sample_count| RenderPipelineDesc {
1184                sample_count,
1185                ..desc()
1186            })
1187            .collect();
1188
1189        cache.enqueue(&listed);
1190        assert_eq!(cache.queued_variants(), 3);
1191        VariantCache::drain_queue(&cache.shared(), |d| counter.compile(d));
1192        assert_eq!(counter.count(), 3);
1193        assert_eq!(cache.queued_variants(), 0);
1194
1195        // Every warmed variant is now a hit, not a compile.
1196        for desc in &listed {
1197            cache.get_or_create(desc, |d| counter.compile(d));
1198        }
1199        assert_eq!(counter.count(), 3, "a warmed variant must not recompile");
1200    }
1201
1202    #[test]
1203    fn enqueueing_the_same_variant_twice_queues_one_job() {
1204        let mut cache = VariantCache::<FakePipeline>::default();
1205        let listed = [desc(), desc()];
1206        cache.enqueue(&listed);
1207        cache.enqueue(&listed);
1208        assert_eq!(cache.queued_variants(), 1);
1209    }
1210
1211    #[test]
1212    fn a_render_thread_request_steals_a_queued_job_and_the_queue_skips_it() {
1213        let mut cache = VariantCache::<FakePipeline>::default();
1214        let counter = Counter::default();
1215        let listed: Vec<_> = [1u32, 2, 4]
1216            .into_iter()
1217            .map(|sample_count| RenderPipelineDesc {
1218                sample_count,
1219                ..desc()
1220            })
1221            .collect();
1222        cache.enqueue(&listed);
1223
1224        // The render thread wants the last-listed variant before the worker
1225        // has started: it builds it inline rather than waiting its turn.
1226        let stolen = cache
1227            .get_or_create(&listed[2], |d| counter.compile(d))
1228            .serial;
1229        assert_eq!(stolen, 0, "the steal is the first compile to run");
1230        assert_eq!(counter.count(), 1);
1231        assert_eq!(cache.queued_variants(), 2, "the stolen job is done");
1232
1233        // The worker then builds only what is left — the stolen entry is
1234        // marked done, not rebuilt.
1235        VariantCache::drain_queue(&cache.shared(), |d| counter.compile(d));
1236        assert_eq!(
1237            counter.count(),
1238            3,
1239            "the stolen variant must not compile twice"
1240        );
1241        assert_eq!(
1242            cache
1243                .get_or_create(&listed[2], |d| counter.compile(d))
1244                .serial,
1245            stolen
1246        );
1247        assert_eq!(counter.count(), 3);
1248    }
1249
1250    #[test]
1251    fn a_worker_thread_drain_publishes_to_the_render_thread() {
1252        let mut cache = VariantCache::<FakePipeline>::default();
1253        let listed: Vec<_> = (0..6)
1254            .map(|i| RenderPipelineDesc {
1255                shader: ShaderId::from_raw(i),
1256                ..desc()
1257            })
1258            .collect();
1259        cache.enqueue(&listed);
1260
1261        let shared = cache.shared();
1262        let worker_counter = Arc::new(Counter::default());
1263        let thread_counter = Arc::clone(&worker_counter);
1264        let worker = std::thread::spawn(move || {
1265            VariantCache::drain_queue(&shared, move |d| thread_counter.compile(d));
1266        });
1267        worker.join().expect("the warm-up worker must not panic");
1268
1269        assert_eq!(worker_counter.count(), 6);
1270        assert_eq!(cache.compiled_variants(), 6);
1271
1272        // The render thread now sees the worker's pipelines through the
1273        // shared map; its own compile function is never called.
1274        let render_counter = Counter::default();
1275        for desc in &listed {
1276            cache.get_or_create(desc, |d| render_counter.compile(d));
1277        }
1278        assert_eq!(render_counter.count(), 0);
1279    }
1280
1281    #[test]
1282    fn an_abandoned_build_marks_the_job_failed_rather_than_pending() {
1283        let cache = VariantCache::<FakePipeline>::default();
1284        let shared = cache.shared();
1285        let guard = BuildGuard::new(&shared, 42);
1286        lock(&shared.inner).jobs.insert(
1287            42,
1288            Job {
1289                desc: desc(),
1290                state: JobState::Building,
1291            },
1292        );
1293        // Dropping without publishing is what an unwinding compile does.
1294        drop(guard);
1295        assert_eq!(
1296            lock(&shared.inner).jobs.get(&42).map(|job| job.state),
1297            Some(JobState::Failed),
1298            "an abandoned build must not leave a Pending job no worker can claim"
1299        );
1300    }
1301
1302    /// A compile that panics once, then behaves — the failure mode a driver
1303    /// or a malformed shader produces on exactly one variant.
1304    struct PanicOnce {
1305        counter: Arc<Counter>,
1306        armed: AtomicBool,
1307    }
1308
1309    impl PanicOnce {
1310        fn new(counter: &Arc<Counter>) -> Self {
1311            Self {
1312                counter: Arc::clone(counter),
1313                armed: AtomicBool::new(true),
1314            }
1315        }
1316
1317        fn compile(&self, desc: &RenderPipelineDesc) -> FakePipeline {
1318            assert!(
1319                !self.armed.swap(false, Ordering::SeqCst),
1320                "the fake compile fails its first call"
1321            );
1322            self.counter.compile(desc)
1323        }
1324    }
1325
1326    #[test]
1327    fn a_panicking_warm_up_compile_costs_one_variant_and_not_the_queue() {
1328        let mut cache = VariantCache::<FakePipeline>::default();
1329        let counter = Arc::new(Counter::default());
1330        let failing = PanicOnce::new(&counter);
1331        let listed: Vec<_> = [1u32, 2, 4]
1332            .into_iter()
1333            .map(|sample_count| RenderPipelineDesc {
1334                sample_count,
1335                ..desc()
1336            })
1337            .collect();
1338
1339        cache.enqueue(&listed);
1340        VariantCache::drain_queue(&cache.shared(), |d| failing.compile(d));
1341
1342        // The unwind took its own variant down and nothing else: the drain
1343        // carried on and built the other two.
1344        assert_eq!(counter.count(), 2);
1345        assert_eq!(
1346            cache.queued_variants(),
1347            0,
1348            "a failed job must not be counted as still queued forever"
1349        );
1350
1351        // And the failed variant is rebuilt on request rather than waited on.
1352        assert_eq!(
1353            cache
1354                .get_or_create(&listed[0], |d| counter.compile(d))
1355                .sample_count,
1356            1
1357        );
1358        assert_eq!(counter.count(), 3);
1359
1360        // Everything the worker did build is still a hit.
1361        for desc in &listed {
1362            cache.get_or_create(desc, |d| counter.compile(d));
1363        }
1364        assert_eq!(counter.count(), 3);
1365    }
1366
1367    #[test]
1368    fn a_panicking_inline_build_surfaces_to_the_requester_and_leaves_it_rebuildable() {
1369        let mut cache = VariantCache::<FakePipeline>::default();
1370        let counter = Arc::new(Counter::default());
1371        let failing = PanicOnce::new(&counter);
1372        let desc = desc();
1373        cache.enqueue(std::slice::from_ref(&desc));
1374
1375        // The render thread steals the queued job; its compile unwinds, and
1376        // the failure lands on the requester rather than anywhere silent.
1377        let stolen = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1378            cache.get_or_create(&desc, |d| failing.compile(d));
1379        }));
1380        assert!(
1381            stolen.is_err(),
1382            "an inline compile must not swallow a panic"
1383        );
1384        assert_eq!(counter.count(), 0);
1385
1386        // The next request rebuilds it instead of blocking on a build that
1387        // will never publish.
1388        assert_eq!(cache.get_or_create(&desc, |d| counter.compile(d)).serial, 0);
1389        assert_eq!(counter.count(), 1);
1390        assert_eq!(cache.queued_variants(), 0);
1391    }
1392
1393    /// A latch a fake compile parks on, so a warm-up worker can be held
1394    /// mid-drain while the test makes further `warm_up` calls against the
1395    /// same cache.
1396    #[derive(Default)]
1397    struct Latch {
1398        open: Mutex<bool>,
1399        changed: Condvar,
1400    }
1401
1402    impl Latch {
1403        fn wait(&self) {
1404            let mut open = lock(&self.open);
1405            while !*open {
1406                open = self
1407                    .changed
1408                    .wait(open)
1409                    .unwrap_or_else(PoisonError::into_inner);
1410            }
1411        }
1412
1413        fn open(&self) {
1414            *lock(&self.open) = true;
1415            self.changed.notify_all();
1416        }
1417    }
1418
1419    fn shader_variants(count: u32) -> Vec<RenderPipelineDesc> {
1420        (0..count)
1421            .map(|i| RenderPipelineDesc {
1422                shader: ShaderId::from_raw(i),
1423                ..desc()
1424            })
1425            .collect()
1426    }
1427
1428    #[test]
1429    fn repeated_warm_up_calls_keep_at_most_one_worker() {
1430        let mut cache = VariantCache::<FakePipeline>::default();
1431        let counter = Arc::new(Counter::default());
1432        let latch = Arc::new(Latch::default());
1433        let workers = AtomicU64::new(0);
1434        let listed = shader_variants(6);
1435
1436        // Every worker this cache starts calls the factory exactly once, so
1437        // counting factory calls counts workers.
1438        let make_compile = || {
1439            workers.fetch_add(1, Ordering::SeqCst);
1440            let counter = Arc::clone(&counter);
1441            let latch = Arc::clone(&latch);
1442            let held = AtomicBool::new(false);
1443            move |d: &RenderPipelineDesc| {
1444                if !held.swap(true, Ordering::SeqCst) {
1445                    latch.wait();
1446                }
1447                counter.compile(d)
1448            }
1449        };
1450
1451        // One worker starts on the first listed variant and is held there,
1452        // so it is unambiguously still live for every later call.
1453        cache.warm_up(&listed[..1], make_compile);
1454        for _ in 0..8 {
1455            cache.warm_up(&listed, make_compile);
1456        }
1457        assert_eq!(
1458            workers.load(Ordering::SeqCst),
1459            1,
1460            "a live worker must absorb further warm-up calls, not be joined by more"
1461        );
1462
1463        latch.open();
1464        cache.shutdown();
1465
1466        // Whatever the worker did not reach before shutdown is built on
1467        // request; either way each variant is compiled exactly once.
1468        for desc in &listed {
1469            cache.get_or_create(desc, |d| counter.compile(d));
1470        }
1471        assert_eq!(counter.count(), listed.len() as u64);
1472        assert_eq!(cache.compiled_variants(), listed.len() as u64);
1473    }
1474
1475    #[test]
1476    fn dropping_the_cache_joins_its_worker() {
1477        // Stands in for the `wgpu::Device` handle a real compile function
1478        // clones into the worker: if the thread outlived the cache, this
1479        // clone would still be alive after the drop.
1480        let device = Arc::new(());
1481        let counter = Arc::new(Counter::default());
1482        let listed = shader_variants(4);
1483
1484        let mut cache = VariantCache::<FakePipeline>::default();
1485        cache.warm_up(&listed, || {
1486            let device = Arc::clone(&device);
1487            let counter = Arc::clone(&counter);
1488            move |d: &RenderPipelineDesc| {
1489                let _held = Arc::clone(&device);
1490                counter.compile(d)
1491            }
1492        });
1493        drop(cache);
1494
1495        assert_eq!(
1496            Arc::strong_count(&device),
1497            1,
1498            "no warm-up worker may outlive the cache that started it"
1499        );
1500    }
1501
1502    #[test]
1503    fn warming_up_after_shutdown_starts_no_worker_and_still_builds_on_request() {
1504        let mut cache = VariantCache::<FakePipeline>::default();
1505        let counter = Arc::new(Counter::default());
1506        let workers = AtomicU64::new(0);
1507        let listed = shader_variants(3);
1508
1509        cache.shutdown();
1510        // Idempotent: a second call is a no-op, not a double join.
1511        cache.shutdown();
1512        cache.warm_up(&listed, || {
1513            workers.fetch_add(1, Ordering::SeqCst);
1514            let counter = Arc::clone(&counter);
1515            move |d: &RenderPipelineDesc| counter.compile(d)
1516        });
1517
1518        assert_eq!(workers.load(Ordering::SeqCst), 0);
1519        for desc in &listed {
1520            cache.get_or_create(desc, |d| counter.compile(d));
1521        }
1522        assert_eq!(counter.count(), listed.len() as u64);
1523    }
1524
1525    /// Pops a validation error scope, pumping the device until the pop
1526    /// resolves — the same drain `frust-render`'s shader-effect compile uses,
1527    /// since the pop is a future a plain block-on would park on.
1528    fn drain_error_scope(
1529        device: &wgpu::Device,
1530        scope: wgpu::ErrorScopeGuard,
1531    ) -> Option<wgpu::Error> {
1532        use std::task::{Context, Poll, Waker};
1533
1534        let waker = Waker::noop();
1535        let mut cx = Context::from_waker(waker);
1536        let mut future = std::pin::pin!(scope.pop());
1537        loop {
1538            match future.as_mut().poll(&mut cx) {
1539                Poll::Ready(error) => return error,
1540                Poll::Pending => {
1541                    let _ = device.poll(wgpu::PollType::wait_indefinitely());
1542                }
1543            }
1544        }
1545    }
1546
1547    /// One real pipeline on real hardware: the host tests above all run
1548    /// against a fake compile function, so this is the only case that proves
1549    /// a `RenderPipelineDesc` actually describes a pipeline wgpu accepts —
1550    /// entry points, vertex layout, color target and multisample state
1551    /// included — and that the cache still compiles it exactly once.
1552    #[test]
1553    #[ignore = "requires a GPU (Metal/Vulkan); run locally with `cargo test -p frust-gpu -- --ignored`"]
1554    fn creates_one_real_pipeline() {
1555        const WGSL: &str = r#"
1556@vertex
1557fn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
1558    let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
1559    return vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
1560}
1561@fragment
1562fn fs_main() -> @location(0) vec4<f32> {
1563    return vec4<f32>(0.0, 1.0, 0.0, 1.0);
1564}
1565"#;
1566
1567        let (device, _queue) = pollster::block_on(async {
1568            let instance = wgpu::Instance::new(
1569                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
1570            );
1571            // The environment-aware initializer, so `WGPU_ADAPTER_NAME` picks
1572            // the GPU on a multi-adapter host instead of the run silently
1573            // landing on whichever one enumerates first.
1574            let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
1575                .await
1576                .expect("no compatible GPU adapter");
1577            println!("frust-gpu pipeline test adapter: {:?}", adapter.get_info());
1578            adapter
1579                .request_device(&wgpu::DeviceDescriptor {
1580                    label: Some("frust-gpu pipeline test device"),
1581                    required_features: wgpu::Features::empty(),
1582                    required_limits: wgpu::Limits::default(),
1583                    ..Default::default()
1584                })
1585                .await
1586                .expect("failed to create the device")
1587        });
1588
1589        let mut library = ShaderLibrary::new();
1590        let shader = library.insert_wgsl(&device, "fullscreen-green", WGSL);
1591        let mut cache = PipelineCache::new(Arc::new(library), None);
1592
1593        let desc = RenderPipelineDesc::new(
1594            shader,
1595            "vs_main",
1596            "fs_main",
1597            wgpu::TextureFormat::Rgba8Unorm,
1598        );
1599        let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
1600        for _ in 0..2 {
1601            let _pipeline = cache.get_or_create(&device, &desc);
1602        }
1603        let error = drain_error_scope(&device, scope);
1604
1605        assert!(error.is_none(), "pipeline creation raised {error:?}");
1606        assert_eq!(
1607            cache.compiled_variants(),
1608            1,
1609            "a repeat request must reuse the pipeline"
1610        );
1611    }
1612}